Files
hermes-agent/tests/agent/test_aux_progress_streaming.py
Teknium 1e866e34bf fix(aux): the first-token window honours providers.<id>.stale_timeout_seconds
The pre-first-token window used the env stale base, so an explicit per-provider
stale timeout was ignored (900 configured, window capped at 180). It now goes
through the main loop's _cloud_stale_timeout_for with the attempt's routed
provider, so the explicit setting wins exactly as it does for the main loop.
2026-10-05 16:00:18 -07:00

883 lines
35 KiB
Python

"""Tests for the auxiliary forward-progress streaming layer.
Slow summary models must not be punished like hung ones (#see PR): when a
forward-progress hook is installed (context compression), the primary
auxiliary call streams and ticks the hook only for substantive payloads, so
outer watchdogs (gateway session hygiene) can extend their deadline on
liveness. Without a hook, behavior is byte-for-byte the old non-streaming call.
"""
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from agent.auxiliary_client import (
_AnthropicCompletionsAdapter,
_ChatStreamAccumulator,
_CodexCompletionsAdapter,
_acreate_with_progress,
_acreate_with_stream,
_aggregate_chat_stream,
_aggregate_chat_stream_async,
_anthropic_event_has_content,
_aux_dispatch,
_aux_thread_local_hook,
_create_with_progress,
_create_with_progress_once,
_notify_aux_progress,
_provider_requires_stream,
aux_progress_hook,
)
from agent.codex_runtime import _codex_event_has_content
from agent.conversation_compression import CompressionCommitFence
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _chunk(content=None, reasoning=None, reasoning_details=None,
finish_reason=None, usage=None, tool_calls=None, model="m1",
chunk_id="c1"):
delta = SimpleNamespace(
content=content,
reasoning=reasoning,
reasoning_content=None,
reasoning_details=reasoning_details,
tool_calls=tool_calls,
)
choice = SimpleNamespace(delta=delta, finish_reason=finish_reason)
return SimpleNamespace(
id=chunk_id, model=model, choices=[choice], usage=usage,
)
class _FakeClient:
"""OpenAI-shaped client whose create() returns a canned value or stream."""
def __init__(self, response=None, stream_chunks=None, stream_error=None):
self.calls = []
self._response = response
self._stream_chunks = stream_chunks
self._stream_error = stream_error
completions = SimpleNamespace(create=self._create)
self.chat = SimpleNamespace(completions=completions)
def _create(self, **kwargs):
self.calls.append(kwargs)
if kwargs.get("stream"):
if self._stream_error is not None:
raise self._stream_error
return iter(self._stream_chunks or [])
return self._response
_COMPLETE = SimpleNamespace(
id="r1", model="m1", object="chat.completion",
choices=[SimpleNamespace(
index=0,
message=SimpleNamespace(role="assistant", content="non-streamed"),
finish_reason="stop",
)],
usage=None,
)
# ---------------------------------------------------------------------------
# aux_progress_hook plumbing
# ---------------------------------------------------------------------------
class TestAuxProgressHook:
def test_hook_installed_and_restored(self):
ticks = []
with aux_progress_hook(lambda: ticks.append(1)):
_notify_aux_progress()
_notify_aux_progress() # outside — must not tick
assert ticks == [1]
def test_hook_is_thread_local(self):
ticks = []
seen_in_thread = []
def _other_thread():
# No hook installed on this thread.
_notify_aux_progress()
seen_in_thread.append(len(ticks))
with aux_progress_hook(lambda: ticks.append(1)):
t = threading.Thread(target=_other_thread)
t.start()
t.join()
assert seen_in_thread == [0]
# ---------------------------------------------------------------------------
# _create_with_progress
# ---------------------------------------------------------------------------
class TestCreateWithProgress:
def test_hook_upgrades_to_streaming_and_ticks_only_for_payload(self):
empty_tool_call = SimpleNamespace(
index=0,
id=None,
function=SimpleNamespace(name=None, arguments=""),
)
chunks = [
SimpleNamespace(id=None, model=None, choices=[], usage=None),
_chunk(content="", reasoning="", tool_calls=[empty_tool_call]),
_chunk(reasoning="thinking..."),
_chunk(content="Hello "),
_chunk(content="world", finish_reason="stop",
usage=SimpleNamespace(prompt_tokens=5, completion_tokens=2,
total_tokens=7)),
]
client = _FakeClient(stream_chunks=chunks)
ticks = []
with aux_progress_hook(lambda: ticks.append(1)):
result = _create_with_progress(
client, {"model": "m1", "messages": [], "timeout": 30},
)
assert client.calls[0]["stream"] is True
assert result.choices[0].message.content == "Hello world"
assert result.choices[0].message.reasoning == "thinking..."
assert result.choices[0].finish_reason == "stop"
assert result.usage.total_tokens == 7
# 1 tick per substantive chunk (reasoning, "Hello ", "world"); the
# dispatch itself is not progress (#114938).
assert ticks == [1, 1, 1]
def test_reasoning_only_in_model_extra_is_captured_and_counts_as_progress(self):
# Non-SDK delta objects (proxies, relays) may carry reasoning only in ``model_extra``;
# the accumulator must read it like the main streaming path does (#56516).
chunk = _chunk(finish_reason="stop")
chunk.choices[0].delta.model_extra = {"reasoning_content": "thinking..."}
client = _FakeClient(stream_chunks=[chunk])
ticks = []
with aux_progress_hook(lambda: ticks.append(1)):
result = _create_with_progress(client, {"model": "m1", "messages": [], "timeout": 30})
assert result.choices[0].message.reasoning == "thinking..."
assert ticks == [1] # the reasoning chunk only; dispatch is not progress (#114938)
def test_completed_response_ticks_only_terminal_signals(self):
calls = []
def _create(**kwargs):
calls.append(kwargs)
return _COMPLETE
client = SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=_create))
)
ticks = []
with aux_progress_hook(lambda: ticks.append(1)):
result = _create_with_progress(client, {"model": "m1", "messages": []})
assert calls[0]["stream"] is True
assert result is _COMPLETE
# A completed response object carries the full summary payload: one
# terminal tick, and no dispatch tick (#114938).
assert ticks == [1]
def test_streaming_rejected_falls_back_to_plain_call(self):
client = _FakeClient(
response=_COMPLETE,
stream_error=RuntimeError("stream is not supported by this model"),
)
with aux_progress_hook(lambda: None):
result = _create_with_progress(
client, {"model": "m1", "messages": []},
)
assert result is _COMPLETE
# streamed attempt + non-streaming fallback
assert len(client.calls) == 2
assert client.calls[0].get("stream") is True
assert "stream" not in client.calls[1]
def test_dispatch_only_auth_failure_does_not_tick_progress(self):
"""Bug pin (#114938): a dispatch that dies before any payload (401) must
not reset the compression inactivity fence. Dispatch telemetry still
fires, but progress_observed stays False."""
fence = CompressionCommitFence()
dispatches = []
class _AuthError(Exception):
status_code = 401
client = _FakeClient(stream_error=_AuthError("unauthorized"))
with (
aux_progress_hook(fence.touch_progress),
_aux_thread_local_hook(_aux_dispatch, lambda: dispatches.append(1)),
pytest.raises(_AuthError),
):
_create_with_progress_once(
client, {"model": "m1", "messages": [], "timeout": 30},
)
assert dispatches == [1] # dispatch telemetry preserved
assert fence.progress_observed is False
# ---------------------------------------------------------------------------
# _aggregate_chat_stream
# ---------------------------------------------------------------------------
class TestAggregateChatStream:
def test_tool_call_deltas_are_reassembled(self):
tc0 = SimpleNamespace(
index=0, id="call_1",
function=SimpleNamespace(name="do_thing", arguments='{"a"'),
)
tc1 = SimpleNamespace(
index=0, id=None,
function=SimpleNamespace(name=None, arguments=': 1}'),
)
chunks = [
_chunk(tool_calls=[tc0]),
_chunk(tool_calls=[tc1], finish_reason="tool_calls"),
]
result = _aggregate_chat_stream(iter(chunks))
tool_calls = result.choices[0].message.tool_calls
assert len(tool_calls) == 1
assert tool_calls[0].id == "call_1"
assert tool_calls[0].function.name == "do_thing"
assert tool_calls[0].function.arguments == '{"a": 1}'
assert result.choices[0].finish_reason == "tool_calls"
def test_stream_close_is_called(self):
closed = []
class _Stream:
def __iter__(self):
return iter([_chunk(content="ok", finish_reason="stop")])
def close(self):
closed.append(True)
result = _aggregate_chat_stream(_Stream())
assert result.choices[0].message.content == "ok"
assert closed == [True]
@staticmethod
def _sse_server(gates: dict):
"""Loopback HTTP/1.1 keepalive SSE endpoint; the request's ``model`` picks the script."""
import http.server
import json
def frame(handler, payload):
raw = (payload if isinstance(payload, str) else f"data: {json.dumps(payload)}\n\n").encode()
handler.wfile.write(b"%x\r\n%s\r\n" % (len(raw), raw))
handler.wfile.flush()
def chunk(content=None, finish=None, usage=None):
return {"id": "c1", "object": "chat.completion.chunk", "created": 0, "model": "m",
"choices": [{"index": 0, "delta": {"content": content} if content else {},
"finish_reason": finish}], **({"usage": usage} if usage else {})}
class _Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_POST(self):
script = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))["model"]
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
self.wfile.flush()
try:
if script in ("openai/o3", "slow"): # silent reasoning before the first token
time.sleep(2.0)
if script == "b":
gates["b_started"].set()
frame(self, chunk(content="partial"))
if script == "usage": # terminal chunk with billed usage, then a stalled teardown
frame(self, chunk(finish="stop", usage={
"prompt_tokens": 100, "completion_tokens": 9, "total_tokens": 109}))
gates.get(script, gates["release"]).wait(30)
if script != "a": # "a" ends its body at EOF (no [DONE]), freeing the keepalive socket
frame(self, "data: [DONE]\n\n")
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
except OSError:
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
server.daemon_threads = True
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
@pytest.fixture
def sse(self, monkeypatch):
"""(gates, call(model, task)) against the real OpenAI SDK + loopback transport. The default
window is 3s and ``auxiliary.compression.no_progress_timeout`` is 0.4s."""
import openai
gates = {name: threading.Event() for name in ("release", "a", "b", "b_started", "usage")}
server = self._sse_server(gates)
monkeypatch.setattr("agent.auxiliary_client._AUX_STREAM_NO_PROGRESS_TIMEOUT_SECONDS", 3.0)
monkeypatch.setattr("agent.auxiliary_client._get_task_no_progress_timeout",
lambda task: 0.4 if task == "compression" else None)
# Loopback stands in for a remote endpoint: the first-token window applies to non-local ones.
monkeypatch.setattr("agent.model_metadata.is_local_endpoint", lambda _url: False)
client = openai.OpenAI(api_key="k", base_url=f"http://127.0.0.1:{server.server_port}/v1", max_retries=0)
def call(model, task, *, via_relay_seam=False, provider=None):
from agent.auxiliary_client import _relay_aux_call_scope, _relay_sync_completion
request = {"model": model, "messages": [], "timeout": 20.0}
with aux_progress_hook(lambda: None):
if via_relay_seam: # a recovery rung / credential retry: the default relay callback
with _relay_aux_call_scope((task,), {}):
return _relay_sync_completion(client, request, provider=provider)
return _create_with_progress(client, request, task)
try:
yield gates, call
finally:
for gate in gates.values():
gate.set()
server.shutdown()
client.close()
@pytest.mark.parametrize("via_relay_seam", [False, True], ids=["primary", "recovery_rung"])
def test_silent_stream_fails_fast_through_the_real_transport(self, sse, via_relay_seam):
"""#100501: an endpoint that sends one chunk then goes silent fails at the task's no-progress
window as a timeout (so retry/fallback run), not at the request read timeout. Recovery rungs
go through the default relay callback and must keep the task's window."""
from agent.auxiliary_client import _should_skip_same_provider_retry
_gates, call = sse
started = time.monotonic()
with pytest.raises(TimeoutError) as excinfo:
call("m", "compression", via_relay_seam=via_relay_seam)
assert time.monotonic() - started < 5.0
assert "stalled: no new output for 0.4s" in str(excinfo.value)
assert _should_skip_same_provider_retry("compression", excinfo.value)
@pytest.mark.parametrize("case", ["reasoning_before_first_token", "provider_stale_timeout_before_first_token",
"stall_after_terminal_usage", "late_timer_after_pool_reuse"])
def test_watchdog_only_cuts_its_own_unfinished_stream(self, sse, case, monkeypatch):
"""The no-progress watchdog must not cut (a) a model thinking silently before its first token
for 5x the inter-chunk window (main-loop reasoning floor, or the routed provider's explicit
``providers.<id>.stale_timeout_seconds`` over a shorter env stale timeout), (b) a response
whose terminal chunk and billed usage already arrived, nor (c) a later request that reused
the keepalive connection after a stalled attempt's timer had already woken."""
gates, call = sse
if case == "reasoning_before_first_token":
gates["release"].set() # the stream ends right after its first chunk
assert call("openai/o3", "compression").choices[0].message.content == "partial"
return
if case == "provider_stale_timeout_before_first_token":
gates["release"].set()
monkeypatch.setenv("HERMES_STREAM_STALE_TIMEOUT", "0.5")
monkeypatch.setattr("hermes_cli.config.load_config_readonly",
lambda: {"providers": {"openrouter": {"stale_timeout_seconds": 900}}})
result = call("slow", "compression", via_relay_seam=True, provider="openrouter")
assert result.choices[0].message.content == "partial"
return
if case == "stall_after_terminal_usage":
result = call("usage", "compression")
assert result.choices[0].message.content == "partial"
assert (result.usage.prompt_tokens, result.usage.completion_tokens) == (100, 9)
return
# Hold the stalled attempt's timer at the moment it shuts a socket down; meanwhile let
# attempt A end at EOF (its connection may return to the pool) and start B on the client.
paused, resume, outcome = threading.Event(), threading.Event(), {}
def tracer(frame, event, _arg):
if (event == "call" and frame.f_code.co_name == "_shutdown_socket"
and not paused.is_set()):
paused.set()
resume.wait(3)
def run(name, model, task):
try:
outcome[name] = call(model, task)
except Exception as exc:
outcome[name] = exc
threading.settrace(tracer)
try:
attempt_a = threading.Thread(target=run, args=("a", "a", "compression"))
attempt_a.start()
assert paused.wait(3)
gates["a"].set()
attempt_a.join(0.3)
attempt_b = threading.Thread(target=run, args=("b", "b", None))
attempt_b.start()
assert gates["b_started"].wait(3)
resume.set()
time.sleep(0.2)
gates["b"].set()
attempt_b.join(5)
attempt_a.join(5)
finally:
threading.settrace(None)
resume.set()
assert isinstance(outcome["a"], TimeoutError)
assert getattr(outcome["b"], "choices", None), outcome["b"]
assert outcome["b"].choices[0].message.content == "partial"
# ---------------------------------------------------------------------------
# Content-bearing progress classification
# ---------------------------------------------------------------------------
class TestContentBearingProgress:
@pytest.mark.parametrize(
"event",
[
SimpleNamespace(type="response.created"),
SimpleNamespace(type="response.output_item.added", item={"type": "message"}),
SimpleNamespace(type="response.content_part.added", part={"type": "output_text"}),
SimpleNamespace(type="response.output_text.delta", delta=""),
SimpleNamespace(type="response.reasoning_summary_text.delta", delta=""),
SimpleNamespace(type="response.function_call_arguments.delta", delta=""),
SimpleNamespace(
type="response.output_item.added",
item=SimpleNamespace(type="function_call"),
),
{"type": "response.output_text.delta", "delta": ""},
],
)
def test_codex_empty_and_structural_events_are_not_progress(self, event):
assert _codex_event_has_content(event) is False
@pytest.mark.parametrize(
"event",
[
SimpleNamespace(type="response.output_text.delta", delta="token"),
SimpleNamespace(type="response.reasoning_summary_text.delta", delta="thought"),
SimpleNamespace(type="response.function_call_arguments.delta", delta='{"x"'),
SimpleNamespace(
type="response.output_item.added",
item=SimpleNamespace(
type="function_call",
id="item_1",
call_id="call_1",
name="lookup",
),
),
{"type": "response.output_text.delta", "delta": "token"},
],
)
def test_codex_nonempty_deltas_are_progress(self, event):
assert _codex_event_has_content(event) is True
@pytest.mark.parametrize(
("delta", "expected"),
[
(SimpleNamespace(type="text_delta", text="", thinking=None), False),
(SimpleNamespace(type="text_delta", text="token", thinking=None), True),
(SimpleNamespace(type="thinking_delta", text=None, thinking="thought"), True),
(SimpleNamespace(type="input_json_delta", partial_json=""), False),
(SimpleNamespace(type="input_json_delta", partial_json='{"x"'), True),
# Signed-thinking / citation payloads the transport emits
# (relay_llm.py signature_delta + citations_delta).
(SimpleNamespace(type="signature_delta", signature="sig-1"), True),
(SimpleNamespace(type="signature_delta", signature=""), False),
(SimpleNamespace(type="citations_delta", citation={"cited": 1}), True),
],
)
def test_anthropic_requires_nonempty_delta_payload(self, delta, expected):
event = SimpleNamespace(type="content_block_delta", delta=delta)
assert _anthropic_event_has_content(event) is expected
@pytest.mark.parametrize(
("block", "expected"),
[
(SimpleNamespace(type="text"), False),
(SimpleNamespace(type="tool_use", id=None, name=None), False),
(SimpleNamespace(type="tool_use", id="toolu_1", name="lookup"), True),
],
)
def test_anthropic_tool_start_requires_identity(self, block, expected):
event = SimpleNamespace(type="content_block_start", content_block=block)
assert _anthropic_event_has_content(event) is expected
def test_chat_stream_empty_tool_scaffolding_is_not_progress(self):
ticks = []
empty_tool_call = SimpleNamespace(
index=0,
id=None,
function=SimpleNamespace(name=None, arguments=""),
)
accumulator = _ChatStreamAccumulator()
with aux_progress_hook(lambda: ticks.append(1)):
accumulator.feed(_chunk(tool_calls=[empty_tool_call]))
assert ticks == []
@pytest.mark.parametrize(
"tool_call",
[
SimpleNamespace(index=0, id="call_1", function=None),
SimpleNamespace(
index=0,
id=None,
function=SimpleNamespace(name="lookup", arguments=""),
),
SimpleNamespace(
index=0,
id=None,
function=SimpleNamespace(name=None, arguments='{"q"'),
),
],
)
def test_chat_stream_substantive_tool_fragments_are_progress(self, tool_call):
ticks = []
accumulator = _ChatStreamAccumulator()
with aux_progress_hook(lambda: ticks.append(1)):
accumulator.feed(_chunk(tool_calls=[tool_call]))
assert ticks == [1]
def test_openrouter_reasoning_details_keep_compression_alive(self):
"""Reasoning-only streams must refresh the compression idle fence."""
ticks = []
accumulator = _ChatStreamAccumulator()
detail = {"type": "reasoning.summary", "summary": "working..."}
with aux_progress_hook(lambda: ticks.append(1)):
accumulator.feed(_chunk(reasoning_details=[detail]))
result = accumulator.finish()
assert ticks == [1]
assert result.choices[0].message.reasoning_details == [detail]
def test_structural_reasoning_details_are_not_progress(self):
ticks = []
accumulator = _ChatStreamAccumulator()
with aux_progress_hook(lambda: ticks.append(1)):
accumulator.feed(
_chunk(
reasoning_details=[
{"type": "reasoning.encrypted", "signature": "sig"}
]
)
)
assert ticks == []
def test_codex_adapter_updates_fence_only_for_substantive_events(self):
events = [
SimpleNamespace(type="response.created"),
SimpleNamespace(type="response.output_text.delta", delta=""),
SimpleNamespace(type="response.output_text.delta", delta="token"),
SimpleNamespace(
type="response.output_item.added",
item=SimpleNamespace(
type="function_call", id="item_1", call_id="call_1", name="lookup"
),
),
]
real_client = SimpleNamespace(
base_url="https://chatgpt.com/backend-api/codex",
responses=SimpleNamespace(create=lambda **_kwargs: iter(events)),
)
adapter = _CodexCompletionsAdapter(real_client, "gpt-5.6-sol")
fence = CompressionCommitFence()
touches = []
def _touch():
touches.append(1)
fence.touch_progress()
def _consume(stream, *, model, on_event):
del model
for event in stream:
on_event(event)
return SimpleNamespace(output=[], usage=None)
with (
patch("agent.codex_runtime._consume_codex_event_stream", _consume),
aux_progress_hook(_touch),
):
adapter.create(messages=[{"role": "user", "content": "summarize"}])
assert touches == [1, 1]
def test_anthropic_adapter_updates_fence_only_for_substantive_events(self):
events = [
SimpleNamespace(type="ping"),
SimpleNamespace(
type="content_block_delta",
delta=SimpleNamespace(type="text_delta", text=""),
),
SimpleNamespace(
type="content_block_delta",
delta=SimpleNamespace(type="input_json_delta", partial_json='{"q"'),
),
SimpleNamespace(
type="content_block_start",
content_block=SimpleNamespace(
type="tool_use", id="toolu_1", name="lookup"
),
),
]
adapter = _AnthropicCompletionsAdapter(
MagicMock(), "claude-sonnet-4-6", is_oauth=False
)
fence = CompressionCommitFence()
touches = []
def _touch():
touches.append(1)
fence.touch_progress()
def _create_message(*_args, **kwargs):
for event in events:
kwargs["on_stream_event"](event)
raise RuntimeError("stop after callback verification")
with (
patch(
"agent.anthropic_adapter.build_anthropic_kwargs",
return_value={"model": "claude-sonnet-4-6", "messages": []},
),
patch(
"agent.anthropic_adapter.create_anthropic_message",
side_effect=_create_message,
),
aux_progress_hook(_touch),
pytest.raises(RuntimeError, match="stop after callback verification"),
):
adapter.create(messages=[{"role": "user", "content": "summarize"}])
assert touches == [1, 1]
def test_keepalive_chunks_do_not_reset_the_compression_fence(self, monkeypatch):
"""End-to-end bug pin (#96707): content-free frames must not refresh
CompressionCommitFence._last_progress.
The waiter in conversation_compression charges its idle budget from
seconds_since_progress(); before the fix, every keepalive chunk fed
through _ChatStreamAccumulator ticked the fence, so a stalled
summary stream never hit the inactivity timeout."""
now = [10.0]
monkeypatch.setattr("agent.conversation_compression.time.monotonic", lambda: now[0])
fence = CompressionCommitFence()
now[0] = 20.0
accumulator = _ChatStreamAccumulator()
keepalive = SimpleNamespace(id=None, model=None, choices=[], usage=None)
empty_role_chunk = _chunk(content="", reasoning="")
with aux_progress_hook(fence.touch_progress):
for _ in range(5):
accumulator.feed(keepalive)
accumulator.feed(empty_role_chunk)
# No substantive payload arrived: the fence must have stayed stale.
assert fence.seconds_since_progress() > 0.0
with aux_progress_hook(fence.touch_progress):
accumulator.feed(_chunk(content="token"))
assert fence.seconds_since_progress() < 0.05
def test_content_free_frames_still_record_ttfp_timing(self):
"""The fast-lane telemetry contract (#96945/#96963) survives the
#96707 gating: the provider-response (time_to_first_progress_ms)
hook must fire on the FIRST frame of any kind (transport liveness),
not only on the first token."""
from agent.auxiliary_client import (
_aux_provider_response,
_aux_thread_local_hook,
)
responses: list = []
keepalive = SimpleNamespace(id=None, model=None, choices=[], usage=None)
accumulator = _ChatStreamAccumulator()
with (
_aux_thread_local_hook(_aux_provider_response, lambda: responses.append("response")),
aux_progress_hook(lambda: None),
):
accumulator.feed(keepalive)
assert responses, "content-free first frame must still record TTFP"
# ---------------------------------------------------------------------------
# Ceiling arithmetic
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# CompressionCommitFence progress surface
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Stream-only providers (credit @kudi88, PR #60686)
# ---------------------------------------------------------------------------
class TestProviderRequiresStream:
def test_normal_endpoints_are_not(self):
assert _provider_requires_stream(
"openrouter", "https://openrouter.ai/api/v1"
) is False
assert _provider_requires_stream("auto", None) is False
assert _provider_requires_stream("auto", "") is False
def test_config_marker_matches_custom_endpoint(self):
with patch(
"hermes_cli.config.load_config",
return_value={"auxiliary": {"stream_only_base_urls": ["my-proxy.example.com"]}},
):
assert _provider_requires_stream(
"custom", "https://my-proxy.example.com/v1"
) is True
assert _provider_requires_stream(
"custom", "https://other.example.com/v1"
) is False
class TestForceStream:
def test_force_stream_streams_without_a_hook(self):
chunks = [_chunk(content="hi", finish_reason="stop")]
client = _FakeClient(stream_chunks=chunks)
# NO aux_progress_hook installed — force_stream alone must stream.
result = _create_with_progress(
client, {"model": "m1", "messages": []}, force_stream=True,
)
assert client.calls[0]["stream"] is True
assert result.choices[0].message.content == "hi"
def test_force_stream_does_not_retry_nonstreaming_on_failure(self):
client = _FakeClient(
response=_COMPLETE,
stream_error=RuntimeError("HTTP 400 bad request"),
)
with pytest.raises(RuntimeError, match="bad request"):
_create_with_progress(
client, {"model": "m1", "messages": []}, force_stream=True,
)
# No silent non-streaming retry — the provider rejects those anyway.
assert len(client.calls) == 1
class TestAsyncStreamAggregation:
@pytest.mark.asyncio
async def test_async_stream_is_consumed_with_async_for(self):
# The sweeper review of PR #60686 flagged that awaiting create() and
# then iterating synchronously raises — the async contract is
# ``async for``. Verify the async aggregator consumes a real async
# iterator and preserves tool-call deltas.
tc0 = SimpleNamespace(
index=0, id="call_9",
function=SimpleNamespace(name="lookup", arguments='{"q":'),
)
tc1 = SimpleNamespace(
index=0, id=None,
function=SimpleNamespace(name=None, arguments='"x"}'),
)
raw_chunks = [
_chunk(content="part1 "),
_chunk(tool_calls=[tc0]),
_chunk(tool_calls=[tc1], content="part2", finish_reason="tool_calls"),
]
class _AsyncStream:
def __init__(self, items):
self._items = list(items)
self.closed = False
def __aiter__(self):
return self
async def __anext__(self):
if not self._items:
raise StopAsyncIteration
return self._items.pop(0)
async def close(self):
self.closed = True
stream = _AsyncStream(raw_chunks)
result = await _aggregate_chat_stream_async(stream)
msg = result.choices[0].message
assert msg.content == "part1 part2"
assert msg.tool_calls[0].function.name == "lookup"
assert msg.tool_calls[0].function.arguments == '{"q":"x"}'
assert result.choices[0].finish_reason == "tool_calls"
assert stream.closed is True
@pytest.mark.asyncio
async def test_acreate_with_stream_passes_stream_kwargs(self):
calls = []
class _AsyncStream:
def __init__(self, items):
self._items = list(items)
def __aiter__(self):
return self
async def __anext__(self):
if not self._items:
raise StopAsyncIteration
return self._items.pop(0)
class _AsyncClient:
def __init__(self):
completions = SimpleNamespace(create=self._create)
self.chat = SimpleNamespace(completions=completions)
async def _create(self, **kwargs):
calls.append(kwargs)
return _AsyncStream([_chunk(content="ok", finish_reason="stop")])
result = await _acreate_with_stream(
_AsyncClient(), {"model": "m1", "messages": [], "timeout": 30},
)
assert calls[0]["stream"] is True
assert result.choices[0].message.content == "ok"
@pytest.mark.asyncio
async def test_async_dispatch_only_auth_failure_does_not_tick_progress(self):
"""Async twin of the #114938 pin: a 401 dispatch must not reset the
compression inactivity fence."""
fence = CompressionCommitFence()
dispatches = []
class _AuthError(Exception):
status_code = 401
class _AsyncClient:
def __init__(self):
completions = SimpleNamespace(create=self._create)
self.chat = SimpleNamespace(completions=completions)
async def _create(self, **kwargs):
raise _AuthError("unauthorized")
with (
aux_progress_hook(fence.touch_progress),
_aux_thread_local_hook(_aux_dispatch, lambda: dispatches.append(1)),
pytest.raises(_AuthError),
):
await _acreate_with_progress(
_AsyncClient(), {"model": "m1", "messages": [], "timeout": 30},
)
assert dispatches == [1]
assert fence.progress_observed is False