feat(hook): receipt failed tool calls via Claude Code PostToolUseFailure#856
Open
ojongerius wants to merge 2 commits into
Open
feat(hook): receipt failed tool calls via Claude Code PostToolUseFailure#856ojongerius wants to merge 2 commits into
ojongerius wants to merge 2 commits into
Conversation
Claude Code fires PostToolUse only on success and PostToolUseFailure on failure. A hook wired to PostToolUse alone left an errored or interrupted tool call — e.g. a lost concurrent write whose Edit no longer matched — with no receipt at all, only inferable retry activity (#853). The hook now handles PostToolUseFailure: detect() recognises it, and readClaudeCode maps it to decision="allowed" with a non-empty error, which the daemon already records as outcome.status=failure. A blank error is replaced with "tool call failed" ("tool call interrupted" when is_interrupt is set) so a failure frame is never silently downgraded to success by the daemon's empty-error rule. Failure frames carry tool_input (target + parameters still captured) but no tool_response. Docs updated with the PostToolUseFailure settings.json block.
Code review follow-ups on the failure-frame handling: - Decode `error` as json.RawMessage and coerce leniently. A non-string error value (object/number/array) previously aborted the whole-frame unmarshal, exiting the hook 1 and dropping the failure receipt — the exact loss this feature prevents. Now a schema variation degrades to the raw JSON text instead. - Cap the failure message at 16 KiB (rune-safe). The error text was uncapped at every layer (only the 1 MiB whole-frame limit bounded it), so a very large message could push the frame over MaxFrameSize and make Emit fail. Truncation degrades to a truncated receipt, not no receipt. - Reword the struct doc (was "always non-empty", contradicting the fallback below it; was "PostToolUse and PreToolUse" only). Tests: non-string error parses without error; oversized error is truncated with a marker rather than dropped.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #853.
Problem
In the attribution demo (#850), three agents raced on a shared file; one agent's
Editlost the race (old_stringno longer matched), retried, and succeeded. The retry showed in the chain as extra reads, but the failed edit attempt produced no receipt — leaving a lost update / failed action with no failure row in the audit trail, only inferable retry activity.Root cause: Claude Code fires
PostToolUseonly on success and a separatePostToolUseFailureevent on failure. The hook only handledPostToolUse, so failed (and interrupted) tool calls were never receipted.Change
detect()now recognisesPostToolUseFailureas a claude-code frame.readClaudeCodemaps aPostToolUseFailureframe todecision="allowed"carrying the frame'serror. The daemon already mapsdecision="allowed"+ a non-empty error tooutcome.status=failure(daemon/internal/pipeline/build.go), so no daemon change is needed — the gap was purely the hook never emitting these frames.PostToolUseFailureframe carrieserror(always a non-empty string from Claude Code) andis_interrupt, but notool_response. It still carriestool_input, so the action target and parameters hash are captured as usual.erroris replaced with"tool call failed"(or"tool call interrupted"whenis_interruptis set) so a failure frame is never silently downgraded to success by the daemon's empty-error → success rule.Enabling it
Add a
PostToolUseFailureblock to~/.claude/settings.jsonalongside the existingPostToolUseone (documented inhook/README.md).Tests
TestReadClaudeCode_PostToolUseFailure— error passes through;decision=allowed; input + target captured; no output; empty-error and interrupt fallbacks; success frames carry no error.TestDetect—PostToolUseFailuredetected as claude-code.TestIntegration_ClaudeCodeFailureFrame— end-to-end: the failure error reaches the listener on the wire.go vet ./...andgo test ./...pass in thehookmodule.Notes
No schema change. The PostToolUseFailure payload shape was confirmed against the installed Claude Code (
v2.1.177):{hook_event_name, tool_name, tool_input, tool_use_id, error, is_interrupt, duration_ms}.