Bug: --resume loads 0% context on v2.1.91 — three regressions in session loading pipeline (source code verified)

Resolved 💬 10 comments Opened Apr 3, 2026 by kolkov Closed Apr 9, 2026

Summary

Session resume (both /resume in the REPL and --resume CLI flag) on v2.1.91 silently loads 0% of conversation history. The model has zero context — not a rendering issue, but actual context loss. Each failed resume attempt corrupts the session JSONL further by forking the parentUuid DAG. Source code analysis reveals three v2.1.91-specific regressions in the session loading pipeline.

We built a Go tool (ccdiag) to recover session context directly from JSONL files.

Environment

  • Claude Code v2.1.91 (npm/Node installation, Windows 10)
  • Opus 4.6, 1M context
  • Session: 20 MB, 5,027 lines, 444 user messages

Important distinction: this is NOT the scrollback rendering bug

Since v2.1.89, terminal scrollback doesn't render properly (#41814, #42024). But on v2.1.89-90 resumes, the context window was still fully loaded — the model remembered the conversation, you just couldn't scroll back to see it visually.

On v2.1.91, resume fails to load context entirely. We used /resume inside the REPL (showed session list, loaded it), then tried claude --resume <id> from CLI — same result. /context confirms:

Messages: 644 tokens (0.1%)
37k/1000k tokens (4%)

The model responds: "context of previous conversation was lost (new session)". This is a context loading regression, not a rendering issue.

Downgrading to v2.1.84 produces the same result — the JSONL was already corrupted by prior resume attempts on v2.1.91.

Root cause analysis (source code verified)

We analyzed the full source code (v2.1.88 TypeScript from the leaked source map) and compared with the v2.1.91 minified cli.js. Three changes in v2.1.91 contribute to the regression:

1. New synchronous reader BxY skips fork pruning (files >5 MB)

v2.1.88 (sessionStorage.ts:3520-3578):

// For files >5MB:
const scan = await readTranscriptForLoad(filePath, size)  // async
buf = walkChainBeforeParse(buf)  // PRUNES dead fork branches
const entries = parseJSONL(buf)

v2.1.91 (cli.js offset ~11454550):

// For files >5MB:
let b = BxY(q, R, V, ()=>{_.clear(), W.clear(), k.clear()})  // SYNC, inline processing
return JQK(_), MQK(_), y()  // EARLY RETURN — walkChainBeforeParse SKIPPED entirely

Verified: walkChainBeforeParse appears 2 times in v2.1.88 source, 0 times in v2.1.91 cli.js — the function was removed. Our 20 MB file triggers this >5 MB path, and fork branches are no longer pruned.

2. New timestamp fallback ExY can bridge fork boundaries

v2.1.88 (sessionStorage.ts:2088-2090):

currentMsg = currentMsg.parentUuid
  ? messages.get(currentMsg.parentUuid)
  : undefined
// Parent not found → walk terminates cleanly

v2.1.91 (cli.js offset ~11439744):

let O = q.get($)           // lookup parent by UUID
if(!O) {
  if(O = ExY(q,Y,z), O)   // NEW: timestamp fallback (5-second window)
    d("tengu_chain_timestamp_fallback",{})
}

Verified: tengu_chain_timestamp_fallback appears 1 time in v2.1.91 cli.js, 0 times in v2.1.88 source — this is entirely new. The ExY function searches for any message within 5 seconds of the current timestamp, regardless of session branch. This can incorrectly bridge across fork boundaries, connecting unrelated messages.

3. Missing leafUuids check in getLastSessionLog (pre-existing)

loadFullLog (sessionStorage.ts:2988) — correct:

findLatestMessage(messages.values(), 
  msg => leafUuids.has(msg.uuid) && (msg.type === 'user' || msg.type === 'assistant'))

getLastSessionLog (sessionStorage.ts:3900) — missing check:

findLatestMessage(messages.values(), m => !m.isSidechain)
// ↑ No leafUuids check! Can pick a mid-chain synthetic message from a failed resume

This pre-existing bug becomes critical with DAG forks: findLatestMessage picks a synthetic message from the most recent resume attempt (by timestamp) instead of the actual conversation leaf.

How the fork corrupts the JSONL

Each resume appends synthetic messages via recordTranscript (called by useLogMessages hook on first REPL render). Multiple messages end up sharing parentUuid = msg444.uuid:

