[BUG] Second `/compact` in one process re-appends the history the first one summarized away, transcript grows quadratically

Status Open
Reported on v2.1.260
Maintainer reply None cached
Activity 3 comments · opened Sep 4, 2026

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 parentUuid from the newest row leads through the replayed copy and around every message written between the previous compaction and this one. Readers that honor preservedMessages re-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

  1. Save the script below as repro.py and run it in an empty directory with claude on PATH. I used the binary the desktop app ships, ~/Library/Application Support/Claude/claude-code/2.1.260/claude.app/Contents/MacOS/claude
  2. It drives one claude -p process through two short turns, /compact, two more turns and a second /compact, then prints every row of the session transcript whose uuid already appeared earlier in the file
  3. 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.

View original on GitHub ↗

3 Comments

samvallad33 · 7 days ago

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

thomasbachem · 5 days ago

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:

2026-09-08 14:00:23 [info] LocalSessions.rewindV2: sessionId=local_4136cebc… target=3792774e…
2026-09-08 14:00:24 [info] [Rewind] resumeSessionAt=71ab79d3… + forkSession for session local_4136cebc…
2026-09-08 14:00:25 [warn] [CCD CycleHealth] local_4136cebc… cli_execution_error (error_during_execution): No message found with message.uuid of: 71ab79d3…
2026-09-08 14:00:26 [info] [CCD] Clearing stale pendingRewindTo 71ab79d3… — rewind target not on CLI's active chain

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_boundary at 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.

thomasbachem · 5 days ago

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: tailUuid is the stop_hook_summary row that got the wrong parent, headUuid the row it should point at. Setting that one parentUuid to headUuid put 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 a compact_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.