mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-11 18:20:30 +00:00
d61ca37bd1
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.
245 lines
9.1 KiB
Python
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"
|