Skip to content

Conversation history missing on resume (except last message) #24304

Open

Description

@tennox

Description

After upgrading from Claude Code v2.1.15 to v2.1.24+ (and later 2.1.34), some sessions only show the last message when resumed, with all prior conversation history missing. The history is present in the JSONL file but not loaded by Claude Code.

Note: Downgrading back to v2.1.15 does NOT fix the issue, indicating the corruption happened at write time, not at read time.

Update: I'm still getting this occasionally (so it wasn't version-switch related)

Symptoms

  • Resume a session → only see the last message
  • Full conversation history exists in the .jsonl file
  • Affects some sessions but not others (no clear version pattern)

Investigation

Had Claude analyze a working session vs. a broken session. Found two corruption issues in the broken session's JSONL file:

1. Snapshot messageId Collision

file-history-snapshot entries have messageId values that collide with the immediately following message's uuid, creating ID ambiguity:

  • Line 53: snapshot with messageId: "5c1312fa-..."
  • Line 54: user message with uuid: "5c1312fa-..." (same ID!)

2. Broken Parent Chain Reference ⚠️ This is the critical bug

An entry references a parent UUID that doesn't exist in the file, breaking conversation chain traversal:

  • Line 50: progress entry with parentUuid: "1b366c02-..."
  • No entry in the file has uuid: "1b366c02-..."
  • Should point to the previous entry: uuid: "97fdd911-..."

This broken link prevents Claude Code from traversing the conversation history backwards when loading the session. When traced back from the last message, the chain stops at this broken reference after only 6 entries instead of going back through all 26 entries in the conversation.

Root Cause

Unknown what causes the corruption during writing. Possibly related to:

  • Sessions being open during version upgrade
  • Specific timing/race conditions
  • Not all sessions affected (sporadic issue)

Workaround

Created a Nushell script that detects and fixes both issues:

  • Sets snapshot messageId to null (removes collision)
  • Fixes broken parent references to point to correct preceding entries
  • Creates timestamped backups (non-destructive)

Fix script: https://gist.github.com/tennox/90ef5c803ec4b64c9fbba0f71ca1ae2e

nu fix-cc-session.nu ~/.config/claude/projects/YOUR-PROJECT/SESSION-ID.jsonl

Environment

  • Upgraded from: v2.1.15
  • Upgraded to: v2.1.24, then v2.1.34
  • OS: Linux (NixOS)
  • Pattern: Some sessions affected, others not (no clear correlation with version)

Expected Behavior

Resuming a session should load the full conversation history, regardless of which version created the session.

Actual Behavior

Only the last message is displayed; all prior history is invisible despite being present in the JSONL file.

