From dd67f342d749c74f192e15f692b35f5dce14d8c3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 24 Jun 2026 19:01:19 -0600 Subject: [PATCH] fix(session): bound continuation fallback sidecar reads --- api/models.py | 14 ++++- tests/test_session_switch_performance.py | 80 ++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tests/test_session_switch_performance.py diff --git a/api/models.py b/api/models.py index ffee1d76..6c7274c9 100644 --- a/api/models.py +++ b/api/models.py @@ -540,6 +540,16 @@ def _find_top_level_json_key(text, key): return None +def _read_file_head(path: Path, max_prefix_bytes: int = 4096) -> str: + """Read at most ``max_prefix_bytes`` bytes from ``path`` and decode UTF-8.""" + if not isinstance(path, Path): + path = Path(path) + if max_prefix_bytes <= 0: + return '' + with path.open('rb') as fp: + return fp.read(max_prefix_bytes).decode('utf-8', errors='ignore') + + def _read_metadata_json_prefix(path, max_prefix_bytes=65536): """Read only the metadata portion before the top-level messages array.""" buf = '' @@ -2129,9 +2139,7 @@ def _has_compression_continuation(session) -> bool: if path.name.startswith('_') or path.stem == sid: continue try: - head = path.read_text(encoding='utf-8', errors='ignore')[:4096] - except TypeError: - head = path.read_text(encoding='utf-8')[:4096] + head = _read_file_head(path, max_prefix_bytes=4096) except OSError: continue if needle in head: diff --git a/tests/test_session_switch_performance.py b/tests/test_session_switch_performance.py new file mode 100644 index 00000000..95d69673 --- /dev/null +++ b/tests/test_session_switch_performance.py @@ -0,0 +1,80 @@ +import api.models as models + + +def test_compression_continuation_fallback_reads_only_file_head(monkeypatch, tmp_path): + session_dir = tmp_path / "sessions" + session_dir.mkdir() + index_file = tmp_path / "_index.json" + index_file.write_text("[]", encoding="utf-8") + + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file) + models.SESSIONS.clear() + + parent_sid = "parent" + candidate = session_dir / "child.json" + candidate.write_text("x" * 8192 + f'"parent_session_id": "{parent_sid}"', encoding="utf-8") + + opened_for_read = [] + read_text_calls = [] + + original_path_open = models.Path.open + original_path_read_text = models.Path.read_text + + class _TrackingHandle: + def __init__(self, path, inner): + self._path = str(path) + self._inner = inner + + def read(self, size=-1): + opened_for_read.append((self._path, size)) + return self._inner.read(size) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return self._inner.__exit__(exc_type, exc, tb) + + def __getattr__(self, name): + return getattr(self._inner, name) + + def tracking_open(path, *args, **kwargs): + return _TrackingHandle(path, original_path_open(path, *args, **kwargs)) + + def tracking_read_text(self, *args, **kwargs): + read_text_calls.append(str(self)) + return original_path_read_text(self, *args, **kwargs) + + monkeypatch.setattr(models.Path, "open", tracking_open) + monkeypatch.setattr(models.Path, "read_text", tracking_read_text) + + session = models.Session(session_id=parent_sid) + + assert models._has_compression_continuation(session) is False + + assert str(index_file) in read_text_calls + assert all(path != str(candidate) for path in read_text_calls) + + candidate_reads = [size for path, size in opened_for_read if path == str(candidate)] + assert candidate_reads, "fallback should read the candidate sidecar" + assert all(size != -1 and size <= 4096 for size in candidate_reads) + + +def test_compression_continuation_prefix_match_stays_true(monkeypatch, tmp_path): + session_dir = tmp_path / "sessions" + session_dir.mkdir() + index_file = tmp_path / "_index.json" + index_file.write_text("[]", encoding="utf-8") + + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file) + models.SESSIONS.clear() + + parent_sid = "parent" + candidate = session_dir / "child.json" + candidate.write_text('{"session_id":"child", "parent_session_id": "parent",', encoding="utf-8") + + session = models.Session(session_id=parent_sid) + + assert models._has_compression_continuation(session) is True