diff --git a/CHANGELOG.md b/CHANGELOG.md index df48b94..cd517b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Interactive question handling (`onQuestionAsked`)** ([#33](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk/pull/33)) - New opt-in model setting that answers or rejects OpenCode `question.asked` events during streaming, which previously always surfaced as an unsupported-question stream error and left the session stuck ([#15](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk/issues/15)). The handler receives the question request (`requestId`, `sessionId`, `questions`, and the originating `tool` when present) and returns `{ answers }` to reply via `question.reply`, `{ reject: true }` to reject via `question.reject`, or `undefined`/`null` to keep the existing unsupported-question error. Handler exceptions and API-level reply failures surface as stream errors instead of leaving OpenCode waiting on the question, and reply/reject requests are tied to the per-stream abort signal so they are cancelled on stream teardown. The question types (`OpencodeQuestionRequest`, `OpencodeQuestionResponse`, `OpencodeQuestionInfo`, `OpencodeQuestionOption`, `OpencodeQuestionAnswer`) are exported from the package entry point. Thanks to [@slegarraga](https://github.com/slegarraga) for the contribution. + ### 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. diff --git a/README.md b/README.md index 33664e6..d953daa 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,26 @@ for await (const part of result.fullStream) { } ``` +### Interactive Questions + +Some OpenCode flows emit `question.asked` events and wait for an answer before +continuing. Provide `onQuestionAsked` to answer or reject those prompts during +streaming: + +```typescript +const model = opencode("openai/gpt-5.3-codex-spark", { + onQuestionAsked: async (request) => ({ + answers: request.questions.map((question) => [ + question.options[0]?.label ?? "", + ]), + }), +}); +``` + +Return `{ reject: true }` to reject a question. If `onQuestionAsked` is omitted +or returns `undefined`, the provider keeps the existing unsupported-question +stream error. + ## Feature Support | Feature | Support | Notes | @@ -251,6 +271,7 @@ for await (const part of result.fullStream) { | Structured output (JSON) | ⚠️ Partial | Native `json_schema`; use prompt+validation fallback for strict reliability | | Custom tools | ❌ None | Server-side only | | Tool approvals | ✅ Full | `tool-approval-request` / `tool-approval-response` | +| Interactive questions | ✅ Full | `onQuestionAsked` answers or rejects OpenCode `question.asked` events | | File/source streaming | ✅ Full | Emits `file` and `source` stream parts | | temperature/topP/topK | ❌ None | Provider defaults | | maxTokens | ❌ None | Agent config | diff --git a/src/index.ts b/src/index.ts index 6d2e6af..9c4e866 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,11 @@ export type { OpencodePermissionAction, OpencodePermissionRule, OpencodePermissionRuleset, + OpencodeQuestionOption, + OpencodeQuestionInfo, + OpencodeQuestionRequest, + OpencodeQuestionAnswer, + OpencodeQuestionResponse, ToolStreamState, StreamingUsage, } from "./types.js"; diff --git a/src/opencode-language-model.test.ts b/src/opencode-language-model.test.ts index 2f709f8..643c544 100644 --- a/src/opencode-language-model.test.ts +++ b/src/opencode-language-model.test.ts @@ -70,6 +70,14 @@ const mockClient = { data: true, }), }, + question: { + reply: vi.fn().mockResolvedValue({ + data: true, + }), + reject: vi.fn().mockResolvedValue({ + data: true, + }), + }, }; const mockClientManager = { @@ -1033,6 +1041,268 @@ describe("opencode-language-model", () => { expect(mockClient.permission.reply).toHaveBeenCalledTimes(1); }); + it("should answer OpenCode question requests with onQuestionAsked", async () => { + const onQuestionAsked = vi.fn().mockResolvedValue({ + answers: [["Deploy now"]], + }); + model = new OpencodeLanguageModel({ + modelId: "anthropic/claude-3-5-sonnet-20241022", + settings: { onQuestionAsked }, + clientManager: mockClientManager as unknown as OpencodeClientManager, + }); + mockClient.event.subscribe.mockResolvedValueOnce({ + stream: (async function* () { + yield { + type: "question.asked", + properties: { + id: "question-1", + sessionID: "session-123", + questions: [ + { + header: "Deploy", + question: "Pick deployment strategy", + options: [ + { + label: "Deploy now", + description: "Continue immediately", + }, + ], + }, + ], + }, + }; + yield { + type: "session.idle", + properties: { + sessionID: "session-123", + }, + }; + })(), + }); + + const result = await model.doStream({ + prompt: basicPrompt, + }); + + const parts: unknown[] = []; + for await (const part of result.stream) { + parts.push(part); + } + + expect(onQuestionAsked).toHaveBeenCalledWith({ + requestId: "question-1", + sessionId: "session-123", + questions: [ + { + header: "Deploy", + question: "Pick deployment strategy", + options: [ + { + label: "Deploy now", + description: "Continue immediately", + }, + ], + }, + ], + }); + expect(mockClient.question.reply).toHaveBeenCalledWith( + { + requestID: "question-1", + answers: [["Deploy now"]], + }, + { signal: expect.any(AbortSignal) }, + ); + const hasErrorPart = parts.some((part) => { + if (part == null || typeof part !== "object" || !("type" in part)) { + return false; + } + + return (part as { type?: unknown }).type === "error"; + }); + expect(hasErrorPart).toBe(false); + }); + + it("should reject OpenCode question requests when onQuestionAsked returns reject", async () => { + model = new OpencodeLanguageModel({ + modelId: "anthropic/claude-3-5-sonnet-20241022", + settings: { + onQuestionAsked: vi.fn().mockResolvedValue({ reject: true }), + }, + clientManager: mockClientManager as unknown as OpencodeClientManager, + }); + mockClient.event.subscribe.mockResolvedValueOnce({ + stream: (async function* () { + yield { + type: "question.asked", + properties: { + id: "question-2", + sessionID: "session-123", + questions: [], + }, + }; + yield { + type: "session.idle", + properties: { + sessionID: "session-123", + }, + }; + })(), + }); + + const result = await model.doStream({ + prompt: basicPrompt, + }); + for await (const _part of result.stream) { + // drain stream + } + + expect(mockClient.question.reject).toHaveBeenCalledWith( + { + requestID: "question-2", + }, + { signal: expect.any(AbortSignal) }, + ); + }); + + it("should emit error and close stream when question.reply returns an API error", async () => { + mockClient.question.reply.mockResolvedValueOnce({ + error: { message: "unknown question request" }, + }); + model = new OpencodeLanguageModel({ + modelId: "anthropic/claude-3-5-sonnet-20241022", + settings: { + onQuestionAsked: vi.fn().mockResolvedValue({ answers: [["Yes"]] }), + }, + clientManager: mockClientManager as unknown as OpencodeClientManager, + }); + mockClient.event.subscribe.mockResolvedValueOnce({ + stream: (async function* () { + yield { + type: "question.asked", + properties: { + id: "question-3", + sessionID: "session-123", + questions: [], + }, + }; + yield { + type: "session.idle", + properties: { + sessionID: "session-123", + }, + }; + })(), + }); + + const result = await model.doStream({ + prompt: basicPrompt, + }); + const parts: unknown[] = []; + for await (const part of result.stream) { + parts.push(part); + } + + const errorPart = parts.find( + (part) => + part != null && + typeof part === "object" && + (part as { type?: unknown }).type === "error", + ) as { error?: Error } | undefined; + expect(errorPart).toBeDefined(); + expect(String(errorPart?.error)).toContain( + "Failed to apply OpenCode question response for question-3", + ); + }); + + it("should emit error when onQuestionAsked throws", async () => { + model = new OpencodeLanguageModel({ + modelId: "anthropic/claude-3-5-sonnet-20241022", + settings: { + onQuestionAsked: vi.fn().mockRejectedValue(new Error("handler boom")), + }, + clientManager: mockClientManager as unknown as OpencodeClientManager, + }); + mockClient.event.subscribe.mockResolvedValueOnce({ + stream: (async function* () { + yield { + type: "question.asked", + properties: { + id: "question-4", + sessionID: "session-123", + questions: [], + }, + }; + })(), + }); + + const result = await model.doStream({ + prompt: basicPrompt, + }); + const parts: unknown[] = []; + for await (const part of result.stream) { + parts.push(part); + } + + expect(mockClient.question.reply).not.toHaveBeenCalled(); + const errorPart = parts.find( + (part) => + part != null && + typeof part === "object" && + (part as { type?: unknown }).type === "error", + ) as { error?: Error } | undefined; + expect(String(errorPart?.error)).toContain( + "OpenCode question handler failed for question-4: handler boom", + ); + }); + + it("should fall back to the unsupported-question error when onQuestionAsked returns undefined", async () => { + const onQuestionAsked = vi.fn().mockResolvedValue(undefined); + model = new OpencodeLanguageModel({ + modelId: "anthropic/claude-3-5-sonnet-20241022", + settings: { onQuestionAsked }, + clientManager: mockClientManager as unknown as OpencodeClientManager, + }); + mockClient.event.subscribe.mockResolvedValueOnce({ + stream: (async function* () { + yield { + type: "question.asked", + properties: { + id: "question-5", + sessionID: "session-123", + questions: [], + }, + }; + yield { + type: "session.idle", + properties: { + sessionID: "session-123", + }, + }; + })(), + }); + + const result = await model.doStream({ + prompt: basicPrompt, + }); + const parts: unknown[] = []; + for await (const part of result.stream) { + parts.push(part); + } + + expect(onQuestionAsked).toHaveBeenCalledTimes(1); + expect(mockClient.question.reply).not.toHaveBeenCalled(); + expect(mockClient.question.reject).not.toHaveBeenCalled(); + const errorPart = parts.find( + (part) => + part != null && + typeof part === "object" && + (part as { type?: unknown }).type === "error", + ) as { error?: Error } | undefined; + expect(String(errorPart?.error)).toContain( + "OpenCode question.asked events are not yet mapped to AI SDK responses", + ); + }); + it("should close event iterator on normal session completion", async () => { const completionEvents = [ { diff --git a/src/opencode-language-model.ts b/src/opencode-language-model.ts index 91aef88..3d4764f 100644 --- a/src/opencode-language-model.ts +++ b/src/opencode-language-model.ts @@ -13,6 +13,7 @@ import { InvalidArgumentError } from "@ai-sdk/provider"; import type { Logger, OpencodeSettings, + OpencodeQuestionResponse, ParsedModelId, StreamingUsage, } from "./types.js"; @@ -27,6 +28,7 @@ import { isEventForSession, isSessionComplete, STRUCTURED_OUTPUT_TOOL, + type EventQuestionAsked, type Message, type Part, } from "./convert-from-opencode-events.js"; @@ -64,6 +66,26 @@ interface ApprovalClient { }; } +interface QuestionClient { + question?: { + reply?: ( + parameters: { + requestID: string; + answers: string[][]; + directory?: string; + }, + options?: { signal?: AbortSignal }, + ) => Promise | unknown; + reject?: ( + parameters: { + requestID: string; + directory?: string; + }, + options?: { signal?: AbortSignal }, + ) => Promise | unknown; + }; +} + /** * Convert OpenCode token usage to the AI SDK v6 usage shape. */ @@ -168,6 +190,12 @@ function extractToolApprovalResponses( return responses; } +function isRejectQuestionResponse( + response: OpencodeQuestionResponse, +): response is { reject: true } { + return "reject" in response && response.reject === true; +} + /** * OpenCode Language Model implementation of LanguageModelV3. */ @@ -602,6 +630,26 @@ export class OpencodeLanguageModel implements LanguageModelV3 { continue; } + if (event.type === "question.asked") { + const questionResult = await this.replyToQuestionIfConfigured( + client, + event as EventQuestionAsked, + state.questionRequests, + requestAbortController.signal, + ); + if (questionResult.handled) { + if (questionResult.error) { + controller.enqueue({ + type: "error", + error: questionResult.error, + }); + await closeIterator(); + break; + } + continue; + } + } + const streamParts = convertEventToStreamParts( event, state, @@ -783,6 +831,108 @@ export class OpencodeLanguageModel implements LanguageModelV3 { return warnings; } + private async replyToQuestionIfConfigured( + client: QuestionClient, + event: EventQuestionAsked, + seenQuestionIds: Set, + signal: AbortSignal, + ): Promise<{ handled: boolean; error?: Error }> { + const handler = this.settings.onQuestionAsked; + if (!handler) { + return { handled: false }; + } + + const requestId = event.properties.id; + if (seenQuestionIds.has(requestId)) { + return { handled: true }; + } + + let response: OpencodeQuestionResponse | null | undefined; + try { + response = await handler({ + requestId, + sessionId: event.properties.sessionID, + questions: event.properties.questions, + ...(event.properties.tool ? { tool: event.properties.tool } : {}), + }); + } catch (error) { + return { + handled: true, + error: new Error( + `OpenCode question handler failed for ${requestId}: ${extractErrorMessage(error)}`, + ), + }; + } + + if (response == null) { + return { handled: false }; + } + + const directory = this.getRequestDirectory(); + try { + let result: unknown; + if (isRejectQuestionResponse(response)) { + if (typeof client.question?.reject !== "function") { + return { + handled: true, + error: new Error( + "OpenCode question.reject is unavailable; question response was ignored.", + ), + }; + } + + result = await client.question.reject( + { + requestID: requestId, + ...(directory ? { directory } : {}), + }, + { signal }, + ); + } else { + if (typeof client.question?.reply !== "function") { + return { + handled: true, + error: new Error( + "OpenCode question.reply is unavailable; question response was ignored.", + ), + }; + } + + result = await client.question.reply( + { + requestID: requestId, + answers: response.answers, + ...(directory ? { directory } : {}), + }, + { signal }, + ); + } + + // Fields-style clients report API failures via the result rather than + // throwing, so a missing check would record the question as answered + // while OpenCode keeps waiting (issue #15). + const { error: resultError } = extractSdkResult(result); + if (resultError) { + return { + handled: true, + error: new Error( + `Failed to apply OpenCode question response for ${requestId}: ${extractErrorMessage(resultError)}`, + ), + }; + } + + seenQuestionIds.add(requestId); + return { handled: true }; + } catch (error) { + return { + handled: true, + error: new Error( + `Failed to apply OpenCode question response for ${requestId}: ${extractErrorMessage(error)}`, + ), + }; + } + } + private getPendingApprovalResponses( responses: ToolApprovalResponse[], repliedApprovalIds: Set, diff --git a/src/types.ts b/src/types.ts index b306e6e..cfb981f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,6 +57,35 @@ export interface OpencodePermissionRule { */ export type OpencodePermissionRuleset = OpencodePermissionRule[]; +export interface OpencodeQuestionOption { + label: string; + description: string; +} + +export interface OpencodeQuestionInfo { + header: string; + question: string; + options: OpencodeQuestionOption[]; + multiple?: boolean; + custom?: boolean; +} + +export interface OpencodeQuestionRequest { + requestId: string; + sessionId: string; + questions: OpencodeQuestionInfo[]; + tool?: { + messageID: string; + callID: string; + }; +} + +export type OpencodeQuestionAnswer = string[]; + +export type OpencodeQuestionResponse = + | { answers: OpencodeQuestionAnswer[] } + | { reject: true }; + /** * Settings for individual model instances. */ @@ -128,6 +157,18 @@ export interface OpencodeSettings { */ outputFormatRetryCount?: number; + /** + * Answer or reject OpenCode interactive question requests during streaming. + * Return undefined/null to keep the default unsupported-question error. + */ + onQuestionAsked?: ( + request: OpencodeQuestionRequest, + ) => + | OpencodeQuestionResponse + | null + | undefined + | Promise; + /** * Logger instance or false to disable logging. */ diff --git a/src/validation.test.ts b/src/validation.test.ts index f13046f..9432e6a 100644 --- a/src/validation.test.ts +++ b/src/validation.test.ts @@ -35,6 +35,7 @@ describe("validation", () => { cwd: "/home/user", directory: "/home/user", outputFormatRetryCount: 2, + onQuestionAsked: vi.fn(), verbose: true, }; diff --git a/src/validation.ts b/src/validation.ts index 7289846..59b63e6 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -44,6 +44,12 @@ export const opcodeSettingsSchema = z.object({ cwd: z.string().optional(), directory: z.string().optional(), outputFormatRetryCount: z.number().int().nonnegative().optional(), + onQuestionAsked: z + .any() + .refine((val) => val === undefined || typeof val === "function", { + message: "onQuestionAsked must be a function", + }) + .optional(), logger: z.union([loggerSchema, z.literal(false)]).optional(), verbose: z.boolean().optional(), });