diff --git a/CHANGELOG.md b/CHANGELOG.md index 08698a0..df48b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Silent permission reply failures** ([#35](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk/pull/35)) - `replyToPendingApprovals` never inspected the resolved value of `permission.reply`. Managed clients use `responseStyle: "fields"` without `throwOnError`, so API-level failures resolve as `{ error }` instead of throwing — a failed reply was silently recorded as replied and never retried, leaving OpenCode waiting on the permission request. The result is now checked via `extractSdkResult` (matching the question-reply handling): on error, a warning is logged and surfaced in the response `warnings`, and the approval id is not recorded as replied so the next turn retries it. + ## [3.0.6] - 2026-06-11 ### Fixed diff --git a/src/opencode-language-model.test.ts b/src/opencode-language-model.test.ts index e7d0282..2f709f8 100644 --- a/src/opencode-language-model.test.ts +++ b/src/opencode-language-model.test.ts @@ -634,6 +634,55 @@ describe("opencode-language-model", () => { expect(mockClient.permission.reply).toHaveBeenCalledTimes(1); }); + it("should warn and not record the approval as replied when permission.reply returns an API error", async () => { + mockClient.permission.reply.mockResolvedValueOnce({ + error: { message: "unknown permission request" }, + }); + const logger = { warn: vi.fn(), error: vi.fn() }; + model = new OpencodeLanguageModel({ + modelId: "anthropic/claude-3-5-sonnet-20241022", + settings: { logger }, + clientManager: mockClientManager as unknown as OpencodeClientManager, + }); + const promptWithApproval: LanguageModelV3Prompt = [ + { + role: "user", + content: [{ type: "text", text: "Continue after approval." }], + }, + { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "approval-1", + approved: true, + }, + ], + }, + ]; + + const result = await model.doGenerate({ + prompt: promptWithApproval, + }); + + const expectedWarning = expect.stringContaining( + "Failed to apply tool approval response for approval-1", + ); + expect(logger.warn).toHaveBeenCalledWith(expectedWarning); + expect(result.warnings).toContainEqual({ + type: "other", + message: expectedWarning, + }); + + // The failed reply must not be recorded as replied, so the next turn + // retries it. + await model.doGenerate({ + prompt: promptWithApproval, + }); + + expect(mockClient.permission.reply).toHaveBeenCalledTimes(2); + }); + it("should not emit duplicate document sources for local files with source metadata", async () => { mockClient.session.prompt.mockResolvedValueOnce({ data: { diff --git a/src/opencode-language-model.ts b/src/opencode-language-model.ts index 5204fc0..91aef88 100644 --- a/src/opencode-language-model.ts +++ b/src/opencode-language-model.ts @@ -750,12 +750,26 @@ export class OpencodeLanguageModel implements LanguageModelV3 { for (const response of pendingResponses) { try { - await permissionApi.reply({ + const result = await permissionApi.reply({ requestID: response.approvalId, reply: response.approved ? "once" : "reject", ...(response.reason ? { message: response.reason } : {}), ...(directory ? { directory } : {}), }); + + // Fields-style clients report API failures via the result rather + // than throwing, so a missing check would record the approval as + // replied while OpenCode keeps waiting on the permission request. + const { error: resultError } = extractSdkResult(result); + if (resultError) { + const warning = + `Failed to apply tool approval response for ${response.approvalId}: ` + + `${extractErrorMessage(resultError)}`; + this.logger.warn(warning); + warnings.push(warning); + continue; + } + repliedApprovalIds.add(response.approvalId); } catch (error) { const warning =