Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export type {
OpencodePermissionAction,
OpencodePermissionRule,
OpencodePermissionRuleset,
OpencodeQuestionOption,
OpencodeQuestionInfo,
OpencodeQuestionRequest,
OpencodeQuestionAnswer,
OpencodeQuestionResponse,
ToolStreamState,
StreamingUsage,
} from "./types.js";
Expand Down
270 changes: 270 additions & 0 deletions src/opencode-language-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ const mockClient = {
data: true,
}),
},
question: {
reply: vi.fn().mockResolvedValue({
data: true,
}),
reject: vi.fn().mockResolvedValue({
data: true,
}),
},
};

const mockClientManager = {
Expand Down Expand Up @@ -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 = [
{
Expand Down
Loading
Loading