Repository navigation
refactor(core): settle declined tool calls durably with typed reasons - #38734
Merged
Merged
Conversation
kitlangton
enabled auto-merge (squash)
July 24, 2026 18:12
jinhuang712
pushed a commit
to jinhuang712/opencode
that referenced
this pull request
Sep 26, 2026
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.
What
When a user declines a permission or dismisses a question, the declined tool call's durable record now says so —
"The user declined this tool call"/"The user dismissed this question"— instead of the generic"Tool execution interrupted"sweep. Under the hood, the decline defect tunnel gets a typed exit, and both ends of the tunnel are now documented in the code.Before / After
Before:
PermissionV2.assertand the question tool deliberatelyEffect.dieon a bare decline so the leaves' blanketmapError(~30 sites) can't convert a user's "no" into model-facing tool output. The defect tunneled through the registry untyped; the runner sniffed it back out of causes withinstanceofon defects (isUserDeclined); settlement couldn't attribute it to a call, so the declined call was swept together with everything else asaborted: "Tool execution interrupted". Clients saw a decline and an infrastructure interrupt identically.After: the tunnel is unchanged at its entrance (a decline still never enters leaf error channels) but resurfaces as a typed failure exactly once, at
SessionModelRequest.executeTool— the seam the runner executes through. The classifier reads failure_tags instead of defect sniffing, pairs each decline with its originating call viatoolRuns(exits align by construction), and settlement publishesTool.Failedfor that specific call with its actual reason before the generic sweeps close the rest. The drain still halts via interrupt, andCorrectedError(decline with feedback) still rides the typed lane intoToolFailureso the model continues.flowchart LR A[leaf tool<br/>blanket mapError] -->|"decline dies (tunnel)"| B[registry<br/>permission-agnostic] B --> C["SessionModelRequest.executeTool<br/>catchCauseFilter: defect → typed failure"] C --> D["classifier: _tag match,<br/>paired with its ToolCall"] D --> E["Tool.Failed for THAT call,<br/>with the decline reason"] E --> F[sweep rest as aborted;<br/>drain halts]How
model-request.ts— newExecuteError = ToolOutputStore.Error | PermissionV2.DeclinedError | QuestionTool.CancelledError;declineDefectfilter (Effect.catchCauseFilter) resurfaces the tunnel defects as typed failures. The widened union lives only onPrepared.executeTool;ToolRegistry.ToolSet["execute"]keeps its clean error type and the registry stays permission-agnostic.runner/llm.ts—isUserDeclined(defectinstanceof) replaced by a typedisDeclineguard;classifyToolExitstakes the calls and returns per-calldeclines: [{ call, reason }]. Thefailurecause is rebuilt viaCause.isFailReason/makeFailReason/fromReasonswith decline reasons stripped, so the drain's error channel isCause<ToolOutputStore.Error>— no decline type leak, no casts. When the settlement await itself is interrupted there are no per-call exits and behavior degrades to today's sweep.publish-llm-event.ts—failTool(callID, error)extracted fromfailTools' loop body; the bulk sweep now delegates to it and skips already-settled calls, so the decline settles first and the sweep never double-publishes.permission.ts/tool/question.ts— the two bareEffect.diesites gained comments explaining the tunnel and pointing at the typed exit.tool/AGENTS.md— the leaf contract now states declines must never be caught or converted by leaves, and where they resurface.Scope
The durable error stays
{ type: "aborted" }with a specific message; a first-classdeclinederror variant in the schema (visible to clients as a distinct type) is deferred — it needs client regeneration and its own decision. Single-writer settlement (c26e466a) and the two-phase tool contract (041a640b) remain separate follow-ups.Testing
From
packages/core:bun typecheck— cleanbun run test test/session-runner.test.ts test/session-runner-recorded.test.ts --rerun-each 3— 417/417location-layerandconfig/pluginfail intermittently — verified flaky on cleanv2with identical reruns (3 failures, zero changes)