Skip to content

feat: SessionStore.list_session_summaries — batch summary fetch for list-all#847

Merged
qing-ant merged 17 commits intomainfrom
qing/session-store-summaries
Apr 21, 2026
Merged

feat: SessionStore.list_session_summaries — batch summary fetch for list-all#847
qing-ant merged 17 commits intomainfrom
qing/session-store-summaries

Conversation

@qing-ant
Copy link
Copy Markdown
Contributor

@qing-ant qing-ant commented Apr 19, 2026

Problem

list_sessions_from_store() calls store.list_sessions() for IDs, then store.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

# New TypedDict (3 fields; `data` is opaque, stores persist verbatim)
class SessionSummaryEntry(TypedDict):
    session_id: str
    mtime: int
    data: dict[str, Any]

# New helper — adapters call this from append()
def fold_session_summary(
    prev: SessionSummaryEntry | None,
    key: SessionKey,
    entries: list[SessionStoreEntry],
) -> SessionSummaryEntry: ...

# New optional Protocol method (default: raise NotImplementedError)
class SessionStore(Protocol):
    async def list_session_summaries(self, project_key: str) -> list[SessionSummaryEntry]: ...

Unchanged: SessionStore.{append,load,list_sessions,delete,list_subkeys}, list_sessions_from_store() signature/return, get_session_info_from_store(), SDKSessionInfo, SessionStoreListEntry, InMemorySessionStore public methods. Stores that don't implement list_session_summaries work 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 inside append() with the entries already in hand — no re-reads.

