Skip to content

fix(history): three Past Sessions data-quality bugs (automated-session noise, cross-contaminated previews, blank restart-heavy rows) - #215

Merged
Ark0N merged 6 commits into
Ark0N:masterfrom
timkjr:fix/past-sessions-history-quality
Aug 5, 2026
Merged

fix(history): three Past Sessions data-quality bugs (automated-session noise, cross-contaminated previews, blank restart-heavy rows)#215
Ark0N merged 6 commits into
Ark0N:masterfrom
timkjr:fix/past-sessions-history-quality

Conversation

@timkjr

Copy link
Copy Markdown

Summary

Three data-quality bugs in the Past Sessions list (Cmd+K Session Manager / phone overview PAST SESSIONS), all rooted in scanProjectDir()/extractTranscriptEntrypoint() in session-routes.ts and mergeUnifiedSessions() in unified-session-service.ts. Bundled together since they touch the same history-scanning path and were found while investigating the same user-facing symptom (a noisy, sometimes-wrong session list).

1. Automated/SDK-driven transcripts cluttering history

Claude Code stamps an entrypoint field on message records: 'cli' for a real interactive session, 'sdk-py'/'sdk-cli' for an SDK-driven one (CI review bots, automated tooling, etc.). Those were showing up in Past Sessions indistinguishably from real sessions a user could actually resume into — they never were resumable, so they were pure noise.

extractTranscriptEntrypoint() now scans a transcript's "type":"user"/"type":"assistant" lines for the entrypoint field and excludes the transcript only when every entrypoint-bearing message is non-'cli'. On my own ~/.claude/projects (321 transcripts as of this PR), that's 257 excluded (80%) — 76 sdk-cli, 178 sdk-py, 3 with no entrypoint field at all (left visible, fail-open). This is a hard exclusion, not a toggle — deliberately: these transcripts were never something a user could resume into, so there's no legitimate "show me the automated ones too" use case in this list. Fully scoped to Claude Code transcripts only; the other four CLI backends (OpenCode, Codex, Gemini, Antigravity) have their own separate history-reading code paths, untouched by this change (confirmed by researching each one's actual on-disk session format before writing this).

The detection logic itself went through a second pass after an independent review caught a real bug: the original version returned the first entrypoint-bearing message's value rather than scanning all of them, so a transcript that started under an older Claude Code build (no entrypoint field on its true first message) and later picked up a non-'cli' entrypoint on some later message was wrongly excluded — the opposite of the intended fail-open behavior. It now returns 'cli' the moment any scanned message carries it.

2. Cross-contaminated firstPrompt/lastPrompt on resumed sessions

mergeUnifiedSessions()'s workingDir-based backfill (used to recover a firstPrompt/lastPrompt for a session row whose own transcript extraction came up empty) didn't check whether the row already had its own history entry. A resumed session with its own aliased transcript could have its real preview text overwritten by a same-directory sibling's — showing the wrong conversation's content next to the right session. Fixed by skipping the backfill whenever the row already has an entry from its own history source; borrowing only ever applies to rows with no history entry of their own.

3. Blank rows from restart-bookkeeping accumulation, plus a real perf fix

Every session restart writes a small batch of bookkeeping-only lines (mode, permission-mode, last-prompt, queue-operation) ahead of the next real message. A session restarted many times over a long conversation accumulates enough of these that the original 16KB head-read window landed entirely on bookkeeping, producing a blank row.

This went through two iterations:

  • First fix: raised the head-read buffer to 128KB unconditionally, matching an existing precedent elsewhere in the file. That fixed the blank rows but, per the same independent review, roughly quadrupled per-request scan cost — measured on my real transcript tree, ~4x both bytes read and wall time — since every file now paid the 128KB cost even though only a minority actually need it.
  • Current fix: two-tier read — try 16KB first, escalate to 128KB only when that wasn't enough. Verified against my real ~/.claude/projects tree (320 files): 36% fewer bytes read, ~17.5% faster wall time than the unconditional-128KB version, with identical output.

