Skip to content

Commit 1e7b75c

Browse files
committed
fix: load_range path builds _LiteSessionFile directly (head/tail isolated, file_size=None); ReorderingStore overrides load_range; bump conformance count to 14
1 parent cfffdf5 commit 1e7b75c

4 files changed

Lines changed: 85 additions & 30 deletions

File tree

src/claude_agent_sdk/_internal/sessions.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ class _LiteSessionFile:
346346

347347
__slots__ = ("mtime", "size", "head", "tail")
348348

349-
def __init__(self, mtime: int, size: int, head: str, tail: str) -> None:
349+
def __init__(self, mtime: int, size: int | None, head: str, tail: str) -> None:
350350
self.mtime = mtime
351351
self.size = size
352352
self.head = head
@@ -1506,16 +1506,18 @@ def _filter_transcript_entries(entries: list[Any]) -> list[_TranscriptEntry]:
15061506
return result
15071507

15081508

1509-
async def _load_store_entries_as_jsonl(
1509+
async def _load_store_session_lite(
15101510
store: SessionStore, session_id: str, directory: str | None
1511-
) -> str | None:
1512-
"""Load entries from a SessionStore and serialize to a JSONL string.
1511+
) -> _LiteSessionFile | None:
1512+
"""Load a session from a SessionStore as a ``_LiteSessionFile``.
15131513
15141514
Only the head/tail slice needed for lite-parse summary derivation is
15151515
fetched when the store implements :meth:`SessionStore.load_range`;
1516-
otherwise falls back to a full :meth:`SessionStore.load`. Either way the
1517-
result is fed to ``_jsonl_to_lite`` which itself slices to a 64KB
1518-
head/tail, so the parse path is identical.
1516+
otherwise falls back to a full :meth:`SessionStore.load` and slices via
1517+
``_jsonl_to_lite``. Head and tail are serialized separately on the
1518+
``load_range`` path so head-only scans (e.g. ``first_prompt``) never see
1519+
tail entries; ``size`` is ``None`` on that path since only a slice was
1520+
fetched.
15191521
15201522
Returns ``None`` if the session has no entries.
15211523
"""
@@ -1525,12 +1527,24 @@ async def _load_store_entries_as_jsonl(
15251527
head, tail = await store.load_range(
15261528
key, head=_LITE_LOAD_HEAD_ENTRIES, tail=_LITE_LOAD_TAIL_ENTRIES
15271529
) or ([], [])
1528-
entries_for_lite: list[Any] | None = list(head) + list(tail)
1529-
else:
1530-
entries_for_lite = await store.load(key)
1531-
if not entries_for_lite:
1530+
if not head and not tail:
1531+
return None
1532+
head_jsonl = _entries_to_jsonl(list(head)) if head else ""
1533+
# Mirror the disk path's "tail = head when small" semantics so
1534+
# adapters that return an empty tail for short sessions still expose
1535+
# the head entries to tail-scanning extractors.
1536+
tail_jsonl = _entries_to_jsonl(list(tail)) if tail else head_jsonl
1537+
return _LiteSessionFile(
1538+
mtime=_mtime_from_jsonl_tail(tail_jsonl or head_jsonl),
1539+
size=None,
1540+
head=head_jsonl,
1541+
tail=tail_jsonl,
1542+
)
1543+
entries = await store.load(key)
1544+
if not entries:
15321545
return None
1533-
return _entries_to_jsonl(entries_for_lite)
1546+
jsonl = _entries_to_jsonl(entries)
1547+
return _jsonl_to_lite(jsonl, _mtime_from_jsonl_tail(jsonl))
15341548

15351549

15361550
async def list_sessions_from_store(
@@ -1595,9 +1609,9 @@ async def list_sessions_from_store(
15951609
# disk path.
15961610
sem = asyncio.Semaphore(_STORE_LIST_LOAD_CONCURRENCY)
15971611

1598-
async def _bounded_load(sid: str) -> str | None:
1612+
async def _bounded_load(sid: str) -> _LiteSessionFile | None:
15991613
async with sem:
1600-
return await _load_store_entries_as_jsonl(session_store, sid, directory)
1614+
return await _load_store_session_lite(session_store, sid, directory)
16011615

16021616
settled = await asyncio.gather(
16031617
*(_bounded_load(e["session_id"]) for e in listing),
@@ -1614,9 +1628,7 @@ async def _bounded_load(sid: str) -> str | None:
16141628
continue
16151629
if outcome is None:
16161630
continue
1617-
parsed = _parse_session_info_from_lite(
1618-
sid, _jsonl_to_lite(outcome, mtime), project_path
1619-
)
1631+
parsed = _parse_session_info_from_lite(sid, outcome, project_path)
16201632
if parsed is None:
16211633
# Sidechain or no extractable summary — drop, matching the
16221634
# filesystem path.
@@ -1648,10 +1660,9 @@ async def get_session_info_from_store(
16481660
"""
16491661
if not _validate_uuid(session_id):
16501662
return None
1651-
jsonl = await _load_store_entries_as_jsonl(session_store, session_id, directory)
1652-
if jsonl is None:
1663+
lite = await _load_store_session_lite(session_store, session_id, directory)
1664+
if lite is None:
16531665
return None
1654-
lite = _jsonl_to_lite(jsonl, _mtime_from_jsonl_tail(jsonl))
16551666
project_path = _canonicalize_path(str(directory) if directory is not None else ".")
16561667
return _parse_session_info_from_lite(session_id, lite, project_path)
16571668

src/claude_agent_sdk/testing/session_store_conformance.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Shared conformance test suite for :class:`SessionStore` adapters.
22
33
Call :func:`run_session_store_conformance` from an async test to assert the
4-
13 behavioral contracts every adapter must satisfy. Tests for optional
5-
methods (``list_sessions``, ``delete``, ``list_subkeys``) are skipped when
6-
named in ``skip_optional`` or when the store does not override that method.
4+
14 behavioral contracts every adapter must satisfy. Tests for optional
5+
methods (``list_sessions``, ``delete``, ``list_subkeys``, ``load_range``) are
6+
skipped when named in ``skip_optional`` or when the store does not override
7+
that method.
78
89
Example::
910
@@ -53,12 +54,12 @@ async def run_session_store_conformance(
5354
*,
5455
skip_optional: frozenset[str] = frozenset(),
5556
) -> None:
56-
"""Assert the 13 :class:`SessionStore` behavioral contracts.
57+
"""Assert the 14 :class:`SessionStore` behavioral contracts.
5758
5859
``make_store`` is invoked once per contract to provide isolation. It may be
5960
sync or async. Contracts for optional methods (``list_sessions``,
60-
``delete``, ``list_subkeys``) are skipped when named in ``skip_optional``
61-
or when the store does not override that method.
61+
``delete``, ``list_subkeys``, ``load_range``) are skipped when named in
62+
``skip_optional`` or when the store does not override that method.
6263
"""
6364
invalid = skip_optional - _OPTIONAL_METHODS
6465
assert not invalid, f"unknown optional methods in skip_optional: {invalid}"

tests/test_session_helpers_store.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -553,13 +553,18 @@ async def test_tag_survives_adapter_key_reordering(self) -> None:
553553
object keys (Postgres JSONB does this). The tag extractor must not
554554
depend on ``type`` being the first key in the serialized line."""
555555

