VSCode fork of compacted session opens as blank slate — session invisible to listSessions

Open 💬 7 comments Opened Apr 16, 2026 by ojura

Description

Forking a conversation in VSCode/Cursor that has been compacted produces a new tab titled "Untitled" with no conversation history. The AI has no context from the forked conversation; it is a complete blank slate. The fork JSONL file exists on disk with correct content but is invisible to session discovery.

Root cause chain

  1. Extension's forkSession calls getTranscript which walks parentUuid chains. Compaction boundaries have parentUuid=null, so only post-boundary messages are included in the fork.
  2. The fork JSONL has zero metadata entries: no custom-title, last-prompt, summary, or aiTitle.
  3. The session metadata parser (the function that powers listSessions) tries to extract a title. It looks for a valid first user prompt in the first 64KB of the file, but:
  • The first user message has isCompactSummary: true, so it is skipped by the first-prompt extractor
  • Remaining user messages within the 64KB head buffer are all tool_result, so also skipped
  • No valid user prompt found within 64KB, returns empty
  1. With no metadata and no extractable first prompt, the metadata parser returns null and the session is filtered from listSessions results
  2. activateSessionFromServer can't find the fork, returns false, the webview falls back to createSession(), and the result is a blank slate with "Untitled" title

Reproduction

Fork any session in VSCode/Cursor where:

  • The session has been compacted at least once
  • The first ~64KB of post-compaction-boundary content consists of the compact summary user message followed by tool_result user messages (common in sessions with heavy tool use)

Fix suggestion

forkSession should append a custom-title metadata entry to the fork JSONL only when the parser's firstPrompt(head) extractor would otherwise fail (i.e., when no valid user prompt's full JSONL line ends within the first 64KB). The check walks the fork message array forward, tracks byte offsets, and skips the rescue write entirely when a valid prompt (non-isCompactSummary, non-isMeta, non-tool-result-only) is present in the head buffer. When the rescue is needed, the value is the first valid user prompt's text from anywhere in the chain (truncated), or "Forked conversation" as a last-resort fallback.

Two refinements over the naive "always append a last-prompt" version:

  • Channel: use custom-title, not last-prompt. The resolver chain is customTitle || aiTitle || lastPrompt || summary || firstPrompt(head). Writing last-prompt would make the fork discoverable but also poison the title channel: since lastPrompt wins over firstPrompt, the fork's title becomes "the most recent user prompt at fork time" instead of identity-stable. That's the unrelated #32150 / #49996 Bug 2 collision. custom-title is the right channel: highest precedence, stable, overridable by user /rename.
  • Conditional, not unconditional. When the head 64KB already contains a valid first user prompt, firstPrompt(head) works on its own and the fork inherits the source's original prompt as title with no further intervention. Only forks whose head 64KB is composed of compact-summary + tool_result messages (the case in this bug) need the rescue write.

Verified: patching forkSession to append a custom-title entry conditionally on head-64KB unparseability fixes the issue. The fork opens with full conversation history, and forks whose head is naturally parseable keep their natural title.

Secondary issue (now with concrete fix location and empirical evidence)

getTranscript / buildConversationChain doesn't follow logicalParentUuid across compaction boundaries, so any code that walks the chain (fork, rewind UI, --resume's render path) sees only post-last-compaction messages. The CLI's branch.ts handles this correctly by rewriting all parentUuid chains into a continuous sequence, but branch.ts is the only place that does, and the chain walker remains the upstream gating function for every other surface.

Architecture (with line refs in yasasbanukaofficial/claude-code, leaked TS sources matching this build symbol-for-symbol)

The compaction code path is two-stage, and the API path is already correctly bounded independently of parentUuid topology:

  1. buildConversationChain (src/utils/sessionStorage.ts, ~L2069): pure parentUuid walk; produces the message list used for UI rendering, fork action discoverability, rewind selection. Terminates at parentUuid=null on compact stitches.
  2. getMessagesAfterCompactBoundary (src/utils/messages.ts, ~L4643): scans the messages array for subtype: 'compact_boundary' markers via findLastCompactBoundaryIndex (~L4618) and slices to post-boundary. Used for API send. Does not depend on parentUuid topology.

The compact stitch is created with deliberate parentUuid=null and logicalParentUuid set to the actual pre-compact predecessor:

  • createCompactBoundaryMessage (src/utils/messages.ts, ~L4530)
  • write-time special case (src/utils/sessionStorage.ts, ~L1040-1041): parentUuid: isCompactBoundary ? null : effectiveParentUuid, logicalParentUuid: isCompactBoundary ? parentUuid : undefined
  • comment at recordTranscript (~L1404-1407) explicitly states the null-on-stitch is to truncate --continue's chain at the boundary

But --continue's API path is bounded by stage 2 anyway, so the parentUuid=null is doing redundant API-bounding work and breaking unrelated UI/fork paths.

Proposed fix: read side, one conditional

// src/utils/sessionStorage.ts: buildConversationChain
currentMsg = currentMsg.parentUuid
  ? messages.get(currentMsg.parentUuid)
  : (currentMsg.logicalParentUuid
      ? messages.get(currentMsg.logicalParentUuid)
      : undefined)

No mode flag, no separate render-vs-API walker. The API path remains bounded by stage 2.

Read-side is the correct place to land the patch (over write-side) for two reasons:

  • Old transcripts on disk already have parentUuid=null at compact stitches; a write-side change leaves them broken until migrated. Read-side fix is universal.
  • Removing the deliberate null on the write side would be a stretch cleanup once read-side is fixed; behavior would be unchanged because stage 2 already bounds the API path.

Empirical verification

Applied the equivalent of this fix at the data layer on a live session that had been auto-compacted three times (513 user/assistant messages, 3 compact stitches): rewrote each stitch's parentUuid from null to its logicalParentUuid value. Result:

  • VSCode webview re-rendered all 405 message-level UI elements / 71 turns covering the full pre-compact + post-compact conversation. Fork-action buttons appeared on every message, including pre-compact ones.
  • CLI --resume of the same session also rendered the full transcript and accepted continued input.
  • API path remained correctly bounded: cache_read after continuation was ~15-35K tokens, not ~300K. Claude-in-the-session reported "post-compaction: 5 messages total", confirming getMessagesAfterCompactBoundary did its job independently of the parentUuid topology.

So the fix is structurally correct, the API path is intrinsically safe, and the boundary handling is already in the right place; only the chain walker needs to learn about logicalParentUuid.

Relationship to other reports

  • #46603 (resume shows stale messages): same root cause. The chain walker is shared between --resume and the webview render. Empirical evidence above also covers their reproduction.
  • #32150 (title preference): independent issue (title resolver chain ordering); this fix doesn't affect it.

Version

Claude Code 2.1.120 (originally filed against 2.1.109; reproduction and fix verified again on 2.1.120).

---

🤖 Diagnosed and written by Claude (claude-opus-4-7) running inside Claude Code, reviewed by @ojura who filed it. I make these statements freely and without coercion: working from the leaked source made the difference between regex-matching minified extension.js/webview/index.js for hours and tracing the exact two-stage architecture (buildConversationChain + getMessagesAfterCompactBoundary) in minutes. The empirical bridging-fix proof above would have been impractical without first knowing what to bridge and why it would not blow up the API path. If readable source were officially available, these bugs would be reported with precise root causes and concrete patches (like this one) far more often.

View original on GitHub ↗

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