Files
hermes-webui/tests/test_issue4718_claude_code_parse_cache.py
T
nesquena-hermes d61ca37bd1 fix(#4718): harden parse-cache key with ctime_ns + preserve epoch-0 mtime fallback
Dual-gate (Codex + Opus) findings on the Claude Code transcript parse cache:

1. Cache-key staleness hardening: add st_ctime_ns to the (path, mtime_ns, size,
   max_messages) key so a rare same-size, same-mtime in-place edit can't serve a
   stale parse. Size is already the strong discriminator for append-only JSONL;
   ctime_ns is cheap defense-in-depth (a spurious bump costs one harmless re-parse,
   never a stale read).

2. Timestamp-fallback bug-for-bug fix: the row-builder guard used identity
   (first_ts is None and last_ts is None) but the field assignment uses truthiness
   (first_ts or last_ts or _mtime). They disagreed on a falsy 0.0 timestamp
   (epoch-0/1970 transcripts), leaving created_at/updated_at/last_message_at = None
   instead of the file mtime the pre-cache inline code fell back to. Guard now uses
   the same 'not first_ts and not last_ts' test.

Adds 3 regression tests: epoch-0 mtime fallback, the shared-dict read-only
contract (pins the load-bearing invariant), and same-size+mtime ctime invalidation.
2026-06-25 02:46:00 +00:00

245 lines
9.1 KiB
Python

"""Regression tests for the Claude Code transcript parse cache (#4718/#4662).
The sidebar/profile-switch cold path was dominated by re-parsing every Claude
Code JSONL transcript on each /api/sessions build. ``_parse_claude_code_jsonl``
is now memoized by the file's (path, mtime_ns, size) so a warm build re-stats
instead of re-parsing, while any genuine edit transparently invalidates just
the changed file. These tests pin that behavior.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
def _write_jsonl(path: Path, rows: list) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(json.dumps(row) for row in rows) + "\n", encoding="utf-8")
def _rows(text: str = "hello") -> list:
return [
{"summary": "Cache QA"},
{"timestamp": "2026-04-18T12:00:01Z", "message": {"role": "user", "content": text}},
{"timestamp": "2026-04-18T12:00:02Z", "message": {"role": "assistant", "content": "ok"}},
]
def test_parse_cache_hit_skips_reparse(tmp_path, monkeypatch):
import api.models as models
models.clear_claude_code_parse_cache()
fixture = tmp_path / "claude" / "projects" / "p" / "s.jsonl"
_write_jsonl(fixture, _rows())
calls = {"n": 0}
real = models._parse_claude_code_jsonl
def _counting(path, **kw):
calls["n"] += 1
return real(path, **kw)
monkeypatch.setattr(models, "_parse_claude_code_jsonl", _counting)
first = models._parse_claude_code_jsonl_cached(fixture)
second = models._parse_claude_code_jsonl_cached(fixture)
# Second call is served from cache: underlying parser ran exactly once.
assert calls["n"] == 1
assert first == second
assert first[0][0]["content"] == "hello"
def test_parse_cache_invalidates_on_content_change(tmp_path, monkeypatch):
import api.models as models
models.clear_claude_code_parse_cache()
fixture = tmp_path / "claude" / "projects" / "p" / "s.jsonl"
_write_jsonl(fixture, _rows("first"))
calls = {"n": 0}
real = models._parse_claude_code_jsonl
def _counting(path, **kw):
calls["n"] += 1
return real(path, **kw)
monkeypatch.setattr(models, "_parse_claude_code_jsonl", _counting)
first = models._parse_claude_code_jsonl_cached(fixture)
assert first[0][0]["content"] == "first"
# Rewrite with different content + a guaranteed-distinct mtime/size so the
# stat signature changes and the cache must miss.
time.sleep(0.01)
_write_jsonl(fixture, _rows("second-edition-longer"))
import os
st = fixture.stat()
os.utime(fixture, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000))
second = models._parse_claude_code_jsonl_cached(fixture)
assert calls["n"] == 2 # re-parsed after the edit
assert second[0][0]["content"] == "second-edition-longer"
def test_parse_cache_returns_independent_message_lists(tmp_path):
"""A caller mutating the returned list must not corrupt the cached entry."""
import api.models as models
models.clear_claude_code_parse_cache()
fixture = tmp_path / "claude" / "projects" / "p" / "s.jsonl"
_write_jsonl(fixture, _rows())
first_msgs, *_ = models._parse_claude_code_jsonl_cached(fixture)
first_msgs.append({"role": "user", "content": "injected"})
second_msgs, *_ = models._parse_claude_code_jsonl_cached(fixture)
assert not any(m.get("content") == "injected" for m in second_msgs)
def test_parse_cache_is_bounded(tmp_path, monkeypatch):
import api.models as models
models.clear_claude_code_parse_cache()
monkeypatch.setattr(models, "_CLAUDE_CODE_PARSE_CACHE_MAX", 5)
for i in range(12):
f = tmp_path / "claude" / "projects" / "p" / f"s{i}.jsonl"
_write_jsonl(f, _rows(f"msg-{i}"))
models._parse_claude_code_jsonl_cached(f)
assert len(models._CLAUDE_CODE_PARSE_CACHE) <= 5
def test_parse_cache_handles_missing_file(tmp_path):
import api.models as models
models.clear_claude_code_parse_cache()
missing = tmp_path / "nope.jsonl"
# Must not raise; matches the empty-tuple contract of the uncached parser.
assert models._parse_claude_code_jsonl_cached(missing) == ([], None, None, None)
def test_get_claude_code_sessions_warm_uses_cache(tmp_path, monkeypatch):
"""End-to-end: a 2nd get_claude_code_sessions() does not re-parse files."""
import api.models as models
models.clear_claude_code_parse_cache()
projects_dir = tmp_path / "claude" / "projects"
for i in range(3):
_write_jsonl(projects_dir / f"proj{i}" / "s.jsonl", _rows(f"row-{i}"))
calls = {"n": 0}
real = models._parse_claude_code_jsonl
def _counting(path, **kw):
calls["n"] += 1
return real(path, **kw)
monkeypatch.setattr(models, "_parse_claude_code_jsonl", _counting)
cold = models.get_claude_code_sessions(projects_dir=projects_dir)
cold_calls = calls["n"]
warm = models.get_claude_code_sessions(projects_dir=projects_dir)
assert cold_calls == 3 # parsed each file once on the cold build
assert calls["n"] == cold_calls # warm build added zero re-parses
assert [s["title"] for s in cold] == [s["title"] for s in warm]
def test_epoch_zero_timestamps_fall_back_to_mtime(tmp_path):
"""A transcript whose timestamps parse to 0.0 still gets a real mtime fallback.
The cached row-builder guards the mtime fallback with ``not first_ts and not
last_ts`` so a falsy-but-not-None ``0.0`` timestamp (epoch-0 / 1970
transcript) falls back to the file mtime, matching the pre-cache inline
``first_ts or last_ts or path.stat().st_mtime``. An identity (``is None``)
guard would have left these rows with ``None``.
"""
import api.models as models
models.clear_claude_code_parse_cache()
projects_dir = tmp_path / "claude" / "projects"
fixture = projects_dir / "p" / "s.jsonl"
# All message timestamps are epoch 0 -> _parse_claude_code_timestamp -> 0.0.
rows = [
{"summary": "Epoch QA"},
{"timestamp": "1970-01-01T00:00:00Z", "message": {"role": "user", "content": "hi"}},
{"timestamp": "1970-01-01T00:00:00Z", "message": {"role": "assistant", "content": "ok"}},
]
_write_jsonl(fixture, rows)
sessions = models.get_claude_code_sessions(projects_dir=projects_dir)
assert len(sessions) == 1
s = sessions[0]
# Must NOT be None — falls back to the file mtime (a real positive float).
assert s["created_at"] is not None
assert s["updated_at"] is not None
assert s["last_message_at"] is not None
assert s["created_at"] > 0
def test_parse_cache_dicts_are_read_only_contract(tmp_path):
"""Pin the load-bearing invariant: per-message dicts are SHARED across hits.
The cache returns a shallow ``list(messages)`` copy, so the per-message dicts
are shared between calls. Every production caller treats them as read-only;
this test documents that contract by proving the sharing exists — a future
caller that mutates a returned dict in place would corrupt the cache, and
this test makes that sharing explicit so such a change is a conscious one.
"""
import api.models as models
models.clear_claude_code_parse_cache()
fixture = tmp_path / "claude" / "projects" / "p" / "s.jsonl"
_write_jsonl(fixture, _rows())
first_msgs, *_ = models._parse_claude_code_jsonl_cached(fixture)
second_msgs, *_ = models._parse_claude_code_jsonl_cached(fixture)
# List wrappers are distinct copies (append isolation, covered above)...
assert first_msgs is not second_msgs
# ...but the dict objects are shared (the read-only contract). If a future
# change deep-copies on read, update this assertion deliberately.
assert first_msgs[0] is second_msgs[0]
def test_parse_cache_invalidates_on_same_size_mtime_ctime_edit(tmp_path, monkeypatch):
"""A same-size, same-mtime in-place edit still misses the cache via ctime_ns."""
import os
import api.models as models
models.clear_claude_code_parse_cache()
fixture = tmp_path / "claude" / "projects" / "p" / "s.jsonl"
_write_jsonl(fixture, _rows("aaaaa"))
calls = {"n": 0}
real = models._parse_claude_code_jsonl
def _counting(path, **kw):
calls["n"] += 1
return real(path, **kw)
monkeypatch.setattr(models, "_parse_claude_code_jsonl", _counting)
first = models._parse_claude_code_jsonl_cached(fixture)
assert first[0][0]["content"] == "aaaaa"
st = fixture.stat()
# Rewrite with same byte length and force the SAME mtime back; the os.utime
# call still stamps ctime with the current wall clock, so a real in-place
# edit moves ctime even when size+mtime are unchanged. Sleep so that ctime is
# measurably distinct from the original mtime_ns we restore.
time.sleep(0.02)
_write_jsonl(fixture, _rows("bbbbb"))
os.utime(fixture, ns=(st.st_atime_ns, st.st_mtime_ns))
st2 = fixture.stat()
assert st2.st_size == st.st_size
assert st2.st_mtime_ns == st.st_mtime_ns
assert st2.st_ctime_ns != st.st_ctime_ns # only ctime moved (the edit signal)
second = models._parse_claude_code_jsonl_cached(fixture)
assert calls["n"] == 2 # re-parsed despite identical size+mtime (ctime caught it)
assert second[0][0]["content"] == "bbbbb"