556+
def _reorder(entries):
557+
# Sort keys alphabetically — deep-equal but different order.
558+
return [dict(sorted(e.items())) for e in entries]
559+
556560
class ReorderingStore(InMemorySessionStore):
557561
async def load(self, key): # type: ignore[override]
558562
entries = await super().load(key)
559-
if entries is None:
560-
return None
561-
# Sort keys alphabetically — deep-equal but different order.
562-
return [dict(sorted(e.items())) for e in entries]
563+
return None if entries is None else _reorder(entries)
564+
565+
async def load_range(self, key, *, head=0, tail=0): # type: ignore[override]
566+
r = await super().load_range(key, head=head, tail=tail)
567+
return None if r is None else (_reorder(r[0]), _reorder(r[1]))
563568

564569
store = ReorderingStore()
565570
sid = str(uuid_mod.uuid4())

tests/test_session_store_load_range.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,9 @@ async def test_first_prompt_and_title_derived_from_head_tail(self) -> None:
294294
assert info.tag == "important"
295295
# created_at parsed from first entry's timestamp.
296296
assert info.created_at is not None
297+
# Only a slice was fetched, so file_size is unknown — None, not the
298+
# misleading byte-count of the ~30-entry slice.
299+
assert info.file_size is None
297300
# Only load_range was used — full transcript never fetched.
298301
assert store.load_range_calls == 1
299302
assert store.load_calls == 0
@@ -305,5 +308,40 @@ async def test_first_prompt_and_title_derived_from_head_tail(self) -> None:
305308
assert len(sessions) == 1
306309
assert sessions[0].summary == "My Big Session"
307310
assert sessions[0].first_prompt == "prompt 0"
311+
assert sessions[0].file_size is None
308312
assert store.load_range_calls == 1
309313
assert store.load_calls == 0
314+
315+
async def test_first_prompt_does_not_leak_from_tail(self) -> None:
316+
"""When the head slice contains only skippable noise (isMeta/system),
317+
``first_prompt`` must be ``None`` rather than a recent user prompt
318+
pulled from the tail. Regression for head/tail being concatenated
319+
before ``_jsonl_to_lite`` so the head scan saw tail entries."""
320+
store = _SpyStoreWithRange()
321+
sid = str(uuid_mod.uuid4())
322+
key: SessionKey = {"project_key": PROJECT_KEY, "session_id": sid}
323+
324+
# head=10 will be these 10 system/meta noise entries.
325+
noise: list[dict[str, Any]] = [
326+
{"type": "system", "uuid": f"n{i}", "isMeta": True} for i in range(10)
327+
]
328+
# Middle filler so the head/tail slices don't overlap.
329+
filler: list[dict[str, Any]] = [
330+
{"type": "system", "uuid": f"f{i}"} for i in range(50)
331+
]
332+
# tail=20 will include this recent user prompt + a customTitle so
333+
# the session has an extractable summary.
334+
u = str(uuid_mod.uuid4())
335+
recent = [
336+
_user("recent prompt", u, None, sid, "2024-01-01T00:59:00Z"),
337+
{"type": "custom-title", "customTitle": "Noise Head"},
338+
]
339+
await store.append(key, noise + filler + recent) # type: ignore[arg-type]
340+
341+
info = await get_session_info_from_store(store, sid, directory=DIR)
342+
assert info is not None
343+
assert info.summary == "Noise Head"
344+
# Head contained no user prompt → first_prompt must not be sourced
345+
# from the tail's "recent prompt".
346+
assert info.first_prompt is None
347+
assert info.file_size is None

0 commit comments

Comments
 (0)