@@ -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