Files
hermes-webui/tests/test_stream_offline_gap_recovery.py
T
dcb515611d Release — SSE Last-Event-ID resumption fix (#6886) (#7129)
* fix(sse): scope gateway SSE probe and honor Last-Event-ID on /api/chat/stream

Two narrowly-scoped cross-client SSE fixes from the hermes-android#58
follow-up:

1. The /api/sessions/gateway/stream?probe=1 response only describes the
   optional gateway/agent-sessions stream, but its payload gave clients no
   way to tell that. A 404 'agent sessions not enabled' result could be
   misread as SSE being unsupported server-wide, even though the persistent
   per-session stream (/api/session/stream) is always on and independent of
   the show_cli_sessions setting. Add additive scope markers to the probe
   payload (scope, session_stream_available, session_stream_path) and
   clarify the handler docstring.

2. /api/chat/stream emitted id: stream_id:seq on every journaled event
   but ignored the Last-Event-ID request header, so any spec-compliant SSE
   client that auto-reconnects (browser EventSource, Android/CLI clients)
   replayed the journal from seq 0 and double-rendered the transcript.
   _chat_stream_resume_after_seq() now resolves the resume cursor with
   explicit after_event_id/after_seq query params first and the
   Last-Event-ID header as fallback — the same resolution chain
   api/kanban_bridge.py already uses. Both the dead-stream journal replay
   and the live-subscribe gap check consume it; direct callers of the gap
   check that only pass query params keep historical behavior.

Also documents the cross-client SSE endpoint contract in
docs/sse-streams.md.

Related hermes-webui/hermes-android#58

* fix(sse): split cursor presence from validity on /api/chat/stream resume

Address PR #6886 review (gate-fail): the resume logic conflated "no
cursor" with "invalid cursor" into a single None, which broke replay on
three edges. Root cause and fix:

Cursor presence vs validity — _chat_stream_resume_cursor() now returns
(after_seq, resume_requested) instead of a bare seq. A client that
SUPPLIED any cursor (query params, replay=1, or Last-Event-ID) asked to
resume; an invalid / foreign-run / unparseable cursor resolves to
after_seq=None but keeps resume_requested=True, and is normalized to
replay-from-start so no journal events are silently skipped. A genuinely
cursor-less request stays a fresh subscribe.

1. Explicit-query precedence is now decided by parameter PRESENCE, not
   successful parsing: a malformed/foreign after_event_id/after_seq never
   falls through to let the Last-Event-ID header override it and skip
   events.
2. Invalid/foreign cursors no longer bypass replay + gap detection and
   silently drain a truncated buffer; an ahead-of-stream cursor is no
   longer clamped onto the snapshot's terminal fence (which emptied the
   dead-stream replay by filtering the terminal frame).
3. The runner-local observe path now receives the resolved header cursor
   (retaining explicit-query precedence) so header-only runner reconnects
   resume instead of duplicating events from the start.

Regression tests cover invalid-cursor replay-from-start, ahead-of-stream
terminal survival, header-only runner reconnect, and explicit-query
precedence; the stale clamp test is updated to the corrected contract.

Related hermes-webui/hermes-android#58

* fix(sse): consume resolved resume cursor at every consumer on /api/chat/stream

Address PR #6886 r2 review (gate-fail). The r1 presence/validity split
fixed the entry resolution, but the individual consumers still re-derived
ahead/equality/re-parse decisions, so the invariant leaked at the edges.
This makes every consumer honor the resolved (after_seq, resume_requested)
pair and normalizes ahead-of-stream in one place.

1. Equality off-by-one: a valid cursor EQUAL to the snapshot cutoff now
   enters the drain dedup bound (the event at the cursor was already
   delivered), instead of being treated as "ahead" and double-sending the
   retained buffer. Ahead is now strictly after_seq > snapshot_cutoff_seq.
2. Numeric ahead-of-stream cursors are normalized to replay-from-start:
   on the active path, after_seq > snapshot_cutoff_seq resets to 0 before
   gap-coverage/replay so a truncated buffer can no longer falsely declare
   coverage and lose the pre-tail events; on the dead-stream path the
   cursor is normalized against the journal summary's authoritative
   last_seq before _replay_run_journal, so an ahead cursor returns the
   full replay instead of an empty SSE body.