This PR adds:

  • SessionSummaryEntry (TypedDict) — 3-field record (session_id, mtime, opaque data). Stores persist it verbatim and never interpret data.
  • fold_session_summary(prev, key, entries) — pure helper that folds new entries into the previous summary. Adapters call this from append() so derivation logic lives in one place (no per-adapter drift). created_at latches 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.
  • Fast path in list_sessions_from_store() — when the store implements list_session_summaries: build a unified slot list (summary-derived slots + gap-fill placeholders for sessions present in list_sessions() but lacking a sidecar), sort by mtime, apply offset/limit, then load() 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 to None after load can short-page, so a store with complete sidecars never short-pages. load() count is bounded by page size, not total missing — zero load() calls when sidecars are complete. Gap-fill is best-effort: if the store lacks list_sessions it's skipped with a debug log. Otherwise falls back to the existing per-session load() path (bounded at 16 concurrent).
  • InMemorySessionStore reference impl (entry-timestamp-derived mtime so 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 — full load() is fine there.

Correctness

tests/test_session_summary.py::TestParityWithLiteParse proves summary_entry_to_sdk_info(fold_session_summary(...)) produces the same SDKSessionInfo as the existing _parse_session_info_from_lite batch 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.py fast-path block — fallback semantics
  • types.py — public surface: SessionSummaryEntry, fold_session_summary, list_session_summaries

Supersedes #846. TS port: anthropics/claude-cli-internal#30520.

@qing-ant qing-ant force-pushed the qing/session-store-summaries branch from 9d5fdc9 to c8942ba Compare April 19, 2026 21:42
@qing-ant
Copy link
Copy Markdown
Contributor Author

Gap-fill for mixed sessions (c8942ba)

Behavior. The list_session_summaries() fast path now gap-fills sessions that have transcript entries but no summary sidecar (e.g. a store that adopted the method mid-stream). After fetching summaries, it calls list_sessions() (cheap — ids+mtimes only), diffs the two sets, and runs the bounded per-session load() derivation on just the missing ones. Results are merged before sort/limit/offset.

Backward-compat guarantees:

  • Store without list_session_summaries → unchanged: probes _store_implements, takes the existing list_sessions() + per-session load() fallback. No new requirements.
  • Store with list_session_summaries and complete sidecars → one batch call + one cheap list_sessions() enumeration; missing is empty, zero load() calls (verified by test_fast_path_skips_load which raises if load() is touched).
  • Store with partial sidecars → covered sessions go fast-path, uncovered ones are gap-filled via bounded load() (test_mixed_sessions_gap_filled). Concurrency bound of 16 applies to the gap (test_gap_fill_bounded_concurrency).
  • Store with list_session_summaries but without list_sessions → fast path returns whatever summaries it has; gap-fill is best-effort skipped (no way to enumerate the gap).
  • __all__ diff vs main is purely additive (SessionSummaryEntry, fold_session_summary); no existing export touched. SessionStore Protocol gains one optional method with a NotImplementedError default; existing method signatures unchanged. The new examples/session_stores/ adapters from feat(examples): S3, Redis, Postgres SessionStore reference adapters #842 don't implement the method and pass conformance unchanged (auto-skipped by _has_optional).

/sdk-e2e-proof: skill not available in this environment (only commit and generate-changelog are registered). CI e2e suites cover the equivalent surface — test-e2e (ubuntu/macos/docker) all pass on c8942ba. The lone test-e2e (windows-latest) failure is test_agents_and_settings.py::test_setting_sources_user_only — a pre-existing WinError 32 tempdir-cleanup race in a file this PR does not touch.

@qing-ant qing-ant marked this pull request as ready for review April 19, 2026 22:01
Comment thread src/claude_agent_sdk/_internal/sessions.py Outdated
Comment thread src/claude_agent_sdk/types.py
Comment thread src/claude_agent_sdk/_internal/sessions.py
Comment thread src/claude_agent_sdk/_internal/sessions.py Outdated
@qing-ant
Copy link
Copy Markdown
Contributor Author

E2E Verification — list_session_summaries (Py #847 ↔ TS #30520)

Tested SHAs: Py a1e70f0 · TS 8f3ea36

Python #847

Check Result
ruff / mypy / pytest ✅ clean / clean / 709 passed, 3 skipped
S3 (moto) / Redis (fakeredis) / Postgres (live 16-alpine) ✅ 27 / 17 / 5 passed
FallbackRedisSessionStore, _store_implements(..., "list_session_summaries")False, 3 sessions → 3 correct rows
Fast pathInMemorySessionStore with load() patched to raise → 3 rows, 0 load() calls
Gap-fill bounded — summaries return 1/6, limit=2 → 2 rows, load() called (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
FallbacklistSessionSummaries: undefined → 3 rows via slow path
Fast pathload() stubbed to throw → 3 rows, 0 load() calls
Gap-fill bounded — summaries return 1/6, limit:2 → 2 rows, load() called
Non-breakingapi.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
Comment thread tests/test_session_helpers_store.py
Comment thread src/claude_agent_sdk/_internal/session_summary.py Outdated
Comment thread src/claude_agent_sdk/_internal/sessions.py Outdated
Comment thread src/claude_agent_sdk/_internal/session_summary.py
Comment thread src/claude_agent_sdk/_internal/sessions.py
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 3425fec2cd5f971d6e4ac8188b8b46fec6e6835c18, 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.
@qing-ant
Copy link
Copy Markdown
Contributor Author

🤖 Added mtime staleness guard. See latest commit.

Comment thread src/claude_agent_sdk/_internal/sessions.py
Comment thread src/claude_agent_sdk/_internal/sessions.py Outdated
…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.
@qing-ant
Copy link
Copy Markdown
Contributor Author

🤖 Mirrored TS fix for mtime clock mismatch. SessionSummaryEntry.mtime is now contractually storage write time (same clock as list_sessions().mtime). See latest commit.

Comment thread src/claude_agent_sdk/testing/session_store_conformance.py Outdated
…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.
@qing-ant
Copy link
Copy Markdown
Contributor Author

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 (pytest tests/ --ignore=tests/live-stores: 712 passed, 3 skipped).

@qing-ant
Copy link
Copy Markdown
Contributor Author

E2E Test Results

Proves the SessionStore.list_session_summaries fast path and stale-sidecar gap-fill introduced in this PR, exercised against the real CLI + real API via query().

"""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)
>>> query #1
  msg: SystemMessage
  msg: RateLimitEvent
  msg: AssistantMessage
  msg: ResultMessage
>>> query #2
  msg: SystemMessage
  msg: RateLimitEvent
  msg: AssistantMessage
  msg: ResultMessage

>>> list_sessions_from_store (fresh sidecars):
  sessions returned: 2
  load() calls: 0  (expected: 0)
  elapsed: 0.11ms
    - efa320eb...  summary='Say the word bravo'  mtime=1776747759387
    - 16b22c85...  summary='Say the word alpha'  mtime=1776747753165

>>> list_sessions_from_store (one stale sidecar):
  sessions returned: 2
  load() calls: ['efa320eb']

OK — fast path + gap-fill both verified end-to-end.

Summary: With two real sessions persisted via query(), list_sessions_from_store on the fresh-sidecar fast path returns both rows in 0.11ms with zero per-session load() calls, and rewinding one sidecar's mtime to simulate staleness routes exactly that one session through gap-fill (one load(), not N).

@qing-ant qing-ant enabled auto-merge (squash) April 21, 2026 05:04
Comment thread tests/test_session_helpers_store.py
@qing-ant qing-ant merged commit aa3d023 into main Apr 21, 2026
10 checks passed
@qing-ant qing-ant deleted the qing/session-store-summaries branch April 21, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants