[BUG] session.compact hook: a hook-built assistant message with N tool uses is written as N assistant records with fresh ids; the request normaliser's next-message-only pairing then drops N−1 of their results
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet (closest: #99727, #96502, #96485 describe other
session.compactdefects: resume replay and duplicate rendering; none covers the result pairing below) - [x] This is a single bug report
- [x] Version: observed on 2.1.293 and the bundle read on 2.1.295; 2.1.296 was published today and has not been re-tested
What's Wrong?
When a session.compact function hook returns { messages } in which a hook-built assistant message (no handle) carries two or more toolUses, followed by a hook-built user message carrying their toolResults, the engine writes the assistant message to the transcript as one assistant record per tool use, each with a fresh message.id, followed by the user record(s).
On the next request the normaliser groups consecutive assistant records by message.id, merges consecutive user records into one user message, and pairs each assistant message only with the message immediately after it. For N tool uses the shape is A1 … AN U: only the N-th finds its result. The other N−1 receive a synthesized { type: "tool_result", content: "[Tool result missing due to internal error]", is_error: true } and their real results are filtered out as orphans.
The substitution is in memory only. The on-disk transcript holds every real result (exact-match census over two days: 0 placeholder results on disk across 65,816 results). The model, shown N−1 missing results, re-runs those calls, which lengthens every post-compaction turn.
What Should Happen?
The pair survives the compaction as it was: one assistant message with N tool uses, the following user message with N results, all N paired on the next request. Either the N records written for one hook-built assistant message should share one message.id (or be one record), or the normaliser should pair tool results by tool_use_id within the merged following user message rather than by adjacency alone.
Error Messages/Logs
[Tool result missing due to internal error]
Seen by the model, in the first request after the compaction, for tool uses 1…N−1 of the rebuilt message. On disk the same compaction wrote N assistant records with distinct fresh message.ids (stop_reason: "stop_sequence", no wireToolInputs) followed by the user record(s) carrying all N real results; the original turn's records had shared one message.id.
Steps to Reproduce
- Enable function hooks (
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS) and load a plugin whosehooks/hooks.jsonis{ "modules": ["./repro.ts"] }with this module. It rebuilds one existing parallel-call pair without changing its content, which is enough to trigger the behaviour:
```ts
import type { Register } from 'claude-code';
export const register: Register = (on) => {
on('session.compact', async ($, event, next) => {
if (event.trigger === 'precompute') return { skip: 'repro decides at the live compaction' };
const messages = event.messages;
// The newest assistant message with 2+ tool uses whose results sit in the next message.
const i = messages.findIndex(
(m, k) => m.role === 'assistant' && m.toolUses.length >= 2 &&
(messages[k + 1]?.toolResults?.length ?? 0) === m.toolUses.length,
);
if (i < 0) return next(event);
const a = messages[i];
const u = messages[i + 1];
// Same text, same tool uses, same results — but hook-built (no handle).
const rebuiltA = { role: 'assistant' as const, text: a.text, toolUses: a.toolUses.map((t) => ({ ...t })) };
const rebuiltU = { role: 'user' as const, text: u.text, toolUses: [], toolResults: (u.toolResults ?? []).map((r) => ({ ...r })) };
return { messages: [...messages.slice(0, i), rebuiltA, rebuiltU, ...messages.slice(i + 2)] };
});
};
```
- In a session, make at least one turn with two or more parallel tool calls (for example two
Readcalls in one assistant turn). - Run
/compact. - Inspect the session
.jsonlafter thecompact_boundary: the rebuilt pair appears as N assistant records with distinct freshmessage.ids, then the user record(s) with all N results. - Ask the model what it sees for those tool results. Tool uses 1…N−1 show the placeholder; only tool use N shows its real result.
Observation lane used here: from inside the hook's own session (the assistant's view of its post-compaction context) against the on-disk records, on three compaction boundaries. Predicted from the record shapes: 5, 5 and 6 placeholders. Seen: 5, 5 and 6. Every real result was present on disk each time.
Claude Model
Other (Fable 5.1 and Opus 5.5 sessions; the behaviour is in the request normaliser, not the model)
Is this a regression?
I don't know
Last Working Version
Unknown. Function-hook session.compact replacements are new; this has been observed on every version tried (2.1.293, 2.1.295).
Claude Code Version
2.1.293 (Claude Code), the CLI bundled in Claude desktop 2.26454.2, where the transcripts were observed; 2.1.295 (Claude Code), whose bundle was read for the normaliser; the hooks module is written against the /plugin-types contract generated by 2.1.274.
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Other (Claude desktop app, Code tab; also the CLI)
Additional Information
Bundle read (2.1.295). The request normaliser, for an assistant message carrying tool_use ids, reads only the next message; ids with no tool_result there get the synthesized error result above; tool_results whose id is not in that assistant message are filtered out as orphans. Nothing is written back to the transcript.
Scale (two days, 62 sessions, one such plugin in daily use). 158 hook replacements of this shape; 70 lost at least one result; 206 of 1,611 kept results (13%) shown as the placeholder; up to 7 per compaction; 32 of 62 sessions affected.
Hook-side workaround (what an engine fix would make unnecessary). Emit N single-tool-use assistant messages, each immediately followed by a user message carrying exactly its result, paired by tool_use_id.
Engine-side candidates. Keep one message.id across the N records written for one hook-built assistant message (or write one record), or pair tool results by tool_use_id within the merged following user message rather than by adjacency alone.
Question. What is the intended contract for a hook-built assistant message with several toolUses: one record or N? And should the normaliser pair by tool_use_id when the following user message carries the result?
Related: #99727 (resume merges records sharing a message.id across compact boundaries), #96502 (kept messages rendered twice), #96485 (hook transcript dropped on resume). Those concern records that keep their original message.id; this report concerns the fresh ids minted for a hook-built message and the pairing that follows.