A session.compact function hook's compaction is undone when the session is resumed

Status Open
Reported on v2.1.276
Maintainer reply None cached
Activity 5 comments · opened Sep 18, 2026

Version: Claude Code 2.1.276, macOS arm64, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

What happens. A plugin answers session.compact with { messages }, handing most
messages back unchanged (with the engine's handle) and leaving some out. The running
process is compacted. After claude --resume <id> the dropped messages are back: the
first API call carried 311,812 input tokens on a session compacted from 309,936, and
the model could quote a tool call the hook had dropped.

Why (from the transcript and the resume loader).

  1. A kept tool_result record is re-persisted after the boundary with a new uuid, but

its sourceToolAssistantUUID — written as parentUuid — still names the old
assistant record behind the boundary. The parent walk from the newest record leaves
the boundary behind and returns the whole history.

  1. A kept assistant record keeps its message.id. The loader's parallel-tool-result

recovery gathers every record in the file sharing a chain record's message.id, so
the pre-boundary originals — dropped tool_use records and their results included —
are recovered. A core compaction avoids this because records before the boundary that
are not in preservedSegment are discarded; a hook's boundary has no such list.

Also. compact_boundary.compactMetadata.postTokens (51,140) and the /context
figure after a resume do not reflect the context actually sent (kept assistant records
carry their old usage; core zeroes it for a preserved segment).

Expected. Records a hook keeps are re-parented and re-identified like a preserved
segment, or records before a hook's boundary are discarded on load.

Workaround I use. Hand kept tool_result messages and older assistant
messages back without their handle;
a small script that walks parentUuid and message.id across the boundary confirms both causes on the transcript.

Function hooks are experimental and undocumented, so this may be out of scope — filing it in case the resume loader change is cheap. Reproduced headless with claude -p "/compact" --resume <id> --fork-session followed by claude --resume of the fork.

View original on GitHub ↗

3 Comments

MG-ge · 26 days ago

Reproduced independently on Claude Code 2.1.274 (desktop app's bundled binary, macOS arm64, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1), with a different session.compact plugin that keeps recent messages with their handle and rebuilds older ones without it.

  • Same first cause: a kept tool_result re-persisted after the boundary has a parentUuid pointing at the pre-boundary assistant record, so --resume walks back into the full history. A session compacted from 166k to 19k came back at ~168k on the first turn after resume.
  • Your workaround works here too: returning the recent tool_result messages without their handle (older messages were already handle-less). A 244k session compacted to 86k now resumes at 100k on the first turn, instead of the full 244k.
  • Also seen: compact_boundary.compactMetadata.postTokens understates the context actually sent on the next turn (85k reported, 210k sent) when kept records still carry hidden reasoning and images.

A loader-side fix would still be preferable, since the workaround costs the engine's own bookkeeping for those records.

JohnathanWhite · 10 days ago

Still reproduces on Claude Code 2.1.289 (macOS arm64, mods loaded with --plugin-dir), with a session.compact mod that returns one user note plus the turns since the last prompt.

  • Tail returned with handles: every kept tool_result row after the boundary has parentUuid = its pre-boundary assistant, and the resume walk from the last message goes around the boundary. With three auto folds in one tool run (CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=2), the walk ends at the first fold's boundary, so resume brings back turns that the second and third folds removed.
  • Tail rebuilt without handles ({role, text, toolUses, toolResults}): no row after any boundary is parented before it, and the walk stops at the last boundary. That held for one /compact, two in a row, and three auto folds mid tool run, on Haiku and Opus. The note and the kept turns survive resume.

So the workaround above still works, at the cost of the tail's thinking blocks and images. Passing compactMetadata.preservedSegment (or the equivalent re-linking) for a hook's fold would let a hook keep them.

TOSTES-LAB · 7 days ago

Still reproduces on Claude Code 2.1.292 (Windows 11, desktop app and claude -p --resume), with a session.compact mod derived from fast-jev-compaction.

  • Kept tool_result returned with its handle: 129K → 38K in memory, 130K on the first turn after --resume.
  • Same message rebuilt without handle: 55K after resume. The ~17K over the in-memory figure shows up on every resume, also when no tool result is kept, so it is attachments a fresh process re-sends, not this bug.
  • Kept results with images or PDFs: instead of keeping the engine object (which brings the bug back) or rebuilding with an empty result, we rebuild the message without handle and replace each non-text block with a short text placeholder naming its source ([image removed at compaction: <path>]). The model loses the image but knows it existed and can read it again. Resume probe: 139.5K before, 38.8K in memory, 51.4K after --resume.

A loader-side fix (or preservedSegment for hook folds) would still be better, since the workaround drops the kept records' hidden reasoning and images.

Showing cached comments. Read the full discussion on GitHub ↗