Function hooks: a transcript returned from a session.compact hook is dropped on --resume, and the full pre-compact history is replayed

Status Open
Reported on v2.1.280
Maintainer reply None cached
Activity 0 comments · opened Sep 23, 2026

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 }
  })
}
  1. Make a large file (about 1,500 lines) with a unique marker on one line, e.g. THE CANARY WORD IS PERIWINKLE-7731.
  2. claude -p "Read big.txt in full with the Read tool. Then reply only: done." --allowedTools Read --output-format json and note the session_id.
  3. CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "/compact" --resume <id> --plugin-dir ./mod
  4. claude -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_boundary with compactMetadata: { trigger: "manual", preTokens: 127280, postTokens: 16629, durationMs: 6 } and parentUuid: 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:

  1. Persist a hook-returned transcript so --resume honors it, or
  2. If that's deliberately unsupported, document it and warn when a resumed session falls back past a compact_boundary.

View original on GitHub ↗