PreToolUse hooks read a transcript that lacks the current assistant message until the tool result lands

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

Summary

A PreToolUse hook that reads transcript_path sees, in some sessions, a transcript that ends at the previous message for the hook's whole life: the assistant message carrying the tool_use under review is written only after the tool's result is appended. In other sessions on the same machine, with the same hooks, the same message is written within about a second of the hook firing. The hooks reference says the transcript "is written asynchronously and may lag the in-memory conversation"; this report traces the lag to the writer's hold condition and shows that in the first kind of session no wait bound helps, because the write that carries the message is the write that carries the tool's result.

Environment

  • macOS (Darwin 25.6.0), interactive REPL, permission mode auto, effort xhigh in both sessions.
  • Session A: Claude Code 2.1.280, model claude-fable-5-1, session 7a1caa1e-d023-4d75-8ab0-cc8915c19c10.
  • Session B: Claude Code 2.1.283, model claude-opus-5-5, session b28643a1-2127-4508-bd66-5a26b48abf17, cwd a git worktree of the same repository.
  • The mechanism below was read from the installed binary, 2.1.292. The same behaviour has been observed on earlier builds, and the session that writes promptly is on the older build, so the build is not the variable.
  • The hook: a PreToolUse hook on Edit|Write that indexes the transcript, polls it every 100 ms for the line whose tool_use id is the payload's tool_use_id, gives up after 50 polls, and logs how the wait ended.

Measurements

| session | waits that saw the call | waits that ended behind at 50 polls |
|---|---|---|
| A | 425 | 2 |
| B | 40 | 472 |

In every sampled behind entry of B the transcript carried no line with the call's id at the bound; the newest call on disk was the previous message's. B's 40 caught-up waits fall into two shapes: 15 saw the call at 0 polls, on a message whose call sat at block 2 or later with every block, later ones included, already on disk; the rest took 6 to 10 polls, on a call at block 0 with exactly one more block behind it. In both shapes the message carried a second tool whose result landed first.

Transcript evidence

Session A, one Edit under review (times are each line's timestamp, the record's creation time; line numbers are file order, which is write order):

| line | kind | created |
|---|---|---|
| 2170 | assistant record, block 0 | 18:42:05.846 |
| 2171 | assistant record, block 1 | 18:42:05.850 |
| 2172 | assistant record, block 2, the Edit | 18:42:14.801 |
| 2173 to 2177 | last-prompt, ai-title, mode, permission-mode, atis-latch | |
| 2178 | attachment hook_success | 18:42:15.110 |
| 2179 to 2182 | attachments hook_success, hook_additional_context | 18:42:35.908 to 35.962 |
| 2183 | user, the tool result | 18:43:51.038 |

The five metadata lines are the writer's per-batch trailer, so the Edit's record was written in a batch that closed before the first hook finished, 309 ms after the record was created.

Session B, the Edit under a 118 s review:

| line | kind | created |
|---|---|---|
| 109437 | assistant record, block 0 | 14:35:03.606 |
| 109438 | assistant record, block 1 | 14:35:03.607 |
| 109439 | assistant record, block 2, the Edit | 14:35:07.753 |
| 109440 | attachment hook_success | 14:35:08.078 |
| 109441 | attachment hook_additional_context | 14:37:05.866 |
| 109442 | user, the tool result | 14:37:05.868 |

No trailer stands between the record and the attachments: one batch, written when the result landed. The hook polled from 14:35:07.8 for 5 s and saw no line with the call's id. The hook_success attachment created at +0.3 s was written in that same batch, so hook attachments are appended to the conversation with the tool's result, not when the hook completes.

Both messages are three blocks with the Edit last. Every assistant record in both transcripts was written with its final stop_reason ("tool_use"); the transcripts carry 8 and 6 lines with "stop_reason":null in total.

Mechanism, from the 2.1.292 binary

  • Transcript writes are driven by an effect on the messages array (Nke, which calls Oke.write, which calls Iv, which diffs the array against the uuids already on disk and appends the rest through insertMessageChain). The effect re-runs when the array changes. The isResponseInFlight option it passes is the REPL's isLoading, the same value handed to the UI as isLoading.
  • Each write is bounded by this function, with r the in-flight flag:
function FWt(e,n,r){if(!r)return e.length;for(let s=n;s<e.length;s++){let g=e[s];if(g.type==="assistant"&&g.message.stop_reason===null)return s}return e.length}

While the turn is loading, nothing from the first assistant record with a null stop_reason onward is written.

  • Per-block assistant records are built in the stream loop at content_block_stop as {message:{...Nf,content:bl},…,apiBlockIndex:bs.index}, a copy of the in-progress message, whose stop_reason is null until the message ends. They are finalized later, in place.
  • The streaming tool executor queues every tool_use as its record arrives (addTool) and starts it as soon as nothing non-concurrency-safe is executing (canExecuteTool), so PreToolUse fires while the message's records are held.
  • The REPL's stream-event handler (HKr) appends no message at message_delta or message_stop; it only updates stream mode, metrics, and streaming display state.
  • So the held records are written on the next change to the messages array after the message ends. In B, for a message whose only tool is the Edit, that change is the Edit's own result. In A something changes the array within about 300 ms of the stream's end; it is not a new message, since the record's batch trailer follows the record directly, and it has not been identified.

Ruled out

  • The build: A, which writes promptly, is the older of the two.
  • Permission mode and effort: both sessions auto and xhigh.
  • The thinking display: A's transcript carries server summary blocks and B's carries none, the inverse of what a summary-side delay would need.
  • API context management: present in both transcripts.
  • The out-of-line tool-results/ store: it holds tool outputs written after the tool runs; the assistant records are inline.

Impact

A hook that reviews the agent's current message, its reasoning and its statement for the call under review, which is the use transcript_path invites, reads a window ending at the previous message, and no poll bound changes that. The PreToolUse input carries no field with the message; last_assistant_message exists only on Stop and SubagentStop, after the call has run.

Ask

  1. Flush the held transcript records when the assistant message finalizes, or run the writer before PreToolUse hooks are dispatched, so transcript_path carries the message under review when a hook reads it.
  2. Failing that, add the current assistant message, its blocks so far with the tool_use, to the PreToolUse hook input.
  3. Either way, document the contract: what transcript_path is guaranteed to carry when PreToolUse fires.

The one workaround observed is the shape of B's 40 caught-up waits: a second, concurrency-safe tool in the same message, whose result changes the array and flushes the message within about a second.

View original on GitHub ↗