Skip to content

fix(sessions): latch sidechain flag only when explicitly present - #1468

Open
senanurcetin wants to merge 5 commits into
anthropics:mainfrom
senanurcetin:fix/sidechain-summary-explicit-flag-20261102
Open

senanurcetin wants to merge 5 commits into
anthropics:mainfrom
senanurcetin:fix/sidechain-summary-explicit-flag-20261102

Conversation

@senanurcetin

@senanurcetin senanurcetin commented Nov 2, 2026 •

Copy link
Copy Markdown

Summary

Subagent transcripts could appear as regular sessions in list_sessions_from_store(). If the first entry had no isSidechain key (for example a summary record), fold_session_summary() latched is_sidechain=False and ignored a later explicit isSidechain: true.

Changes

  • session_summary.py: latch is_sidechain only when the entry has an explicit boolean isSidechain. The first explicit value wins, including across append batches.
  • tests/test_session_summary.py: three fold_session_summary regression tests (flag after an unmarked first entry, flag across append batches, explicit false stays latched) and one list_sessions_from_store fast-path test.

No public API or CLI protocol changes.

Out of scope

The disk lite reader (_parse_session_info_from_lite()) is unchanged. It still checks only the first line of the 64 KiB head window, as the TypeScript SDK does. A transcript whose first explicit isSidechain comes after the first line is still listed by list_sessions(). Fixing that without reading more than the head and tail on the listing hot path needs its own discussion, so I've left it for a separate issue.

Testing

On 1d7344b (Windows, Python 3.11): full suite 1633 passed, 21 skipped; tests/test_session_summary.py 54 passed; ruff check, ruff format --check and mypy src/ scripts/ clean.

AI assistance

This contribution was developed with AI assistance (ChatGPT). The bug was reproduced, regression tests were added, and the changes were verified against the local SDK test suite before submission.
The rework that reduced this PR to the minimal fix was done with Claude Code (Claude Opus 5.5).

Preserve a missing isSidechain value until the first explicit boolean
appears in incremental SessionStore transcript entries.

Include regressions for mixed metadata entries, batched appends,
explicit false values, and InMemorySessionStore list filtering.

Local verification: 1,633 passed, 21 skipped; Ruff and targeted mypy clean.
A metadata-only first JSONL line can precede the first isSidechain flag.
The disk lite-reader previously ignored a later explicit flag and diverged
from the incremental SessionStore summary. Read the first top-level boolean
flag in the bounded head buffer and add disk/store parity regressions.

Local verification: 1,636 passed, 21 skipped; Ruff clean.

@mangeshraut712 mangeshraut712 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Checking isinstance(entry.get('isSidechain'), bool) in fold_session_summary ensures that initial header or summary records without an isSidechain key do not prematurely latch is_sidechain=False and mask subsequent subagent entries.

Matching this in _parse_session_info_from_lite by checking lines across head preserves parity between disk fast-path listing and in-memory session folding. The unit and parity tests in test_session_summary.py cover append batches and nested metadata checks thoroughly.

@mangeshraut712 mangeshraut712 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the fix — the "first explicit boolean wins" semantics in fold_session_summary and the new regression tests are clear, and all 57 tests in tests/test_session_summary.py pass locally.

One parity gap remains: the lite fast path only scans head (the first 64 KiB, LITE_READ_BUF_SIZE), and tail — which _read_session_lite already reads — is never scanned for isSidechain. src/claude_agent_sdk/_internal/sessions.py:438 still misses a subagent whose first explicit "isSidechain":true lands after the head windowestrategy, so the batch path included a session the fold path excluded.

Reproduction (all entries before the flag are valid JSON, no truncation at the boundary):

lines = ['{"type":"summary","summary":"agent task"}',
         '{"type":"user","message":{"role":"user","content":"first prompt"}}']
lines += ['{"type":"assistant","message":{"role":"assistant","content":"'+'p'*7000+'"}}' for _ in range(10)]
lines.append('{"type":"user","message":{"role":"user","content":"subagent prompt"},"isSidechain":true}')
jsonl = "\n".join(lines) + "\n"   # 70,848 bytes; flag line starts past byte 65,536

lite = _jsonl_to_lite(jsonl, 0)
_parse_session_info_from_lite("sid", lite, DIR)  # -> SDKSessionInfo (included)
fold_session_summary(None, key, [json.loads(l) for l in lines])  # is_sidechain True -> summary_entry_to_sdk_info -> None (excluded)

A subagent transcript with a large tool result or long assistant context before its first user message easily exceeds 64 KiB, so this is the same "subagent appears in the regular session list" bug this PR fixes — it just moved from "first line" to "first 64 KiB". The same edge applies to a JSON line that straddles the head/tail read boundary: json.loads fails via the continue on ValueError at line 443 and the flag is silently dropped even though the full line sits in tail.

