Function hooks: a transcript returned from a session.compact hook is dropped on --resume, and the full pre-compact history is replayed
Summary
The session.compact docs say a hook can "answer { messages } of your own". Doing that works within the process. The engine installs the returned transcript, writes a compact_boundary with the right postTokens, and the model only sees the returned messages.
When the session is resumed, that transcript is ignored and the full history from before the compaction is replayed. The compaction is silently undone. If the hook calls next(e) and returns core's result, the compaction survives resume.
Environment
- Claude Code 2.1.280, macOS
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, plugin loaded with--plugin-dir
Repro
hooks/register.ts. This replaces tool results over 5,000 characters with a stub, keeps everything else, and doesn't call next:
const STUB = '[output removed to save context]'
export function register(on: any) {
on('session.compact', async ($: any, e: any, next: any) => {
const messages = e.messages.map((m: any) => {
if (!(m.toolResults ?? []).some((r: any) => (r.text ?? '').length > 5000)) return m
return {
role: m.role, text: m.text, toolUses: m.toolUses ?? [],
toolResults: m.toolResults.map((r: any) =>
(r.text ?? '').length > 5000 ? { tool_use_id: r.tool_use_id, text: STUB, isError: false } : r),
}
})
return { messages }
})
}
- Make a large file (about 1,500 lines) with a unique marker on one line, e.g.
THE CANARY WORD IS PERIWINKLE-7731. claude -p "Read big.txt in full with the Read tool. Then reply only: done." --allowedTools Read --output-format jsonand note thesession_id.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "/compact" --resume <id> --plugin-dir ./modclaude -p "Without tools: what's the canary word in big.txt? Say UNKNOWN if it isn't in your context." --resume <id> --disallowedTools Read,Bash,Grep,Glob --output-format json
Expected
Step 4 answers UNKNOWN and loads roughly the post-compact size.
Actual
Step 4 answers PERIWINKLE-7731. cache_creation_input_tokens is about 126K, the size before compaction.
The .jsonl shows the compaction was recorded correctly:
- a
compact_boundarywithcompactMetadata: { trigger: "manual", preTokens: 127280, postTokens: 16629, durationMs: 6 }andparentUuid: null - after it, the returned messages, with the large tool results replaced by the stub
The loader still replays the history from before the boundary.
Comparison (same repro, different hook bodies)
| Hook | Same process* | After --resume |
|---|---|---|
| Returns own { messages }, no next | Evicted (125.7K → 16.8K), model answers UNKNOWN | Full history replayed |
| return next({ ...e, messages: stubbed }) | Evicted | Evicted (9.8K), model answers UNKNOWN |
| [summaryFromNext, ...stubbed] (core's summary message, then the hook's own messages) | — | Full history replayed |
| No hook, plain /compact | — | Honored (about 43K) |
\* Same process means /compact and the question sent as two turns of one --input-format stream-json run.
Row 3 matters: a real core summary message (with its handle) at the front still isn't enough. The fallback seems to trigger whenever the messages after the boundary aren't exactly core's own output.
Why it matters
Returning your own { messages } is the only way to evict content selectively, e.g. stubbing old large tool results while keeping the rest of the conversation verbatim, without summarizing it all. That's the main use for a context-budget plugin. Today it lasts only until the session is resumed or restarted, and nothing tells the user it has been undone.
Ask
One of:
- Persist a hook-returned transcript so
--resumehonors it, or - If that's deliberately unsupported, document it and warn when a resumed session falls back past a
compact_boundary.