Repository navigation
[Bug]: Claude rewind fails after a /compact that preserved recent messages, and leaves the forked transcript on disk #13638
Description
Activity
Triage
Confirmed on current
main(1c12706638) and onv0.0.42(719a76ca1d). A Claude rewind whose fork drops a retained user or assistant message fails with this error, and the fork transcript is left on disk. No other open issue tracks it.What the report gets right
Rewind does not truncate the source session. It calls the standalone
forkSession(sessionId, { upToMessageId }), reads the fork back, and only then swaps the live session. The cut is the entry immediately before the first removed human turn, which is a trailingstop_hook_summarywhen Stop hooks are on:const rollbackAt = retainedCount > 0 ? messages[firstRemoved - 1]?.uuid : undefined; // ... const remappedBoundaries = remapClaudeForkTurnBoundaries( messages, forkMessages, firstRemoved, retainedBoundaries, ); if (!remappedBoundaries) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "thread/rollback", detail: "Claude fork history did not preserve the retained turn boundaries.", }); }
#11954 only makes a length change acceptable when every retained user and assistant body is still present. System entries are ignored, so a trailing
stop_hook_summarydisappearing fromgetSessionMessagesdoes not fail the check. A shorter conversation does:const remapClaudeForkTurnBoundaries = ( messages: ReadonlyArray<ClaudeHistoryMessage>, forkMessages: ReadonlyArray<ClaudeHistoryMessage>, firstRemoved: number, retainedBoundaries: ReadonlyArray<string | null>, ): Array<string | null> | undefined => { const retainedConversation = messages.slice(0, firstRemoved).filter(isClaudeConversationMessage); const forkConversation = forkMessages.filter(isClaudeConversationMessage); // ... const offset = forkConversation.length - retainedConversation.length; if ( offset < 0 || retainedConversation.some((message, index) => { const forkMessage = forkConversation[index + offset]; return ( forkMessage === undefined || forkMessage.type !== message.type || !NodeUtil.isDeepStrictEqual(forkMessage.message, message.message) ); }) ) { return undefined; }
offset === -1with one missing assistant body is the reject path. The compact-prefix test is the opposite shape: the fork has extra earlier user/assistant messages and alignment from the end succeeds. A preserved assistant thatgetSessionMessagescan no longer splice in is supposed to fail.That splice fails because of anthropics/claude-agent-sdk-typescript#472, still open.
forkSessionremaps message UUIDs andparentUuid, and copiescompactMetadata(preservedSegment/preservedMessages) unchanged. The pinned SDK is0.3.276. The report reproduces the helper on0.3.274and0.3.280, so a newer lockfile does not clear this.v0.0.42already contains the same error string.The failure returns before
stopSessionInternal, which matches the adapter tests (closeCallsstays 0, the resume cursor is unchanged). The source thread is still the one the user had. The fork file is not. Nothing in the server callsdeleteSession, and the history worker only implementsgetSessionMessagesandforkSession. Each failed attempt writes another full copy and leaves it.What to be careful with
resume+resumeSessionAtis the SDK's truncation API on the Agent SDK path, and this adapter deliberately does not send it. A storedresumeSessionAtis kept on the cursor and omitted fromquery()("passes Claude resume ids without pinning a stale assistant checkpoint"). #7198 added a pin and was closed as superseded by #11338. The review note on that PR is already how main behaves: the cut comes fromgetSessionMessages, not from the empty in-memory turn list on a recovered session. UsingresumeSessionAtmeans a new pin thatstartSessionactually honors, cleared after the next message, not a reuse of the current cursor field.Cutting at the last retained assistant is the wrong general rule for that API. SDK
0.3.276says to fork at the kept turn's last chain entry, whatever it is. The currentmessages[firstRemoved - 1]is that entry. It is a badforkSessionreadback point when it is a system leaf, becausegetSessionMessagesthen drops it.resumeSessionAtaccepts that UUID. Cutting earlier drops a kept-turntool_result. This path is still untested against a preserved-segment compact, as the report says.Repairing
preservedSegmentinside the fork depends on the transcript layout the SDK bug is about. A fail-fast check beforeforkSessionalso cannot see that metadata on the desktop path: a distinctCLAUDE_CONFIG_DIRreads history through the worker, anddecodeSessionMessageskeeps onlytype,uuid,parent_tool_use_id, andmessage.Workaround
Send the next message in the same thread, or start a new thread. Rewind did not stop the source session. The extra files are the rejected fork transcripts under the Claude project directory, not the session T3 is still resumed on.
Labels / next
- Type: bug, accepted.
- Labels:
bug,accepted. - Next: a rejected fork has to be removed with
deleteSession(new worker method on the isolated-home path). Rewind after a preserved-segment compact stays broken until it stops using the standaloneforkSessionhelper, or until Add run button for runnable markdown command code blocks #472 remapscompactMetadata. Preferresume+ a one-shotresumeSessionAttaken from durable history over editing the fork file. Do not sendresumeSessionAton an ordinary resume.
- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.acceptedfeature request acceptedfeature request acceptedvia-triageFiled through npx t3 triageFiled through npx t3 triage
on Sep 25, 2026 Reproduced on macOS arm64 (Darwin 27.0.0), T3 Code desktop nightly
0.0.43-nightly.20260925.2251.Local logs show four single-turn rollback failures across two Claude threads on September 25. Both threads previously emitted
compact_boundarywithpreserved_segmentandpreserved_messages. One compaction was automatic; the other was manual.All four failed with:
Claude fork history did not preserve the retained turn boundaries.This adds evidence that automatic compaction also precedes this failure. No application state or transcripts were modified during investigation.
Investigated by Codex (GPT-6) via T3 triage.
Thanks for taking the time to report this and provide the details. We revisited it during the orchestrator V2 cleanup.
V2 rollback records a native conversation-head reference and resumes at that checkpoint. It no longer calls forkSession for rewind, removing the standalone fork UUID-remapping and rejected-copy mechanism in this report.
I’m closing this report because the implementation it targets has been replaced. That does not mean every similar symptom is fixed.
Explicit fork still uses forkSession and is a separate action. No live preserved-compaction rewind was run.
If you still hit this on a current build, please reply with the app/server versions and the steps that reproduce it. We can reopen this if the original problem is still there.
Before submitting
Area
apps/server
Steps to reproduce
claudeAgent) thread, run a manual/compactthat keeps recent messages verbatim, so thecompact_boundaryentry getscompactMetadata.preservedSegment/preservedMessages.Expected behavior
The thread rewinds to just before that message, as it does in threads that were never compacted.
Actual behavior
The rewind fails:
The transcript
forkSessionalready wrote is never removed. It is a full copy of the source transcript (107 MB in my case) left in~/.claude/projects/<project>/, and each failed attempt leaves another one.I measured the real session read-only with the SDK's own
getSessionMessages(includeSystemMessages: true):system/stop_hook_summary) are the two entries in the compact boundary'spreservedMessages.uuids. The fork rewrote their UUIDs but copiedcompactMetadataverbatim, sogetSessionMessagescannot relink them on the fork. This is [Bug] forkSession leaves stale compaction UUID references, restoring discarded history anthropics/claude-agent-sdk-typescript#472.system/stop_hook_summary) is the fork point itself. The fork ends on a system entry, andgetSessionMessagesstarts the chain from a leaf's nearest user/assistant ancestor, so the trailing system entry is dropped. I have Stop hooks configured, so every turn ends with one of these. On main, fix(server): keep Claude rewind when fork history length changes #11954 already covers this part by comparing only user/assistant messages.I ported
remapClaudeForkTurnBoundariesfrom main @ 1c12706 and ran it against the same two transcripts: retained conversation 389, fork conversation 388, offset -1, rejected. The rejection is right, because the fork really did lose a retained assistant message. So on main, rewind stays broken for any thread with a preserved-segment compaction, as long as it goes through the fork helper and the SDK bug is open.Possible fixes (none need the SDK fix)
resume+resumeSessionAtinstead offorkSession(). Leave the source transcript untouched and restart withresume: <source session>andresumeSessionAt: <cut>. The CLI's own loader then truncates the transcript it wrote, and the SDK bug is specific to the standalone helper. New turns branch from the cut in the same file, andgetSessionMessagesalready follows the newest leaf. This removes the full-transcript copy, the UUID remap, the fork verification step, and the orphaned files. Retained turn boundaries keep their original UUIDs.getSessionMessages), not from in-memory turns. That was the recovered-session problem raised in review of fix(server): point Claude resume cursor at reverted transcript #7198.messages[firstRemoved - 1], so a trailingstop_hook_summaryis never the cut point.resumeSessionAtuntil the first new message lands on the new branch, then clear it. That keeps the existing guard against passing a staleresumeSessionAton ordinary resume.forkSession()and repair the fork. The helper copies message entries in source order, so the k-th copied entry corresponds to the k-th source entry. Check each pair by type and message body, as fix(server): keep Claude rewind when fork history length changes #11954 already does, and build the old-to-new UUID map. Then rewritepreservedSegment/preservedMessagesin the fork'scompact_boundaryand run the existing verification. Only do this when those UUIDs don't resolve in the fork, so it becomes a no-op once the SDK bug is fixed. The catch is that it depends on the SDK's private transcript layout.compact_boundarywith preserved-message metadata and fail fast with the existing "Start a new thread instead" message, instead of writing a full copy first.Impact
Major degradation or frequent failure
Version or commit
v0.0.42 desktop (bundled
@anthropic-ai/claude-agent-sdk0.3.260). Rewind logic also checked against main @ 1c12706 (SDK^0.3.276; the SDK issue reports the bug on 0.3.274 and 0.3.280).Environment
Windows 11 (10.0.26200), T3 Code desktop v0.0.42,
claudeAgentprovider, two Stop hooks configured.Logs or stack traces
Workaround
Resend in the same thread instead of rewinding, or start a new thread.