Skip to content

Commit d4ff4a8

Browse files
authored
feat(ai): OpenCode provider via HTTP + SSE (#379)
* feat(ai): add OpenCode provider via HTTP + SSE Bundle @opencode-ai/sdk to spawn `opencode serve` and communicate via HTTP REST + SSE. Richest provider: fork, resume, streaming, tools, and runtime permission approvals all work natively. - OpenCodeProvider: lazy server spawn, shared HTTP client, dynamic model discovery via GET /provider - OpenCodeSession: SSE event drain loop, system prompt via API field, permission reply (once/reject), abort via HTTP - mapOpenCodeEvent: SSE → AIMessage mapping (text deltas, tool lifecycle, permissions, session status, errors) - 11 new event mapping tests (78 total, 157 expect calls) For provenance purposes, this commit was AI assisted. * docs(ai): add OpenCode to AI features guide and code review docs For provenance purposes, this commit was AI assisted. * fix(ai): prevent double server spawn in OpenCode ensureServer() Use a promise-mutex pattern so concurrent callers (e.g. fetchModels + createSession) share a single server spawn instead of racing to create two. Also remove empty finally block from SSE drain loop. For provenance purposes, this commit was AI assisted. * fix(ai): await SSE subscribe in OpenCode provider The SDK's event.subscribe() is async — must be awaited before destructuring the stream. For provenance purposes, this commit was AI assisted. * fix(ui): scrollable model dropdown with search filter Cap dropdown height at min(320px, 50vh) so it doesn't overflow the viewport when providers expose many models (e.g. OpenCode with 20+). Add a search/filter input when there are more than 8 models. For provenance purposes, this commit was AI assisted. * fix(ai): close SSE stream, rename to opencode-sdk, move dep to root - Close SSE stream in finally block to prevent connection leak on abort/early return - Rename provider from opencode to opencode-sdk for consistency with claude-agent-sdk, codex-sdk, pi-sdk - Move @opencode-ai/sdk dependency to root package.json alongside the other SDK dependencies For provenance purposes, this commit was AI assisted. * feat(ui): add first-run AI setup dialog for provider selection Show a one-time modal on first review when AI providers are available. Users pick a default provider and model from a 2-column grid, with a brief feature intro and auth disclaimer. Persists via cookie, can be changed later in Settings. For provenance purposes, this commit was AI assisted. * fix(ai): clear startPromise on failure so OpenCode provider can retry If doStart() fails (port conflict, timeout, binary crash), the cached rejected promise prevented all future ensureServer() calls from retrying. Now cleared on failure and on dispose(). Also updates stale "future" comment in types.ts. For provenance purposes, this commit was AI assisted.
1 parent 877b0c7 commit d4ff4a8

16 files changed

Lines changed: 986 additions & 30 deletions

File tree

apps/marketing/src/content/docs/commands/code-review.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ Plannotator supports multiple AI providers. Providers are auto-detected based on
8484
- **Claude** — Requires the `claude` CLI ([Claude Code](https://docs.anthropic.com/en/docs/claude-code))
8585
- **Codex** — Requires the `codex` CLI ([OpenAI Codex](https://github.com/openai/codex))
8686
- **Pi** — Requires the `pi` CLI ([Pi](https://github.com/mariozechner/pi-coding-agent))
87+
- **OpenCode** — Requires the `opencode` CLI ([OpenCode](https://opencode.ai))
8788

88-
All providers can be available simultaneously. Plannotator does not manage API keys — you must be authenticated with each CLI independently (`claude` uses `~/.claude/` credentials, `codex` uses `OPENAI_API_KEY`, `pi` uses its own local configuration).
89+
All providers can be available simultaneously. Plannotator does not manage API keys — you must be authenticated with each CLI independently (`claude` uses `~/.claude/` credentials, `codex` uses `OPENAI_API_KEY`, `pi` and `opencode` use their own local configuration).
8990

9091
### Choosing a provider
9192

apps/marketing/src/content/docs/guides/ai-features.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ Requires the `pi` CLI installed and configured. Plannotator spawns `pi --mode rp
3939

4040
No API keys are managed by Plannotator — Pi uses its own local configuration.
4141

42+
### OpenCode (via OpenCode SDK)
43+
44+
Requires the `opencode` CLI installed and authenticated. Plannotator spawns `opencode serve` and communicates via HTTP + SSE. Models are discovered dynamically from your connected providers.
45+
46+
OpenCode supports session forking, resuming, and runtime permission approvals — the richest capability set of all four providers.
47+
4248
## Configuration
4349

4450
Provider and model selection is available in **Settings > AI**. These persist via cookies across sessions.
@@ -55,6 +61,8 @@ A session is created lazily on your first question. Until then, no resources are
5561

5662
**Pi sessions** inject the review context as a system prompt prefix, similar to Codex. Pi uses its full default toolset (read, bash, edit, write). Pi sessions are always standalone — fork and resume are not available.
5763

64+
**OpenCode sessions** pass the review context via the `system` field on the prompt API. OpenCode supports forking from a parent session and resuming previous sessions. Permission requests work the same as Claude — approval cards appear inline.
65+
5866
**Diff context handling:** Large diffs are truncated at roughly 40k characters to stay within context limits. However, when you select specific lines and ask a question, the selected code is always sent alongside the question regardless of truncation.
5967

6068
## Permission requests
@@ -63,19 +71,21 @@ When using Claude, the AI may request permission to use tools like Read, Glob, G
6371

6472
Codex sessions run in a sandboxed read-only mode, so permission requests do not apply.
6573

74+
OpenCode supports the same permission approval flow as Claude — tool calls that need approval appear as inline cards. You can approve or deny each request.
75+
6676
Pi does not expose a permission approval gate over RPC, so tool execution is handled entirely by Pi's own runtime.
6777

6878
## Reasoning effort
6979

7080
Codex supports a reasoning effort setting with four levels: **Low**, **Medium**, **High**, and **Max**. This is available in the config bar at the bottom of the AI sidebar. Higher effort means slower but more thorough responses.
7181

72-
This setting only applies to Codex — Claude and Pi do not expose a reasoning effort control.
82+
This setting only applies to Codex — Claude, Pi, and OpenCode do not expose a reasoning effort control.
7383

7484
## Available settings
7585

7686
| Setting | Description | Provider |
7787
|---------|-------------|----------|
78-
| Provider | Claude, Codex, or Pi | All |
88+
| Provider | Claude, Codex, Pi, or OpenCode | All |
7989
| Model | Model selection per provider | All |
8090
| Reasoning effort | Low / Medium / High / Max | Codex only |
8191
| Default tools | Read, Glob, Grep, WebSearch | Claude only |

bun.lock

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"dependencies": {
3535
"@anthropic-ai/claude-agent-sdk": "^0.2.81",
3636
"@openai/codex-sdk": "^0.116.0",
37+
"@opencode-ai/sdk": "^1.3.0",
3738
"@pierre/diffs": "^1.1.0-beta.19",
3839
"diff": "^8.0.3",
3940
"dompurify": "^3.3.3",

packages/ai/ai.test.ts

Lines changed: 174 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,11 +326,11 @@ describe("ProviderRegistry", () => {
326326
test("mixed provider types", () => {
327327
const reg = new ProviderRegistry();
328328
reg.register(mockProvider("claude-agent-sdk"), "claude-1");
329-
reg.register(mockProvider("opencode"), "oc-1");
329+
reg.register(mockProvider("opencode-sdk"), "oc-1");
330330

331331
expect(reg.size).toBe(2);
332332
expect(reg.getByType("claude-agent-sdk").length).toBe(1);
333-
expect(reg.getByType("opencode").length).toBe(1);
333+
expect(reg.getByType("opencode-sdk").length).toBe(1);
334334
});
335335

336336
test("disposeAll clears everything", () => {
@@ -403,7 +403,7 @@ describe("AI endpoints", () => {
403403
test("capabilities lists multiple providers", async () => {
404404
const { reg, endpoints } = setup();
405405
reg.register(mockProvider("claude-agent-sdk"), "claude-1");
406-
reg.register(mockProvider("opencode"), "oc-1");
406+
reg.register(mockProvider("opencode-sdk"), "oc-1");
407407

408408
const res = await endpoints["/api/ai/capabilities"](
409409
new Request("http://localhost/api/ai/capabilities")
@@ -466,7 +466,7 @@ describe("AI endpoints", () => {
466466
test("session creation with specific provider ID", async () => {
467467
const { reg, endpoints } = setup();
468468
reg.register(mockProvider("claude-agent-sdk"), "claude-fast");
469-
reg.register(mockProvider("opencode"), "oc-default");
469+
reg.register(mockProvider("opencode-sdk"), "oc-default");
470470

471471
const createRes = await endpoints["/api/ai/session"](
472472
new Request("http://localhost/api/ai/session", {
@@ -1054,3 +1054,173 @@ describe("mapPiEvent", () => {
10541054
}]);
10551055
});
10561056
});
1057+
1058+
// ---------------------------------------------------------------------------
1059+
// OpenCode event mapping
1060+
// ---------------------------------------------------------------------------
1061+
1062+
import { mapOpenCodeEvent } from "./providers/opencode-sdk.ts";
1063+
1064+
describe("mapOpenCodeEvent", () => {
1065+
const SESSION_ID = "oc_session_1";
1066+
1067+
test("message.part.delta with text field maps to text_delta", () => {
1068+
const result = mapOpenCodeEvent("message.part.delta", {
1069+
sessionID: SESSION_ID,
1070+
messageID: "msg_1",
1071+
partID: "part_1",
1072+
field: "text",
1073+
delta: "Hello ",
1074+
}, SESSION_ID);
1075+
expect(result).toEqual([{ type: "text_delta", delta: "Hello " }]);
1076+
});
1077+
1078+
test("message.part.delta with non-text field returns empty", () => {
1079+
const result = mapOpenCodeEvent("message.part.delta", {
1080+
field: "reasoning",
1081+
delta: "thinking...",
1082+
}, SESSION_ID);
1083+
expect(result).toEqual([]);
1084+
});
1085+
1086+
test("message.part.updated with running tool maps to tool_use", () => {
1087+
const result = mapOpenCodeEvent("message.part.updated", {
1088+
part: {
1089+
id: "part_1",
1090+
type: "tool",
1091+
tool: "read",
1092+
callID: "call_1",
1093+
state: {
1094+
status: "running",
1095+
input: { path: "/foo.ts" },
1096+
time: { start: 1000 },
1097+
},
1098+
},
1099+
}, SESSION_ID);
1100+
expect(result).toEqual([{
1101+
type: "tool_use",
1102+
toolName: "read",
1103+
toolInput: { path: "/foo.ts" },
1104+
toolUseId: "call_1",
1105+
}]);
1106+
});
1107+
1108+
test("message.part.updated with completed tool maps to tool_result", () => {
1109+
const result = mapOpenCodeEvent("message.part.updated", {
1110+
part: {
1111+
id: "part_1",
1112+
type: "tool",
1113+
tool: "read",
1114+
callID: "call_1",
1115+
state: {
1116+
status: "completed",
1117+
output: "file contents here",
1118+
time: { start: 1000, end: 1100 },
1119+
},
1120+
},
1121+
}, SESSION_ID);
1122+
expect(result).toEqual([{
1123+
type: "tool_result",
1124+
toolUseId: "call_1",
1125+
result: "file contents here",
1126+
}]);
1127+
});
1128+
1129+
test("message.part.updated with error tool maps to tool_result with [Error] prefix", () => {
1130+
const result = mapOpenCodeEvent("message.part.updated", {
1131+
part: {
1132+
id: "part_1",
1133+
type: "tool",
1134+
tool: "read",
1135+
callID: "call_1",
1136+
state: {
1137+
status: "error",
1138+
error: "file not found",
1139+
time: { start: 1000, end: 1100 },
1140+
},
1141+
},
1142+
}, SESSION_ID);
1143+
expect(result).toEqual([{
1144+
type: "tool_result",
1145+
toolUseId: "call_1",
1146+
result: "[Error] file not found",
1147+
}]);
1148+
});
1149+
1150+
test("permission.updated maps to permission_request", () => {
1151+
const result = mapOpenCodeEvent("permission.updated", {
1152+
id: "perm_1",
1153+
type: "write_file",
1154+
title: "Write to /src/foo.ts",
1155+
callID: "call_2",
1156+
sessionID: SESSION_ID,
1157+
metadata: { path: "/src/foo.ts" },
1158+
}, SESSION_ID);
1159+
expect(result).toEqual([{
1160+
type: "permission_request",
1161+
requestId: "perm_1",
1162+
toolName: "write_file",
1163+
toolInput: { path: "/src/foo.ts" },
1164+
title: "Write to /src/foo.ts",
1165+
toolUseId: "call_2",
1166+
}]);
1167+
});
1168+
1169+
test("session.status idle maps to result", () => {
1170+
const result = mapOpenCodeEvent("session.status", {
1171+
sessionID: SESSION_ID,
1172+
status: { type: "idle" },
1173+
}, SESSION_ID);
1174+
expect(result).toEqual([{
1175+
type: "result",
1176+
sessionId: SESSION_ID,
1177+
success: true,
1178+
}]);
1179+
});
1180+
1181+
test("session.status busy returns empty", () => {
1182+
const result = mapOpenCodeEvent("session.status", {
1183+
sessionID: SESSION_ID,
1184+
status: { type: "busy" },
1185+
}, SESSION_ID);
1186+
expect(result).toEqual([]);
1187+
});
1188+
1189+
test("message.updated with error maps to error", () => {
1190+
const result = mapOpenCodeEvent("message.updated", {
1191+
info: {
1192+
id: "msg_1",
1193+
sessionID: SESSION_ID,
1194+
role: "assistant",
1195+
error: {
1196+
name: "ProviderAuthError",
1197+
data: { message: "Invalid API key", providerID: "anthropic" },
1198+
},
1199+
},
1200+
}, SESSION_ID);
1201+
expect(result).toEqual([{
1202+
type: "error",
1203+
error: "Invalid API key",
1204+
code: "opencode_message_error",
1205+
}]);
1206+
});
1207+
1208+
test("session.error maps to error", () => {
1209+
const result = mapOpenCodeEvent("session.error", {
1210+
sessionID: SESSION_ID,
1211+
error: { message: "Something went wrong" },
1212+
}, SESSION_ID);
1213+
expect(result).toEqual([{
1214+
type: "error",
1215+
error: "Something went wrong",
1216+
code: "opencode_session_error",
1217+
}]);
1218+
});
1219+
1220+
test("ignored events return empty", () => {
1221+
expect(mapOpenCodeEvent("session.created", { sessionID: SESSION_ID }, SESSION_ID)).toEqual([]);
1222+
expect(mapOpenCodeEvent("session.updated", { sessionID: SESSION_ID }, SESSION_ID)).toEqual([]);
1223+
expect(mapOpenCodeEvent("server.connected", {}, SESSION_ID)).toEqual([]);
1224+
expect(mapOpenCodeEvent("file.edited", { file: "foo.ts" }, SESSION_ID)).toEqual([]);
1225+
});
1226+
});

packages/ai/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export type {
7474
ClaudeAgentSDKConfig,
7575
CodexSDKConfig,
7676
PiSDKConfig,
77+
OpenCodeConfig,
7778
} from "./types.ts";
7879

7980
// Provider registry

packages/ai/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"./endpoints": "./endpoints.ts",
1313
"./providers/claude-agent-sdk": "./providers/claude-agent-sdk.ts",
1414
"./providers/codex-sdk": "./providers/codex-sdk.ts",
15-
"./providers/pi-sdk": "./providers/pi-sdk.ts"
16-
}
15+
"./providers/pi-sdk": "./providers/pi-sdk.ts",
16+
"./providers/opencode-sdk": "./providers/opencode-sdk.ts"
17+
},
18+
"dependencies": {}
1719
}

0 commit comments

Comments
 (0)