Skip to content

fix(claude-code): capture Stop transcripts as validated snapshots - #2099

Open
MuskanPaliwal wants to merge 3 commits into
entireio:mainfrom
MuskanPaliwal:fix-claude-transcript-readiness
Open

fix(claude-code): capture Stop transcripts as validated snapshots#2099
MuskanPaliwal wants to merge 3 commits into
entireio:mainfrom
MuskanPaliwal:fix-claude-transcript-readiness

Conversation

@MuskanPaliwal

@MuskanPaliwal MuskanPaliwal commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Refs #2091, item 3 only.

Before this change, Claude Code's Stop hook waited for a transcript sentinel that no longer appears, then fell back to a 500 ms quiet window. The wait only validated a mutable path. Lifecycle copied that path later, and provisional-checkpoint finalization opened it once more. Entire could therefore store bytes other than the ones it had treated as ready.

This PR replaces that sequence with one owned, validated snapshot for Claude Stop. Modern Claude payloads usually finish in one polling interval. Older payloads keep the existing quiet-window protection.

The wait did not own the transcript

TranscriptPreparer returns only an error. It gives the caller no bytes, open handle, identity, size, or file version. A successful wait says that path state A looked ready, but lifecycle can then copy state B and checkpoint finalization can read state C.

Shortening the wait alone would reduce latency while leaving that race intact. Valid final JSON would not be enough either. A complete older prefix can be syntactically valid even when the current turn has not reached the file.

Proposed solution

The built-in Claude Code adapter now has a Stop-specific capture operation. It fingerprints the transcript, reads exactly the observed byte range, validates the JSONL, and calculates the transcript position from those bytes. The lifecycle copy, Claude transcript analysis, token calculation, position advancement, and checkpoint finalization all use that snapshot. None of them reopens Claude's live transcript for the same Stop.

For modern payloads, non-empty last_assistant_message supplies current-turn evidence. Capture requires a transcript position successfully measured at TurnStart, searches only after that boundary, reconstructs the latest assistant text, and fails closed if it does not match. A measured position of zero is valid on the first turn. A missing transcript or analyzer failure may also produce zero, but without the measured-position bit it cannot qualify.

When the final-response field is missing, null, or empty, capture keeps the 500 ms quiet window used by older Claude Code versions. Missing, stale, incomplete, continuously changing, timed-out, and canceled captures return no snapshot.

Capture timing

A 20-run focused test using production capture timings measured:

Stop path Median Range
Modern payload with current-turn evidence 50 ms 50–60 ms
Legacy payload using the quiet window 500 ms 500–510 ms

The modern path reduces median local capture time by 450 ms, or 90%. These numbers measure deterministic transcript capture, not end-to-end Claude Stop latency.

Why the snapshot belongs in the Claude adapter

Approach Why it looked reasonable Decision
Patch only the existing wait loop Removes the dead sentinel and cuts healthy Stop latency. Not enough. Lifecycle and finalization could still reopen a different file state.
Use two stable polls plus valid final JSON Rejects a half-written final record and is fast when the file is already stable. Not enough. A complete older prefix can pass while the current turn is still missing.
Make TranscriptPreparer return bytes everywhere Gives every agent one prepare-and-read contract. Too broad for item 3. OpenCode materializes exports through preparation, and the change would spread into attach, condensation, and other callers.
Add a general evidence and snapshot protocol Leaves room for other producers to add their own readiness signals. Premature. Claude is the only agent with this evidence today.
Require Claude Code 2.1.47 or newer Removes the legacy timing path. A separate product-policy decision. Entire does not currently enforce a minimum Claude version.
Keep capture inside Claude, with modern evidence and a legacy fallback Makes validated bytes identical to consumed bytes, scopes matching to the current turn, and preserves older versions. Chosen. It closes the ownership race and removes the modern latency floor without widening this PR beyond Claude Stop.

Fixtures now model a real first turn

The integration harness has an explicit helper for modern Stop payloads. Every integration call site that sends last_assistant_message creates the transcript before TurnStart and records a real boundary.

TestHookRunner_SimulateStop now writes the empty first-turn transcript before simulating UserPromptSubmit, then sends the modern Stop payload. This keeps the production rule honest instead of exempting synthetic fixtures.

Focused tests cover repeated assistant text before the turn boundary, partial final JSON, continued growth, truncation, replacement, complete JSON without a trailing newline, timeout, cancellation, source mutation after capture, finalization ownership, and measured-zero versus unmeasured-zero state. A same-size rewrite is covered when its modification time changes.

What the fingerprint cannot prove

The portable fingerprint uses file identity, size, and modification time. It catches ordinary appends, truncation, replacement, and rewrites, but it cannot prove that a same-size rewrite did not happen when the filesystem also reports the same timestamp. Claude writes transcripts by appending, so this is an acceptable limit here. The tests do not claim stronger detection.

Modern readiness also depends on Claude's last_assistant_message matching the assistant text reconstructed after the measured boundary. A mismatch fails closed instead of falling back to timing. Legacy payloads still pay the 500 ms wait and remain a timing heuristic.

Verification

Fresh on e6b57ada44ffb13973eda18b720c1c459731a309:

  • mise run lint passed with 0 issues.
  • mise run test passed 9,615 tests with 5 skipped in 78.835s.
  • go test -race -count=1 -tags=integration ./cmd/entire/cli/integration_test -run 'Test(ClaudeStop_|HookRunner_SimulateStop$)' passed in 11.194s.
  • git diff --check upstream/main...HEAD passed.

At the current PR head, go test -json -count=20 -run 'TestCaptureTranscript_(ReturnsOwnedValidatedBytes|LegacyStableUsesQuietWindow)$' ./cmd/entire/cli/agent/claudecode passed all 40 test executions. The modern path had a 50 ms median and the legacy path had a 500 ms median.

Before the commit, mise run check passed in 296.93s. Formatting and lint passed, race-enabled unit and integration tests passed, Vogon passed 56/56, and Roger-Roger passed 4/4. A separate unrestricted mise run lint immediately before the existing push also passed with 0 issues.

Kept out of this PR

This PR does not include condensation batching, session pruning, full-redaction reuse, subagent transcript atomicity, a repository-wide TranscriptPreparer replacement, external-agent capability changes, or a minimum Claude Code version policy.

@MuskanPaliwal
MuskanPaliwal marked this pull request as ready for review August 21, 2026 17:58
@MuskanPaliwal
MuskanPaliwal requested a review from a team as a code owner August 21, 2026 17:58
@MuskanPaliwal

Copy link
Copy Markdown
Contributor Author

Hi @Soph, this pr is ready for an early review. Could you take a look and let me know if the overall approach makes sense, especially how I'm handling Claude transcript readiness?

…pt-readiness

# Conflicts:
#	cmd/entire/cli/lifecycle.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant