[BUG] Second `/compact` in one process re-appends the history the first one summarized away, transcript grows quadratically
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report
- [x] I am using the latest version of Claude Code
What's Wrong?
The first /compact in a Claude Code process writes its compact_boundary and summary rows and nothing else. A later /compact in the same process first re-appends, to the session .jsonl, a copy of every message the process has held from where its history began (the transcript start, or the boundary it resumed from) up to the row before the previous boundary's preservedSegment.headUuid. The copies keep their original uuid, parentUuid and timestamp.
The finished turn's stop_hook_summary row is then written with parentUuid pointing at the last copied row instead of the assistant message it follows, and the new boundary's logicalParentUuid and preservedSegment.tailUuid point at that hook row while preservedSegment.headUuid points at the real last assistant message, thousands of rows earlier in a long file.
Two effects:
- The transcript grows quadratically with the number of compactions, since each replay repeats everything before the previous boundary. One desktop session of mine is 166 MB and 17,234 rows, of which 7,394 rows (104 MB) are these copies
- Following
parentUuidfrom the newest row leads through the replayed copy and around every message written between the previous compaction and this one. Readers that honorpreservedMessagesre-stitch the chain, readers that don't see that stretch as a dead branch
In the 166 MB case the Claude desktop app logged transcript is 165736911 bytes; tail-loading last 52428800 bytes and its conversation view ended at the last message before the compaction, with a day of later turns missing, until I relaunched the app. I assume that is a downstream effect of the two points above.
Not the same as #78592, which was about per-edit file content. The duplicate rows here are whole message rows tied to compaction.
What Should Happen?
A /compact appends the boundary and the summary. Rows that are already in the file are not written again, and the stop_hook_summary row's parentUuid is the message it follows.
Error Messages/Logs
Transcript written by the script below, uuids shortened, bookkeeping rows omitted
line 3 user uuid=1318e849 parentUuid=-
line 9 assistant uuid=a5ef5047 parentUuid=0b91f309
line 10 assistant uuid=2e4578cf parentUuid=a5ef5047
line 13 system stop_hook_summary uuid=0d7a39b1 parentUuid=2e4578cf
line 14 user uuid=9f124e88 parentUuid=0d7a39b1
line 15 attachment uuid=bea57c1f parentUuid=9f124e88
line 16 assistant uuid=adafd410 parentUuid=bea57c1f <- head of the first boundary
line 17 assistant uuid=3056d6fb parentUuid=adafd410
line 22 system stop_hook_summary uuid=410bf7fa parentUuid=3056d6fb
line 23 system compact_boundary uuid=456f5f65 logicalParentUuid=410bf7fa preservedSegment{head=adafd410 tail=410bf7fa}
line 24 user (isCompactSummary) uuid=5b264742 parentUuid=456f5f65
... two more turns
line 41 assistant uuid=2122c90c parentUuid=4d78410b <- last message before the second /compact
line 42 assistant uuid=2503f4e8 parentUuid=2122c90c
line 47 user uuid=1318e849 parentUuid=- <- copy of line 3
line 48 attachment uuid=e68f0a35 parentUuid=1318e849 <- copy of line 4
... copies of lines 5 to 14
line 56 attachment uuid=bea57c1f parentUuid=9f124e88 <- copy of line 15, the row before the first boundary's head
line 57 system stop_hook_summary uuid=cdf60317 parentUuid=bea57c1f <- new row, parent is the copy above, not 2503f4e8
line 58 system compact_boundary uuid=334cf755 logicalParentUuid=cdf60317 preservedSegment{head=2122c90c tail=cdf60317}
line 59 user (isCompactSummary) uuid=ed9a72bc parentUuid=334cf755
Steps to Reproduce
- Save the script below as
repro.pyand run it in an empty directory withclaudeonPATH. I used the binary the desktop app ships,~/Library/Application Support/Claude/claude-code/2.1.260/claude.app/Contents/MacOS/claude - It drives one
claude -pprocess through two short turns,/compact, two more turns and a second/compact, then prints every row of the session transcript whoseuuidalready appeared earlier in the file - Expected output is nothing. Actual output is the ten rows before the first boundary's
preservedSegment.headUuid, listed a second time
import json, os, subprocess, time, uuid
session = str(uuid.uuid4())
proc = subprocess.Popen(
['claude', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--model', 'haiku', '--session-id', session],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
def send(text, until):
proc.stdin.write(json.dumps({'type': 'user', 'message': {'role': 'user', 'content': text}}) + '\n')
proc.stdin.flush()
for line in proc.stdout:
message = json.loads(line)
if message.get('type') in until or message.get('subtype') in until:
return
for step in ['Reply with one word: alpha', 'Reply with one word: beta', '/compact', 'Reply with one word: gamma', 'Reply with one word: delta', '/compact']:
send(step, ('result', 'compact_boundary'))
time.sleep(2)
proc.stdin.close()
proc.wait()
path = os.path.expanduser('~/.claude/projects/' + os.getcwd().replace('/', '-') + '/' + session + '.jsonl')
rows = [json.loads(line) for line in open(path)]
seen = {}
for index, row in enumerate(rows):
if row.get('uuid') in seen:
print('line %d duplicates line %d: %s uuid=%s' % (index + 1, seen[row['uuid']] + 1, row['type'], row['uuid'][:8]))
elif row.get('uuid'):
seen[row['uuid']] = index
line 47 duplicates line 3: user uuid=1318e849
line 48 duplicates line 4: attachment uuid=e68f0a35
line 49 duplicates line 5: attachment uuid=f405d3dc
line 50 duplicates line 6: attachment uuid=7e8e32e4
line 51 duplicates line 7: attachment uuid=0b91f309
line 52 duplicates line 9: assistant uuid=a5ef5047
line 53 duplicates line 10: assistant uuid=2e4578cf
line 54 duplicates line 13: system uuid=0d7a39b1
line 55 duplicates line 14: user uuid=9f124e88
line 56 duplicates line 15: attachment uuid=bea57c1f
The same happens in a process started with --resume on an existing session, the copy then starts at the boundary the process resumed from. My desktop app transcripts agree: all 21 compactions that were the first in their process are clean, and 13 of the 15 later ones replayed. I could not explain the 2 clean ones.
Claude Model
Not sure / Multiple models. The script uses haiku, my desktop transcripts show it on claude-opus-5, claude-fable-5 and claude-fable-5-1.
Is this a regression?
I don't know
Last Working Version
Unknown, the oldest replay I have is from 2026-08-24 on 2.1.237
Claude Code Version
2.1.260, also seen with 2.1.255, 2.1.247, 2.1.246 and 2.1.237
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Other. I only use the Claude desktop app (1.46388.2), which drives its bundled CLI headless in stream-json mode, and the script runs that binary the same way. I have never used the interactive terminal.
Additional Information
Every compaction in my transcripts was a manual /compact, I have no auto-compaction data. The copied rows are exactly the process's in-memory history: rows on a rewound branch are not copied, and the copies come in memory order, a compaction's summary before its preserved messages, not file order.
3 Comments
Second /compact replays the history the first one summarized away. The transcript grows quadratic. I do not keep facts in that transcript. Vestige already has them. Compact should append a boundary not a replay.
https://github.com/samvallad33/vestige
Two things from a desktop session hit by this on 2026-09-08, its two compactions two hours and twenty minutes apart.
Rewind stops working session-wide, not just the view. The app resolves a rewind target's resume point against the same walk, so it lands inside the replayed copy and the CLI refuses the turn:
The target was a message from 14:00 and
71ab79d3…a row from 10:05:10. Every target in the session fails this way, surfacing as "Claude couldn't process that message".The truncated view is not only the 166 MB tail-load case. This transcript is 5 MB, well under the 50 MiB threshold, and the scrollback still jumps from before the first compaction straight to the newest messages, because the chain itself skips that stretch: 413 rows off the walk, 278 of them user and assistant turns. I have not tried a relaunch on this one, but the rows are absent from the chain on disk rather than merely unread, so re-reading the file should not help.
Nothing is lost on disk either way. Pointing the second
compact_boundaryat the last message before it, and the row after it at that boundary's summary, put all 413 rows back on the walk when I tried it on a copy.I opened #92849 for this before finding this thread, now closed as a duplicate.
Correcting the repair I gave in my last comment – it is one field on one row, not two edits.
The boundary the second compaction writes already carries the answer in
compactMetadata.preservedSegment:tailUuidis thestop_hook_summaryrow that got the wrong parent,headUuidthe row it should point at. Setting that oneparentUuidtoheadUuidput all 413 rows of the affected session back on the walk, applied to the file this time rather than to a copy. My two-edit version rested on the assumption that a healthy walk ends on acompact_boundary, which is wrong – boundaries sit off the chain as parentless branch roots, and a healthy walk reaches the transcript root without touching one.That may point at the fix as well – the same turn writes the wrong parent onto the hook row and, moments later, the correct one into the boundary.