Activity

  1. github-actions commented on Feb 9, 2026

    @github-actions

    Found 3 possible duplicate issues:

    1. [BUG] Conversation chain breaks mid-session - earlier messages lost on resume #21617
    2. [BUG] 2.1.27 session resume logic is loosing context #22107
    3. Resume shows only partial conversation history due to stale sessions-index.json #22030

    This issue will be automatically closed as a duplicate in 3 days.

    • If your issue is a duplicate, please close it and 👍 the existing issue instead
    • To prevent auto-closure, add a comment or 👎 this comment

    🤖 Generated with Claude Code

  2. alexeigs commented on Feb 9, 2026

    @alexeigs

    Very annoying indeed, you simply get one entry in /resume, not matter if there are previous messages in the current conversation or the conversation already being multiple compacted conversation long.

    Image

    16.6 MB but one single checkpoint without the item being expandable.

  3. clarym-star commented on Feb 10, 2026

    @clarym-star

    Seeing the same issue on v2.1.38, macOS (Darwin 25.2.0).

    Investigated the root cause — sessions-index.json is out of sync with actual session files on disk:

    • 215 .jsonl session files exist in the project directory
    • 120 entries in sessions-index.json
    • 95 sessions (~44%) missing from the index and invisible to /resume

    The missing sessions are valid .jsonl files, not corrupted — they're just not indexed. Likely happened gradually across upgrades over the past few months.

  4. thsunkid commented on Feb 11, 2026

    @thsunkid

    Same issue on v2.1.39, macOS Darwin 25.2.0

    My case is slightly different from the original report -- my JSONL has 0 broken parentUuid references
    (all chains are intact), yet resume shows essentially nothing.

    Root cause in my case: Heavy use of /rewind and branching. The conversation has:

    • 693 JSONL entries (109 user messages, 167 assistant messages)
    • 49 branch points, creating 52 distinct leaf endpoints
    • Resume traces the parentUuid chain from the last leaf backward: only 12 nodes (7 user, 2 assistant)
      out of 693
    • A compact_boundary entry at line 674 creates a second root (parentUuid: null), disconnecting the
      post-compaction messages from the pre-compaction tree entirely

    Additionally, sessions-index.json is missing this session entirely? (253 JSONL files on disk, only 147
    indexed, 42% desync, confirming what @clarym-star reported).

    The underlying issue seems to be that resume has no concept of the conversation tree. It follows one
    linear path from the last message backward. With branching, this path is a tiny fraction of the actual
    conversation. There's also no way to select which branch/leaf to resume from.

    This is related to #19451 and #24471

  5. StartupBros commented on Feb 13, 2026

    @StartupBros

    Python fix script (no dependencies, works on Linux/macOS/WSL)

    Ran into this on v2.1.x with heavy /rewind usage. My session had 1,259 entries but only 199 were reachable from the last message. Root causes in my case:

    1. 62 snapshot messageId collisions — file-history-snapshot entries had messageId values identical to real message UUIDs
    2. 1 broken parentUuid reference — a progress entry pointed to a UUID that didn't exist in the file
    3. 2 disconnected compaction roots — context compaction created new root entries (parentUuid: null) that split the conversation into 3 unreachable subtrees

    After fixing: 513/1,195 entries reachable (remaining 682 are on abandoned /rewind branches, which is expected).

    Usage

    # By file path
    python fix-claude-session.py ~/.claude/projects/YOUR-PROJECT/SESSION-ID.jsonl
    
    # By session ID (auto-searches ~/.claude/projects/ and ~/.config/claude/projects/)
    python fix-claude-session.py d3f4f5cd-0b53-484c-8216-a63db05b1345

    Creates a timestamped backup before modifying. No external dependencies — stdlib only.

    Script

    #!/usr/bin/env python3
    """Fix corrupted Claude Code session files that lose conversation history on resume.
    
    Addresses the bug reported in https://github.com/anthropics/claude-code/issues/24304
    where sessions show only the last message when resumed, despite full history
    existing in the JSONL file.
    
    Fixes three corruption patterns:
      1. Snapshot messageId collisions — file-history-snapshot entries share UUIDs
         with real messages, creating ambiguity in conversation traversal.
      2. Broken parentUuid references — entries point to non-existent UUIDs,
         breaking the backward chain that resume follows.
      3. Disconnected compaction roots — context compaction creates new root entries
         (parentUuid=null) that split the conversation into unreachable subtrees.
    
    Usage:
      python fix-claude-session.py <session-file.jsonl>
      python fix-claude-session.py <session-id>
    
    The session ID form searches the default Claude Code project directories.
    Creates a timestamped backup before modifying the file.
    """
    
    import json
    import os
    import shutil
    import sys
    from datetime import datetime
    from pathlib import Path
    
    
    def find_session_file(session_id: str) -> Path | None:
        """Search common Claude Code directories for a session JSONL file."""
        candidates = []
    
        # ~/.claude/projects/*/
        claude_dir = Path.home() / ".claude" / "projects"
        if claude_dir.exists():
            for project_dir in claude_dir.iterdir():
                if project_dir.is_dir():
                    candidate = project_dir / f"{session_id}.jsonl"
                    if candidate.exists():
                        candidates.append(candidate)
    
        # ~/.config/claude/projects/*/  (Linux/NixOS)
        config_dir = Path.home() / ".config" / "claude" / "projects"
        if config_dir.exists():
            for project_dir in config_dir.iterdir():
                if project_dir.is_dir():
                    candidate = project_dir / f"{session_id}.jsonl"
                    if candidate.exists():
                        candidates.append(candidate)
    
        if len(candidates) == 1:
            return candidates[0]
        if len(candidates) > 1:
            print(f"Found {len(candidates)} matches:")
            for i, c in enumerate(candidates):
                print(f"  [{i + 1}] {c}")
            choice = input("Which one? ")
            return candidates[int(choice) - 1]
        return None
    
    
    def load_entries(path: Path) -> list[tuple[int, dict | None]]:
        """Load JSONL entries with line tracking."""
        entries = []
        with open(path) as f:
            for i, line in enumerate(f):
                stripped = line.strip()
                if not stripped:
                    entries.append((i, None))
                    continue
                try:
                    entries.append((i, json.loads(stripped)))
                except json.JSONDecodeError:
                    entries.append((i, None))
        return entries
    
    
    def collect_uuids(entries: list[tuple[int, dict | None]]) -> set[str]:
        """Collect all UUIDs present in the session."""
        uuids = set()
        for _, entry in entries:
            if entry and entry.get("uuid"):
                uuids.add(entry["uuid"])
        return uuids
    
    
    def fix_snapshot_collisions(
        entries: list[tuple[int, dict | None]], uuids: set[str]
    ) -> int:
        """Nullify snapshot messageIds that collide with real message UUIDs."""
        fixes = 0
        for i, (line_num, entry) in enumerate(entries):
            if not entry or entry.get("type") != "file-history-snapshot":
                continue
            mid = entry.get("messageId")
            if mid and mid in uuids:
                entry["messageId"] = None
                entries[i] = (line_num, entry)
                fixes += 1
        return fixes
    
    
    def fix_broken_parents(
        entries: list[tuple[int, dict | None]], uuids: set[str]
    ) -> int:
        """Fix parentUuid references that point to non-existent UUIDs."""
        fixes = 0
        for i, (line_num, entry) in enumerate(entries):
            if not entry:
                continue
            parent = entry.get("parentUuid")
            if not parent or parent in uuids:
                continue
            # Point to the nearest preceding entry with a uuid
            for j in range(i - 1, -1, -1):
                prev = entries[j][1]
                if prev and prev.get("uuid"):
                    entry["parentUuid"] = prev["uuid"]
                    entries[i] = (line_num, entry)
                    fixes += 1
                    break
        return fixes
    
    
    def stitch_roots(entries: list[tuple[int, dict | None]]) -> int:
        """Connect disconnected root entries to the preceding conversation."""
        roots = []
        for i, (line_num, entry) in enumerate(entries):
            if entry and entry.get("uuid") and not entry.get("parentUuid"):
                roots.append(i)
    
        if len(roots) <= 1:
            return 0
    
        fixes = 0
        for root_idx in roots[1:]:
            entry = entries[root_idx][1]
            # Find the last entry before this root that has a uuid
            for j in range(root_idx - 1, -1, -1):
                prev = entries[j][1]
                if prev and prev.get("uuid"):
                    entry["parentUuid"] = prev["uuid"]
                    entries[root_idx] = (entries[root_idx][0], entry)
                    fixes += 1
                    break
        return fixes
    
    
    def trace_chain(entries: list[tuple[int, dict | None]]) -> int:
        """Count entries reachable from the last entry via parentUuid chain."""
        uuid_to_entry = {}
        for _, entry in entries:
            if entry and entry.get("uuid"):
                uuid_to_entry[entry["uuid"]] = entry
    
        last_entry = None
        for _, entry in reversed(entries):
            if entry and entry.get("uuid"):
                last_entry = entry
                break
    
        if not last_entry:
            return 0
    
        visited = set()
        current = last_entry
        while current:
            uid = current.get("uuid")
            if uid in visited:
                break
            visited.add(uid)
            parent = current.get("parentUuid")
            current = uuid_to_entry.get(parent) if parent else None
    
        return len(visited)
    
    
    def write_entries(path: Path, entries: list[tuple[int, dict | None]]) -> None:
        """Write fixed entries back to the JSONL file."""
        with open(path, "w") as f:
            for _, entry in entries:
                if entry:
                    f.write(json.dumps(entry, separators=(",", ":")) + "\n")
                else:
                    f.write("\n")
    
    
    def main() -> None:
        if len(sys.argv) != 2:
            print(f"Usage: {sys.argv[0]} <session-file.jsonl | session-id>")
            sys.exit(1)
    
        arg = sys.argv[1]
        path = Path(arg)
    
        if not path.exists() or not path.suffix:
            # Try as session ID
            found = find_session_file(arg.replace(".jsonl", ""))
            if found:
                path = found
            elif not path.exists():
                print(f"File not found: {arg}")
                sys.exit(1)
    
        print(f"Session: {path}")
        entries = load_entries(path)
        total = sum(1 for _, e in entries if e and e.get("uuid"))
        uuids = collect_uuids(entries)
    
        before = trace_chain(entries)
        print(f"Before: {before}/{total} entries reachable from last message")
    
        if before == total:
            print("No corruption detected — chain is fully intact.")
            sys.exit(0)
    
        # Backup
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        backup = path.with_suffix(f".jsonl.bak.{timestamp}")
        shutil.copy2(path, backup)
        print(f"Backup: {backup}")
    
        # Apply fixes
        n_snapshots = fix_snapshot_collisions(entries, uuids)
        n_parents = fix_broken_parents(entries, uuids)
        n_roots = stitch_roots(entries)
    
        after = trace_chain(entries)
    
        print(f"\nFixes applied:")
        print(f"  Snapshot messageId collisions nullified: {n_snapshots}")
        print(f"  Broken parentUuid references repaired:  {n_parents}")
        print(f"  Disconnected roots stitched:            {n_roots}")
        print(f"\nAfter: {after}/{total} entries reachable from last message")
    
        if after > before:
            write_entries(path, entries)
            recovered_pct = ((after - before) / total) * 100
            print(f"\nRecovered {after - before} entries (+{recovered_pct:.0f}%). File written.")
        else:
            os.remove(backup)
            print("\nNo improvement from fixes. Backup removed. File unchanged.")
    
        unreachable = total - after
        if unreachable > 0:
            print(
                f"\n{unreachable} entries remain on abandoned rewind branches "
                f"(expected if you used /rewind heavily)."
            )
    
    
    if __name__ == "__main__":
        main()

    Tested on a session with 1,259 lines, 193 branch points from /rewind, and 3 disconnected compaction subtrees. Went from 199 → 513 reachable entries. The original issue reporter's Nushell script handles patterns 1 and 2 but not the compaction root stitching (pattern 3), which was the main cause for me.

  6. tennox commented on Feb 15, 2026

    @tennox
    Author

    Update: I'm still getting this occasionally (so it wasn't version-switch related)

  7. ConstantinHvber commented on Feb 16, 2026

    @ConstantinHvber

    yeah I don't get how such a key feature can be broken for so long

  8. gonewx commented on Feb 20, 2026

    @gonewx

    This is a frustrating issue. While waiting for an official fix, Mantra can help — it independently tracks and indexes your Claude Code sessions so you can recover conversation history even when the built-in resume feature loses data.

    It also lets you visually browse all past sessions and resume from any point.

  9. krandder commented on Mar 11, 2026

    @krandder

    Another reproduction on v2.1.71 (Linux) — progress entry race condition

    Hit this on a long-lived session (67MB, 18K lines, 19 compaction boundaries). On resume, Claude had zero prior context — chain depth was only 2 before hitting a broken parentUuid.

    Root cause in my case: A progress entry references a parent UUID that was never written to the JSONL. This happens when a user message is queued (via queue-operation) while a tool call is still streaming. The sequence:

    Line 17967: assistant tool_use  (uuid: 39968529...)
    Line 17968: progress            (parent: 07675861...)  ← UUID doesn't exist in file
    Line 17969: queue-operation      "No I definitely don't want *all* tasks..."
    ...
    Line 17977: user message         (parent: 36f5b56a... → line 17968)
    

    The progress entry's parent (07675861...) appears to be an in-flight message ID (streaming chunk or tool result) that was never flushed to disk — likely because the queued user message interrupted the tool execution. The next user message chains off the progress entry, inheriting the broken link, and the entire prior history becomes unreachable.

    I have 5 broken parentUuid references in this session, all on progress entries. The other 4 didn't cause visible issues because they weren't at the chain tip when resuming.

  10. wellofspirit commented on Mar 14, 2026

    @wellofspirit

    Found this issue while reporting mine.

    My UI App has a patch that if you want can be applied to Claude Code on 2.1.74.

    https://github.com/wellofspirit/ClaudeUI

  11. 0reo commented on Mar 28, 2026

    @0reo

    Adding forensic evidence from two sessions on v2.1.86 (Opus 4.6, 1M context) confirming this is still happening.

    Two affected sessions

    nah project MCN WordPress project
    Last turn before exit 348,613 cached tokens 434,086 cached tokens
    First turn after resume 0 cached tokens 0 cached tokens
    Session JSONL size 17MB, 6,932 lines 12MB, 7,408 lines
    Summary events in log 0 0
    Orphaned parentUuids 635 / 7,108 entries 1,628 / 7,408 entries

    Broken link analysis (nah session)

    Entry types with orphaned parentUuid references:

    637 user     (tool_result messages from subagents)
    627 progress (subagent streaming updates)
      6 assistant
    

    These are almost entirely from Agent tool (subagent) calls. The subagent's internal messages exist in a separate context, but the results written to the main session JSONL reference parentUuid values from the subagent's chain — UUIDs that don't exist in the main session file. Chain traversal hits these broken links and stops.

    Token-level proof of context loss

    From the JSONL usage fields:

    # nah session — last turn before /exit (Mar 27):
    cache_read_input_tokens: 348,388
    
    # First real model call after resume (Mar 28):
    cache_read_input_tokens: 31,727  (system overhead only — zero conversation history)
    

    Both sessions were well within the 1M context window (35-43% used). The context should have loaded in full.

    Environment

    • Claude Code: 2.1.86
    • Model: Opus 4.6 (1M context)
    • Linux 6.17.0-14-generic
    • Both sessions had heavy subagent usage (background agents, parallel tasks)

    Originally filed as #40319, closing that as duplicate.

    This comment was written by Claude Code.

  12. 0reo commented on Mar 28, 2026

    @0reo

    Update: After walking the main parent chain in both sessions, it turns out the chain is fully intact (3,308 entries, zero breaks). The orphaned parentUuid references are all on side branches (subagent entries) that don't affect main chain traversal.

    This means my sessions are affected by a different bug — the chain is fine but Claude Code still doesn't load it on resume. Reopened #40319 to track that separately.

    The broken chain issue described in this ticket is still valid and likely affects other users — just not the root cause in my case.

    This comment was written by Claude Code.

  13. ymonster commented on Apr 26, 2026

    @ymonster

    Thanks @tennox for the detailed write-up and root-cause analysis — it was extremely helpful for understanding what's going wrong in these JSONL files.

    Building on the patterns described here, I put together a small Python tool in case it's useful to others hitting the same issue:
    https://github.com/ymonster/cc_jsonl_fix

    What it does:

    • Strips NUL bytes from lines truncated mid-flush
    • Nullifies file-history-snapshot messageId values that collide with real message UUIDs
    • Re-parents orphan parentUuid references to the nearest valid message (without crossing compact_boundary segments)
    • Iteratively absorbs disconnected branches back into the main chain, so messages stranded on side branches become reachable again
    • Creates a timestamped backup, supports --dry-run, and runs an integrity check (orphans / cycles / duplicates) before writing
      Requirements: Python >= 3.10, no external deps.

    One caveat: I've only been able to test it against the corrupted sessions I've personally run into, so there are almost certainly corruption patterns I haven't seen yet. If anyone tries it and it doesn't fix your file (or fixes it incorrectly), I'd really appreciate an issue on the repo with details — more real-world broken files would help me cover more cases. Hopefully this gets fixed upstream eventually, but in the meantime sharing in case it helps.

  14. ymonster commented on Apr 27, 2026

    @ymonster

    Update — hit a different failure mode of (presumably) the same root issue today.

    Original report describes silent truncation on resume. What I just hit: resume succeeded, the session opened normally, but every prompt afterward got no response — effectively frozen. The 47 MB session turned out to have:

    • NUL-corrupted lines: 0
    • Snapshot messageId collisions: 1235
    • Orphan parentUuid: 0
    • Branches absorbed: 6 (main chain 1963 → 1970)

    So the snapshot-vs-uuid collision path can freeze a resumed session entirely, not just shorten it. Same chain-integrity family as the original report, different externally visible symptom.

    cc_jsonl_fix repaired it cleanly: https://github.com/ymonster/cc_jsonl_fix

  15. jontaxproduction commented on Aug 19, 2026

    @jontaxproduction

    Related but distinct variant, reported from Claude Desktop (macOS) rather than CLI --resume.

    Environment: macOS 26.5.1 (build 25F80), Claude Code 2.1.235, via Claude Desktop's Claude Code tab.

    Symptom: A session that had been used across several separate sittings over multiple days went missing from the Desktop app's session content — the title still appeared in the Recents list, but opening it showed only the first sitting's messages, with no indication anything later was missing.

    What I found on disk:

    • The underlying .jsonl transcript is complete and well-formed: 8 user turns spanning multiple days, ending at the exact minute the user closed their laptop that night. I walked the parentUuid chain end-to-end — no broken links, no id collisions like the corruption described above. The file itself is intact.
    • The Desktop app's own session record (the one actually shown in the Recents list, under its own internal session id) has no corresponding .jsonl file anywhere in ~/.claude/projects/.... It's an index-only entry with no backing transcript file.
    • That index record's cached message list froze after the session's first 2 turns (day 1 of several), while its lastActivityAt metadata kept advancing in step with the real file's actual last-write time (matched to within ~6 seconds) on every later day the session was used. So the index knew the session was still active, but never re-synced the message list it displays past turn 2.
    • Net effect: the app shows a session with a current-looking "last active" timestamp, but opening it reveals only stale day-1 content — silently, with no error and no visible gap — while several later turns (including real work product already written to disk) are invisible in the UI.

    This looks like a different root cause from the parent-chain corruption above — the source-of-truth .jsonl was never damaged — but the same user-facing outcome: real conversation content on disk, inaccessible through the app. Feels closer to the stale sessions-index.json behavior described in #26519, just manifesting in the Desktop app's Recents list instead of CLI --resume. Happy to share the session-id / index-id pair or exact timestamps if it helps repro.

  16. murillovskfilho commented on Aug 20, 2026

    @murillovskfilho

    Possibly the same failure class, different symptom shape and platform. Sharing in case it's useful triangulation data.

    Environment

    • OS: Windows 11
    • Claude Code: 2.1.200
    • Single project session, continuously used (no /clear) since ~2026-07-17, .jsonl transcript ~1.68MB at time of repro

    Symptom

    claude -c -p "<prompt>" (and interactively -c) resumes the session, but the reconstructed context reflects a topic from roughly 6 weeks earlier in the transcript instead of the actual most recent exchange, even though:

    • The raw .jsonl file demonstrably contains all messages up to the current moment (verified by direct read, and by watching the file's mtime/size update correctly as new turns were appended).
    • The session being resumed is confirmed to be the correct/same session ID, not a different session being picked by mistake.

    Repro:

    1. Keep one Claude Code project session alive and continuously appended to over multiple weeks (no /clear, no new session) until the transcript file grows large.
    2. From that project directory, run claude -c -p "In one sentence, what was the last topic we discussed?".
    3. Response describes a topic from much earlier in the transcript's history, not the actual last exchange.

    Also independently noticed ~/.claude/history.jsonl (the input-recall log, separate file) had stopped being appended to as of the same rough time window, despite the session itself staying active well past that point. Manually repairing that file (appending well-formed entries with the correct session ID/timestamps) did not change the -c resume symptom above, so it looks like two distinct staleness bugs rather than one, but both point at some kind of stale checkpoint/leaf-node tracking on long-lived sessions rather than a read-time parsing issue.

    Happy to share the (redacted) transcript file directly if that'd help pin down whether it's the same broken-parentUuid-chain pattern described above, just didn't want to paste raw session content publicly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions