Skip to content

feat: SessionStore.load_range — cheap head/tail reads for summaries#846

Closed
qing-ant wants to merge 4 commits intomainfrom
qing/session-store-load-range
Closed

feat: SessionStore.load_range — cheap head/tail reads for summaries#846
qing-ant wants to merge 4 commits intomainfrom
qing/session-store-load-range

Conversation

@qing-ant
Copy link
Copy Markdown
Contributor

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

Stacked on #837.

What

Adds an optional load_range(key, *, head, tail) method to the SessionStore Protocol that returns only the first head and last tail entries of a transcript.

Why

list_sessions_from_store() and get_session_info_from_store() currently call full store.load() on every session just to derive a title / first prompt / tag. For remote backends with multi-MB sessions (S3 egress, Postgres large-row reads, Redis network round-trips) this is expensive — the lite-parser only needs a small head/tail slice.

Adapters that can range-read natively (S3 first/last part-file, Postgres LIMIT, Redis LRANGE) can implement load_range to make session listing O(head+tail) per session instead of O(full transcript).

How

  • SessionStore.load_range — optional Protocol method; default raises NotImplementedError (same opt-in pattern as list_sessions/delete/list_subkeys).
  • _load_store_session_lite — probes for load_range via _store_implements(); uses it when present, otherwise falls back to full load(). On the range path, head and tail are serialized to separate JSONL strings and packed directly into _LiteSessionFile (head/tail isolated so head-only scans like first_prompt never see tail entries; file_size=None since only a slice was fetched).
  • get_session_messages_from_store keeps using full load() — it needs the whole chain.
  • InMemorySessionStore implements load_range as a list slice.
  • run_session_store_conformance gains contract Fix KeyError: 'cost_usd' for Max subscription users #14 covering load_range (skipped when the adapter doesn't implement it).

Range sizing (head=20 / tail=200)

The CLI re-appends custom-title/tag/last-prompt metadata on a 32 KB byte threshold, not an entry count — so up to ~160 small entries can accumulate between re-appends. A rename_session_via_store followed by a long turn can push the title 100+ entries deep before the next re-append. tail=200 covers that window with margin.

head=20 covers the preamble before the first user message (mode/progress/attachment/system/snapshot entries — observed up to index 14 in real transcripts). When the preamble exceeds the head slice, first_prompt is None and summary degrades to lastPrompt from the tail rather than dropping the row.

No new public exports — Protocol method only.

Tests

tests/test_session_store_load_range.py:

  • InMemorySessionStore.load_range head/tail/overlap/zero/unknown-key.
  • Spy stores assert list_sessions_from_store / get_session_info_from_store route to load_range when implemented and fall back to load() when absent; get_session_messages_from_store still full-load.
  • 100-entry session with title at end + first prompt at start derives correct summary via the range path; file_size is None.
  • first_prompt does not leak from tail when head is all noise.
  • Regression: 250-entry session with custom-title at index 50 followed by 199 small entries (simulating rename → long turn before 32 KB re-append) — title found via tail=200.
  • Degradation: preamble longer than head=20first_prompt is None but summary falls through to lastPrompt (row not dropped).

Updated two existing InMemorySessionStore-subclass spy tests (error degradation, concurrency bound) to override load_range since that's now the read path for InMemorySessionStore. ReorderingStore likewise overrides load_range.

648 passed; ruff + mypy clean.

Comment thread tests/test_session_helpers_store.py
Comment thread src/claude_agent_sdk/testing/session_store_conformance.py
Comment thread src/claude_agent_sdk/_internal/sessions.py Outdated
@qing-ant qing-ant force-pushed the qing/session-store-load-range branch from b0572f8 to dd336e7 Compare April 19, 2026 03:24
Comment thread src/claude_agent_sdk/_internal/sessions.py Outdated
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 four issues from my earlier review are addressed (head/tail isolation, file_size=None, ReorderingStore override, docstring count) and the bug hunter found nothing new — looks solid, but since this adds a new public SessionStore Protocol method and is stacked on #837, a human should sign off on the API shape.

Extended reasoning...

Overview