Also fixes a fallback regression introduced during that same rework: a failed head read (e.g. EMFILE while scanning hundreds of files) on a file at or under the head-buffer size no longer got a shot at the tail-read fallback, silently dropping the session from history instead of giving it a second chance.

Testing

  • test/routes/session-routes.test.ts (87 tests) — includes new coverage discriminating the "any cli wins" vs "first field wins" entrypoint logic (verified to fail against the pre-fix version), and the restart-bookkeeping head-window case.
  • test/services/unified-session-service.test.ts (28 tests) — includes new coverage for the resumed-session cross-contamination case (verified to fail against the pre-fix version).
  • tsc --noEmit clean, npm run lint clean.
  • All three fixes verified against real production transcript data (live curl requests, before/after) as well as the automated suite.

timkjrand others added 6 commits August 5, 2026 11:11
…ast Sessions
Automated tools (CI review bots, etc.) invoke Claude Code via the SDK
and write their transcripts into the same ~/.claude/projects tree as
real interactive sessions, but were never something a user can resume
into -- no PTY, no running process. Their one-shot review prompts also
embed the full diff inline as a single message, often exceeding the
16KB head / 32KB tail windows this scanner reads, so they cluttered
Past Sessions two ways: as blank rows when the huge message couldn't
be parsed, or as N identical "Review this change for security
vulnerabilities..." rows when it could.
Claude Code stamps `entrypoint` on its own message records ('cli' for
a real interactive session, e.g. 'sdk-py' for an SDK invocation).
Exclude any transcript whose entrypoint isn't 'cli' from the history
list entirely, checked last so it reuses whatever head/tail the prompt
extraction already read. Missing entrypoint (older transcripts) reads
as interactive -- fail open, matching every other gating check in this
codebase. Shared by /api/history/sessions and /api/sessions/unified,
since both call the same scanProjectDir().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cross-contaminating history rows
COD-140's backfill was meant to cover live/persisted rows whose Codeman
id doesn't match an on-disk transcript UUID, guessing from the newest
transcript in the same workingDir as a last resort. It was also firing
for pure history rows whose OWN transcript scan already ran (and
genuinely found nothing, e.g. an oversized first message) -- those got
silently backfilled with the newest OTHER session's opening line from
the same directory. Not a blank row, but actively wrong: old sessions
displayed a completely unrelated (often today's live) conversation's
first prompt as if it were their own.
Skip the workingDir guess for any item that already has its own
'history' source -- it already had a real, direct attempt. Rows with
no history source at all (their transcript isn't linked/scanned under
their own id yet) still get the guess, matching the original intent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…okkeeping
Blank firstPrompt rows weren't all oversized messages -- traced one
directly: a session restarted many times (mux deaths, redeploys)
accumulates a batch of small bookkeeping lines (mode/permission-mode/
last-prompt/queue-operation, one batch per restart) ahead of the real
first message. With enough restarts these alone crossed the old 16KB
head-read window, so extraction found nothing even though the actual
first message was tiny (measured case: ~17.5KB of bookkeeping pushed a
189-byte real message just past the boundary).
Raise the head buffer from 16KB to 128KB (matching the existing
precedent at the codex-history head-read a few hundred lines up) and
fix three now-stale `> 16384`/`> 65536` fallback thresholds to
reference headBuf.length instead of hardcoded numbers, so the tail-read
fallbacks stay correctly scoped to "beyond what head already covered."
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…erage
Two follow-ups from reviewing the entrypoint-filter and head-buffer
fixes before submitting them upstream:
1. extractTranscriptEntrypoint() scanned any line containing the
substring "entrypoint", not specifically the first "type":"user"/
"type":"assistant" message line (unlike its sibling
extractFirstUserPrompt, which does scope to type). A transcript
that started under an older Claude Code version (no entrypoint
field) and got resumed under a newer one mid-conversation could
pick up the field from a much later message than the true first
one, misattributing the session's origin. Scoped it to match.
2. Added a regression test proving the tail-read fallback still
engages correctly when bookkeeping accumulation exceeds even the
new 128KB head window, not just the 16KB it previously blanked at.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ession test
The comment implied the fallback could be "silently skipped" by the
stale hardcoded threshold, which isn't actually true -- the old
smaller numbers were always more eager to trigger the fallback, never
less (same correction as the commit this test belongs to). What the
test actually protects against is the fallback logic itself breaking
(e.g. a copy-paste slip dropping the check entirely), not the exact
threshold value. Reworded to say that.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ier head read
extractTranscriptEntrypoint returned the FIRST entrypoint-bearing message's
value instead of scanning for any 'cli' occurrence, so a transcript that
started under an older Claude Code build (no entrypoint field) and later
picked up a non-'cli' entrypoint on some later message was wrongly excluded
from history — the opposite of the fail-open behavior the function's own
comment claimed. Now returns 'cli' the moment any scanned message carries it,
and only falls back to a non-cli value when nothing else qualifies. Head/tail
entrypoints are merged the same way (either side being 'cli' wins).
Also restructures scanProjectDir's head read into two tiers: try 16KB first
and escalate to 128KB only when that wasn't enough, instead of reading 128KB
for every file unconditionally. Measured against a real ~/.claude/projects
tree, the unconditional-128KB version roughly quadrupled scan cost to fix a
problem only a minority of files actually have; the two-tier version cuts
bytes read by ~36% and wall time by ~17% while producing identical output.
Also fixes a fallback regression where a failed head read (e.g. EMFILE) on a
file at or under the head buffer size no longer got a shot at the tail-read
fallback, silently dropping the session from history.
@Ark0N
Ark0N merged commit c19d884 into Ark0N:masterAug 5, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Aug 5, 2026
…not cli"
#215 filters non-interactive transcripts out of Past Sessions with
`entrypoint !== 'cli'`. That is an allowlist on a value, and the check
hides rows, so it fails CLOSED on anything Claude Code has not shipped
yet: the day it stamps a new interactive entrypoint (a rename, or a
second interactive host), no transcript matches 'cli' any more and the
entire Past Sessions list goes blank with nothing in the UI explaining
why.
Invert it to a blocklist on the SDK shape (`sdk`, `sdk-cli`, `sdk-py`).
An automated entrypoint we do not recognize yet now costs a few noisy
rows, which is the annoyance the filter set out to fix, rather than a
dead feature. Matches the fail-open reasoning #215 already applied to a
MISSING entrypoint field; only the unknown-VALUE case was inverted.
Test fails against the pre-fix line and passes after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Merged, thank you! 🙏

Three real bugs, each with the reasoning shown, and the two-pass history on #1 and #3 (fixing the first attempt after review caught the "first field wins" misattribution and the 4x scan cost) is exactly the kind of iteration I like to see in a PR description rather than hidden in force-pushes. The two-tier 16KB→128KB read is a genuinely better answer than either extreme.

Verified here against real data before merging:

One follow-up I pushed on top (ecd3f3f), flagging it since it changes your logic:

The exclusion shipped as entrypoint !== 'cli', which is an allowlist on a value. Because the check hides rows, it fails closed on anything Claude Code has not shipped yet: if a future release renames the interactive entrypoint or adds a second interactive host, nothing matches 'cli' and the whole Past Sessions list goes blank with nothing in the UI to explain it. I inverted it to a blocklist on the SDK shape (sdk, sdk-cli, sdk-py), so an unrecognized automated entrypoint costs a few noisy rows instead of a dead feature.

This is the same fail-open reasoning your own comment applies to a missing entrypoint field ("matching every other gating check in this codebase"); only the unknown-value case was inverted. Added a test that fails against the original line. No change to behavior on any entrypoint value that actually exists today, so your verification still holds.

Thanks for the thorough work on this one.

Sign up for freeto 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

@timkjr@Ark0N