Skip to content

fix: stop dropping the final answer of turns that use tools - #10

Open
olzhik11 wants to merge 2 commits into
mainfrom
fix/stop-hook-flush-race
Open

olzhik11 wants to merge 2 commits into
mainfrom
fix/stop-hook-flush-race

Conversation

@olzhik11

@olzhik11 olzhik11 commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Problem

I tested the plugin end to end against a local Laminar, with headless claude -p sessions and interactive sessions driven through expect. Every turn that used a tool lost its final answer:

  • the root span's output was "";
  • the last LLM Call N span was missing;
  • the late transcript row was then dropped for good, because SessionEnd logged "Processed 0 turns".

The cause is that Claude Code fires Stop before it writes the turn's final assistant row. A probe hook measured this:

Mode Final row in the transcript when Stop fires? When it lands
claude -p (4/4 runs) no 61–82 ms later
interactive (2/3 runs) no, and the file doesn't grow at all while the hook runs only after the hook exits

The hook saw an assistant row, assumed the turn was complete, and emitted it. The late final row then had no user prompt in front of it, so it was ignored.

Replaying the finished transcript through the same bundle produced correct traces, so the parser was fine and timing was the only problem. Because interactive sessions write the row only after the hook exits, waiting inside the hook can't fix this; I tried a 2 s wait and it timed out.

Fix

splitTrailingIncompleteTurn now also holds the trailing turn (in pendingTurnRows, as it already does for a prompt with no reply) when either:

  • its last chat row is a tool_result: the reply to it hasn't been written yet; or
  • none of its assistant rows carries the end of the Stop payload's last_assistant_message. That field is the turn's final text, so this also covers a final message whose thinking row landed but whose text row didn't.

The held turn is emitted whole on the next hook run: the next Stop, or SessionEnd, which never holds. When the row is late, the trace arrives one hook later; in -p, SessionEnd runs right after Stop, so the delay is about a second. Nothing is lost, and the hook adds no wait.

Also fixed: empty output on background subagents

A background subagent ends with a SubagentHandback tool call instead of a final text message, so the Subagent: … span's output was "". Its output now falls back to the call's message.

Testing

  • npm run typecheck, and npm test with 61/61 passing. That includes 3 new tests: a turn ending in a tool_result, a turn whose payload text isn't written yet, and the SubagentHandback report.
  • End to end against a local Laminar, checked in ClickHouse. Headless and interactive sessions with a plain turn, a turn with Read and Bash calls, and a turn that runs a subagent (both sync and background). Every turn now has its final answer, its last LLM call, and its subagent output.
Session Before After
headless, Read + Bash 4 spans, root output "", no LLM Call 2 5 spans, root output "notes.txt has 2 lines…", LLM Call 2
interactive, Read + Bash 4 spans, root output "", no LLM Call 2 5 spans, root output "notes.txt has 2 lines…", LLM Call 2
interactive, background subagent Subagent: output "" Subagent: output "14…"

CLAUDE.md now documents the hold rules. dist/hook.cjs is rebuilt. The version bump is in #9.

🤖 Generated with Claude Code


Note

Medium Risk
Changes transcript→turn gating and export timing for every Stop hook; wrong hold logic could delay or duplicate traces, but SessionEnd flush and existing offset/export retry limits blast radius.

Overview
Fixes incomplete Laminar traces when Claude Code’s Stop hook runs before the transcript has the turn’s final assistant row (common after tool use).

Trailing-turn hold (pendingTurnRows): splitTrailingIncompleteTurn no longer treats “any assistant row” as complete. It also holds when the tail ends on a tool_result, or when assistant rows don’t yet include the suffix of the Stop payload’s last_assistant_message (wired from hook.ts as finalAssistantText). Held rows replay on the next hook run; SessionEnd still flushes everything.

Subagent spans: getSubagentReport sets subagent output from normal assistant text or from a SubagentHandback tool’s message when background subagents don’t end with text.

Docs (CLAUDE.md), tests (tool-result race, payload text race, handback), and dist/hook.cjs are updated.

Reviewed by Cursor Bugbot for commit 561dc75. Bugbot is set up for automated code reviews on this repo. Configure here.

olzhik11 and others added 2 commits October 9, 2026 17:24
Claude Code fires Stop before it writes the turn's final assistant row; in
interactive sessions often only after the hook exits. A turn that used tools
was emitted at Stop without its final answer or last LLM call, and the late
row was then dropped as an assistant row with no prompt in front of it.

Hold the trailing turn when its last chat row is a tool_result, or when none
of its assistant rows carries the end of the Stop payload's
last_assistant_message. The next hook run emits it whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…port

A background subagent ends with a SubagentHandback tool call instead of a
final text message, so its span's output was empty. Fall back to the call's
message. Also document the trailing-turn hold rules in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants