Files
hermes-webui/tests/test_issue1624_repair_stale_pending_grace.py
T
nesquena-hermes bea57beba9 fix(streaming): SSE heartbeat alignment, repair grace period, local-server model id preservation (#1623, #1624, #1625)
Closes #1623 — Lower SSE app heartbeat from 30s to 5s at every long-lived
handler (main agent, terminal, gateway-watcher, approval-poller, clarify-poller).
Kernel TCP keepalive declares peer dead at 25s worst-case (10s KEEPIDLE +
5s KEEPINTVL * 3 KEEPCNT, added v0.50.289 #1581). 30s app heartbeat let the
kernel tear sockets down on flaky networks before the app sent its first
keepalive byte — drops at ~10s during long thinking phases. New named
constant _SSE_HEARTBEAT_INTERVAL_SECONDS=5; regression test pins the
inequality (app_heartbeat * 2 <= kernel_window) so future tuning can't
re-introduce the misalignment.

Closes #1624 — Add 30s grace period to _repair_stale_pending() trigger.
Without it, any narrow race between the streaming thread clearing
pending_user_message and STREAMS.pop(stream_id) produces a false-positive
'Previous turn did not complete.' marker on a turn that finished correctly
(reproducible after every command-approval turn). Defense-in-depth, not
the root-cause fix — the actual streaming-thread leak path is tracked
separately. Falsy pending_started_at (legacy sidecars) treated as
'old enough' so legitimate legacy-data recovery still works. Plus
logger.warning telemetry on every legitimate repair so the next batch of
user reports tells us whether the underlying race still fires.

Closes #1625 — Local model servers (LM Studio, Ollama, llama.cpp, vLLM,
TabbyAPI, koboldcpp, textgen-webui) now keep the full HuggingFace-style
model id (e.g. 'qwen/qwen3.6-27b' instead of stripped 'qwen3.6-27b'). New
_LOCAL_SERVER_PROVIDERS set + _base_url_points_at_local_server() loopback/
RFC1918 heuristic — either signal triggers no-strip. Backward compat
preserved for OpenAI-compatible proxies on public hosts (LiteLLM at
litellm.example.com still strips openai/gpt-5.4 -> gpt-5.4). Updated the
existing #230/#433 test to reflect that #1625 supersedes the strip-on-custom
rule for loopback hosts (see api/config.py and test_model_resolver.py
docstring update). Reported by @akarichan8231 in Discord on 2026-05-04.

42 regression tests across:
  tests/test_issue1623_sse_heartbeat_alignment.py (3)
  tests/test_issue1624_repair_stale_pending_grace.py (9)
  tests/test_issue1625_local_server_model_id_preservation.py (30)

4142 -> 4184 passing. 0 regressions.
2026-05-04 16:49:43 +00:00

161 lines
6.8 KiB
Python

"""Tests for #1624: _repair_stale_pending must not fire on fresh turns.
Bug shape: _repair_stale_pending() fires whenever pending_user_message is set
and the active_stream_id is not in the live STREAMS registry. There's a
narrow race between the streaming thread clearing pending_user_message and
STREAMS.pop(stream_id), so any fast turn (e.g. command approval) that exits
the thread before the on-disk pending clear flushes gets misdiagnosed as a
crashed turn — producing a spurious "Previous turn did not complete." marker.
Fix: add a grace-period guard. A turn whose pending_started_at is younger
than _REPAIR_STALE_PENDING_GRACE_SECONDS is treated as "the streaming thread
may still be in its post-loop cleanup window" and the repair bails. Missing
or falsy pending_started_at (legacy sidecars that pre-date the field) is
treated as "old enough" to preserve current legacy-data recovery semantics.
"""
import time
import threading
from unittest.mock import patch
import pytest
# ── _repair_stale_pending grace guard ───────────────────────────────────
class _FakeSession:
"""Minimal stand-in for api.models.Session — only the fields _repair_stale_pending reads."""
def __init__(self, sid="abcdef123456", pending="hi", stream_id="stream_xyz",
pending_started_at=None, profile="default"):
self.session_id = sid
self.pending_user_message = pending
self.active_stream_id = stream_id
self.pending_started_at = pending_started_at
self.profile = profile
self.messages = []
def _setup_repair_environment(monkeypatch, tmp_path):
"""Stub out the costly side-channels in _repair_stale_pending so the
tests exercise the guard logic alone, not the full lock+sidecar pipeline."""
import api.models as models
# No live streams — the predicate's "stream not in registry" branch fires.
monkeypatch.setattr(models, "_active_stream_ids", lambda: set())
# Profile home -> tmp dir; sessions/<sid>.json doesn't need to exist
# because we'll stub _apply_core_sync_or_error_marker.
monkeypatch.setattr(models, "_get_profile_home", lambda profile: tmp_path)
(tmp_path / "sessions").mkdir(parents=True, exist_ok=True)
# Track whether the heavy-lift function was called so we can assert.
calls = {"applied": 0}
def fake_apply(session, core_path, **kw):
calls["applied"] += 1
return True
monkeypatch.setattr(models, "_apply_core_sync_or_error_marker", fake_apply)
return calls
def test_repair_skips_fresh_turn(tmp_path, monkeypatch):
"""A turn that started 5 seconds ago is too fresh — repair must bail."""
import api.models as models
calls = _setup_repair_environment(monkeypatch, tmp_path)
s = _FakeSession(pending_started_at=time.time() - 5.0)
result = models._repair_stale_pending(s)
assert result is False, "Repair must skip a 5s-old turn"
assert calls["applied"] == 0, "Heavy-lift _apply_core_sync_or_error_marker must not be called"
def test_repair_skips_almost_grace_window(tmp_path, monkeypatch):
"""A turn 1 second younger than the grace threshold must still bail."""
import api.models as models
calls = _setup_repair_environment(monkeypatch, tmp_path)
grace = models._REPAIR_STALE_PENDING_GRACE_SECONDS
s = _FakeSession(pending_started_at=time.time() - (grace - 1.0))
result = models._repair_stale_pending(s)
assert result is False, f"Repair must skip a turn {grace - 1}s old"
assert calls["applied"] == 0
def test_repair_fires_after_grace_window(tmp_path, monkeypatch):
"""A turn older than the grace window should trigger repair as before."""
import api.models as models
calls = _setup_repair_environment(monkeypatch, tmp_path)
grace = models._REPAIR_STALE_PENDING_GRACE_SECONDS
s = _FakeSession(pending_started_at=time.time() - (grace + 30.0))
result = models._repair_stale_pending(s)
assert result is True, f"Repair must fire on a turn older than {grace}s"
assert calls["applied"] == 1, "Heavy-lift _apply_core_sync_or_error_marker should be called"
def test_repair_fires_when_pending_started_at_missing(tmp_path, monkeypatch):
"""Legacy sidecars predate `pending_started_at`; missing/falsy must NOT
block repair — preserves current behavior for legacy data."""
import api.models as models
calls = _setup_repair_environment(monkeypatch, tmp_path)
s = _FakeSession(pending_started_at=None)
result = models._repair_stale_pending(s)
assert result is True, "Missing pending_started_at must not block legitimate repair"
assert calls["applied"] == 1
def test_repair_fires_when_pending_started_at_zero(tmp_path, monkeypatch):
"""Falsy 0 must also be treated as 'old enough' (defense against accidental zeroing)."""
import api.models as models
calls = _setup_repair_environment(monkeypatch, tmp_path)
s = _FakeSession(pending_started_at=0)
result = models._repair_stale_pending(s)
assert result is True, "pending_started_at=0 must not block legitimate repair"
def test_repair_fires_when_pending_started_at_garbage(tmp_path, monkeypatch):
"""Garbage values (string, dict, etc.) shouldn't crash and shouldn't block repair."""
import api.models as models
calls = _setup_repair_environment(monkeypatch, tmp_path)
s = _FakeSession(pending_started_at="not-a-number")
result = models._repair_stale_pending(s)
assert result is True, "Garbage pending_started_at should be treated as 'old enough'"
def test_repair_skips_when_no_pending_message(tmp_path, monkeypatch):
"""Without pending_user_message, repair must always bail (existing contract)."""
import api.models as models
calls = _setup_repair_environment(monkeypatch, tmp_path)
s = _FakeSession(pending="", pending_started_at=time.time() - 60)
result = models._repair_stale_pending(s)
assert result is False
assert calls["applied"] == 0
def test_repair_skips_when_stream_still_alive(tmp_path, monkeypatch):
"""If the stream is still in the registry, repair must bail even past grace."""
import api.models as models
monkeypatch.setattr(models, "_active_stream_ids", lambda: {"stream_xyz"})
monkeypatch.setattr(models, "_get_profile_home", lambda profile: tmp_path)
s = _FakeSession(pending_started_at=time.time() - 600)
result = models._repair_stale_pending(s)
assert result is False, "Stream-alive bail predates the grace guard"
def test_grace_constant_exists_and_is_sane():
"""The grace constant is exposed and sized in a sane range (10s..120s)."""
import api.models as models
grace = models._REPAIR_STALE_PENDING_GRACE_SECONDS
assert isinstance(grace, (int, float))
assert 10 <= grace <= 120, (
f"Grace window {grace}s should be 10s-120s — too small re-introduces "
"the false-positive race; too large delays legitimate recovery."
)