root → msg1 → ... → msg444 (original: 444 messages, 20 MB)
                     ├→ synthetic_1 → hook_1  (resume #1: 2 messages)
                     ├→ synthetic_2 → hook_2  (resume #2: 2 messages)
                     └→ synthetic_3 → hook_3  (resume #3: 2 messages)

getLastSessionLog without leafUuids → picks synthetic_3 (newest timestamp) → buildConversationChain walks: synthetic_3 → msg444 → ... → root. But with walkChainBeforeParse removed in v2.1.91, the reader loads ALL entries including forks, and the chain walk may terminate early or bridge incorrectly via ExY.

Additional findings

  • Multiple resume attempts make it worse — each attempt adds another fork branch to the JSONL. This is not documented anywhere. Resume should be a read-only operation.
  • /export shows full context while API receives truncated version — see ArkNill's cache analysis documenting silent microcompact (327 events) and tool result budget cap (200K chars).
  • Financial impact: the combination of broken resume + cache invalidation + silent microcompact cost us $364.87 in extra usage in a single day (April 2) on top of a $200/month Max subscription — a $10,920/month rate.

Related issues

  • #40319 — Session resume loads zero history (regression v2.1.85)
  • #39856 — Context drops from 67% to 2.5% on resume
  • #40487 — Duplicate UUID fork, 1,232 messages dropped
  • #42376 — --continue v2.1.90 silently drops context
  • #42338 — Resume invalidates prompt cache (cache_read = 0)
  • #42542 — 3 silent microcompact mechanisms, DISABLE_AUTO_COMPACT doesn't disable all
  • #34629 — deferred_tools_delta causes resume cache miss (v2.1.69+)
  • #40524 — Cache invalidation on subsequent turns (74 comments, assigned to qing-ant)
  • #41666 — v2.1.88 yanked release + token loss analysis

Workaround: ccdiag recover

Since Claude Code's resume is broken, we built a Go tool that reads JSONL directly:

# Install
go install github.com/kolkov/ccdiag@latest

# Recover session context
ccdiag recover session.jsonl

# Find and recover latest session for a project
ccdiag recover --latest D:\projects\myproject

# Full dump (handoff summary + all messages)
ccdiag recover --output full -o recovery.md session.jsonl

Extracts: all user/assistant messages, files modified (Write/Edit with counts), GitHub commands (classified), git commands, web searches, issue references — everything needed to reconstruct session context for a new conversation. Zero dependencies, pure Go.

Source: https://github.com/kolkov/ccdiag

Suggested fixes

Fix 1: Restore fork pruning for large files

The walkChainBeforeParse optimization was removed in v2.1.91 when BxY replaced the async reader. Restore it or integrate equivalent pruning into BxY:

// After BxY loads all entries, prune fork branches before chain walk
const leafUuids = computeLeafUuids(messages)
// Remove messages not reachable from the longest leaf chain

Fix 2: Add leafUuids check to getLastSessionLog

sessionStorage.ts:3900 — align with loadFullLog at line 2988:

// Before (broken):
const lastMessage = findLatestMessage(messages.values(), m => !m.isSidechain)

// After (correct):
const lastMessage = findLatestMessage(
  messages.values(),
  msg => leafUuids.has(msg.uuid) && !msg.isSidechain,
)

Fix 3: Guard ExY timestamp fallback

The 5-second window bridge should not cross session/fork boundaries:

function timestampFallback(messages, currentMsg, seen) {
  for (const m of messages.values()) {
    if (seen.has(m.uuid)) continue
    if (m.sessionId !== currentMsg.sessionId) continue  // Don't bridge forks
    const delta = currentTs - ts
    if (delta >= 0 && delta <= 5000 && delta < bestDelta) { bestDelta = delta; best = m }
  }
  return best
}

Fix 4: Make resume read-only

Resume should NEVER append to the JSONL it's loading. Synthetic messages from the resume session should go to a NEW file, not the original.

Prompts for Claude Code to fix itself

Prompt 1: Fix getLastSessionLog leaf selection

Read src/utils/sessionStorage.ts. Find the function `getLastSessionLog` (around line 3869). 
On line 3900, `findLatestMessage` uses predicate `m => !m.isSidechain` WITHOUT checking 
`leafUuids`. Compare with `loadFullLog` (around line 2988) which correctly uses 
`msg => leafUuids.has(msg.uuid) && (msg.type === 'user' || msg.type === 'assistant')`.

Fix: compute `leafUuids` in `getLastSessionLog` (same as `loadFullLog` does) and add the 
`leafUuids.has(msg.uuid)` check to the `findLatestMessage` predicate on line 3900. 
This prevents the chain walker from starting at a synthetic resume message instead of the 
actual conversation leaf.

Prompt 2: Restore fork pruning in the new BxY reader

In v2.1.91, the function `walkChainBeforeParse` was removed when the new synchronous 
inline reader (BxY) replaced `readTranscriptForLoad` for files >5MB. This means fork 
branches from failed resume attempts are no longer pruned before chain walking.

Find the BxY reader (sync file reader using openSync/readSync for large JSONL files). 
After it finishes loading all entries, add a pruning step that removes messages not 
reachable from the longest parentUuid chain. This restores the behavior of the removed 
`walkChainBeforeParse` function.

Prompt 3: Guard timestamp fallback against fork bridging

In `buildConversationChain` (sessionStorage.ts), v2.1.91 added a timestamp-based fallback 
(ExY function, telemetry event "tengu_chain_timestamp_fallback") that finds the nearest 
message within a 5-second window when parentUuid lookup fails.

This fallback can incorrectly bridge across fork boundaries — connecting a message from 
one resume attempt to a message from a different branch. Add a guard: the fallback should 
only consider messages with the same sessionId as the current message. Also skip messages 
already in the `seen` set.

Prompt 4: Make resume append to a new file

When resuming a session, `useLogMessages` hook calls `recordTranscript` which appends 
synthetic messages (NO_RESPONSE_REQUESTED, hook outputs) to the ORIGINAL session JSONL. 
This creates forks in the parentUuid DAG that break subsequent resumes.

Fix: on resume, create a NEW continuation JSONL file (e.g., {sessionId}-{timestamp}.jsonl) 
for new messages. The original file stays read-only. The session loader should scan for 
continuation files and merge them in order when loading.

Prompt 5: Add resume diagnostics

Add a diagnostic check before resume: after loading the JSONL and building the conversation 
chain, compare chain length with total non-sidechain messages. If the chain contains less 
than 50% of messages, warn the user:

"Warning: resume loaded {chain_length} of {total_messages} messages ({percentage}%). 
The session may be corrupted by fork branches. Use `ccdiag recover` to extract full context."

Log the discrepancy to telemetry as "tengu_resume_chain_truncation".

Workaround: ccdiag — session analyzer, recovery & proxy

Until these bugs are fixed, we built a Go CLI tool that bypasses the broken resume logic:

go install github.com/kolkov/ccdiag@latest

# Recover lost session context (reads JSONL directly, not via broken walker)
ccdiag recover --latest D:\projects\myproject
ccdiag recover --output full -o recovery.md session.jsonl

# Real-time API traffic proxy — detect cache invalidation, token burn, silent fallback
ccdiag proxy --verbose
# .claude/settings.local.json: { "env": { "ANTHROPIC_BASE_URL": "http://localhost:9119" } }
ccdiag proxy stats --last 1h --cost

The proxy logs per-session JSONL files (session UUID = file name) with token usage, cache ratio, cost, and latency for every API request. Makes it trivial to detect watchdog false positives (99% cache ratio = model was thinking, not hung), cache invalidation after resume (#42338), and silent 529 fallback.

Zero dependencies, Go stdlib only: https://github.com/kolkov/ccdiag

Expected behavior

  1. Warn when context is truncated — don't silently drop 99.9% of history
  2. Don't corrupt the JSONL on failed resume — read-only operation
  3. Follow the longest chain — not the most recent fork
  4. Don't bridge forks via timestamp fallback
  5. Provide a built-in recovery tool — users shouldn't need third-party tools to access their own session data

---

cc: @bcherny @ant-kurt @fvolcic @bogini @ashwin-ant @OctavianGuzu @whyuan-cc @hackyon-anthropic @chrislloyd @catherinewu @ThariqS @rboyce-ant @dicksontsai @dhollman @qing-ant @ddworken @km-anthropic @sid374

View original on GitHub ↗

This issue has 10 comments on GitHub. Read the full discussion on GitHub ↗