Files
hermes-webui/tests/test_issue3875_recovery_anchor_dedup.py
T
nesquena-hermesandnesquena-hermes e8d71a2fb8 Release v0.51.347 — Release LK (streaming & render reliability cluster #3892 #3898 #3885 #3882 #3868) (#3902)
* stage v0.51.347: render/stream cluster (#3892 #3898 #3885 #3882 #3868) + 2 Opus SHOULD-FIX

* stage v0.51.347: trim #3885 error-guard comment to fit diagnostic-test window

* Stamp v0.51.347 — Release LK (streaming & render reliability cluster)

* Remove stray uv.lock accidentally staged (not part of any cluster PR)

---------

Co-authored-by: nesquena-hermes <[email protected]>
2026-06-09 17:02:35 -07:00

131 lines
5.8 KiB
Python

"""Regression tests for #3875 — blank transcript from accumulated empty recovery anchors.
A session that was interrupted-and-recovered many times accumulated thousands of
empty-content assistant rows tagged ``_recovered_from_run_journal``. Each was an
"anchor" created by ``_append_journaled_partial_output`` / ``ensure_assistant_anchor``
to host recovered tool cards for a tool-first stream (one that emitted tools before
any visible text). Because a tool-first stream has no text to dedup on, the read-side
lazy-retry path re-created a fresh empty anchor on every retry, and every distinct
interrupted stream added its own — so the session filled with empty rows. Combined
with the render path (which dropped empty-content reasoning-only rows), the transcript
painted blank (only date separators).
This file covers the DATA side: the anchor-dedup guard so recovery reuses a single
empty anchor per stream instead of appending an unbounded run of them. The render-side
fix (surface the message's `reasoning` field so an empty-content turn never paints
blank) is covered by tests/test_issue3875_blank_transcript_failsafe.py.
"""
from __future__ import annotations
import pytest
import api.profiles as profiles
from api.models import Session, _append_journaled_partial_output
from api.run_journal import append_run_event
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
# Mirror tests/test_session_lost_response_regression.py — isolate HERMES_HOME
# so Session.save() + run-journal writes land in a throwaway sandbox.
home = tmp_path / "hermes_home"
home.mkdir()
(home / "sessions").mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", home)
return home
def _tool_first_journal(session_id: str, stream_id: str) -> None:
"""Write a run journal for a stream that emitted a tool BEFORE any text —
the shape that forces an empty assistant anchor on recovery."""
append_run_event(session_id, stream_id, "tool", {"name": "search_files", "preview": "q=x"})
append_run_event(session_id, stream_id, "tool_complete", {"name": "search_files", "preview": "done"})
def test_tool_first_recovery_creates_single_empty_anchor(hermes_home):
sid = "issue3875_anchor"
stream_id = "stream-A"
_tool_first_journal(sid, stream_id)
s = Session(session_id=sid, title="repro", messages=[{"role": "user", "content": "go"}])
# First recovery: one empty anchor is created to host the recovered tool card.
assert _append_journaled_partial_output(s, stream_id, dedupe_existing=True) is True
anchors = [
m for m in s.messages
if isinstance(m, dict)
and m.get("_recovered_from_run_journal")
and m.get("role") == "assistant"
and not str(m.get("content") or "").strip()
]
assert len(anchors) == 1, "first recovery should create exactly one empty anchor"
def test_repeated_recovery_does_not_accumulate_empty_anchors(hermes_home):
"""The #3875 root cause: re-running recovery for the SAME stream must reuse the
existing empty anchor, not pile up a fresh one each time."""
sid = "issue3875_repeat"
stream_id = "stream-B"
_tool_first_journal(sid, stream_id)
s = Session(session_id=sid, title="repro", messages=[{"role": "user", "content": "go"}])
for _ in range(20):
_append_journaled_partial_output(s, stream_id, dedupe_existing=True)
anchors = [
m for m in s.messages
if isinstance(m, dict)
and m.get("_recovered_from_run_journal")
and m.get("role") == "assistant"
and not str(m.get("content") or "").strip()
]
assert len(anchors) == 1, (
f"repeated recovery for one stream must reuse a single empty anchor, "
f"got {len(anchors)} (the unbounded-accumulation bug)"
)
def test_distinct_streams_get_distinct_anchors(hermes_home):
"""Dedup is scoped per stream — two genuinely different interrupted streams
each keep their own anchor (we are not collapsing unrelated turns)."""
sid = "issue3875_multi"
s = Session(session_id=sid, title="repro", messages=[{"role": "user", "content": "go"}])
for stream_id in ("stream-X", "stream-Y", "stream-Z"):
_tool_first_journal(sid, stream_id)
# run each twice to prove per-stream reuse on top of per-stream distinctness
_append_journaled_partial_output(s, stream_id, dedupe_existing=True)
_append_journaled_partial_output(s, stream_id, dedupe_existing=True)
anchors = [
m for m in s.messages
if isinstance(m, dict)
and m.get("_recovered_from_run_journal")
and m.get("role") == "assistant"
and not str(m.get("content") or "").strip()
]
stream_ids = {m.get("_recovered_stream_id") for m in anchors}
assert len(anchors) == 3, f"expected one anchor per distinct stream, got {len(anchors)}"
assert stream_ids == {"stream-X", "stream-Y", "stream-Z"}
def test_text_bearing_recovery_still_appends_real_content(hermes_home):
"""The dedup guard must not suppress recovery of genuine visible text — a
stream that emitted tokens still produces a content-bearing recovered row."""
sid = "issue3875_text"
stream_id = "stream-T"
append_run_event(sid, stream_id, "token", {"text": "Hello "})
append_run_event(sid, stream_id, "token", {"text": "world"})
append_run_event(sid, stream_id, "done", {})
s = Session(session_id=sid, title="repro", messages=[{"role": "user", "content": "go"}])
assert _append_journaled_partial_output(s, stream_id, dedupe_existing=True) is True
recovered_text = [
m for m in s.messages
if isinstance(m, dict)
and m.get("_recovered_from_run_journal")
and str(m.get("content") or "").strip()
]
assert any("Hello world" in m["content"] for m in recovered_text), (
"token-bearing recovery must still append the visible assistant text"
)