feat(sdk): emit tool-output-denied so the UI can tell a denial from a failure#5381
Conversation
… failure When a user denies a gated tool call, the runner replied reject and the harness closed the call as a failed tool result, so the Vercel egress projected the generic tool-output-error frame -- the UI rendered a decline as a breakage. Add a structural denied marker at the runner deny mapping (never string-match the error text): otel.markToolCallDenied flags the gated tool-call id, and the closing failed tool_result carries denied: true. The SDK egress (both projection sites) projects the AI SDK tool-output-denied chunk for a denied result and keeps tool-output-error for a genuine failure. Covers the cold decision-map deny (Claude and Pi gates) and the live keep-alive resume-reject. Design record: D-022 (final-sweep-decisions.md). Claude-Session: https://claude.ai/code/session_01DnWRxU3dCJ11hgDidm26vW
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
📝 WalkthroughWalkthroughDenied gated tool calls now carry a structural ChangesDenied tool-call projection
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Railway Preview Environment
Updated at 2026-07-18T23:39:46.044Z |
The problem
When a user denies a gated tool call in the agent playground, the UI showed it as a
tool failure — the same red error box a real breakage produces. A decline ("you chose not
to run this") and a breakage ("the tool crashed") are different things and should read
differently.
The cause is on the wire. A denial reaches the SDK as an
isErrortool result: the runnerreplies
rejectto the approval gate, and the harness (Claude / Pi) then closes the call asa failed tool call — indistinguishable, byte for byte, from a genuine tool error. The
Vercel egress had no way to tell them apart, so every denied call became a
tool-output-errorframe.The fix
Add a structural marker at the runner's deny mapping, never a guess from the error text
(which varies by harness and version):
otel.markToolCallDenied) at the point itdecides to reject the gate, and the closing failed
tool_resultcarriesdenied: true.This covers the cold decision-map deny (both the Claude ACP gate and the Pi ACP gate) and
the live keep-alive resume-reject.
stream.py) projects the AI SDK's dedicatedtool-output-deniedchunk for a denied result, and keepstool-output-errorfor a realfailure.
The frontend already renders
output-deniedas a distinct decline (ToolActivity.tsx), sono UI change was needed — the missing piece was purely the egress emitting the frame.
The wire, before and after
A user denies a gated
bashcall, then the turn resumes:tool-output-deniedis a strict{type, toolCallId}chunk inai@6'suiMessageChunkSchema— noerrorText/output— so the denied frame carries no leakederror payload.
Design record
D-022 in
docs/design/agent-workflows/projects/qa/final-sweep-decisions.md— the deny pathwas designed to emit
tool-output-denied; this PR is the egress half that was still missing.Testing
pnpm test, 1209 green): the otel marker stampsdeniedon a failedclose only when flagged (and a genuine failure is left untouched); the ACP responder flags
the gated id on a deny verdict and not on an allow.
pnpm run typecheckclean.tool-output-deniedfor a deniedresult and keep
tool-output-errorfor an unmarked failure; the conformance test'svendored
ai@6schema mirror now pinstool-output-deniedas{type, toolCallId}. Thepre-existing test that asserted "a deny rides isError, never a denied field" was updated to
the new contract.
bashcall through the product
/services/agent/v0/invokeendpoint, denied it inline, andcaptured the resumed turn's frames:
… tool-input-available, tool-output-denied, text-start, … finish. The release-gatedeny journey — which asserts
outcome == "denied", a state only the new frameproduces — went green (
denied: the gated command never executed (outcome=denied)).https://claude.ai/code/session_01DnWRxU3dCJ11hgDidm26vW