Suggest scanning tail as well (it is already in memory; if head and tail overlap, dedupe by size/last-newline head.find(), and skip lines already seen in head when tail == head), and/or adding a test with a >64 KiB transcript where the first explicit flag is past the window.

The lite disk reader only considered isSidechain flags in the first
64 KiB, so long subagent transcripts could be listed as main sessions.
Determine the first explicitly present boolean from the sampled bytes,
reconstruct overlapping head/tail reads exactly for <=128 KiB files,
and inspect the tail for larger transcripts without extra file reads.

Add six regression tests for late flags, head false precedence,
long JSONL lines spanning read boundaries, real disk reads, disjoint
tail windows and multibyte UTF-8.

Verified locally: 1,642 SDK tests passed, 21 skipped; 63 session
summary tests, Ruff lint/format, and mypy passed.
@senanurcetin

Copy link
Copy Markdown
Author

Thanks @mangeshraut712 for the detailed reproduction and the boundary-case analysis.

I inspected the current PR head (25e2f11). Your finding is valid: _parse_session_info_from_lite() checks isSidechain only in the head window, so the disk fast path can disagree with fold_session_summary() when the first explicit flag occurs later.

One additional consideration: scanning the tail alone may not guarantee first explicit boolean wins for larger transcripts, because the first flag could occur in the unexamined middle. A complete fix should preserve that ordering, potentially using a streaming fallback when the head contains no explicit flag.

The regression coverage should include:

  • First explicit isSidechain: true beyond 64 KiB.
  • JSONL records crossing read boundaries.
  • Conflicting explicit boolean values in different windows.
  • Parity between the disk fast path and in-memory folding.

The current implementation does not resolve these cases yet. No passing validation or code fix is claimed by this response.

For transcripts exceeding two 64-KiB windows, sampled head/tail flags
cannot prove which explicit top-level boolean occurred first. Preserve
the fast path when the complete head is decisive; otherwise scan JSONL
records sequentially, on disk via a file iterator and in the already
resident store bytes via BytesIO. Never allow a tail flag to override
an earlier explicit flag in the unobserved middle.

Add five regressions for conflicting middle/tail booleans, oversized
single JSONL lines, Unicode-escaped flag keys and head fast-path
preservation. These fail before and pass after the fix.

Local verification: 68/68 session summary tests; 1,647 SDK tests
passed, 21 skipped; Ruff lint/format and targeted mypy passed.
@senanurcetin

Copy link
Copy Markdown
Author

@mangeshraut712 follow-up on the head/tail parity gap you found: it's fixed in two commits, and the current head is d435801.

  • 182c597: the lite reader now takes the first explicit isSidechain boolean from the sampled bytes. For files up to 128 KiB it reconstructs the overlapping head and tail reads exactly. For larger files it also checks the tail, with no extra file reads.
  • d435801: as I noted earlier, the tail alone can't prove ordering for larger transcripts. So when the head has no explicit flag and the file is larger than the two 64 KiB windows, it now scans the JSONL records in order. On disk it reads line by line; for store bytes already in memory it uses BytesIO. A tail flag can no longer override an earlier explicit flag in the unread middle, and the fast path is kept when the head already decides it.

New regression tests cover:

  • late flags and head false precedence;
  • lines that span the read boundaries, and multibyte UTF-8;
  • conflicting booleans in the middle and tail;
  • oversized single lines;
  • Unicode-escaped flag keys.

The d435801 tests fail before the fix and pass after it.

Locally at d435801: 68/68 in tests/test_session_summary.py, and 1,647 passed / 21 skipped across the SDK suite. Ruff lint/format and targeted mypy are clean. Upstream CI hasn't run on this head yet.

Would you mind re-running your boundary reproduction against d435801?

Keep this PR to the fold_session_summary fix and its regression tests.
The head/tail and ordered JSONL scans added to _read_session_lite and
_jsonl_to_lite could read a whole large transcript per session during
list_sessions(), which the lite reader exists to avoid. The disk lite
path goes back to its original first-line check, matching the
TypeScript SDK. A later first explicit flag on that path is a known
limitation, left for a separate issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@senanurcetin

Copy link
Copy Markdown
Author

@mangeshraut712 thanks again for the reproduction. You were right that the disk lite path can disagree with the fold when the first explicit flag comes after the head.

I've cut the PR back to the fold_session_summary fix and its tests (1d7344b). The head/tail and ordered-scan code I added later made list_sessions() read the whole file for any large transcript with no flag in its head. That reader exists to avoid exactly that, so the fix cost more than the edge case is worth, and the tail can't show which flag came first anyway.

So the gap you found is still there: _parse_session_info_from_lite() checks only the first line, as the TypeScript SDK does. I'd rather open a separate issue for it, with a bounded option (complete lines in the head window only), than grow this PR.

This branch has not been deployed

No deployments
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