Files
hermes-webui/tests/test_issue3548_sessiondb_self_heal.py
T
6903891139 Release v0.51.257 — Release HY (stage-r5) (#3598)
## Release v0.51.257 — Release HY (stage-r5)

Two rebased ★★★ fixes.

### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3546 | @rodboev | **"Refresh Models" on a provider card no longer returns "Error: Not found".** Sent `POST /api/models/refresh` but no route matched (404). Added the route, wired to the existing `invalidate_provider_models_cache(provider_id)`. |
| #3548 | @franksong2702 | **Credential self-heal no longer writes to a dead `SessionDB` handle.** A credential-refresh evicted/closed the cached agent, but the retry rebuilt a new agent from kwargs still holding the old closed `SessionDB` → persistence silently targeted a dead handle. Per-request `SessionDB` construction centralized + refreshed on the retry. |

### Dropped from this batch
- **#2660** (session-event SSE profile scoping) — the Codex regression gate found **two SILENT dropped-refresh bugs** the scoping introduced: (1) the `maxsize=1` subscriber-queue coalescing overwrites a pending profile-A event with a profile-B event → A-tabs filter B out and never refresh for the A change; (2) renamed-root/`default` alias mismatch (backend `_profiles_match` treats them equal, the client filter uses a literal `!==`). A dropped refresh (stale sidebar) is worse than the extra refreshes the PR removes. Held with `changes-requested` + repros + the fail-safe fix (coalesce to unscoped on a profile mismatch; normalize root aliases).

### Gate
- Full pytest suite: **7622 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (#2660 dropped-refresh bugs) → #2660 dropped + held → **SAFE TO SHIP**

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-04 13:04:01 -07:00

86 lines
2.6 KiB
Python

"""Regression tests for issue #3548: closed SessionDB handles are not reused on self-heal retry."""
from __future__ import annotations
from pathlib import Path
import sys
import types
from unittest import mock
def test_session_db_helper_uses_request_state_db_path():
import api.streaming as streaming
calls = {}
class FakeSessionDB:
def __init__(self, db_path=None):
calls["db_path"] = db_path
def close(self):
calls["closed"] = True
fake_state = types.ModuleType("hermes_state")
fake_state.SessionDB = FakeSessionDB
with mock.patch.dict(sys.modules, {"hermes_state": fake_state}):
state_db_path = Path("/tmp/profile") / "state.db"
db = streaming._build_session_db_for_stream(state_db_path)
assert db is not None
assert calls["db_path"] == state_db_path
assert isinstance(db, FakeSessionDB)
def test_session_db_helper_returns_none_when_constructor_fails():
import api.streaming as streaming
def failing_session_db(db_path=None):
raise RuntimeError("SessionDB unavailable")
fake_state = types.ModuleType("hermes_state")
fake_state.SessionDB = mock.Mock(side_effect=failing_session_db)
with mock.patch.dict(sys.modules, {"hermes_state": fake_state}):
db = streaming._build_session_db_for_stream(Path("/tmp/profile/state.db"))
assert db is None
def test_self_heal_session_db_handle_is_replaced_safely():
import api.streaming as streaming
class FakeDb:
def __init__(self, label):
self.label = label
def close(self):
self.closed = True
old_db = FakeDb("old")
new_db = FakeDb("new")
with mock.patch.object(
streaming, "_build_session_db_for_stream", return_value=new_db
) as build_db:
kwargs = {"session_db": old_db}
assigned_db = streaming._replace_session_db_in_kwargs(kwargs, Path("/tmp/profile/state.db"))
assert assigned_db is new_db
assert kwargs["session_db"] is new_db
build_db.assert_called_once_with(Path("/tmp/profile/state.db"))
assert getattr(old_db, "closed", False) is True
assert hasattr(new_db, "closed") is False
def test_session_db_handle_not_double_closed_when_rebuilt_to_same_instance():
import api.streaming as streaming
db = mock.Mock(name="session_db")
with mock.patch.object(streaming, "_build_session_db_for_stream", return_value=db):
kwargs = {"session_db": db}
returned_db = streaming._replace_session_db_in_kwargs(kwargs, Path("/tmp/profile/state.db"))
assert returned_db is db
assert kwargs["session_db"] is db
db.close.assert_not_called()