feat: SessionStore.list_session_summaries — batch summary fetch for list-all#847
feat: SessionStore.list_session_summaries — batch summary fetch for list-all#847
Conversation
…eplacing per-session load_range
…ions_from_store docstring
… opaque to stores
9d5fdc9 to
c8942ba
Compare
Gap-fill for mixed sessions (c8942ba)Behavior. The Backward-compat guarantees:
|
…n cost-note docstring
…align InMemory mtime clock (parity with TS review)
…with slow path (parity with TS review)
E2E Verification —
|
| Check | Result |
|---|---|
| ruff / mypy / pytest | ✅ clean / clean / 709 passed, 3 skipped |
S3 (moto) / Redis (fakeredis) / Postgres (live 16-alpine) |
✅ 27 / 17 / 5 passed |
Fallback — RedisSessionStore, _store_implements(..., "list_session_summaries") → False, 3 sessions → 3 correct rows |
✅ |
Fast path — InMemorySessionStore with load() patched to raise → 3 rows, 0 load() calls |
✅ |
Gap-fill bounded — summaries return 1/6, limit=2 → 2 rows, load() called 2× (page-bounded, not 5) |
✅ |
Parity — 30-entry adversarial stream: fold == _parse_session_info_from_lite on every field except file_size (documented None on store path) |
✅ |
Non-breaking — all 113 origin/main __all__ names still importable |
✅ |
TypeScript #30520
| Check | Result |
|---|---|
| tsc / tests / api-extractor | ✅ clean / 89 passed / clean |
Fallback — listSessionSummaries: undefined → 3 rows via slow path |
✅ |
Fast path — load() stubbed to throw → 3 rows, 0 load() calls |
✅ |
Gap-fill bounded — summaries return 1/6, limit:2 → 2 rows, load() called 2× |
✅ |
Non-breaking — api.md diff vs main purely additive (only - line is a pre-existing warning line-number shift) |
✅ |
Bottom line: Both verified end-to-end. Fast-path proven zero-load(); gap-fill proven page-bounded; fold↔lite parity holds; public API fully backward-compatible.
…edupe fallback docstring; correct Args/Raises contract
…agent appends don't affect main summary
There was a problem hiding this comment.
All my earlier feedback has been addressed (through 6835c18) and I have nothing further to flag — deferring only because this adds new public API surface (SessionSummaryEntry, fold_session_summary, SessionStore.list_session_summaries) and a non-trivial fast-path/gap-fill design that warrants a human sign-off.
Extended reasoning...
Overview
Adds a batch summary fetch path for list_sessions_from_store(): new public SessionSummaryEntry TypedDict, public fold_session_summary() helper, optional SessionStore.list_session_summaries() Protocol method, a ~225-line _internal/session_summary.py, a substantial fast-path/gap-fill block in sessions.py, conformance contract #14, and ~690 lines of new tests. InMemorySessionStore is updated as the reference implementation.
Security risks
None identified. Pure in-process data derivation over transcript dicts the SDK already handles; no new I/O surface, subprocess, network, auth, or path handling. The opaque data field is SDK-produced and SDK-consumed.
Level of scrutiny
Medium-high. The implementation is well-factored and now thoroughly tested (parity tests against the existing lite-parse path, conformance suite extension, fast-path/gap-fill/bounded-concurrency coverage), but it introduces new public API that adapter authors will build against (two __all__ additions plus a Protocol method). API shape and the documented adapter contract (fold inside append, skip subpaths, serialize sidecar writes) are design decisions a maintainer should ratify.
Other factors
Over the course of this PR I raised eight issues (one functional pagination divergence, the rest doc/contract/dead-code nits); every one was addressed in 3425fec → 2cd5f97 → 1d6e4ac → 8188b8b → 46fec6e → 6835c18, and the bug-hunting pass on the current head found nothing. The one residual behavioral caveat — gap-fill placeholders that resolve to None can short-page — is intentional, documented inline, and bounded to the partial-sidecar migration case. I'm comfortable with the code; the deferral is purely for human sign-off on the public API addition.
Compare SessionSummaryEntry.mtime against the session's current mtime from list_sessions. When the sidecar lags the transcript, route the session into the existing gap-fill path so the SDK re-folds from source entries. Adds a conformance case and staleness docs.
|
🤖 Added mtime staleness guard. See latest commit. |
…time Clarify and enforce that SessionSummaryEntry.mtime shares a clock with list_sessions().mtime for the same session — typically storage write time (file mtime, S3 LastModified, Postgres updated_at). Adapters using batched/deferred writes report storage times strictly later than the last entry's ISO timestamp, making every sidecar look stale under the previous semantics. Fold helper no longer sets mtime; adapter stamps it at persist time. Reference store updated to use a single clock for both list_sessions and list_session_summaries. Adds a test proving a storage-newer sidecar with entry-older timestamps stays on the fast path.
|
🤖 Mirrored TS fix for mtime clock mismatch. SessionSummaryEntry.mtime is now contractually storage write time (same clock as list_sessions().mtime). See latest commit. |
…path skip note, conformance clock alignment - sessions.py: fast-path doctring note + inline return comment now say 'complete and fresh' sidecars never short-page; a present-but-stale sidecar (summary.mtime < list_sessions.mtime) is routed through the same gap-fill as a missing one and can short-page. - types.py: SessionStore.list_session_summaries docstring explicitly notes that subagent (subpath) keys must be skipped by the fold and that results are scoped to project_key like list_sessions. - conformance: replace tautological 'refolded[mtime] >= summ[mtime]' check (the fold preserves prev mtime verbatim) with an explicit equality, and add a clock-alignment assertion under has_list_sessions that catches adapters deriving sidecar mtime from entry ISO timestamps (which would make every sidecar look stale to the fast-path freshness check). - test: pin test_limit_offset_applied_after_sidechain_filter to the slow path (list_session_summaries -> NotImplementedError) so it keeps covering slow-path filter-THEN-paginate — the fast path deliberately locks in paginate-THEN-drop for summary-backed sidechain slots.
|
Addressed the yellow review batch in 108cb6e on top of the already-landed red/yellow work. New fixes (108cb6e)
Already addressed at HEAD (pointed to existing commits in replies)
Test + mypy + ruff all green locally ( |
E2E Test ResultsProves the """End-to-end proof for PR #847: SessionStore.list_session_summaries fast path."""
from __future__ import annotations
import os
import time
from claude_agent_sdk import (
ClaudeAgentOptions,
InMemorySessionStore,
list_sessions_from_store,
query,
)
from claude_agent_sdk.types import SessionKey
class CountingStore(InMemorySessionStore):
"""InMemorySessionStore that counts load() calls."""
def __init__(self) -> None:
super().__init__()
self.load_calls: list[str] = []
async def load(self, key: SessionKey): # type: ignore[override]
self.load_calls.append(key["session_id"])
return await super().load(key)
async def main() -> None:
store = CountingStore()
options = ClaudeAgentOptions(session_store=store, max_turns=1)
print(">>> query #1")
async for msg in query(
prompt="Say the word 'alpha' and nothing else.", options=options
):
print(f" msg: {type(msg).__name__}")
print(">>> query #2")
async for msg in query(
prompt="Say the word 'bravo' and nothing else.", options=options
):
print(f" msg: {type(msg).__name__}")
# --- Fast path: fresh sidecars, zero load() calls ---
store.load_calls.clear()
t0 = time.perf_counter()
sessions = await list_sessions_from_store(store, directory=os.getcwd())
elapsed_ms = (time.perf_counter() - t0) * 1000
print("\n>>> list_sessions_from_store (fresh sidecars):")
print(f" sessions returned: {len(sessions)}")
print(f" load() calls: {len(store.load_calls)} (expected: 0)")
print(f" elapsed: {elapsed_ms:.2f}ms")
for s in sessions:
print(
f" - {s.session_id[:8]}... summary={s.summary!r} "
f"mtime={s.last_modified}"
)
assert len(sessions) == 2
assert len(store.load_calls) == 0, f"fast path must issue zero load()s, got {store.load_calls}"
# --- Gap-fill: simulate a stale sidecar on one session ---
target_sid = sessions[0].session_id
target_pk = next(pk for pk in store._summaries if pk[1] == target_sid)
store._summaries[target_pk] = {**store._summaries[target_pk], "mtime": 1}
store.load_calls.clear()
sessions_after = await list_sessions_from_store(store, directory=os.getcwd())
print("\n>>> list_sessions_from_store (one stale sidecar):")
print(f" sessions returned: {len(sessions_after)}")
print(f" load() calls: {[sid[:8] for sid in store.load_calls]}")
assert len(sessions_after) == 2
assert len(store.load_calls) == 1 and store.load_calls[0] == target_sid
print("\nOK — fast path + gap-fill both verified end-to-end.")
if __name__ == "__main__":
import anyio
anyio.run(main)Summary: With two real sessions persisted via |
Problem
list_sessions_from_store()callsstore.list_sessions()for IDs, thenstore.load()per session to derive title/first-prompt/branch/etc. With N sessions that's N full-transcript round-trips just to render a list. #846 (load_range) cut bytes-per-session but not round-trips — 500 sessions still meant 500+ store calls.Public API change — additive only, no breaking changes
Unchanged:
SessionStore.{append,load,list_sessions,delete,list_subkeys},list_sessions_from_store()signature/return,get_session_info_from_store(),SDKSessionInfo,SessionStoreListEntry,InMemorySessionStorepublic methods. Stores that don't implementlist_session_summarieswork exactly as before.Approach
Every field the list view needs is append-incremental: 4 are set-once from the first entries (
is_sidechain,created_at,cwd,first_prompt), 7 are last-write-wins from the tail (custom_title,ai_title,last_prompt,summary_hint,git_branch,tag,mtime), 0 require a full scan. So a store can maintain a small summary record alongside each session, updated insideappend()with the entries already in hand — no re-reads.This PR adds:
SessionSummaryEntry(TypedDict) — 3-field record (session_id,mtime, opaquedata). Stores persist it verbatim and never interpretdata.fold_session_summary(prev, key, entries)— pure helper that folds new entries into the previous summary. Adapters call this fromappend()so derivation logic lives in one place (no per-adapter drift).created_atlatches the first parseable entry timestamp — a documented divergence from the lite-parse path only when the very first entry lacks a timestamp (never happens in CLI-produced transcripts).SessionStore.list_session_summaries(project_key)— optional Protocol method returning all summaries for a project in one call.list_sessions_from_store()— when the store implementslist_session_summaries: build a unified slot list (summary-derived slots + gap-fill placeholders for sessions present inlist_sessions()but lacking a sidecar), sort bymtime, applyoffset/limit, thenload()only the placeholders that landed in the page. Summary-backed sidechain/empty sessions are pre-filtered before pagination so they don't consume page positions (matching disk/slow-path filter-then-paginate); only gap-fill placeholders that resolve toNoneafter load can short-page, so a store with complete sidecars never short-pages.load()count is bounded by page size, not total missing — zeroload()calls when sidecars are complete. Gap-fill is best-effort: if the store lackslist_sessionsit's skipped with a debug log. Otherwise falls back to the existing per-sessionload()path (bounded at 16 concurrent).InMemorySessionStorereference impl (entry-timestamp-derivedmtimeso fast/slow paths sort on one clock) + conformance contract Fix KeyError: 'cost_usd' for Max subscription users #14.get_session_info_from_store()(single session) is unchanged — fullload()is fine there.Correctness
tests/test_session_summary.py::TestParityWithLiteParseprovessummary_entry_to_sdk_info(fold_session_summary(...))produces the sameSDKSessionInfoas the existing_parse_session_info_from_litebatch path on the same entry stream.For reviewers
_internal/session_summary.py— fold logic, esp. first-prompt skip filter (mirrors_extract_first_prompt_from_head)_internal/sessions.pyfast-path block — fallback semanticstypes.py— public surface:SessionSummaryEntry,fold_session_summary,list_session_summariesSupersedes #846. TS port: anthropics/claude-cli-internal#30520.