Skip to content

Commit ee2e2c6

Browse files
committed
fix: bump load_range head/tail to 20/200 to cover CLI 32KB byte-threshold re-append window
1 parent 1e7b75c commit ee2e2c6

2 files changed

Lines changed: 94 additions & 8 deletions

File tree

src/claude_agent_sdk/_internal/sessions.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,14 @@
3434
_STORE_LIST_LOAD_CONCURRENCY = 16
3535

3636
# Head/tail entry counts requested via ``store.load_range()`` when deriving
37-
# session summaries. Head needs the first user prompt + sidechain marker +
38-
# created_at timestamp; tail needs the latest title/tag/summary/gitBranch
39-
# entries. These are upper bounds — short sessions return fewer.
40-
_LITE_LOAD_HEAD_ENTRIES = 10
41-
_LITE_LOAD_TAIL_ENTRIES = 20
37+
# session summaries. Tail must cover the CLI's metadata re-append threshold
38+
# (custom-title/tag/last-prompt are re-appended every ~32 KB of transcript,
39+
# i.e. up to ~160 small entries between re-appends — 200 leaves margin).
40+
# Head must cover the preamble before the first user message (mode/progress/
41+
# attachment/system/snapshot entries — observed up to index 14 in real
42+
# transcripts). These are upper bounds — short sessions return fewer.
43+
_LITE_LOAD_HEAD_ENTRIES = 20
44+
_LITE_LOAD_TAIL_ENTRIES = 200
4245

4346
# Maximum length for a single filesystem path component. Most filesystems
4447
# limit individual components to 255 bytes. We use 200 to leave room for

tests/test_session_store_load_range.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,16 +286,16 @@ async def test_first_prompt_and_title_derived_from_head_tail(self) -> None:
286286
info = await get_session_info_from_store(store, sid, directory=DIR)
287287

288288
assert info is not None
289-
# first_prompt comes from entry[0] (within head=10 slice).
289+
# first_prompt comes from entry[0] (within head slice).
290290
assert info.first_prompt == "prompt 0"
291-
# custom_title + tag come from entries[98:100] (within tail=20 slice).
291+
# custom_title + tag come from entries[98:100] (within tail slice).
292292
assert info.custom_title == "My Big Session"
293293
assert info.summary == "My Big Session"
294294
assert info.tag == "important"
295295
# created_at parsed from first entry's timestamp.
296296
assert info.created_at is not None
297297
# Only a slice was fetched, so file_size is unknown — None, not the
298-
# misleading byte-count of the ~30-entry slice.
298+
# misleading byte-count of a partial slice.
299299
assert info.file_size is None
300300
# Only load_range was used — full transcript never fetched.
301301
assert store.load_range_calls == 1
@@ -345,3 +345,86 @@ async def test_first_prompt_does_not_leak_from_tail(self) -> None:
345345
# from the tail's "recent prompt".
346346
assert info.first_prompt is None
347347
assert info.file_size is None
348+
349+
async def test_tail_covers_32kb_reappend_window(self) -> None:
350+
"""Regression: the CLI re-appends ``custom-title``/``tag``/``last-prompt``
351+
on a ~32 KB **byte** threshold, not an entry count, so up to ~160 small
352+
entries can accumulate between re-appends. ``rename_session_via_store``
353+
followed by a long turn can therefore push the title >20 entries deep
354+
before the next re-append. ``_LITE_LOAD_TAIL_ENTRIES`` must be sized to
355+
cover that window."""
356+
from claude_agent_sdk._internal import sessions as _sessions
357+
358+
store = _SpyStoreWithRange()
359+
sid = str(uuid_mod.uuid4())
360+
key: SessionKey = {"project_key": PROJECT_KEY, "session_id": sid}
361+
362+
# 250 entries: a short head with the first user prompt, then a
363+
# custom-title at index 50 (rename mid-session), then 199 small
364+
# progress entries mirrored before the CLI's next 32 KB re-append.
365+
entries: list[dict[str, Any]] = []
366+
u0 = str(uuid_mod.uuid4())
367+
entries.append(_user("first prompt", u0, None, sid, "2024-01-01T00:00:00Z"))
368+
entries.extend({"type": "system", "uuid": f"s{i}"} for i in range(49))
369+
entries.append({"type": "custom-title", "customTitle": "Renamed Mid-Turn"})
370+
entries.extend({"type": "progress", "uuid": f"p{i}"} for i in range(199))
371+
assert len(entries) == 250
372+
# The title sits at the very edge of tail[-TAIL:] — would be missed
373+
# with the old tail=20.
374+
assert entries.index(
375+
{"type": "custom-title", "customTitle": "Renamed Mid-Turn"}
376+
) == (len(entries) - _sessions._LITE_LOAD_TAIL_ENTRIES)
377+
378+
await store.append(key, entries) # type: ignore[arg-type]
379+
380+
info = await get_session_info_from_store(store, sid, directory=DIR)
381+
382+
assert info is not None
383+
assert info.custom_title == "Renamed Mid-Turn"
384+
assert info.summary == "Renamed Mid-Turn"
385+
assert info.first_prompt == "first prompt"
386+
assert store.load_range_calls == 1
387+
assert store.load_calls == 0
388+
389+
async def test_degrades_to_last_prompt_when_preamble_exceeds_head(self) -> None:
390+
"""When the preamble (mode/progress/attachment/system entries) is
391+
longer than ``_LITE_LOAD_HEAD_ENTRIES`` and the first user message
392+
falls outside the head slice, ``first_prompt`` is ``None`` but the
393+
session is still listed — ``summary`` degrades to ``lastPrompt`` from
394+
the tail rather than dropping the row."""
395+
from claude_agent_sdk._internal import sessions as _sessions
396+
397+
store = _SpyStoreWithRange()
398+
sid = str(uuid_mod.uuid4())
399+
key: SessionKey = {"project_key": PROJECT_KEY, "session_id": sid}
400+
401+
head_n = _sessions._LITE_LOAD_HEAD_ENTRIES
402+
# Preamble longer than the head slice — head=20 sees only system noise.
403+
preamble: list[dict[str, Any]] = [
404+
{"type": "system", "uuid": f"pre{i}"} for i in range(head_n + 10)
405+
]
406+
u = str(uuid_mod.uuid4())
407+
a = str(uuid_mod.uuid4())
408+
body = [
409+
_user("real first prompt", u, None, sid, "2024-01-01T00:01:00Z"),
410+
_assistant("reply", a, u, sid, "2024-01-01T00:01:01Z"),
411+
]
412+
# Filler so head and tail slices don't overlap (the user prompt at
413+
# index head_n+10 must not appear in the head OR the tail).
414+
tail_n = _sessions._LITE_LOAD_TAIL_ENTRIES
415+
filler: list[dict[str, Any]] = [
416+
{"type": "system", "uuid": f"f{i}"} for i in range(tail_n)
417+
]
418+
last_prompt = [{"type": "last-prompt", "lastPrompt": "what user did last"}]
419+
420+
await store.append(key, preamble + body + filler + last_prompt) # type: ignore[arg-type]
421+
422+
info = await get_session_info_from_store(store, sid, directory=DIR)
423+
424+
assert info is not None
425+
# First user message at index head_n+10 — outside head[:head_n].
426+
assert info.first_prompt is None
427+
# Graceful degradation: summary falls through to lastPrompt, not None.
428+
assert info.summary == "what user did last"
429+
assert store.load_range_calls == 1
430+
assert store.load_calls == 0

0 commit comments

Comments
 (0)