This PR adds an optional load_range(key, *, head, tail) method to the SessionStore Protocol so summary derivation (list_sessions_from_store, get_session_info_from_store) can fetch only a head/tail slice instead of full transcripts. Touches types.py (new Protocol method), session_store.py (InMemorySessionStore.load_range), sessions.py (refactors _load_store_entries_as_jsonl_load_store_session_lite returning a _LiteSessionFile directly; widens _LiteSessionFile.size to int | None), session_store_conformance.py (contract #14), plus a new 347-line test file and updates to three existing spy-store tests.

Prior feedback — all resolved

I left four inline comments on the first revision; commit af09956 addresses each one and adds regression coverage:

  • Head/tail concatenation leak → now serialized separately into _LiteSessionFile(head=…, tail=…); covered by test_first_prompt_does_not_leak_from_tail.
  • Misleading file_size → now None on the range path; asserted in two tests.
  • ReorderingStore silently bypassed → now overrides load_range too.
  • Docstring drift (13→14, optional-methods list) → fixed in both module and function docstrings.

Security risks

None. Read-only summary derivation; no auth, crypto, subprocess, or filesystem-write changes. Inputs are adapter-supplied JSON-safe dicts already trusted by the existing load() path.

Level of scrutiny

Medium. The implementation is clean and the fallback path is unchanged, but this is a public Protocol method addition — an API commitment that third-party adapter authors will implement against. It also changes the default read path for InMemorySessionStore subclasses (which is why three existing tests needed updates). The design follows the established optional-method pattern exactly (raise NotImplementedError default + _store_implements probe), so there's no novelty, but a human should confirm the head=10/tail=20 constants and the tuple[list, list] | None return shape are what we want to commit to.

Other factors

  • Stacked on #837, so this should land with/after that PR's review.
  • Comprehensive test coverage: routing spies for both presence/absence, conformance contract, overlap/zero/unknown-key edges, and the head/tail-leak regression.
  • 643 tests pass per the description; ruff + mypy clean.

Comment thread tests/test_session_store_load_range.py Outdated
Base automatically changed from qing/session-store-types to main April 19, 2026 06:07
@qing-ant qing-ant force-pushed the qing/session-store-load-range branch from 6d32464 to ee2e2c6 Compare April 19, 2026 06:14
… test_first_prompt_does_not_leak_from_tail to match _LITE_LOAD_HEAD/TAIL_ENTRIES=20/200
@qing-ant qing-ant marked this pull request as draft April 19, 2026 06:47
@qing-ant
Copy link
Copy Markdown
Contributor Author

Moving to draft — exploring a batch list_session_summaries primitive instead of per-session load_range, since the motivating hot path is listing N sessions (round-trips dominate, not bytes-per-session). Will revisit or supersede.

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 prior feedback addressed (including the test-comment drift in 6d0ca60) and the implementation looks correct — deferring only because this adds a new public SessionStore Protocol method with heuristic constants (head=20/tail=200), which is an API-shape decision worth a human glance.

Extended reasoning...

Overview

This PR adds an optional load_range(key, *, head, tail) method to the public SessionStore Protocol so adapters that support range reads (S3 part-files, Postgres LIMIT, Redis LRANGE) can avoid full-transcript loads when list_sessions_from_store/get_session_info_from_store only need a head/tail slice for summary derivation. Touches types.py (Protocol method), sessions.py (loader refactor + new _LITE_LOAD_HEAD/TAIL_ENTRIES constants), session_store.py (InMemorySessionStore.load_range), the conformance suite (contract #14), and ~430 lines of new tests.

Review history

I reviewed this through three iterations:

  • Round 1 flagged a silent test-coverage regression (ReorderingStore not overriding load_range), stale 13→14 docstring counts, and a misleading file_size value on the range path.
  • Round 2 flagged a correctness bug where head+tail concatenation let first_prompt leak from tail entries.
  • Round 3 flagged comment-only drift in a test after the constants were bumped to 20/200.

All were addressed (af09956, 1e7b75c, ee2e2c6, 6d0ca60). The current revision is clean — bug hunting found nothing, and I verified the test comments now reference the constants by name.

Security risks

None. This is read-path optimization for session metadata; no auth, no untrusted-input parsing beyond what already exists, no new write paths.

Level of scrutiny

Medium. The implementation is internal and well-tested, but the Protocol method signature and the head=20/tail=200 constants are public-facing design choices. The constants are justified in the PR body (CLI re-appends metadata on a 32 KB byte threshold ≈ ~160 small entries; preamble observed up to index 14), and the degradation behavior when the slice misses is tested — but a maintainer with CLI-transcript domain knowledge should confirm those numbers are reasonable long-term.

Other factors

The new method follows the same opt-in pattern as the three existing optional Protocol methods (list_sessions/delete/list_subkeys), the conformance suite covers it, and this is stacked on #837 which established the pattern. No CODEOWNERS file in this repo. 648 tests pass per the PR body.

@qing-ant
Copy link
Copy Markdown
Contributor Author

Superseded by #847 (list_session_summaries batch primitive). Per discussion: the motivating hot path is listing N sessions, where round-trips dominate — load_range cuts bytes-per-session but not round-trips. #847 makes listing a single store call. Closing; branch kept for reference.

@qing-ant qing-ant closed this Apr 19, 2026
qing-ant added a commit that referenced this pull request Apr 21, 2026
…ist-all (#847)

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

```python
# 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
#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.
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.

1 participant