3. Runner-local now consumes the resolved cursor instead of re-parsing
   after_event_id or re-reading Last-Event-ID. The resolver also returns
   the opaque raw cursor so the runner uses the already-precedence-checked
   value; explicit opaque cursor= keeps precedence, invalid/foreign
   cursors pass no cursor (full replay), and the header is honored only
   when no explicit cursor was supplied.

Regression tests cover the equality dedup, active+dead ahead-of-stream
normalization (with after_seq-filtering mocks that assert the real replay
body), and the three runner precedence probes (malformed-explicit blocks
header, foreign passes no cursor, opaque cursor= wins).

Related hermes-webui/hermes-android#58

* fix(sse): carry runner-cursor provenance through /api/chat/stream resume

Address PR #6886 r3 review (gate-fail). The (after_seq, resume_requested,
raw_cursor) tuple carried the cursor VALUE but not its PROVENANCE, so the
runner handoff couldn't distinguish a valid opaque runner cursor from a
journal-shaped one from a malformed one — and gated the runner handoff on
journal-specific validity, which is wrong for the runner adapter (its
cursors are opaque).

The resolver now returns a distinct, centrally-resolved runner cursor:

- a valid after_seq PAIRS with whatever after_event_id shape was supplied
  — including an opaque runner id like event:2 that the journal parser
  reads as a foreign run — so the paired runner cursor resumes at the seq
  (never None / full replay, which would duplicate tokens and tool
  events). after_seq is read directly (via _parse_run_journal_after_seq_value)
  rather than through the after_event_id-first journal parser, which would
  swallow a paired opaque runner id.
- a header-only opaque runner id (Last-Event-ID: event:2) is preserved
  as-is so the runner resumes from it.
- a malformed or foreign explicit cursor WITHOUT a valid paired after_seq
  still yields no runner cursor, blocking Last-Event-ID and replaying from
  start — the r2 malformed-blocks-header guarantee is unchanged.

Regression tests pin the paired opaque id, header-only opaque id, and
malformed-with-valid-seq runner resumes, plus the unchanged
malformed-without-pair replay-from-start behavior.

Related hermes-webui/hermes-android#58

* fix(sse): normalize ahead cursor when the snapshot cutoff is unknown

Address PR #6886 r4 review (gate-fail). The ahead-cursor normalization
was guarded by `snapshot_cutoff_seq is not None`, so when the subscribe
snapshot had no parseable last_event_id (channel has not seen an
id-bearing frame yet), a positive ahead cursor (e.g. Last-Event-ID
run_1:999) was never normalized — it was installed verbatim as the live
dedup bound, filtering EVERY queued frame including the terminal
stream_end fence. The reconnect stalled on heartbeats with an empty SSE
body. origin/master ignored the header on this path and delivered the
events, so this was a new regression.

Fix: treat an unknown, non-truncated snapshot cutoff as fence 0 before
the ahead-cursor normalization, so a positive client cursor can't become
the live dedup bound with no cutoff to bound it — it fails closed to
replay-from-start, delivering the buffered events rather than silently
losing them. Frames dropped while the cutoff is unknown still cannot
prove gap coverage (cutoff None → not covered), so the recovery_control
fail-closed path is preserved.

Regression test: Last-Event-ID run_1:999 + snapshot_cutoff_seq=None
delivers queued events run_1:1 and run_1:2 exactly once, emits the
terminal stream_end, and releases the subscriber. Without the fix the
test stalls on heartbeats (pytest-timeout kills it), proving the stall.

Related hermes-webui/hermes-android#58

* Release — SSE Last-Event-ID resumption fix (#6886)

---------

Co-authored-by: Paladin173 <35980893+Paladin173@users.noreply.github.com>
Co-authored-by: n <a@n>
2026-08-18 15:08:14 +00:00

412 lines
15 KiB
Python

"""
Regression tests for #4633/#5788 — the SSE handler must ENFORCE run-journal
coverage before draining a truncated offline tail.
``StreamChannel.subscribe_with_snapshot()`` reports ``offline_dropped_events``
when the capped offline buffer evicted frames during a disconnect gap. The
retained tail is only safe to drain when the run journal provably backfills
everything from the client's replay cursor through the snapshot cutoff —
otherwise the browser would render later frames plus a normal ``stream_end``
with a silent transcript hole in the middle (the journal being missing,
incomplete, or unreadable leaves the evicted frames unrecoverable).
Enforcement contract pinned here:
* dropped > 0 and the journal does NOT cover the gap → emit the established
``apperror`` + ``recovery_control: true`` event (same client contract as
``run_journal.stale_interrupted_event`` — the tab restores the transcript
from persisted state) and do NOT stream the tail.
* dropped > 0 and the journal DOES cover the gap → replay + tail as before.
* a client cursor already at/past the snapshot cutoff has no gap to cover —
no recovery even when the journal is missing.
* the retained tail itself covers [first buffered frame → cutoff]: the journal
only has to bridge (cursor → first buffered frame), a cursor already inside
the retained range needs no journal at all, and the replay/dedup cutoff stops
at the bridge so queued frames the journal never emitted are not skipped.
"""
import io
import json
import queue
from urllib.parse import urlparse
import api.routes as routes
class _Handler:
def __init__(self):
self.wfile = io.BytesIO()
def send_response(self, _code):
pass
def send_header(self, _name, _value):
pass
def end_headers(self):
pass
class _FakeStream:
def __init__(self, snapshot, items):
self.q = queue.Queue()
for item in items:
self.q.put_nowait(item)
self._snapshot = dict(snapshot)
self.unsubscribed = False
def subscribe_with_snapshot(self):
return self.q, dict(self._snapshot)
def unsubscribe(self, q):
self.unsubscribed = q is self.q
def _run_handler(monkeypatch, stream, query):
handler = _Handler()
previous_streams = dict(routes.STREAMS)
routes.STREAMS.clear()
routes.STREAMS["run_1"] = stream
try:
routes._handle_sse_stream(handler, urlparse(f"/api/chat/stream?{query}"))
finally:
routes.STREAMS.clear()
routes.STREAMS.update(previous_streams)
return handler.wfile.getvalue().decode("utf-8")
def _sse_payloads(body, event):
"""Extract parsed data payloads for all SSE frames of the given event."""
payloads = []
lines = body.splitlines()
for i, line in enumerate(lines):
if line == f"event: {event}" and i + 1 < len(lines):
data = lines[i + 1]
assert data.startswith("data: ")
payloads.append(json.loads(data[len("data: "):]))
return payloads
def _journal_events(lo, hi, run_id="run_1"):
return [
{
"event": "token",
"payload": {"text": f"journal-{seq}"},
"event_id": f"{run_id}:{seq}",
"seq": seq,
}
for seq in range(lo, hi + 1)
]
def test_dropped_frames_without_journal_emit_recovery_not_tail(monkeypatch):
"""Missing journal + evicted frames → recovery_control apperror, no tail."""
stream = _FakeStream(
{
"last_event_id": "run_1:9000",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:8999",
},
[
("token", {"text": "tail-frame"}, "run_1:8999"),
("stream_end", {}, "run_1:9000"),
],
)
monkeypatch.setattr(routes, "find_run_summary", lambda _sid: None)
monkeypatch.setattr(routes, "stream_owner_session_id", lambda _sid: "session_1")
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=100")
payloads = _sse_payloads(body, "apperror")
assert len(payloads) == 1
assert payloads[0]["recovery_control"] is True
assert payloads[0]["type"] == "interrupted"
assert payloads[0]["session_id"] == "session_1"
assert payloads[0]["stream_id"] == "run_1"
assert payloads[0]["offline_dropped_events"] == 25
# The truncated tail must NOT be drained: no silent hole + stream_end.
assert "tail-frame" not in body
assert "event: stream_end" not in body
# Every exit path returns the queue to the channel.
assert stream.unsubscribed is True
def test_dropped_frames_with_full_journal_coverage_stream_normally(monkeypatch):
"""Journal covers (cursor → cutoff] → replay + tail drain, no recovery."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:106",
},
[
("token", {"text": "tail-frame"}, "run_1:106"),
("stream_end", {}, "run_1:107"),
],
)
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
monkeypatch.setattr(
routes,
"read_run_events",
lambda _session_id, run_id, after_seq=None, max_seq=None: {
"events": _journal_events(101, 105, run_id=run_id)
},
)
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=100")
assert _sse_payloads(body, "apperror") == []
assert "journal-101" in body and "journal-105" in body
assert "tail-frame" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_incomplete_journal_coverage_emits_recovery(monkeypatch):
"""A journal that stops short of the snapshot cutoff is NOT coverage."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:106",
},
[
("token", {"text": "tail-frame"}, "run_1:106"),
("stream_end", {}, "run_1:107"),
],
)
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
monkeypatch.setattr(
routes,
"read_run_events",
# Journal lost its newest entries: stops at 103 < cutoff 105.
lambda _session_id, run_id, after_seq=None, max_seq=None: {
"events": _journal_events(101, 103, run_id=run_id)
},
)
monkeypatch.setattr(routes, "stream_owner_session_id", lambda _sid: "session_1")
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=100")
payloads = _sse_payloads(body, "apperror")
assert len(payloads) == 1
assert payloads[0]["recovery_control"] is True
assert "tail-frame" not in body
assert stream.unsubscribed is True
def test_cursor_at_cutoff_has_no_gap_and_streams_tail(monkeypatch):
"""A client already at/past the snapshot cutoff missed nothing the buffer
ever held — no recovery even when the journal is missing entirely."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:106",
},
[
("token", {"text": "tail-frame"}, "run_1:106"),
("stream_end", {}, "run_1:107"),
],
)
monkeypatch.setattr(routes, "find_run_summary", lambda _sid: None)
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=105")
assert _sse_payloads(body, "apperror") == []
assert "tail-frame" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_coverage_helper_rejects_mid_window_holes(monkeypatch):
"""Endpoint checks are not enough: a malformed/dropped line INSIDE the
window (journal seqs are contiguous by construction) must fail coverage."""
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
events = [e for e in _journal_events(101, 105) if e["seq"] != 103]
monkeypatch.setattr(
routes,
"read_run_events",
lambda _session_id, _run_id, after_seq=None, max_seq=None: {"events": events},
)
assert routes._run_journal_covers_offline_gap("run_1", 100, 105) is False
def test_coverage_helper_requires_known_cutoff():
"""Without a same-run journaled cutoff nothing can be proven → not covered."""
assert routes._run_journal_covers_offline_gap("run_1", 100, None) is False
def test_cursor_inside_retained_tail_needs_no_journal(monkeypatch):
"""A reconnect whose cursor already sits inside the retained buffer range
is contiguous from the queue alone — no journal required, no recovery,
even though frames were evicted before the retained head (they are all
≤ the client's cursor)."""
stream = _FakeStream(
{
"last_event_id": "run_1:9000",
"offline_buffered_events": 2,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:8999",
},
[
("token", {"text": "tail-frame"}, "run_1:8999"),
("stream_end", {}, "run_1:9000"),
],
)
monkeypatch.setattr(routes, "find_run_summary", lambda _sid: None)
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=8998")
assert _sse_payloads(body, "apperror") == []
assert "tail-frame" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_replay_cutoff_stops_at_buffer_head_so_dedup_keeps_queued_frames(monkeypatch):
"""The journal only bridges (cursor → first buffered frame); the dedup
cutoff must stop there too. With the cutoff at the snapshot's last_event_id
the drain loop's `seq <= replay_cutoff_seq` filter would silently skip the
queued frames the journal never emitted — a hole certified as covered."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 3,
"offline_dropped_events": 25,
"offline_first_event_id": "run_1:103",
},
[
("token", {"text": "buffered-103"}, "run_1:103"),
("token", {"text": "buffered-104"}, "run_1:104"),
("token", {"text": "buffered-105"}, "run_1:105"),
("stream_end", {}, "run_1:106"),
],
)
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
monkeypatch.setattr(
routes,
"read_run_events",
# The journal only holds the bridge (100 → 102]; 103.. live in the buffer.
lambda _session_id, run_id, after_seq=None, max_seq=None: {
"events": _journal_events(101, 102, run_id=run_id)
},
)
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=100")
assert _sse_payloads(body, "apperror") == []
assert "journal-101" in body and "journal-102" in body
# The queued frames past the bridge must all survive the dedup filter.
assert "buffered-103" in body
assert "buffered-104" in body
assert "buffered-105" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_cursor_past_buffer_head_does_not_double_render_queued_frames(monkeypatch):
"""A cursor AT/INSIDE the retained tail (after_seq >= first buffered frame)
must not receive the queued copy of frames it already rendered.
The journal-emission bound (buffer head) and the client-cursor bound are
two DIFFERENT dedup cutoffs: collapsing them onto the buffer head would
re-stream [head, cursor] from the queue — a visible double-render, since
the drain loop's `seq <=` filter is the only dedup for replayed streams
(the client does not de-duplicate token frames itself)."""
stream = _FakeStream(
{
"last_event_id": "run_1:105",
"offline_buffered_events": 3,
"offline_dropped_events": 0,
"offline_first_event_id": "run_1:103",
},
[
("token", {"text": "buffered-103"}, "run_1:103"),
("token", {"text": "buffered-104"}, "run_1:104"),
("token", {"text": "buffered-105"}, "run_1:105"),
("stream_end", {}, "run_1:106"),
],
)
monkeypatch.setattr(
routes,
"find_run_summary",
lambda sid: {"session_id": "session_1", "run_id": sid, "terminal": False},
)
all_events = _journal_events(101, 105)
def _filtering_read(_session_id, _run_id, after_seq=None, max_seq=None):
# Mirror the real read_run_events window filters.
events = [
e
for e in all_events
if (after_seq is None or e["seq"] > after_seq)
and (max_seq is None or e["seq"] <= max_seq)
]
return {"events": events}
monkeypatch.setattr(routes, "read_run_events", _filtering_read)
# Client already rendered up to 104 (e.g. tab flap after a partial drain).
body = _run_handler(monkeypatch, stream, "stream_id=run_1&replay=1&after_seq=104")
assert _sse_payloads(body, "apperror") == []
assert "buffered-103" not in body
assert "buffered-104" not in body
assert "buffered-105" in body
assert "event: stream_end" in body
assert stream.unsubscribed is True
def test_ahead_of_stream_cursor_does_not_filter_terminal_frame(monkeypatch):
"""An ahead-of-stream cursor (after_seq past the snapshot cutoff) is NOT
clamped onto the snapshot's terminal fence.
The old clamp (``min(cursor, snapshot_cutoff)``) set the dedup bound to the
terminal frame's own seq, so the drain loop's ``seq <=`` filter — which runs
BEFORE its terminal break — swallowed the queued terminal frame and pinned
the loop on heartbeats until the write deadline (Codex CORE #2 terminal
filter). Ahead-of-stream means the client already believes it holds
everything the buffer has; nothing must be filtered and the terminal frame
must survive."""
from urllib.parse import parse_qs
monkeypatch.setattr(routes, "find_run_summary", lambda _sid: None)
handler = _Handler()
handled, cutoff = routes._sse_replay_run_journal_gap_checked(
handler,
parse_qs("stream_id=run_1&replay=1&after_seq=999"),
"run_1",
{
"last_event_id": "run_1:105",
"offline_buffered_events": 3,
"offline_dropped_events": 0,
"offline_first_event_id": "run_1:103",
},
)
assert handled is False
# Not clamped to 105: the cutoff stays None (journal replay found nothing,
# and an ahead-of-stream cursor contributes no dedup bound), so the queued
# terminal frame (seq 106) passes the drain loop's filter and terminates
# the connection instead of being filtered onto the fence.
assert cutoff is None