Files
hermes-agent/tests/agent/test_direct_contexts_stream_inline.py
teknium1 96f313590c fix(streaming): retry a stream that died before any visible text instead of an empty stub
When a streaming response died after content deltas had fired but before any
VISIBLE text reached a consumer (whitespace/think-only deltas, or no display
consumer at all as in cron/subagent turns) and no tool call was in flight,
`_handle_stream_error` still classified it as "partial delivery, not retrying"
and `run()` returned an EMPTY finish_reason=length stub. The loop then skipped
the empty assistant row and only appended the "continue exactly where you
left off" nudge — a request byte-identical to the one that just died plus an
instruction to continue from nowhere — so the model re-did the lost step. On a
lossy connection this compounded into hours of repeated work (#112419).

Nothing the user or the model saw was actually delivered in that case, so
there is no text to duplicate: reclassify it as an undelivered stream failure.
The existing transient path retries the same request with a fresh connection;
once the budget is spent (or for non-transient errors) the error propagates to
the conversation loop's fallback/backoff owner instead of masquerading as a
completed assistant step. Real partial delivery (visible text recorded, or a
tool call in flight) keeps the stub/continuation contract unchanged.

Tests: the former "empty stub stays empty" test asserted the removed behaviour
(its #68041 intent — never substitute placeholder text — still holds: nothing
is returned at all); replaced by two invariants (retry recovers; exhausted
raises). Two fixtures that bypassed `_fire_stream_delta` recording were
switched to a real consumer / the new contract.

Fixes #112419

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-16 17:08:02 -07:00

245 lines
9.6 KiB
Python

"""Delegated children and cron turns stream on the wire (#90202, #100260).
``should_use_direct_api_call`` contexts (gateway cron turns, delegate_task
children) must not spawn the interrupt worker — it wedges inside their nested
thread pools (#62151, #60203). The original fix short-circuited them onto the
NON-streaming wire, which silently dropped every liveness property streaming
provides: edge proxies killed the silent POST (z.ai HTTP 524, #90202), and the
non-stream stale watchdog could not tell a reasoning model's thinking phase
from a hung provider (#100260 — children died at exactly ``stale_timeout``).
These tests pin the replacement contract: those contexts stay on the streaming
path, issue ``stream=True`` on the calling thread (no worker), and keep the
stale detector + cross-thread interrupt abort working from the monitor thread.
"""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
import httpx
import pytest
import run_agent
from agent.chat_completion_helpers import (
interruptible_streaming_api_call,
should_use_direct_api_call,
)
# ---------------------------------------------------------------------------
# Real OpenAI-wire SSE server: records the wire ``stream`` flag per request.
# ---------------------------------------------------------------------------
class _Wire:
def __init__(self, *, stall_after_first_chunk: bool = False):
self.requests: list[dict] = []
self.stall = stall_after_first_chunk
self.hits = threading.Semaphore(0)
wire = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_a):
pass
def do_POST(self):
n = int(self.headers.get("content-length", 0))
body = json.loads(self.rfile.read(n) or b"{}")
if not self.path.endswith("/chat/completions"):
# Local-endpoint capability probes (/api/show etc.) —
# answer fast so agent construction never waits on the
# stalling stream below.
self.send_response(404)
self.end_headers()
return
wire.requests.append(body)
wire.hits.release()
self.send_response(200)
self.send_header("content-type", "text/event-stream")
self.end_headers()
first = {
"id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "hello"},
"finish_reason": None}],
}
self.wfile.write(f"data: {json.dumps(first)}\n\n".encode())
self.wfile.flush()
if wire.stall:
try:
for _ in range(400):
time.sleep(0.05)
self.wfile.write(b": keepalive\n\n")
self.wfile.flush()
except Exception:
pass
return
second = {
"id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {"content": " world"}, "finish_reason": None}],
}
fin = {
"id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
}
for c in (second, fin):
self.wfile.write(f"data: {json.dumps(c)}\n\n".encode())
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.base_url = f"http://127.0.0.1:{self.server.server_address[1]}/v1"
def close(self):
self.server.shutdown()
self.server.server_close()
@pytest.fixture
def wire():
w = _Wire()
yield w
w.close()
@pytest.fixture
def stalling_wire():
w = _Wire(stall_after_first_chunk=True)
yield w
w.close()
def _make_agent(base_url: str, *, platform: str):
return run_agent.AIAgent(
api_key="test-key",
base_url=base_url,
model="m",
provider="custom",
platform=platform,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
enabled_toolsets=[],
max_iterations=1,
)
_KW = {"model": "m", "messages": [{"role": "user", "content": "hi"}]}
@pytest.mark.parametrize("platform", ["subagent", "cron"])
def test_direct_contexts_stream_on_the_wire_and_on_the_calling_thread(wire, platform):
agent = _make_agent(wire.base_url, platform=platform)
assert should_use_direct_api_call(agent) is True
issued_on = {}
real_create = agent._create_request_openai_client
def spy(*a, **k):
issued_on["tid"] = threading.get_ident()
return real_create(*a, **k)
agent._create_request_openai_client = spy
response = interruptible_streaming_api_call(agent, dict(_KW))
completions = [r for r in wire.requests if "messages" in r]
assert completions, "no chat completion reached the wire"
assert completions[-1].get("stream") is True, (
f"{platform} turn went out non-streaming: stream={completions[-1].get('stream')!r}"
)
# No interrupt worker: the request was dispatched from the caller's thread
# (the #62151 / #60203 deadlock class needs the request on a spawned worker).
assert issued_on["tid"] == threading.get_ident()
assert response.choices[0].message.content == "hello world"
assert response.choices[0].finish_reason == "stop"
def test_interactive_platform_still_uses_the_worker_thread(wire):
"""Regression guard for the refactor: non-direct contexts keep the
interrupt worker (interactive /stop responsiveness relies on it)."""
agent = _make_agent(wire.base_url, platform="cli")
assert should_use_direct_api_call(agent) is False
issued_on = {}
real_create = agent._create_request_openai_client
def spy(*a, **k):
issued_on["tid"] = threading.get_ident()
return real_create(*a, **k)
agent._create_request_openai_client = spy
response = interruptible_streaming_api_call(agent, dict(_KW))
assert issued_on["tid"] != threading.get_ident()
assert response.choices[0].message.content == "hello world"
def test_inline_stream_stale_detector_still_fires_from_monitor_thread(
stalling_wire, monkeypatch
):
"""The stale-stream detector moved onto a monitor thread for inline
mode; a stream that sends one chunk then only keep-alives must still be
killed at the stale budget instead of hanging until the socket dies."""
monkeypatch.setenv("HERMES_STREAM_STALE_TIMEOUT", "1.0")
monkeypatch.setenv("HERMES_STREAM_RETRIES", "0")
agent = _make_agent(stalling_wire.base_url, platform="subagent")
started = time.time()
# The one delta reached no consumer (inline contexts have none), so the killed
# stream is undelivered: with the retry budget spent the error surfaces to the
# conversation loop instead of an empty partial-stream stub (same contract as the
# worker path, #112419).
with pytest.raises(httpx.RemoteProtocolError):
interruptible_streaming_api_call(agent, dict(_KW))
elapsed = time.time() - started
assert elapsed < 6.0, f"inline stream was not bounded by the stale detector ({elapsed:.1f}s)"
def test_inline_stream_cross_thread_interrupt_aborts_promptly(stalling_wire, monkeypatch):
"""``AIAgent.interrupt()`` from another thread (cron watchdog, delegation
stall monitor) must abort the inline stream and surface InterruptedError
— the property the direct_api_call path guaranteed via
``_active_request_abort``."""
monkeypatch.setenv("HERMES_STREAM_STALE_TIMEOUT", "60")
monkeypatch.setenv("HERMES_STREAM_RETRIES", "0")
agent = _make_agent(stalling_wire.base_url, platform="cron")
box: dict = {}
def _run():
t0 = time.time()
try:
interruptible_streaming_api_call(agent, dict(_KW))
box["outcome"] = "returned"
except BaseException as exc: # noqa: BLE001 — record whatever surfaces
box["outcome"] = type(exc).__name__
box["elapsed"] = time.time() - t0
worker = threading.Thread(target=_run, daemon=True)
worker.start()
assert stalling_wire.hits.acquire(timeout=5.0), "request never reached the wire"
time.sleep(0.3) # let the first chunk land
agent.interrupt("test interrupt")
worker.join(timeout=10.0)
assert not worker.is_alive(), "inline stream did not unwind after interrupt"
assert box["outcome"] == "InterruptedError"
assert box["elapsed"] < 5.0
def test_should_use_direct_api_call_gate_is_unchanged():
"""The routing predicate itself is untouched — only what it routes to."""
def mk(platform, api_mode="chat_completions", provider="openrouter"):
return SimpleNamespace(platform=platform, api_mode=api_mode, provider=provider)
assert should_use_direct_api_call(mk("cron")) is True
assert should_use_direct_api_call(mk("subagent")) is True
assert should_use_direct_api_call(mk("cli")) is False
assert should_use_direct_api_call(mk("cron", api_mode="anthropic_messages")) is False
assert should_use_direct_api_call(mk("cron", provider="moa")) is False