History

feat: add opencode provider support (#1758)

Shoubhit Dash committed 7 months ago

Commit ce94fee1 parent 39ca3ee52 files · +4727 −228Browse files

Unified · Split

52 files
  1. apps/server/package.json +1 −0
  2. apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts +259 −0
  3. apps/server/src/git/Layers/OpenCodeTextGeneration.ts +422 −0
  4. apps/server/src/git/Layers/RoutingTextGeneration.ts +20 −2
  5. apps/server/src/git/Services/TextGeneration.ts +1 −1
  6. apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +486 −0
  7. apps/server/src/provider/Layers/OpenCodeAdapter.ts +1344 −0
  8. apps/server/src/provider/Layers/OpenCodeProvider.test.ts +138 −0
  9. apps/server/src/provider/Layers/OpenCodeProvider.ts +342 −0
  10. apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +23 −1
  11. apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +2 −1
  12. apps/server/src/provider/Layers/ProviderRegistry.test.ts +27 −12
  13. apps/server/src/provider/Layers/ProviderRegistry.ts +28 −6
  14. apps/server/src/provider/Layers/ProviderService.test.ts +56 −0
  15. apps/server/src/provider/Layers/ProviderService.ts +3 −1
  16. apps/server/src/provider/Layers/ProviderSessionDirectory.ts +1 −1
  17. apps/server/src/provider/Services/OpenCodeAdapter.ts +12 −0
  18. apps/server/src/provider/Services/OpenCodeProvider.ts +9 −0
  19. apps/server/src/provider/opencodeRuntime.test.ts +38 −0
  20. apps/server/src/provider/opencodeRuntime.ts +573 −0
  21. apps/server/src/provider/providerSnapshot.test.ts +34 −0
  22. apps/server/src/provider/providerStatusCache.test.ts +13 −0
  23. apps/server/src/provider/providerStatusCache.ts +5 −3
  24. apps/server/src/server.ts +5 −0
  25. apps/server/src/serverSettings.test.ts +20 −0
  26. apps/server/src/serverSettings.ts +1 −1
  27. apps/web/src/components/ChatView.tsx +6 −9
  28. apps/web/src/components/Icons.tsx +4 −2
  29. apps/web/src/components/KeybindingsToast.browser.tsx +7 −0
  30. apps/web/src/components/chat/ChatComposer.tsx +36 −24
  31. apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +35 −0
  32. apps/web/src/components/chat/CompactComposerControlsMenu.tsx +17 −12
  33. apps/web/src/components/chat/ProviderModelPicker.tsx +2 −4
  34. apps/web/src/components/chat/TraitsPicker.browser.tsx +128 −1
  35. apps/web/src/components/chat/TraitsPicker.tsx +93 −9
  36. apps/web/src/components/chat/composerProviderRegistry.test.tsx +65 −0
  37. apps/web/src/components/chat/composerProviderRegistry.tsx +54 −68
  38. apps/web/src/components/settings/SettingsPanels.browser.tsx +17 −0
  39. apps/web/src/components/settings/SettingsPanels.tsx +119 −7
  40. apps/web/src/composerDraftStore.ts +56 −45
  41. apps/web/src/modelSelection.ts +19 −6
  42. apps/web/src/session-logic.test.ts +8 −1
  43. apps/web/src/session-logic.ts +1 −0
  44. apps/web/src/store.ts +2 −2
  45. bun.lock +3 −0
  46. packages/contracts/src/model.ts +13 −0
  47. packages/contracts/src/orchestration.ts +14 −3
  48. packages/contracts/src/providerRuntime.ts +1 −0
  49. packages/contracts/src/settings.ts +30 −0
  50. packages/shared/src/model.ts +73 −0
  51. packages/shared/src/serverSettings.test.ts +26 −0
  52. packages/shared/src/serverSettings.ts +35 −6
apps/server/package.json+1−0
@@ -29,6 +29,7 @@ · 28 unmodified lines
2929 "@effect/platform-bun": "catalog:",
3030 "@effect/platform-node": "catalog:",
3131 "@effect/sql-sqlite-bun": "catalog:",
32+ "@opencode-ai/sdk": "^1.3.15",
3233 "@pierre/diffs": "^1.1.0-beta.16",
3334 "effect": "catalog:",
3435 "node-pty": "^1.1.0",
apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts+259−0
@@ -0,0 +1,259 @@
1+import type { ChildProcess } from "node:child_process";
2+
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import { it } from "@effect/vitest";
5+import { Duration, Effect, Layer } from "effect";
6+import { TestClock } from "effect/testing";
7+import { beforeEach, expect, vi } from "vitest";
8+
9+import { ServerConfig } from "../../config.ts";
10+import { ServerSettingsService } from "../../serverSettings.ts";
11+import { TextGeneration } from "../Services/TextGeneration.ts";
12+import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
13+
14+const runtimeMock = vi.hoisted(() => {
15+ const state = {
16+ startCalls: [] as string[],
17+ promptUrls: [] as string[],
18+ authHeaders: [] as Array<string | null>,
19+ closeCalls: [] as string[],
20+ promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined,
21+ };
22+
23+ return {
24+ state,
25+ reset() {
26+ state.startCalls.length = 0;
27+ state.promptUrls.length = 0;
28+ state.authHeaders.length = 0;
29+ state.closeCalls.length = 0;
30+ state.promptResult = undefined;
31+ },
32+ };
33+});
34+
35+vi.mock("../../provider/opencodeRuntime.ts", async () => {
36+ const actual = await vi.importActual<typeof import("../../provider/opencodeRuntime.ts")>(
37+ "../../provider/opencodeRuntime.ts",
38+ );
39+
40+ return {
41+ ...actual,
42+ startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => {
43+ const index = runtimeMock.state.startCalls.length + 1;
44+ const url = `http://127.0.0.1:${4_300 + index}`;
45+ runtimeMock.state.startCalls.push(binaryPath);
46+ return {
47+ url,
48+ process: {} as ChildProcess,
49+ close: () => {
50+ runtimeMock.state.closeCalls.push(url);
51+ },
52+ };
53+ }),
54+ createOpenCodeSdkClient: vi.fn(
55+ ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({
56+ session: {
57+ create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })),
58+ prompt: vi.fn(async () => {
59+ runtimeMock.state.promptUrls.push(baseUrl);
60+ runtimeMock.state.authHeaders.push(
61+ serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
62+ );
63+ return (
64+ runtimeMock.state.promptResult ?? {
65+ data: {
66+ info: {
67+ structured: {
68+ subject: "Improve OpenCode reuse",
69+ body: "Reuse one server for the full action.",
70+ },
71+ },
72+ },
73+ }
74+ );
75+ }),
76+ },
77+ }),
78+ ),
79+ };
80+});
81+
82+const DEFAULT_TEST_MODEL_SELECTION = {
83+ provider: "opencode" as const,
84+ model: "openai/gpt-5",
85+};
86+
87+const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
88+
89+const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(
90+ Layer.provideMerge(
91+ ServerSettingsService.layerTest({
92+ providers: {
93+ opencode: {
94+ binaryPath: "fake-opencode",
95+ },
96+ },
97+ }),
98+ ),
99+ Layer.provideMerge(
100+ ServerConfig.layerTest(process.cwd(), {
101+ prefix: "t3code-opencode-text-generation-test-",
102+ }),
103+ ),
104+ Layer.provideMerge(NodeServices.layer),
105+);
106+
107+const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe(
108+ Layer.provideMerge(
109+ ServerSettingsService.layerTest({
110+ providers: {
111+ opencode: {
112+ binaryPath: "fake-opencode",
113+ serverUrl: "http://127.0.0.1:9999",
114+ serverPassword: "secret-password",
115+ },
116+ },
117+ }),
118+ ),
119+ Layer.provideMerge(
120+ ServerConfig.layerTest(process.cwd(), {
121+ prefix: "t3code-opencode-text-generation-existing-server-test-",
122+ }),
123+ ),
124+ Layer.provideMerge(NodeServices.layer),
125+);
126+
127+beforeEach(() => {
128+ runtimeMock.reset();
129+});
130+
131+const advanceIdleClock = Effect.gen(function* () {
132+ yield* Effect.yieldNow;
133+ yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1));
134+ yield* Effect.yieldNow;
135+});
136+
137+it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => {
138+ it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
139+ Effect.gen(function* () {
140+ const textGeneration = yield* TextGeneration;
141+
142+ yield* textGeneration.generateCommitMessage({
143+ cwd: process.cwd(),
144+ branch: "feature/opencode-reuse",
145+ stagedSummary: "M README.md",
146+ stagedPatch: "diff --git a/README.md b/README.md",
147+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
148+ });
149+ yield* textGeneration.generateCommitMessage({
150+ cwd: process.cwd(),
151+ branch: "feature/opencode-reuse",
152+ stagedSummary: "M README.md",
153+ stagedPatch: "diff --git a/README.md b/README.md",
154+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
155+ });
156+
157+ expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
158+ expect(runtimeMock.state.promptUrls).toEqual([
159+ "http://127.0.0.1:4301",
160+ "http://127.0.0.1:4301",
161+ ]);
162+ expect(runtimeMock.state.closeCalls).toEqual([]);
163+
164+ yield* advanceIdleClock;
165+
166+ expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
167+ }).pipe(Effect.provide(TestClock.layer())),
168+ );
169+
170+ it.effect("starts a new server after the warm server idles out", () =>
171+ Effect.gen(function* () {
172+ const textGeneration = yield* TextGeneration;
173+
174+ yield* textGeneration.generateCommitMessage({
175+ cwd: process.cwd(),
176+ branch: "feature/opencode-reuse",
177+ stagedSummary: "M README.md",
178+ stagedPatch: "diff --git a/README.md b/README.md",
179+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
180+ });
181+
182+ yield* advanceIdleClock;
183+
184+ yield* textGeneration.generateCommitMessage({
185+ cwd: process.cwd(),
186+ branch: "feature/opencode-reuse",
187+ stagedSummary: "M README.md",
188+ stagedPatch: "diff --git a/README.md b/README.md",
189+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
190+ });
191+
192+ expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]);
193+ expect(runtimeMock.state.promptUrls).toEqual([
194+ "http://127.0.0.1:4301",
195+ "http://127.0.0.1:4302",
196+ ]);
197+ expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
198+ }).pipe(Effect.provide(TestClock.layer())),
199+ );
200+
201+ it.effect("returns a typed missing-output error when OpenCode omits info.structured", () =>
202+ Effect.gen(function* () {
203+ runtimeMock.state.promptResult = { data: {} };
204+ const textGeneration = yield* TextGeneration;
205+
206+ const error = yield* textGeneration
207+ .generateCommitMessage({
208+ cwd: process.cwd(),
209+ branch: "feature/opencode-reuse",
210+ stagedSummary: "M README.md",
211+ stagedPatch: "diff --git a/README.md b/README.md",
212+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
213+ })
214+ .pipe(Effect.flip);
215+
216+ expect(error.message).toContain("OpenCode returned no structured output.");
217+ }),
218+ );
219+});
220+
221+it.layer(OpenCodeTextGenerationExistingServerTestLayer)(
222+ "OpenCodeTextGenerationLive with configured server URL",
223+ (it) => {
224+ it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
225+ Effect.gen(function* () {
226+ const textGeneration = yield* TextGeneration;
227+
228+ yield* textGeneration.generateCommitMessage({
229+ cwd: process.cwd(),
230+ branch: "feature/opencode-reuse",
231+ stagedSummary: "M README.md",
232+ stagedPatch: "diff --git a/README.md b/README.md",
233+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
234+ });
235+ yield* textGeneration.generateCommitMessage({
236+ cwd: process.cwd(),
237+ branch: "feature/opencode-reuse",
238+ stagedSummary: "M README.md",
239+ stagedPatch: "diff --git a/README.md b/README.md",
240+ modelSelection: DEFAULT_TEST_MODEL_SELECTION,
241+ });
242+
243+ expect(runtimeMock.state.startCalls).toEqual([]);
244+ expect(runtimeMock.state.promptUrls).toEqual([
245+ "http://127.0.0.1:9999",
246+ "http://127.0.0.1:9999",
247+ ]);
248+ expect(runtimeMock.state.authHeaders).toEqual([
249+ `Basic ${btoa("opencode:secret-password")}`,
250+ `Basic ${btoa("opencode:secret-password")}`,
251+ ]);
252+
253+ yield* advanceIdleClock;
254+
255+ expect(runtimeMock.state.closeCalls).toEqual([]);
256+ }).pipe(Effect.provide(TestClock.layer())),
257+ );
258+ },
259+);
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+422−0
@@ -0,0 +1,422 @@
1+import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";
2+import * as Semaphore from "effect/Semaphore";
3+
4+import {
5+ TextGenerationError,
6+ type ChatAttachment,
7+ type OpenCodeModelSelection,
8+} from "@t3tools/contracts";
9+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
10+
11+import { ServerConfig } from "../../config.ts";
12+import { resolveAttachmentPath } from "../../attachmentStore.ts";
13+import { ServerSettingsService } from "../../serverSettings.ts";
14+import {
15+ buildBranchNamePrompt,
16+ buildCommitMessagePrompt,
17+ buildPrContentPrompt,
18+ buildThreadTitlePrompt,
19+} from "../Prompts.ts";
20+import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
21+import {
22+ sanitizeCommitSubject,
23+ sanitizePrTitle,
24+ sanitizeThreadTitle,
25+ toJsonSchemaObject,
26+} from "../Utils.ts";
27+import {
28+ createOpenCodeSdkClient,
29+ type OpenCodeServerConnection,
30+ type OpenCodeServerProcess,
31+ parseOpenCodeModelSlug,
32+ startOpenCodeServerProcess,
33+ toOpenCodeFileParts,
34+} from "../../provider/opencodeRuntime.ts";
35+
36+const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
37+
38+interface SharedOpenCodeTextGenerationServerState {
39+ server: OpenCodeServerProcess | null;
40+ binaryPath: string | null;
41+ activeRequests: number;
42+ idleCloseFiber: Fiber.Fiber<void, never> | null;
43+}
44+
45+const makeOpenCodeTextGeneration = Effect.gen(function* () {
46+ const serverConfig = yield* ServerConfig;
47+ const serverSettingsService = yield* ServerSettingsService;
48+ const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
49+ Scope.close(scope, Exit.void),
50+ );
51+ const sharedServerMutex = yield* Semaphore.make(1);
52+ const sharedServerState: SharedOpenCodeTextGenerationServerState = {
53+ server: null,
54+ binaryPath: null,
55+ activeRequests: 0,
56+ idleCloseFiber: null,
57+ };
58+
59+ const closeSharedServer = (server: OpenCodeServerProcess) => {
60+ if (sharedServerState.server === server) {
61+ sharedServerState.server = null;
62+ sharedServerState.binaryPath = null;
63+ }
64+ server.close();
65+ };
66+
67+ const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () {
68+ const idleCloseFiber = sharedServerState.idleCloseFiber;
69+ sharedServerState.idleCloseFiber = null;
70+ if (idleCloseFiber !== null) {
71+ yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore);
72+ }
73+ });
74+
75+ const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* (
76+ server: OpenCodeServerProcess,
77+ ) {
78+ yield* cancelIdleCloseFiber();
79+ const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe(
80+ Effect.andThen(
81+ sharedServerMutex.withPermit(
82+ Effect.sync(() => {
83+ if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) {
84+ return;
85+ }
86+ sharedServerState.idleCloseFiber = null;
87+ closeSharedServer(server);
88+ }),
89+ ),
90+ ),
91+ Effect.forkIn(idleFiberScope),
92+ );
93+ sharedServerState.idleCloseFiber = fiber;
94+ });
95+
96+ const acquireSharedServer = (input: {
97+ readonly binaryPath: string;
98+ readonly operation:
99+ | "generateCommitMessage"
100+ | "generatePrContent"
101+ | "generateBranchName"
102+ | "generateThreadTitle";
103+ }) =>
104+ sharedServerMutex.withPermit(
105+ Effect.gen(function* () {
106+ yield* cancelIdleCloseFiber();
107+
108+ const existingServer = sharedServerState.server;
109+ if (existingServer !== null) {
110+ if (
111+ sharedServerState.binaryPath !== input.binaryPath &&
112+ sharedServerState.activeRequests === 0
113+ ) {
114+ closeSharedServer(existingServer);
115+ } else {
116+ if (sharedServerState.binaryPath !== input.binaryPath) {
117+ yield* Effect.logWarning(
118+ "OpenCode shared server binary path mismatch: requested " +
119+ input.binaryPath +
120+ " but active server uses " +
121+ sharedServerState.binaryPath +
122+ "; reusing existing server because there are active requests",
123+ );
124+ }
125+ sharedServerState.activeRequests += 1;
126+ return existingServer;
127+ }
128+ }
129+
130+ const server = yield* Effect.tryPromise({
131+ try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }),
132+ catch: (cause) =>
133+ new TextGenerationError({
134+ operation: input.operation,
135+ detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.",
136+ cause,
137+ }),
138+ });
139+
140+ sharedServerState.server = server;
141+ sharedServerState.binaryPath = input.binaryPath;
142+ sharedServerState.activeRequests = 1;
143+ return server;
144+ }),
145+ );
146+
147+ const releaseSharedServer = (server: OpenCodeServerProcess) =>
148+ sharedServerMutex.withPermit(
149+ Effect.gen(function* () {
150+ if (sharedServerState.server !== server) {
151+ return;
152+ }
153+ sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1);
154+ if (sharedServerState.activeRequests === 0) {
155+ yield* scheduleIdleClose(server);
156+ }
157+ }),
158+ );
159+
160+ yield* Effect.addFinalizer(() =>
161+ sharedServerMutex.withPermit(
162+ Effect.gen(function* () {
163+ yield* cancelIdleCloseFiber();
164+ const server = sharedServerState.server;
165+ sharedServerState.server = null;
166+ sharedServerState.binaryPath = null;
167+ sharedServerState.activeRequests = 0;
168+ if (server !== null) {
169+ server.close();
170+ }
171+ }),
172+ ),
173+ );
174+
175+ const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: {
176+ readonly operation:
177+ | "generateCommitMessage"
178+ | "generatePrContent"
179+ | "generateBranchName"
180+ | "generateThreadTitle";
181+ readonly cwd: string;
182+ readonly prompt: string;
183+ readonly outputSchemaJson: S;
184+ readonly modelSelection: OpenCodeModelSelection;
185+ readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
186+ }) {
187+ const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model);
188+ if (!parsedModel) {
189+ return yield* new TextGenerationError({
190+ operation: input.operation,
191+ detail: "OpenCode model selection must use the 'provider/model' format.",
192+ });
193+ }
194+
195+ const settings = yield* serverSettingsService.getSettings.pipe(
196+ Effect.map(
197+ (value) =>
198+ value.providers?.opencode ?? {
199+ enabled: true,
200+ binaryPath: "opencode",
201+ serverUrl: "",
202+ serverPassword: "",
203+ customModels: [],
204+ },
205+ ),
206+ Effect.orElseSucceed(() => ({
207+ enabled: true,
208+ binaryPath: "opencode",
209+ serverUrl: "",
210+ serverPassword: "",
211+ customModels: [],
212+ })),
213+ );
214+
215+ const fileParts = toOpenCodeFileParts({
216+ attachments: input.attachments,
217+ resolveAttachmentPath: (attachment) =>
218+ resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
219+ });
220+
221+ const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) =>
222+ Effect.tryPromise({
223+ try: async () => {
224+ const client = createOpenCodeSdkClient({
225+ baseUrl: server.url,
226+ directory: input.cwd,
227+ ...(settings.serverUrl.length > 0 && settings.serverPassword
228+ ? { serverPassword: settings.serverPassword }
229+ : {}),
230+ });
231+ const session = await client.session.create({
232+ title: `T3 Code ${input.operation}`,
233+ permission: [{ permission: "*", pattern: "*", action: "deny" }],
234+ });
235+ if (!session.data) {
236+ throw new Error("OpenCode session.create returned no session payload.");
237+ }
238+
239+ const result = await client.session.prompt({
240+ sessionID: session.data.id,
241+ model: parsedModel,
242+ ...(input.modelSelection.options?.agent
243+ ? { agent: input.modelSelection.options.agent }
244+ : {}),
245+ ...(input.modelSelection.options?.variant
246+ ? { variant: input.modelSelection.options.variant }
247+ : {}),
248+ format: {
249+ type: "json_schema",
250+ schema: toJsonSchemaObject(input.outputSchemaJson) as Record<string, unknown>,
251+ },
252+ parts: [{ type: "text", text: input.prompt }, ...fileParts],
253+ });
254+ const structured = result.data?.info?.structured;
255+ if (structured === undefined) {
256+ throw new Error("OpenCode returned no structured output.");
257+ }
258+ return structured;
259+ },
260+ catch: (cause) =>
261+ new TextGenerationError({
262+ operation: input.operation,
263+ detail:
264+ cause instanceof Error ? cause.message : "OpenCode text generation request failed.",
265+ cause,
266+ }),
267+ });
268+
269+ const structuredOutput =
270+ settings.serverUrl.length > 0
271+ ? yield* runAgainstServer({ url: settings.serverUrl })
272+ : yield* Effect.acquireUseRelease(
273+ acquireSharedServer({
274+ binaryPath: settings.binaryPath,
275+ operation: input.operation,
276+ }),
277+ runAgainstServer,
278+ releaseSharedServer,
279+ );
280+
281+ return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe(
282+ Effect.catchTag("SchemaError", (cause) =>
283+ Effect.fail(
284+ new TextGenerationError({
285+ operation: input.operation,
286+ detail: "OpenCode returned invalid structured output.",
287+ cause,
288+ }),
289+ ),
290+ ),
291+ );
292+ });
293+
294+ const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
295+ "OpenCodeTextGeneration.generateCommitMessage",
296+ )(function* (input) {
297+ if (input.modelSelection.provider !== "opencode") {
298+ return yield* new TextGenerationError({
299+ operation: "generateCommitMessage",
300+ detail: "Invalid model selection.",
301+ });
302+ }
303+
304+ const { prompt, outputSchema } = buildCommitMessagePrompt({
305+ branch: input.branch,
306+ stagedSummary: input.stagedSummary,
307+ stagedPatch: input.stagedPatch,
308+ includeBranch: input.includeBranch === true,
309+ });
310+ const generated = yield* runOpenCodeJson({
311+ operation: "generateCommitMessage",
312+ cwd: input.cwd,
313+ prompt,
314+ outputSchemaJson: outputSchema,
315+ modelSelection: input.modelSelection,
316+ });
317+
318+ return {
319+ subject: sanitizeCommitSubject(generated.subject),
320+ body: generated.body.trim(),
321+ ...("branch" in generated && typeof generated.branch === "string"
322+ ? { branch: sanitizeFeatureBranchName(generated.branch) }
323+ : {}),
324+ };
325+ });
326+
327+ const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
328+ "OpenCodeTextGeneration.generatePrContent",
329+ )(function* (input) {
330+ if (input.modelSelection.provider !== "opencode") {
331+ return yield* new TextGenerationError({
332+ operation: "generatePrContent",
333+ detail: "Invalid model selection.",
334+ });
335+ }
336+
337+ const { prompt, outputSchema } = buildPrContentPrompt({
338+ baseBranch: input.baseBranch,
339+ headBranch: input.headBranch,
340+ commitSummary: input.commitSummary,
341+ diffSummary: input.diffSummary,
342+ diffPatch: input.diffPatch,
343+ });
344+ const generated = yield* runOpenCodeJson({
345+ operation: "generatePrContent",
346+ cwd: input.cwd,
347+ prompt,
348+ outputSchemaJson: outputSchema,
349+ modelSelection: input.modelSelection,
350+ });
351+
352+ return {
353+ title: sanitizePrTitle(generated.title),
354+ body: generated.body.trim(),
355+ };
356+ });
357+
358+ const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
359+ "OpenCodeTextGeneration.generateBranchName",
360+ )(function* (input) {
361+ if (input.modelSelection.provider !== "opencode") {
362+ return yield* new TextGenerationError({
363+ operation: "generateBranchName",
364+ detail: "Invalid model selection.",
365+ });
366+ }
367+
368+ const { prompt, outputSchema } = buildBranchNamePrompt({
369+ message: input.message,
370+ attachments: input.attachments,
371+ });
372+ const generated = yield* runOpenCodeJson({
373+ operation: "generateBranchName",
374+ cwd: input.cwd,
375+ prompt,
376+ outputSchemaJson: outputSchema,
377+ modelSelection: input.modelSelection,
378+ attachments: input.attachments,
379+ });
380+
381+ return {
382+ branch: sanitizeBranchFragment(generated.branch),
383+ };
384+ });
385+
386+ const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
387+ "OpenCodeTextGeneration.generateThreadTitle",
388+ )(function* (input) {
389+ if (input.modelSelection.provider !== "opencode") {
390+ return yield* new TextGenerationError({
391+ operation: "generateThreadTitle",
392+ detail: "Invalid model selection.",
393+ });
394+ }
395+
396+ const { prompt, outputSchema } = buildThreadTitlePrompt({
397+ message: input.message,
398+ attachments: input.attachments,
399+ });
400+ const generated = yield* runOpenCodeJson({
401+ operation: "generateThreadTitle",
402+ cwd: input.cwd,
403+ prompt,
404+ outputSchemaJson: outputSchema,
405+ modelSelection: input.modelSelection,
406+ attachments: input.attachments,
407+ });
408+
409+ return {
410+ title: sanitizeThreadTitle(generated.title),
411+ };
412+ });
413+
414+ return {
415+ generateCommitMessage,
416+ generatePrContent,
417+ generateBranchName,
418+ generateThreadTitle,
419+ } satisfies TextGenerationShape;
420+});
421+
422+export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration);
apps/server/src/git/Layers/RoutingTextGeneration.ts+20−2
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 } from "../Services/TextGeneration.ts";
1919 import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
2020 import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
21+import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
2122
2223 // ---------------------------------------------------------------------------
2324 // Internal service tags so both concrete layers can coexist.
@@ -31,6 +32,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( · 7 unmodified lines
3132 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
3233 ) {}
3334
35+class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(
36+ "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",
37+) {}
38+
3439 // ---------------------------------------------------------------------------
3540 // Routing implementation
3641 // ---------------------------------------------------------------------------
@@ -38,9 +43,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( · 1 unmodified line
3843 const makeRoutingTextGeneration = Effect.gen(function* () {
3944 const codex = yield* CodexTextGen;
4045 const claude = yield* ClaudeTextGen;
46+ const openCode = yield* OpenCodeTextGen;
4147
4248 const route = (provider?: TextGenerationProvider): TextGenerationShape =>
43− provider === "claudeAgent" ? claude : codex;
49+ provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex;
4450
4551 return {
4652 generateCommitMessage: (input) =>
@@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect( · 20 unmodified lines
6773 }),
6874 ).pipe(Layer.provide(ClaudeTextGenerationLive));
6975
76+const InternalOpenCodeLayer = Layer.effect(
77+ OpenCodeTextGen,
78+ Effect.gen(function* () {
79+ const svc = yield* TextGeneration;
80+ return svc;
81+ }),
82+).pipe(Layer.provide(OpenCodeTextGenerationLive));
83+
7084 export const RoutingTextGenerationLive = Layer.effect(
7185 TextGeneration,
7286 makeRoutingTextGeneration,
73−).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer));
87+).pipe(
88+ Layer.provide(InternalCodexLayer),
89+ Layer.provide(InternalClaudeLayer),
90+ Layer.provide(InternalOpenCodeLayer),
91+);
apps/server/src/git/Services/TextGeneration.ts+1−1
@@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; · 12 unmodified lines
1313 import type { TextGenerationError } from "@t3tools/contracts";
1414
1515 /** Providers that support git text generation (commit messages, PR content, branch names). */
16−export type TextGenerationProvider = "codex" | "claudeAgent";
16+export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode";
1717
1818 export interface CommitMessageGenerationInput {
1919 cwd: string;
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+486−0
@@ -0,0 +1,486 @@
1+import assert from "node:assert/strict";
2+
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import { it } from "@effect/vitest";
5+import { Effect, Layer, Option } from "effect";
6+import { beforeEach, vi } from "vitest";
7+
8+import { ThreadId } from "@t3tools/contracts";
9+import { ServerConfig } from "../../config.ts";
10+import { ServerSettingsService } from "../../serverSettings.ts";
11+import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
12+import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
13+import {
14+ appendOpenCodeAssistantTextDelta,
15+ makeOpenCodeAdapterLive,
16+ mergeOpenCodeAssistantText,
17+} from "./OpenCodeAdapter.ts";
18+
19+const asThreadId = (value: string): ThreadId => ThreadId.make(value);
20+
21+const runtimeMock = vi.hoisted(() => {
22+ type MessageEntry = {
23+ info: {
24+ id: string;
25+ role: "user" | "assistant";
26+ };
27+ parts: Array<unknown>;
28+ };
29+
30+ const state = {
31+ startCalls: [] as string[],
32+ sessionCreateUrls: [] as string[],
33+ authHeaders: [] as Array<string | null>,
34+ abortCalls: [] as string[],
35+ closeCalls: [] as string[],
36+ revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,
37+ promptAsyncError: null as Error | null,
38+ closeError: null as Error | null,
39+ messages: [] as MessageEntry[],
40+ subscribedEvents: [] as unknown[],
41+ };
42+
43+ return {
44+ state,
45+ reset() {
46+ state.startCalls.length = 0;
47+ state.sessionCreateUrls.length = 0;
48+ state.authHeaders.length = 0;
49+ state.abortCalls.length = 0;
50+ state.closeCalls.length = 0;
51+ state.revertCalls.length = 0;
52+ state.promptAsyncError = null;
53+ state.closeError = null;
54+ state.messages = [];
55+ state.subscribedEvents = [];
56+ },
57+ };
58+});
59+
60+vi.mock("../opencodeRuntime.ts", async () => {
61+ const actual =
62+ await vi.importActual<typeof import("../opencodeRuntime.ts")>("../opencodeRuntime.ts");
63+
64+ return {
65+ ...actual,
66+ startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => {
67+ runtimeMock.state.startCalls.push(binaryPath);
68+ return {
69+ url: "http://127.0.0.1:4301",
70+ process: {
71+ once() {},
72+ },
73+ close() {},
74+ };
75+ }),
76+ connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({
77+ url: serverUrl ?? "http://127.0.0.1:4301",
78+ process: null,
79+ external: Boolean(serverUrl),
80+ close() {
81+ runtimeMock.state.closeCalls.push(serverUrl ?? "http://127.0.0.1:4301");
82+ if (runtimeMock.state.closeError) {
83+ throw runtimeMock.state.closeError;
84+ }
85+ },
86+ })),
87+ createOpenCodeSdkClient: vi.fn(
88+ ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({
89+ session: {
90+ create: vi.fn(async () => {
91+ runtimeMock.state.sessionCreateUrls.push(baseUrl);
92+ runtimeMock.state.authHeaders.push(
93+ serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
94+ );
95+ return { data: { id: `${baseUrl}/session` } };
96+ }),
97+ abort: vi.fn(async ({ sessionID }: { sessionID: string }) => {
98+ runtimeMock.state.abortCalls.push(sessionID);
99+ }),
100+ promptAsync: vi.fn(async () => {
101+ if (runtimeMock.state.promptAsyncError) {
102+ throw runtimeMock.state.promptAsyncError;
103+ }
104+ }),
105+ messages: vi.fn(async () => ({ data: runtimeMock.state.messages })),
106+ revert: vi.fn(
107+ async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => {
108+ runtimeMock.state.revertCalls.push({
109+ sessionID,
110+ ...(messageID ? { messageID } : {}),
111+ });
112+ if (!messageID) {
113+ runtimeMock.state.messages = [];
114+ return;
115+ }
116+
117+ const targetIndex = runtimeMock.state.messages.findIndex(
118+ (entry) => entry.info.id === messageID,
119+ );
120+ runtimeMock.state.messages =
121+ targetIndex >= 0
122+ ? runtimeMock.state.messages.slice(0, targetIndex + 1)
123+ : runtimeMock.state.messages;
124+ },
125+ ),
126+ },
127+ event: {
128+ subscribe: vi.fn(async () => ({
129+ stream: (async function* () {
130+ for (const event of runtimeMock.state.subscribedEvents) {
131+ yield event;
132+ }
133+ })(),
134+ })),
135+ },
136+ }),
137+ ),
138+ };
139+});
140+
141+const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
142+ upsert: () => Effect.void,
143+ getProvider: () =>
144+ Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
145+ getBinding: () => Effect.succeed(Option.none()),
146+ listThreadIds: () => Effect.succeed([]),
147+ listBindings: () => Effect.succeed([]),
148+});
149+
150+const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe(
151+ Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
152+ Layer.provideMerge(
153+ ServerSettingsService.layerTest({
154+ providers: {
155+ opencode: {
156+ binaryPath: "fake-opencode",
157+ serverUrl: "http://127.0.0.1:9999",
158+ serverPassword: "secret-password",
159+ },
160+ },
161+ }),
162+ ),
163+ Layer.provideMerge(providerSessionDirectoryTestLayer),
164+ Layer.provideMerge(NodeServices.layer),
165+);
166+
167+beforeEach(() => {
168+ runtimeMock.reset();
169+});
170+
171+const sleep = (ms: number) =>
172+ Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
173+
174+it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
175+ it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () =>
176+ Effect.gen(function* () {
177+ const adapter = yield* OpenCodeAdapter;
178+
179+ const session = yield* adapter.startSession({
180+ provider: "opencode",
181+ threadId: asThreadId("thread-opencode"),
182+ runtimeMode: "full-access",
183+ });
184+
185+ assert.equal(session.provider, "opencode");
186+ assert.equal(session.threadId, "thread-opencode");
187+ assert.deepEqual(runtimeMock.state.startCalls, []);
188+ assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]);
189+ assert.deepEqual(runtimeMock.state.authHeaders, [
190+ `Basic ${btoa("opencode:secret-password")}`,
191+ ]);
192+ }),
193+ );
194+
195+ it.effect("stops a configured-server session without trying to own server lifecycle", () =>
196+ Effect.gen(function* () {
197+ const adapter = yield* OpenCodeAdapter;
198+ yield* adapter.startSession({
199+ provider: "opencode",
200+ threadId: asThreadId("thread-opencode"),
201+ runtimeMode: "full-access",
202+ });
203+
204+ yield* adapter.stopSession(asThreadId("thread-opencode"));
205+
206+ assert.deepEqual(runtimeMock.state.startCalls, []);
207+ assert.deepEqual(
208+ runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"),
209+ true,
210+ );
211+ }),
212+ );
213+
214+ it.effect("clears session state when stopAll cleanup fails", () =>
215+ Effect.gen(function* () {
216+ const adapter = yield* OpenCodeAdapter;
217+ yield* adapter.startSession({
218+ provider: "opencode",
219+ threadId: asThreadId("thread-stop-all-a"),
220+ runtimeMode: "full-access",
221+ });
222+ yield* adapter.startSession({
223+ provider: "opencode",
224+ threadId: asThreadId("thread-stop-all-b"),
225+ runtimeMode: "full-access",
226+ });
227+
228+ runtimeMock.state.closeError = new Error("close failed");
229+ const error = yield* adapter.stopAll().pipe(Effect.flip);
230+ const sessions = yield* adapter.listSessions();
231+
232+ assert.equal(error._tag, "ProviderAdapterProcessError");
233+ assert.equal(error.detail, "Failed to stop 2 OpenCode sessions.");
234+ assert.deepEqual(runtimeMock.state.closeCalls, [
235+ "http://127.0.0.1:9999",
236+ "http://127.0.0.1:9999",
237+ ]);
238+ assert.deepEqual(sessions, []);
239+ }),
240+ );
241+
242+ it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () =>
243+ Effect.gen(function* () {
244+ const adapter = yield* OpenCodeAdapter;
245+ yield* adapter.startSession({
246+ provider: "opencode",
247+ threadId: asThreadId("thread-send-turn-failure"),
248+ runtimeMode: "full-access",
249+ });
250+
251+ runtimeMock.state.promptAsyncError = new Error("prompt failed");
252+ const error = yield* adapter
253+ .sendTurn({
254+ threadId: asThreadId("thread-send-turn-failure"),
255+ input: "Fix it",
256+ modelSelection: {
257+ provider: "opencode",
258+ model: "openai/gpt-5",
259+ },
260+ })
261+ .pipe(Effect.flip);
262+ const sessions = yield* adapter.listSessions();
263+
264+ assert.equal(error._tag, "ProviderAdapterRequestError");
265+ if (error._tag !== "ProviderAdapterRequestError") {
266+ throw new Error("Unexpected error type");
267+ }
268+ assert.equal(error.detail, "prompt failed");
269+ assert.equal(
270+ error.message,
271+ "Provider adapter request failed (opencode) for session.promptAsync: prompt failed",
272+ );
273+ assert.equal(sessions.length, 1);
274+ assert.equal(sessions[0]?.status, "ready");
275+ assert.equal(sessions[0]?.activeTurnId, undefined);
276+ assert.equal(sessions[0]?.lastError, "prompt failed");
277+ }),
278+ );
279+
280+ it.effect("reverts the full thread when rollback removes every assistant turn", () =>
281+ Effect.gen(function* () {
282+ const adapter = yield* OpenCodeAdapter;
283+ const threadId = asThreadId("thread-rollback-all");
284+ yield* adapter.startSession({
285+ provider: "opencode",
286+ threadId,
287+ runtimeMode: "full-access",
288+ });
289+
290+ runtimeMock.state.messages = [
291+ {
292+ info: { id: "assistant-1", role: "assistant" },
293+ parts: [],
294+ },
295+ {
296+ info: { id: "assistant-2", role: "assistant" },
297+ parts: [],
298+ },
299+ ];
300+
301+ const snapshot = yield* adapter.rollbackThread(threadId, 2);
302+
303+ assert.deepEqual(runtimeMock.state.revertCalls, [
304+ { sessionID: "http://127.0.0.1:9999/session" },
305+ ]);
306+ assert.deepEqual(snapshot.turns, []);
307+ }),
308+ );
309+
310+ it.effect("deduplicates overlapping assistant text deltas after part updates", () =>
311+ Effect.sync(() => {
312+ const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello");
313+ const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world");
314+ const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hello world!");
315+
316+ assert.deepEqual(
317+ [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit],
318+ ["Hello", " world", "!"],
319+ );
320+ assert.equal(secondUpdate.latestText, "Hello world!");
321+ }),
322+ );
323+
324+ it.effect("writes provider-native observability records using the session thread id", () =>
325+ Effect.gen(function* () {
326+ const nativeEvents: Array<{
327+ readonly event?: {
328+ readonly provider?: string;
329+ readonly threadId?: string;
330+ readonly providerThreadId?: string;
331+ readonly type?: string;
332+ };
333+ }> = [];
334+ const nativeThreadIds: Array<string | null> = [];
335+ runtimeMock.state.subscribedEvents = [
336+ {
337+ type: "message.updated",
338+ properties: {
339+ info: {
340+ id: "msg-missing-session",
341+ role: "assistant",
342+ },
343+ },
344+ },
345+ {
346+ type: "message.updated",
347+ properties: {
348+ sessionID: "http://127.0.0.1:9999/other-session",
349+ info: {
350+ id: "msg-other-session",
351+ role: "assistant",
352+ },
353+ },
354+ },
355+ {
356+ type: "message.updated",
357+ properties: {
358+ sessionID: "http://127.0.0.1:9999/session",
359+ info: {
360+ id: "msg-native-log",
361+ role: "assistant",
362+ },
363+ },
364+ },
365+ ];
366+
367+ const nativeEventLogger = {
368+ filePath: "memory://opencode-native-events",
369+ write: (event: unknown, threadId: ThreadId | null) => {
370+ nativeEvents.push(event as (typeof nativeEvents)[number]);
371+ nativeThreadIds.push(threadId ?? null);
372+ return Effect.void;
373+ },
374+ close: () => Effect.void,
375+ };
376+
377+ const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe(
378+ Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
379+ Layer.provideMerge(
380+ ServerSettingsService.layerTest({
381+ providers: {
382+ opencode: {
383+ binaryPath: "fake-opencode",
384+ serverUrl: "http://127.0.0.1:9999",
385+ serverPassword: "secret-password",
386+ },
387+ },
388+ }),
389+ ),
390+ Layer.provideMerge(providerSessionDirectoryTestLayer),
391+ Layer.provideMerge(NodeServices.layer),
392+ );
393+
394+ const session = yield* Effect.gen(function* () {
395+ const adapter = yield* OpenCodeAdapter;
396+ const started = yield* adapter.startSession({
397+ provider: "opencode",
398+ threadId: asThreadId("thread-native-log"),
399+ runtimeMode: "full-access",
400+ });
401+ yield* sleep(10);
402+ return started;
403+ }).pipe(Effect.provide(adapterLayer));
404+
405+ assert.equal(session.threadId, "thread-native-log");
406+ assert.equal(nativeEvents.length, 1);
407+ assert.equal(
408+ nativeEvents.some((record) => record.event?.provider === "opencode"),
409+ true,
410+ );
411+ assert.equal(
412+ nativeEvents.some(
413+ (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session",
414+ ),
415+ true,
416+ );
417+ assert.equal(
418+ nativeEvents.some((record) => record.event?.threadId === "thread-native-log"),
419+ true,
420+ );
421+ assert.equal(
422+ nativeEvents.some((record) => record.event?.type === "message.updated"),
423+ true,
424+ );
425+ assert.equal(
426+ nativeThreadIds.every((threadId) => threadId === "thread-native-log"),
427+ true,
428+ );
429+ }),
430+ );
431+
432+ it.effect("keeps the event pump alive when native event logging fails", () =>
433+ Effect.gen(function* () {
434+ runtimeMock.state.subscribedEvents = [
435+ {
436+ type: "message.updated",
437+ properties: {
438+ sessionID: "http://127.0.0.1:9999/session",
439+ info: {
440+ id: "msg-native-log-failure",
441+ role: "assistant",
442+ },
443+ },
444+ },
445+ ];
446+
447+ const nativeEventLogger = {
448+ filePath: "memory://opencode-native-events",
449+ write: () => Effect.die(new Error("native log write failed")),
450+ close: () => Effect.void,
451+ };
452+
453+ const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe(
454+ Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
455+ Layer.provideMerge(
456+ ServerSettingsService.layerTest({
457+ providers: {
458+ opencode: {
459+ binaryPath: "fake-opencode",
460+ serverUrl: "http://127.0.0.1:9999",
461+ serverPassword: "secret-password",
462+ },
463+ },
464+ }),
465+ ),
466+ Layer.provideMerge(providerSessionDirectoryTestLayer),
467+ Layer.provideMerge(NodeServices.layer),
468+ );
469+
470+ const sessions = yield* Effect.gen(function* () {
471+ const adapter = yield* OpenCodeAdapter;
472+ yield* adapter.startSession({
473+ provider: "opencode",
474+ threadId: asThreadId("thread-native-log-failure"),
475+ runtimeMode: "full-access",
476+ });
477+ yield* sleep(10);
478+ return yield* adapter.listSessions();
479+ }).pipe(Effect.provide(adapterLayer));
480+
481+ assert.equal(sessions.length, 1);
482+ assert.equal(sessions[0]?.threadId, "thread-native-log-failure");
483+ assert.deepEqual(runtimeMock.state.closeCalls, []);
484+ }),
485+ );
486+});
apps/server/src/provider/Layers/OpenCodeAdapter.ts+1344−0
@@ -0,0 +1,1344 @@
1+import { randomUUID } from "node:crypto";
2+
3+import {
4+ EventId,
5+ type ProviderRuntimeEvent,
6+ type ProviderSession,
7+ RuntimeItemId,
8+ RuntimeRequestId,
9+ ThreadId,
10+ type ToolLifecycleItemType,
11+ TurnId,
12+ type UserInputQuestion,
13+} from "@t3tools/contracts";
14+import { Cause, Effect, Layer, Queue, Stream } from "effect";
15+import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
16+
17+import { resolveAttachmentPath } from "../../attachmentStore.ts";
18+import { ServerConfig } from "../../config.ts";
19+import { ServerSettingsService } from "../../serverSettings.ts";
20+import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
21+import {
22+ ProviderAdapterProcessError,
23+ ProviderAdapterRequestError,
24+ ProviderAdapterSessionClosedError,
25+ ProviderAdapterSessionNotFoundError,
26+ ProviderAdapterValidationError,
27+} from "../Errors.ts";
28+import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
29+import {
30+ buildOpenCodePermissionRules,
31+ connectToOpenCodeServer,
32+ createOpenCodeSdkClient,
33+ openCodeQuestionId,
34+ parseOpenCodeModelSlug,
35+ toOpenCodeFileParts,
36+ toOpenCodePermissionReply,
37+ toOpenCodeQuestionAnswers,
38+ type OpenCodeServerConnection,
39+} from "../opencodeRuntime.ts";
40+
41+const PROVIDER = "opencode" as const;
42+
43+interface OpenCodeTurnSnapshot {
44+ readonly id: TurnId;
45+ readonly items: Array<unknown>;
46+}
47+
48+interface OpenCodeSessionContext {
49+ session: ProviderSession;
50+ readonly client: OpencodeClient;
51+ readonly server: OpenCodeServerConnection;
52+ readonly directory: string;
53+ readonly openCodeSessionId: string;
54+ readonly pendingPermissions: Map<string, PermissionRequest>;
55+ readonly pendingQuestions: Map<string, QuestionRequest>;
56+ readonly messageRoleById: Map<string, "user" | "assistant">;
57+ readonly partById: Map<string, Part>;
58+ readonly emittedTextByPartId: Map<string, string>;
59+ readonly completedAssistantPartIds: Set<string>;
60+ readonly turns: Array<OpenCodeTurnSnapshot>;
61+ activeTurnId: TurnId | undefined;
62+ activeAgent: string | undefined;
63+ activeVariant: string | undefined;
64+ stopped: boolean;
65+ readonly eventsAbortController: AbortController;
66+}
67+
68+export interface OpenCodeAdapterLiveOptions {
69+ readonly nativeEventLogPath?: string;
70+ readonly nativeEventLogger?: EventNdjsonLogger;
71+}
72+
73+function nowIso(): string {
74+ return new Date().toISOString();
75+}
76+
77+function isProviderAdapterRequestError(cause: unknown): cause is ProviderAdapterRequestError {
78+ return (
79+ typeof cause === "object" &&
80+ cause !== null &&
81+ "_tag" in cause &&
82+ cause._tag === "ProviderAdapterRequestError"
83+ );
84+}
85+
86+function buildEventBase(input: {
87+ readonly threadId: ThreadId;
88+ readonly turnId?: TurnId | undefined;
89+ readonly itemId?: string | undefined;
90+ readonly requestId?: string | undefined;
91+ readonly createdAt?: string | undefined;
92+ readonly raw?: unknown;
93+}): Pick<
94+ ProviderRuntimeEvent,
95+ "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw"
96+> {
97+ return {
98+ eventId: EventId.make(randomUUID()),
99+ provider: PROVIDER,
100+ threadId: input.threadId,
101+ createdAt: input.createdAt ?? nowIso(),
102+ ...(input.turnId ? { turnId: input.turnId } : {}),
103+ ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}),
104+ ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}),
105+ ...(input.raw !== undefined
106+ ? {
107+ raw: {
108+ source: "opencode.sdk.event",
109+ payload: input.raw,
110+ },
111+ }
112+ : {}),
113+ };
114+}
115+
116+function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType {
117+ const normalized = toolName.toLowerCase();
118+ if (normalized.includes("bash") || normalized.includes("command")) {
119+ return "command_execution";
120+ }
121+ if (
122+ normalized.includes("edit") ||
123+ normalized.includes("write") ||
124+ normalized.includes("patch") ||
125+ normalized.includes("multiedit")
126+ ) {
127+ return "file_change";
128+ }
129+ if (normalized.includes("web")) {
130+ return "web_search";
131+ }
132+ if (normalized.includes("mcp")) {
133+ return "mcp_tool_call";
134+ }
135+ if (normalized.includes("image")) {
136+ return "image_view";
137+ }
138+ if (
139+ normalized.includes("task") ||
140+ normalized.includes("agent") ||
141+ normalized.includes("subtask")
142+ ) {
143+ return "collab_agent_tool_call";
144+ }
145+ return "dynamic_tool_call";
146+}
147+
148+function mapPermissionToRequestType(
149+ permission: string,
150+): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" {
151+ switch (permission) {
152+ case "bash":
153+ return "command_execution_approval";
154+ case "read":
155+ return "file_read_approval";
156+ case "edit":
157+ return "file_change_approval";
158+ default:
159+ return "unknown";
160+ }
161+}
162+
163+function mapPermissionDecision(reply: "once" | "always" | "reject"): string {
164+ switch (reply) {
165+ case "once":
166+ return "accept";
167+ case "always":
168+ return "acceptForSession";
169+ case "reject":
170+ default:
171+ return "decline";
172+ }
173+}
174+
175+function resolveTurnSnapshot(
176+ context: OpenCodeSessionContext,
177+ turnId: TurnId,
178+): OpenCodeTurnSnapshot {
179+ const existing = context.turns.find((turn) => turn.id === turnId);
180+ if (existing) {
181+ return existing;
182+ }
183+
184+ const created: OpenCodeTurnSnapshot = { id: turnId, items: [] };
185+ context.turns.push(created);
186+ return created;
187+}
188+
189+function appendTurnItem(
190+ context: OpenCodeSessionContext,
191+ turnId: TurnId | undefined,
192+ item: unknown,
193+): void {
194+ if (!turnId) {
195+ return;
196+ }
197+ resolveTurnSnapshot(context, turnId).items.push(item);
198+}
199+
200+function ensureSessionContext(
201+ sessions: ReadonlyMap<ThreadId, OpenCodeSessionContext>,
202+ threadId: ThreadId,
203+): OpenCodeSessionContext {
204+ const session = sessions.get(threadId);
205+ if (!session) {
206+ throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId });
207+ }
208+ if (session.stopped) {
209+ throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId });
210+ }
211+ return session;
212+}
213+
214+function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray<UserInputQuestion> {
215+ return request.questions.map((question, index) => ({
216+ id: openCodeQuestionId(index, question),
217+ header: question.header,
218+ question: question.question,
219+ options: question.options.map((option) => ({
220+ label: option.label,
221+ description: option.description,
222+ })),
223+ ...(question.multiple ? { multiSelect: true } : {}),
224+ }));
225+}
226+
227+function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" {
228+ return part?.type === "reasoning" ? "reasoning_text" : "assistant_text";
229+}
230+
231+function textFromPart(part: Part): string | undefined {
232+ switch (part.type) {
233+ case "text":
234+ case "reasoning":
235+ return part.text;
236+ default:
237+ return undefined;
238+ }
239+}
240+
241+function commonPrefixLength(left: string, right: string): number {
242+ let index = 0;
243+ while (index < left.length && index < right.length && left[index] === right[index]) {
244+ index += 1;
245+ }
246+ return index;
247+}
248+
249+function suffixPrefixOverlap(text: string, delta: string): number {
250+ const maxLength = Math.min(text.length, delta.length);
251+ for (let length = maxLength; length > 0; length -= 1) {
252+ if (text.endsWith(delta.slice(0, length))) {
253+ return length;
254+ }
255+ }
256+ return 0;
257+}
258+
259+function resolveLatestAssistantText(previousText: string | undefined, nextText: string): string {
260+ if (previousText && previousText.length > nextText.length && previousText.startsWith(nextText)) {
261+ return previousText;
262+ }
263+ return nextText;
264+}
265+
266+export function mergeOpenCodeAssistantText(
267+ previousText: string | undefined,
268+ nextText: string,
269+): {
270+ readonly latestText: string;
271+ readonly deltaToEmit: string;
272+} {
273+ const latestText = resolveLatestAssistantText(previousText, nextText);
274+ return {
275+ latestText,
276+ deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)),
277+ };
278+}
279+
280+export function appendOpenCodeAssistantTextDelta(
281+ previousText: string,
282+ delta: string,
283+): {
284+ readonly nextText: string;
285+ readonly deltaToEmit: string;
286+} {
287+ const deltaToEmit = delta.slice(suffixPrefixOverlap(previousText, delta));
288+ return {
289+ nextText: previousText + deltaToEmit,
290+ deltaToEmit,
291+ };
292+}
293+
294+function isoFromEpochMs(value: number | undefined): string | undefined {
295+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
296+ return undefined;
297+ }
298+ return new Date(value).toISOString();
299+}
300+
301+function messageRoleForPart(
302+ context: OpenCodeSessionContext,
303+ part: Pick<Part, "messageID" | "type">,
304+): "assistant" | "user" | undefined {
305+ const known = context.messageRoleById.get(part.messageID);
306+ if (known) {
307+ return known;
308+ }
309+ return part.type === "tool" ? "assistant" : undefined;
310+}
311+
312+function detailFromToolPart(part: Extract<Part, { type: "tool" }>): string | undefined {
313+ switch (part.state.status) {
314+ case "completed":
315+ return part.state.output;
316+ case "error":
317+ return part.state.error;
318+ case "running":
319+ return part.state.title;
320+ default:
321+ return undefined;
322+ }
323+}
324+
325+function toolStateCreatedAt(part: Extract<Part, { type: "tool" }>): string | undefined {
326+ switch (part.state.status) {
327+ case "running":
328+ return isoFromEpochMs(part.state.time.start);
329+ case "completed":
330+ case "error":
331+ return isoFromEpochMs(part.state.time.end);
332+ default:
333+ return undefined;
334+ }
335+}
336+
337+function sessionErrorMessage(error: unknown): string {
338+ if (!error || typeof error !== "object") {
339+ return "OpenCode session failed.";
340+ }
341+ const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null;
342+ const message = data && "message" in data ? data.message : null;
343+ return typeof message === "string" && message.trim().length > 0
344+ ? message
345+ : "OpenCode session failed.";
346+}
347+
348+function updateProviderSession(
349+ context: OpenCodeSessionContext,
350+ patch: Partial<ProviderSession>,
351+ options?: {
352+ readonly clearActiveTurnId?: boolean;
353+ readonly clearLastError?: boolean;
354+ },
355+): ProviderSession {
356+ const nextSession = {
357+ ...context.session,
358+ ...patch,
359+ updatedAt: nowIso(),
360+ } as ProviderSession & Record<string, unknown>;
361+ const mutableSession = nextSession as Record<string, unknown>;
362+ if (options?.clearActiveTurnId) {
363+ delete mutableSession.activeTurnId;
364+ }
365+ if (options?.clearLastError) {
366+ delete mutableSession.lastError;
367+ }
368+ context.session = nextSession;
369+ return nextSession;
370+}
371+
372+async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise<void> {
373+ context.stopped = true;
374+ context.eventsAbortController.abort();
375+ try {
376+ await context.client.session
377+ .abort({ sessionID: context.openCodeSessionId })
378+ .catch(() => undefined);
379+ } catch {}
380+ context.server.close();
381+}
382+
383+export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
384+ return Layer.effect(
385+ OpenCodeAdapter,
386+ Effect.gen(function* () {
387+ const serverConfig = yield* ServerConfig;
388+ const serverSettings = yield* ServerSettingsService;
389+ const services = yield* Effect.context<never>();
390+ const nativeEventLogger =
391+ _options?.nativeEventLogger ??
392+ (_options?.nativeEventLogPath !== undefined
393+ ? yield* makeEventNdjsonLogger(_options.nativeEventLogPath, {
394+ stream: "native",
395+ })
396+ : undefined);
397+ const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
398+ const sessions = new Map<ThreadId, OpenCodeSessionContext>();
399+
400+ const emit = (event: ProviderRuntimeEvent) =>
401+ Queue.offer(runtimeEvents, event).pipe(Effect.asVoid);
402+ const emitPromise = (event: ProviderRuntimeEvent) =>
403+ emit(event).pipe(Effect.runPromiseWith(services));
404+ const writeNativeEventPromise = (
405+ threadId: ThreadId,
406+ event: {
407+ readonly observedAt: string;
408+ readonly event: Record<string, unknown>;
409+ },
410+ ) =>
411+ (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void).pipe(
412+ Effect.runPromiseWith(services),
413+ );
414+ const writeNativeEventBestEffort = (
415+ threadId: ThreadId,
416+ event: {
417+ readonly observedAt: string;
418+ readonly event: Record<string, unknown>;
419+ },
420+ ) => writeNativeEventPromise(threadId, event).catch(() => undefined);
421+
422+ const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => {
423+ if (context.stopped) {
424+ return;
425+ }
426+ context.stopped = true;
427+ sessions.delete(context.session.threadId);
428+ context.server.close();
429+ const turnId = context.activeTurnId;
430+ void emitPromise({
431+ ...buildEventBase({ threadId: context.session.threadId, turnId }),
432+ type: "runtime.error",
433+ payload: {
434+ message,
435+ class: "transport_error",
436+ },
437+ }).catch(() => undefined);
438+ void emitPromise({
439+ ...buildEventBase({ threadId: context.session.threadId, turnId }),
440+ type: "session.exited",
441+ payload: {
442+ reason: message,
443+ recoverable: false,
444+ exitKind: "error",
445+ },
446+ }).catch(() => undefined);
447+ };
448+
449+ /** Emit content.delta and item.completed events for an assistant text part. */
450+ const emitAssistantTextDelta = async (
451+ context: OpenCodeSessionContext,
452+ part: Part,
453+ turnId: TurnId | undefined,
454+ raw: unknown,
455+ ): Promise<void> => {
456+ const text = textFromPart(part);
457+ if (text === undefined) {
458+ return;
459+ }
460+ const previousText = context.emittedTextByPartId.get(part.id);
461+ const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text);
462+ context.emittedTextByPartId.set(part.id, latestText);
463+ if (latestText !== text) {
464+ context.partById.set(
465+ part.id,
466+ (part.type === "text" || part.type === "reasoning"
467+ ? { ...part, text: latestText }
468+ : part) satisfies Part,
469+ );
470+ }
471+ if (deltaToEmit.length > 0) {
472+ await emitPromise({
473+ ...buildEventBase({
474+ threadId: context.session.threadId,
475+ turnId,
476+ itemId: part.id,
477+ createdAt:
478+ part.type === "text" || part.type === "reasoning"
479+ ? isoFromEpochMs(part.time?.start)
480+ : undefined,
481+ raw,
482+ }),
483+ type: "content.delta",
484+ payload: {
485+ streamKind: resolveTextStreamKind(part),
486+ delta: deltaToEmit,
487+ },
488+ });
489+ }
490+
491+ if (
492+ part.type === "text" &&
493+ part.time?.end !== undefined &&
494+ !context.completedAssistantPartIds.has(part.id)
495+ ) {
496+ context.completedAssistantPartIds.add(part.id);
497+ await emitPromise({
498+ ...buildEventBase({
499+ threadId: context.session.threadId,
500+ turnId,
501+ itemId: part.id,
502+ createdAt: isoFromEpochMs(part.time.end),
503+ raw,
504+ }),
505+ type: "item.completed",
506+ payload: {
507+ itemType: "assistant_message",
508+ status: "completed",
509+ title: "Assistant message",
510+ ...(latestText.length > 0 ? { detail: latestText } : {}),
511+ },
512+ });
513+ }
514+ };
515+
516+ const startEventPump = (context: OpenCodeSessionContext) => {
517+ void (async () => {
518+ try {
519+ const subscription = await context.client.event.subscribe(undefined, {
520+ signal: context.eventsAbortController.signal,
521+ });
522+
523+ for await (const event of subscription.stream) {
524+ const payloadSessionId =
525+ "properties" in event
526+ ? (event.properties as { sessionID?: unknown }).sessionID
527+ : undefined;
528+ if (payloadSessionId !== context.openCodeSessionId) {
529+ continue;
530+ }
531+
532+ const turnId = context.activeTurnId;
533+ await writeNativeEventBestEffort(context.session.threadId, {
534+ observedAt: nowIso(),
535+ event: {
536+ provider: PROVIDER,
537+ threadId: context.session.threadId,
538+ providerThreadId: context.openCodeSessionId,
539+ type: event.type,
540+ ...(turnId ? { turnId } : {}),
541+ payload: event,
542+ },
543+ });
544+
545+ switch (event.type) {
546+ case "message.updated": {
547+ context.messageRoleById.set(event.properties.info.id, event.properties.info.role);
548+ if (event.properties.info.role === "assistant") {
549+ for (const part of context.partById.values()) {
550+ if (part.messageID !== event.properties.info.id) {
551+ continue;
552+ }
553+ await emitAssistantTextDelta(context, part, turnId, event);
554+ }
555+ }
556+ break;
557+ }
558+
559+ case "message.removed": {
560+ context.messageRoleById.delete(event.properties.messageID);
561+ break;
562+ }
563+
564+ case "message.part.delta": {
565+ const existingPart = context.partById.get(event.properties.partID);
566+ if (!existingPart) {
567+ break;
568+ }
569+ const role = messageRoleForPart(context, existingPart);
570+ if (role !== "assistant") {
571+ break;
572+ }
573+ const streamKind = resolveTextStreamKind(existingPart);
574+ const delta = event.properties.delta;
575+ if (delta.length === 0) {
576+ break;
577+ }
578+ const previousText =
579+ context.emittedTextByPartId.get(event.properties.partID) ??
580+ textFromPart(existingPart) ??
581+ "";
582+ const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(
583+ previousText,
584+ delta,
585+ );
586+ if (deltaToEmit.length === 0) {
587+ break;
588+ }
589+ context.emittedTextByPartId.set(event.properties.partID, nextText);
590+ if (existingPart.type === "text" || existingPart.type === "reasoning") {
591+ context.partById.set(event.properties.partID, {
592+ ...existingPart,
593+ text: nextText,
594+ });
595+ }
596+ await emitPromise({
597+ ...buildEventBase({
598+ threadId: context.session.threadId,
599+ turnId,
600+ itemId: event.properties.partID,
601+ raw: event,
602+ }),
603+ type: "content.delta",
604+ payload: {
605+ streamKind,
606+ delta: deltaToEmit,
607+ },
608+ });
609+ break;
610+ }
611+
612+ case "message.part.updated": {
613+ const part = event.properties.part;
614+ context.partById.set(part.id, part);
615+ const messageRole = messageRoleForPart(context, part);
616+
617+ if (messageRole === "assistant") {
618+ await emitAssistantTextDelta(context, part, turnId, event);
619+ }
620+
621+ if (part.type === "tool") {
622+ const itemType = toToolLifecycleItemType(part.tool);
623+ const title =
624+ part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool;
625+ const detail = detailFromToolPart(part);
626+ const payload = {
627+ itemType,
628+ ...(part.state.status === "error"
629+ ? { status: "failed" as const }
630+ : part.state.status === "completed"
631+ ? { status: "completed" as const }
632+ : { status: "inProgress" as const }),
633+ ...(title ? { title } : {}),
634+ ...(detail ? { detail } : {}),
635+ data: {
636+ tool: part.tool,
637+ state: part.state,
638+ },
639+ };
640+ const runtimeEvent: ProviderRuntimeEvent = {
641+ ...buildEventBase({
642+ threadId: context.session.threadId,
643+ turnId,
644+ itemId: part.callID,
645+ createdAt: toolStateCreatedAt(part),
646+ raw: event,
647+ }),
648+ type:
649+ part.state.status === "pending"
650+ ? "item.started"
651+ : part.state.status === "completed" || part.state.status === "error"
652+ ? "item.completed"
653+ : "item.updated",
654+ payload,
655+ };
656+ appendTurnItem(context, turnId, part);
657+ await emitPromise(runtimeEvent);
658+ }
659+ break;
660+ }
661+
662+ case "permission.asked": {
663+ context.pendingPermissions.set(event.properties.id, event.properties);
664+ await emitPromise({
665+ ...buildEventBase({
666+ threadId: context.session.threadId,
667+ turnId,
668+ requestId: event.properties.id,
669+ raw: event,
670+ }),
671+ type: "request.opened",
672+ payload: {
673+ requestType: mapPermissionToRequestType(event.properties.permission),
674+ detail:
675+ event.properties.patterns.length > 0
676+ ? event.properties.patterns.join("\n")
677+ : event.properties.permission,
678+ args: event.properties.metadata,
679+ },
680+ });
681+ break;
682+ }
683+
684+ case "permission.replied": {
685+ context.pendingPermissions.delete(event.properties.requestID);
686+ await emitPromise({
687+ ...buildEventBase({
688+ threadId: context.session.threadId,
689+ turnId,
690+ requestId: event.properties.requestID,
691+ raw: event,
692+ }),
693+ type: "request.resolved",
694+ payload: {
695+ requestType: "unknown",
696+ decision: mapPermissionDecision(event.properties.reply),
697+ },
698+ });
699+ break;
700+ }
701+
702+ case "question.asked": {
703+ context.pendingQuestions.set(event.properties.id, event.properties);
704+ await emitPromise({
705+ ...buildEventBase({
706+ threadId: context.session.threadId,
707+ turnId,
708+ requestId: event.properties.id,
709+ raw: event,
710+ }),
711+ type: "user-input.requested",
712+ payload: {
713+ questions: normalizeQuestionRequest(event.properties),
714+ },
715+ });
716+ break;
717+ }
718+
719+ case "question.replied": {
720+ const request = context.pendingQuestions.get(event.properties.requestID);
721+ context.pendingQuestions.delete(event.properties.requestID);
722+ const answers = Object.fromEntries(
723+ (request?.questions ?? []).map((question, index) => [
724+ openCodeQuestionId(index, question),
725+ event.properties.answers[index]?.join(", ") ?? "",
726+ ]),
727+ );
728+ await emitPromise({
729+ ...buildEventBase({
730+ threadId: context.session.threadId,
731+ turnId,
732+ requestId: event.properties.requestID,
733+ raw: event,
734+ }),
735+ type: "user-input.resolved",
736+ payload: { answers },
737+ });
738+ break;
739+ }
740+
741+ case "question.rejected": {
742+ context.pendingQuestions.delete(event.properties.requestID);
743+ await emitPromise({
744+ ...buildEventBase({
745+ threadId: context.session.threadId,
746+ turnId,
747+ requestId: event.properties.requestID,
748+ raw: event,
749+ }),
750+ type: "user-input.resolved",
751+ payload: { answers: {} },
752+ });
753+ break;
754+ }
755+
756+ case "session.status": {
757+ if (event.properties.status.type === "busy") {
758+ updateProviderSession(context, { status: "running", activeTurnId: turnId });
759+ }
760+
761+ if (event.properties.status.type === "retry") {
762+ await emitPromise({
763+ ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }),
764+ type: "runtime.warning",
765+ payload: {
766+ message: event.properties.status.message,
767+ detail: event.properties.status,
768+ },
769+ });
770+ break;
771+ }
772+
773+ if (event.properties.status.type === "idle" && turnId) {
774+ context.activeTurnId = undefined;
775+ updateProviderSession(
776+ context,
777+ { status: "ready" },
778+ { clearActiveTurnId: true },
779+ );
780+ await emitPromise({
781+ ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }),
782+ type: "turn.completed",
783+ payload: {
784+ state: "completed",
785+ },
786+ });
787+ }
788+ break;
789+ }
790+
791+ case "session.error": {
792+ const message = sessionErrorMessage(event.properties.error);
793+ const activeTurnId = context.activeTurnId;
794+ context.activeTurnId = undefined;
795+ updateProviderSession(
796+ context,
797+ {
798+ status: "error",
799+ lastError: message,
800+ },
801+ { clearActiveTurnId: true },
802+ );
803+ if (activeTurnId) {
804+ await emitPromise({
805+ ...buildEventBase({
806+ threadId: context.session.threadId,
807+ turnId: activeTurnId,
808+ raw: event,
809+ }),
810+ type: "turn.completed",
811+ payload: {
812+ state: "failed",
813+ errorMessage: message,
814+ },
815+ });
816+ }
817+ await emitPromise({
818+ ...buildEventBase({ threadId: context.session.threadId, raw: event }),
819+ type: "runtime.error",
820+ payload: {
821+ message,
822+ class: "provider_error",
823+ detail: event.properties.error,
824+ },
825+ });
826+ break;
827+ }
828+
829+ default:
830+ break;
831+ }
832+ }
833+ } catch (error) {
834+ if (context.eventsAbortController.signal.aborted || context.stopped) {
835+ return;
836+ }
837+ emitUnexpectedExit(
838+ context,
839+ error instanceof Error ? error.message : "OpenCode event stream failed.",
840+ );
841+ }
842+ })();
843+
844+ context.server.process?.once("exit", (code, signal) => {
845+ if (context.stopped) {
846+ return;
847+ }
848+ emitUnexpectedExit(
849+ context,
850+ `OpenCode server exited unexpectedly (${signal ?? code ?? "unknown"}).`,
851+ );
852+ });
853+ };
854+
855+ const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")(
856+ function* (input) {
857+ const settings = yield* serverSettings.getSettings.pipe(
858+ Effect.mapError(
859+ (cause) =>
860+ new ProviderAdapterProcessError({
861+ provider: PROVIDER,
862+ threadId: input.threadId,
863+ detail: "Failed to read OpenCode settings.",
864+ cause,
865+ }),
866+ ),
867+ );
868+ const binaryPath = settings.providers.opencode.binaryPath;
869+ const serverUrl = settings.providers.opencode.serverUrl;
870+ const serverPassword = settings.providers.opencode.serverPassword;
871+ const directory = input.cwd ?? serverConfig.cwd;
872+ const existing = sessions.get(input.threadId);
873+ if (existing) {
874+ yield* Effect.tryPromise({
875+ try: () => stopOpenCodeContext(existing),
876+ catch: (cause) =>
877+ new ProviderAdapterProcessError({
878+ provider: PROVIDER,
879+ threadId: input.threadId,
880+ detail: "Failed to stop existing OpenCode session.",
881+ cause,
882+ }),
883+ });
884+ sessions.delete(input.threadId);
885+ }
886+
887+ const started = yield* Effect.tryPromise({
888+ try: async () => {
889+ const server = await connectToOpenCodeServer({ binaryPath, serverUrl });
890+ const client = createOpenCodeSdkClient({
891+ baseUrl: server.url,
892+ directory,
893+ ...(server.external && serverPassword ? { serverPassword } : {}),
894+ });
895+ const openCodeSession = await client.session.create({
896+ title: `T3 Code ${input.threadId}`,
897+ permission: buildOpenCodePermissionRules(input.runtimeMode),
898+ });
899+ if (!openCodeSession.data) {
900+ throw new Error("OpenCode session.create returned no session payload.");
901+ }
902+ return { server, client, openCodeSession: openCodeSession.data };
903+ },
904+ catch: (cause) =>
905+ new ProviderAdapterProcessError({
906+ provider: PROVIDER,
907+ threadId: input.threadId,
908+ detail:
909+ cause instanceof Error ? cause.message : "Failed to start OpenCode session.",
910+ cause,
911+ }),
912+ });
913+
914+ // Guard against a concurrent startSession call that may have raced
915+ // and already inserted a session while we were awaiting async work.
916+ const raceWinner = sessions.get(input.threadId);
917+ if (raceWinner) {
918+ // Another call won the race – clean up the session we just created
919+ // (including the remote SDK session) and return the existing one.
920+ yield* Effect.tryPromise({
921+ try: () =>
922+ started.client.session
923+ .abort({ sessionID: started.openCodeSession.id })
924+ .catch(() => undefined),
925+ catch: () => undefined,
926+ }).pipe(Effect.ignore);
927+ started.server.close();
928+ return raceWinner.session;
929+ }
930+
931+ const createdAt = nowIso();
932+ const session: ProviderSession = {
933+ provider: PROVIDER,
934+ status: "ready",
935+ runtimeMode: input.runtimeMode,
936+ cwd: directory,
937+ ...(input.modelSelection ? { model: input.modelSelection.model } : {}),
938+ threadId: input.threadId,
939+ createdAt,
940+ updatedAt: createdAt,
941+ };
942+
943+ const context: OpenCodeSessionContext = {
944+ session,
945+ client: started.client,
946+ server: started.server,
947+ directory,
948+ openCodeSessionId: started.openCodeSession.id,
949+ pendingPermissions: new Map(),
950+ pendingQuestions: new Map(),
951+ partById: new Map(),
952+ emittedTextByPartId: new Map(),
953+ messageRoleById: new Map(),
954+ completedAssistantPartIds: new Set(),
955+ turns: [],
956+ activeTurnId: undefined,
957+ activeAgent: undefined,
958+ activeVariant: undefined,
959+ stopped: false,
960+ eventsAbortController: new AbortController(),
961+ };
962+ sessions.set(input.threadId, context);
963+ startEventPump(context);
964+
965+ yield* emit({
966+ ...buildEventBase({ threadId: input.threadId }),
967+ type: "session.started",
968+ payload: {
969+ message: "OpenCode session started",
970+ },
971+ });
972+ yield* emit({
973+ ...buildEventBase({ threadId: input.threadId }),
974+ type: "thread.started",
975+ payload: {
976+ providerThreadId: started.openCodeSession.id,
977+ },
978+ });
979+
980+ return session;
981+ },
982+ );
983+
984+ const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
985+ const context = ensureSessionContext(sessions, input.threadId);
986+ const turnId = TurnId.make(`opencode-turn-${randomUUID()}`);
987+ const modelSelection =
988+ input.modelSelection ??
989+ (context.session.model
990+ ? { provider: PROVIDER, model: context.session.model }
991+ : undefined);
992+ const parsedModel = parseOpenCodeModelSlug(modelSelection?.model);
993+ if (!parsedModel) {
994+ return yield* new ProviderAdapterValidationError({
995+ provider: PROVIDER,
996+ operation: "sendTurn",
997+ issue: "OpenCode model selection must use the 'provider/model' format.",
998+ });
999+ }
1000+
1001+ const text = input.input?.trim();
1002+ const fileParts = toOpenCodeFileParts({
1003+ attachments: input.attachments,
1004+ resolveAttachmentPath: (attachment) =>
1005+ resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
1006+ });
1007+ if ((!text || text.length === 0) && fileParts.length === 0) {
1008+ return yield* new ProviderAdapterValidationError({
1009+ provider: PROVIDER,
1010+ operation: "sendTurn",
1011+ issue: "OpenCode turns require text input or at least one attachment.",
1012+ });
1013+ }
1014+
1015+ const agent =
1016+ input.modelSelection?.provider === PROVIDER
1017+ ? input.modelSelection.options?.agent
1018+ : undefined;
1019+ const variant =
1020+ input.modelSelection?.provider === PROVIDER
1021+ ? input.modelSelection.options?.variant
1022+ : undefined;
1023+
1024+ context.activeTurnId = turnId;
1025+ context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined);
1026+ context.activeVariant = variant;
1027+ updateProviderSession(
1028+ context,
1029+ {
1030+ status: "running",
1031+ activeTurnId: turnId,
1032+ model: modelSelection?.model ?? context.session.model,
1033+ },
1034+ { clearLastError: true },
1035+ );
1036+
1037+ yield* emit({
1038+ ...buildEventBase({ threadId: input.threadId, turnId }),
1039+ type: "turn.started",
1040+ payload: {
1041+ model: modelSelection?.model ?? context.session.model,
1042+ ...(variant ? { effort: variant } : {}),
1043+ },
1044+ });
1045+
1046+ const promptExit = yield* Effect.exit(
1047+ Effect.tryPromise({
1048+ try: async () => {
1049+ await context.client.session.promptAsync({
1050+ sessionID: context.openCodeSessionId,
1051+ model: parsedModel,
1052+ ...(context.activeAgent ? { agent: context.activeAgent } : {}),
1053+ ...(context.activeVariant ? { variant: context.activeVariant } : {}),
1054+ parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
1055+ });
1056+ },
1057+ catch: (cause) =>
1058+ new ProviderAdapterRequestError({
1059+ provider: PROVIDER,
1060+ method: "session.promptAsync",
1061+ detail: cause instanceof Error ? cause.message : "Failed to send OpenCode turn.",
1062+ cause,
1063+ }),
1064+ }),
1065+ );
1066+ if (promptExit._tag === "Failure") {
1067+ const failure = Cause.squash(promptExit.cause);
1068+ const requestError = isProviderAdapterRequestError(failure)
1069+ ? failure
1070+ : new ProviderAdapterRequestError({
1071+ provider: PROVIDER,
1072+ method: "session.promptAsync",
1073+ detail:
1074+ failure instanceof Error ? failure.message : "Failed to send OpenCode turn.",
1075+ cause: failure,
1076+ });
1077+ const failureMessage = requestError.detail;
1078+ context.activeTurnId = undefined;
1079+ context.activeAgent = undefined;
1080+ context.activeVariant = undefined;
1081+ updateProviderSession(
1082+ context,
1083+ {
1084+ status: "ready",
1085+ model: modelSelection?.model ?? context.session.model,
1086+ lastError: failureMessage,
1087+ },
1088+ { clearActiveTurnId: true },
1089+ );
1090+ yield* emit({
1091+ ...buildEventBase({ threadId: input.threadId, turnId }),
1092+ type: "turn.aborted",
1093+ payload: {
1094+ reason: failureMessage,
1095+ },
1096+ });
1097+ return yield* requestError;
1098+ }
1099+
1100+ return {
1101+ threadId: input.threadId,
1102+ turnId,
1103+ };
1104+ });
1105+
1106+ const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
1107+ function* (threadId, turnId) {
1108+ const context = ensureSessionContext(sessions, threadId);
1109+ yield* Effect.tryPromise({
1110+ try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }),
1111+ catch: (cause) =>
1112+ new ProviderAdapterRequestError({
1113+ provider: PROVIDER,
1114+ method: "session.abort",
1115+ detail: cause instanceof Error ? cause.message : "Failed to abort OpenCode turn.",
1116+ cause,
1117+ }),
1118+ });
1119+ if (turnId ?? context.activeTurnId) {
1120+ yield* emit({
1121+ ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }),
1122+ type: "turn.aborted",
1123+ payload: {
1124+ reason: "Interrupted by user.",
1125+ },
1126+ });
1127+ }
1128+ },
1129+ );
1130+
1131+ const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn(
1132+ "respondToRequest",
1133+ )(function* (threadId, requestId, decision) {
1134+ const context = ensureSessionContext(sessions, threadId);
1135+ if (!context.pendingPermissions.has(requestId)) {
1136+ return yield* new ProviderAdapterRequestError({
1137+ provider: PROVIDER,
1138+ method: "permission.reply",
1139+ detail: `Unknown pending permission request: ${requestId}`,
1140+ });
1141+ }
1142+
1143+ yield* Effect.tryPromise({
1144+ try: () =>
1145+ context.client.permission.reply({
1146+ requestID: requestId,
1147+ reply: toOpenCodePermissionReply(decision),
1148+ }),
1149+ catch: (cause) =>
1150+ new ProviderAdapterRequestError({
1151+ provider: PROVIDER,
1152+ method: "permission.reply",
1153+ detail:
1154+ cause instanceof Error
1155+ ? cause.message
1156+ : "Failed to submit OpenCode permission reply.",
1157+ cause,
1158+ }),
1159+ });
1160+ });
1161+
1162+ const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn(
1163+ "respondToUserInput",
1164+ )(function* (threadId, requestId, answers) {
1165+ const context = ensureSessionContext(sessions, threadId);
1166+ const request = context.pendingQuestions.get(requestId);
1167+ if (!request) {
1168+ return yield* new ProviderAdapterRequestError({
1169+ provider: PROVIDER,
1170+ method: "question.reply",
1171+ detail: `Unknown pending user-input request: ${requestId}`,
1172+ });
1173+ }
1174+
1175+ yield* Effect.tryPromise({
1176+ try: () =>
1177+ context.client.question.reply({
1178+ requestID: requestId,
1179+ answers: toOpenCodeQuestionAnswers(request, answers),
1180+ }),
1181+ catch: (cause) =>
1182+ new ProviderAdapterRequestError({
1183+ provider: PROVIDER,
1184+ method: "question.reply",
1185+ detail: cause instanceof Error ? cause.message : "Failed to submit OpenCode answers.",
1186+ cause,
1187+ }),
1188+ });
1189+ });
1190+
1191+ const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")(
1192+ function* (threadId) {
1193+ const context = ensureSessionContext(sessions, threadId);
1194+ yield* Effect.tryPromise({
1195+ try: () => stopOpenCodeContext(context),
1196+ catch: (cause) =>
1197+ new ProviderAdapterProcessError({
1198+ provider: PROVIDER,
1199+ threadId,
1200+ detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode session.",
1201+ cause,
1202+ }),
1203+ });
1204+ sessions.delete(threadId);
1205+ yield* emit({
1206+ ...buildEventBase({ threadId }),
1207+ type: "session.exited",
1208+ payload: {
1209+ reason: "Session stopped.",
1210+ recoverable: false,
1211+ exitKind: "graceful",
1212+ },
1213+ });
1214+ },
1215+ );
1216+
1217+ const listSessions: OpenCodeAdapterShape["listSessions"] = () =>
1218+ Effect.sync(() => [...sessions.values()].map((context) => context.session));
1219+
1220+ const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) =>
1221+ Effect.sync(() => sessions.has(threadId));
1222+
1223+ const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")(
1224+ function* (threadId) {
1225+ const context = ensureSessionContext(sessions, threadId);
1226+ const messages = yield* Effect.tryPromise({
1227+ try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }),
1228+ catch: (cause) =>
1229+ new ProviderAdapterRequestError({
1230+ provider: PROVIDER,
1231+ method: "session.messages",
1232+ detail: cause instanceof Error ? cause.message : "Failed to read OpenCode thread.",
1233+ cause,
1234+ }),
1235+ });
1236+
1237+ const turns = (messages.data ?? [])
1238+ .filter((entry) => entry.info.role === "assistant")
1239+ .map((entry) => ({
1240+ id: TurnId.make(entry.info.id),
1241+ items: [entry.info, ...entry.parts],
1242+ }));
1243+
1244+ return {
1245+ threadId,
1246+ turns,
1247+ };
1248+ },
1249+ );
1250+
1251+ const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
1252+ function* (threadId, numTurns) {
1253+ const context = ensureSessionContext(sessions, threadId);
1254+ const messages = yield* Effect.tryPromise({
1255+ try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }),
1256+ catch: (cause) =>
1257+ new ProviderAdapterRequestError({
1258+ provider: PROVIDER,
1259+ method: "session.messages",
1260+ detail:
1261+ cause instanceof Error ? cause.message : "Failed to inspect OpenCode thread.",
1262+ cause,
1263+ }),
1264+ });
1265+
1266+ const assistantMessages = (messages.data ?? []).filter(
1267+ (entry) => entry.info.role === "assistant",
1268+ );
1269+ const targetIndex = assistantMessages.length - numTurns - 1;
1270+ const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null;
1271+ yield* Effect.tryPromise({
1272+ try: () =>
1273+ context.client.session.revert({
1274+ sessionID: context.openCodeSessionId,
1275+ ...(target ? { messageID: target.info.id } : {}),
1276+ }),
1277+ catch: (cause) =>
1278+ new ProviderAdapterRequestError({
1279+ provider: PROVIDER,
1280+ method: "session.revert",
1281+ detail: cause instanceof Error ? cause.message : "Failed to revert OpenCode turn.",
1282+ cause,
1283+ }),
1284+ });
1285+
1286+ return yield* readThread(threadId);
1287+ },
1288+ );
1289+
1290+ const stopAll: OpenCodeAdapterShape["stopAll"] = () =>
1291+ Effect.tryPromise({
1292+ try: async () => {
1293+ const contexts = [...sessions.values()];
1294+ sessions.clear();
1295+ const results = await Promise.allSettled(
1296+ contexts.map((context) => stopOpenCodeContext(context)),
1297+ );
1298+ const errors = results
1299+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
1300+ .map((result) => result.reason);
1301+ if (errors.length === 1) {
1302+ throw errors[0];
1303+ }
1304+ if (errors.length > 1) {
1305+ throw new AggregateError(
1306+ errors,
1307+ `Failed to stop ${errors.length} OpenCode sessions.`,
1308+ );
1309+ }
1310+ },
1311+ catch: (cause) =>
1312+ new ProviderAdapterProcessError({
1313+ provider: PROVIDER,
1314+ threadId: "*",
1315+ detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.",
1316+ cause,
1317+ }),
1318+ });
1319+
1320+ return {
1321+ provider: PROVIDER,
1322+ capabilities: {
1323+ sessionModelSwitch: "in-session",
1324+ },
1325+ startSession,
1326+ sendTurn,
1327+ interruptTurn,
1328+ respondToRequest,
1329+ respondToUserInput,
1330+ stopSession,
1331+ listSessions,
1332+ hasSession,
1333+ readThread,
1334+ rollbackThread,
1335+ stopAll,
1336+ get streamEvents() {
1337+ return Stream.fromQueue(runtimeEvents);
1338+ },
1339+ } satisfies OpenCodeAdapterShape;
1340+ }),
1341+ );
1342+}
1343+
1344+export const OpenCodeAdapterLive = makeOpenCodeAdapterLive();
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+138−0
@@ -0,0 +1,138 @@
1+import assert from "node:assert/strict";
2+
3+import * as NodeServices from "@effect/platform-node/NodeServices";
4+import { it } from "@effect/vitest";
5+import { Effect, Layer } from "effect";
6+import { beforeEach, vi } from "vitest";
7+
8+import { ServerConfig } from "../../config.ts";
9+import { ServerSettingsService } from "../../serverSettings.ts";
10+import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
11+import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts";
12+
13+const runtimeMock = vi.hoisted(() => {
14+ const state = {
15+ runVersionError: null as Error | null,
16+ inventoryError: null as Error | null,
17+ };
18+
19+ return {
20+ state,
21+ reset() {
22+ state.runVersionError = null;
23+ state.inventoryError = null;
24+ },
25+ };
26+});
27+
28+vi.mock("../opencodeRuntime.ts", async () => {
29+ const actual =
30+ await vi.importActual<typeof import("../opencodeRuntime.ts")>("../opencodeRuntime.ts");
31+
32+ return {
33+ ...actual,
34+ runOpenCodeCommand: vi.fn(async () => {
35+ if (runtimeMock.state.runVersionError) {
36+ throw runtimeMock.state.runVersionError;
37+ }
38+ return { stdout: "opencode 1.0.0\n", stderr: "", code: 0 };
39+ }),
40+ connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({
41+ url: serverUrl ?? "http://127.0.0.1:4301",
42+ process: null,
43+ external: Boolean(serverUrl),
44+ close() {},
45+ })),
46+ createOpenCodeSdkClient: vi.fn(() => ({})),
47+ loadOpenCodeInventory: vi.fn(async () => {
48+ if (runtimeMock.state.inventoryError) {
49+ throw runtimeMock.state.inventoryError;
50+ }
51+ return {
52+ providerList: { connected: [], all: [] },
53+ agents: [],
54+ };
55+ }),
56+ flattenOpenCodeModels: vi.fn(() => []),
57+ };
58+});
59+
60+beforeEach(() => {
61+ runtimeMock.reset();
62+});
63+
64+const makeTestLayer = (settingsOverrides?: Parameters<typeof ServerSettingsService.layerTest>[0]) =>
65+ makeOpenCodeProviderLive().pipe(
66+ Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
67+ Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)),
68+ Layer.provideMerge(NodeServices.layer),
69+ );
70+
71+it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
72+ it.effect("shows a codex-style missing binary message", () =>
73+ Effect.gen(function* () {
74+ runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT");
75+ const provider = yield* OpenCodeProvider;
76+ const snapshot = yield* provider.refresh;
77+
78+ assert.equal(snapshot.status, "error");
79+ assert.equal(snapshot.installed, false);
80+ assert.equal(snapshot.message, "OpenCode CLI (`opencode`) is not installed or not on PATH.");
81+ }),
82+ );
83+
84+ it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () =>
85+ Effect.gen(function* () {
86+ runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise");
87+ const provider = yield* OpenCodeProvider;
88+ const snapshot = yield* provider.refresh;
89+
90+ assert.equal(snapshot.status, "error");
91+ assert.equal(snapshot.installed, true);
92+ assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check.");
93+ }),
94+ );
95+});
96+
97+it.layer(
98+ makeTestLayer({
99+ providers: {
100+ opencode: {
101+ serverUrl: "http://127.0.0.1:9999",
102+ serverPassword: "secret-password",
103+ },
104+ },
105+ }),
106+)("OpenCodeProviderLive with configured server URL", (it) => {
107+ it.effect("surfaces a friendly auth error for configured servers", () =>
108+ Effect.gen(function* () {
109+ runtimeMock.state.inventoryError = new Error("401 Unauthorized");
110+ const provider = yield* OpenCodeProvider;
111+ const snapshot = yield* provider.refresh;
112+
113+ assert.equal(snapshot.status, "error");
114+ assert.equal(snapshot.installed, true);
115+ assert.equal(
116+ snapshot.message,
117+ "OpenCode server rejected authentication. Check the server URL and password.",
118+ );
119+ }),
120+ );
121+
122+ it.effect("surfaces a friendly connection error for configured servers", () =>
123+ Effect.gen(function* () {
124+ runtimeMock.state.inventoryError = new Error(
125+ "fetch failed: connect ECONNREFUSED 127.0.0.1:9999",
126+ );
127+ const provider = yield* OpenCodeProvider;
128+ const snapshot = yield* provider.refresh;
129+
130+ assert.equal(snapshot.status, "error");
131+ assert.equal(snapshot.installed, true);
132+ assert.equal(
133+ snapshot.message,
134+ "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.",
135+ );
136+ }),
137+ );
138+});
apps/server/src/provider/Layers/OpenCodeProvider.ts+342−0
@@ -0,0 +1,342 @@
1+import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts";
2+import { Cause, Effect, Equal, Layer, Stream } from "effect";
3+
4+import { ServerConfig } from "../../config.ts";
5+import { ServerSettingsService } from "../../serverSettings.ts";
6+import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
7+import {
8+ buildServerProvider,
9+ isCommandMissingCause,
10+ parseGenericCliVersion,
11+ providerModelsFromSettings,
12+} from "../providerSnapshot.ts";
13+import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
14+import {
15+ connectToOpenCodeServer,
16+ DEFAULT_OPENCODE_MODEL_CAPABILITIES,
17+ createOpenCodeSdkClient,
18+ flattenOpenCodeModels,
19+ loadOpenCodeInventory,
20+ runOpenCodeCommand,
21+} from "../opencodeRuntime.ts";
22+
23+const PROVIDER = "opencode" as const;
24+
25+class OpenCodeProbePromiseError extends Error {
26+ override readonly cause: unknown;
27+
28+ constructor(cause: unknown) {
29+ super(cause instanceof Error ? cause.message : String(cause));
30+ this.cause = cause;
31+ this.name = "OpenCodeProbePromiseError";
32+ }
33+}
34+
35+function toOpenCodeProbeError(cause: unknown): OpenCodeProbePromiseError {
36+ return new OpenCodeProbePromiseError(cause);
37+}
38+
39+function normalizedErrorMessage(cause: unknown): string | undefined {
40+ if (!(cause instanceof Error)) {
41+ return undefined;
42+ }
43+
44+ const message = cause.message.trim();
45+ if (message.length === 0) {
46+ return undefined;
47+ }
48+ if (
49+ message === "An error occurred in Effect.tryPromise" ||
50+ message === "An error occurred in Effect.try"
51+ ) {
52+ return undefined;
53+ }
54+ return message;
55+}
56+
57+function formatOpenCodeProbeError(input: {
58+ readonly cause: unknown;
59+ readonly isExternalServer: boolean;
60+ readonly serverUrl: string;
61+}): { readonly installed: boolean; readonly message: string } {
62+ const lower = input.cause instanceof Error ? input.cause.message.toLowerCase() : "";
63+ const detail = normalizedErrorMessage(input.cause);
64+
65+ if (input.isExternalServer) {
66+ if (
67+ lower.includes("401") ||
68+ lower.includes("403") ||
69+ lower.includes("unauthorized") ||
70+ lower.includes("forbidden")
71+ ) {
72+ return {
73+ installed: true,
74+ message: "OpenCode server rejected authentication. Check the server URL and password.",
75+ };
76+ }
77+
78+ if (
79+ lower.includes("econnrefused") ||
80+ lower.includes("enotfound") ||
81+ lower.includes("fetch failed") ||
82+ lower.includes("networkerror") ||
83+ lower.includes("timed out") ||
84+ lower.includes("timeout") ||
85+ lower.includes("socket hang up")
86+ ) {
87+ return {
88+ installed: true,
89+ message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`,
90+ };
91+ }
92+
93+ return {
94+ installed: true,
95+ message: detail ?? "Failed to connect to the configured OpenCode server.",
96+ };
97+ }
98+
99+ if (input.cause instanceof Error && isCommandMissingCause(input.cause)) {
100+ return {
101+ installed: false,
102+ message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
103+ };
104+ }
105+
106+ if (lower.includes("quarantine")) {
107+ return {
108+ installed: true,
109+ message:
110+ "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.",
111+ };
112+ }
113+
114+ if (lower.includes("invalid code signature") || lower.includes("corrupted")) {
115+ return {
116+ installed: true,
117+ message:
118+ "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.",
119+ };
120+ }
121+
122+ return {
123+ installed: true,
124+ message: detail
125+ ? `Failed to execute OpenCode CLI health check: ${detail}`
126+ : "Failed to execute OpenCode CLI health check.",
127+ };
128+}
129+
130+const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => {
131+ const checkedAt = new Date().toISOString();
132+ const models = providerModelsFromSettings(
133+ [],
134+ PROVIDER,
135+ openCodeSettings.customModels,
136+ DEFAULT_OPENCODE_MODEL_CAPABILITIES,
137+ );
138+
139+ if (!openCodeSettings.enabled) {
140+ return buildServerProvider({
141+ provider: PROVIDER,
142+ enabled: false,
143+ checkedAt,
144+ models,
145+ probe: {
146+ installed: false,
147+ version: null,
148+ status: "warning",
149+ auth: { status: "unknown" },
150+ message:
151+ openCodeSettings.serverUrl.trim().length > 0
152+ ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
153+ : "OpenCode is disabled in T3 Code settings.",
154+ },
155+ });
156+ }
157+
158+ return buildServerProvider({
159+ provider: PROVIDER,
160+ enabled: true,
161+ checkedAt,
162+ models,
163+ probe: {
164+ installed: false,
165+ version: null,
166+ status: "warning",
167+ auth: { status: "unknown" },
168+ message: "OpenCode provider status has not been checked in this session yet.",
169+ },
170+ });
171+};
172+
173+export function checkOpenCodeProviderStatus(input: {
174+ readonly settings: OpenCodeSettings;
175+ readonly cwd: string;
176+}): Effect.Effect<ServerProvider> {
177+ const checkedAt = new Date().toISOString();
178+ const customModels = input.settings.customModels;
179+ const isExternalServer = input.settings.serverUrl.trim().length > 0;
180+
181+ const fallback = (cause: unknown, version: string | null = null) => {
182+ const failure = formatOpenCodeProbeError({
183+ cause,
184+ isExternalServer,
185+ serverUrl: input.settings.serverUrl,
186+ });
187+ return buildServerProvider({
188+ provider: PROVIDER,
189+ enabled: input.settings.enabled,
190+ checkedAt,
191+ models: providerModelsFromSettings(
192+ [],
193+ PROVIDER,
194+ customModels,
195+ DEFAULT_OPENCODE_MODEL_CAPABILITIES,
196+ ),
197+ probe: {
198+ installed: failure.installed,
199+ version,
200+ status: "error",
201+ auth: { status: "unknown" },
202+ message: failure.message,
203+ },
204+ });
205+ };
206+
207+ return Effect.gen(function* () {
208+ if (!input.settings.enabled) {
209+ return buildServerProvider({
210+ provider: PROVIDER,
211+ enabled: false,
212+ checkedAt,
213+ models: providerModelsFromSettings(
214+ [],
215+ PROVIDER,
216+ customModels,
217+ DEFAULT_OPENCODE_MODEL_CAPABILITIES,
218+ ),
219+ probe: {
220+ installed: false,
221+ version: null,
222+ status: "warning",
223+ auth: { status: "unknown" },
224+ message: isExternalServer
225+ ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
226+ : "OpenCode is disabled in T3 Code settings.",
227+ },
228+ });
229+ }
230+
231+ let version: string | null = null;
232+ if (!isExternalServer) {
233+ const versionExit = yield* Effect.exit(
234+ Effect.tryPromise({
235+ try: () =>
236+ runOpenCodeCommand({
237+ binaryPath: input.settings.binaryPath,
238+ args: ["--version"],
239+ }),
240+ catch: toOpenCodeProbeError,
241+ }),
242+ );
243+ if (versionExit._tag === "Failure") {
244+ return fallback(Cause.squash(versionExit.cause));
245+ }
246+ version = parseGenericCliVersion(versionExit.value.stdout) ?? null;
247+ }
248+
249+ const inventoryExit = yield* Effect.exit(
250+ Effect.acquireUseRelease(
251+ Effect.tryPromise({
252+ try: () =>
253+ connectToOpenCodeServer({
254+ binaryPath: input.settings.binaryPath,
255+ serverUrl: input.settings.serverUrl,
256+ }),
257+ catch: toOpenCodeProbeError,
258+ }),
259+ (server) =>
260+ Effect.tryPromise({
261+ try: async () => {
262+ const client = createOpenCodeSdkClient({
263+ baseUrl: server.url,
264+ directory: input.cwd,
265+ ...(isExternalServer && input.settings.serverPassword
266+ ? { serverPassword: input.settings.serverPassword }
267+ : {}),
268+ });
269+ return await loadOpenCodeInventory(client);
270+ },
271+ catch: toOpenCodeProbeError,
272+ }),
273+ (server) => Effect.sync(() => server.close()),
274+ ),
275+ );
276+ if (inventoryExit._tag === "Failure") {
277+ return fallback(Cause.squash(inventoryExit.cause), version);
278+ }
279+
280+ const models = providerModelsFromSettings(
281+ flattenOpenCodeModels(inventoryExit.value),
282+ PROVIDER,
283+ customModels,
284+ DEFAULT_OPENCODE_MODEL_CAPABILITIES,
285+ );
286+ const connectedCount = inventoryExit.value.providerList.connected.length;
287+ return buildServerProvider({
288+ provider: PROVIDER,
289+ enabled: true,
290+ checkedAt,
291+ models,
292+ probe: {
293+ installed: true,
294+ version,
295+ status: connectedCount > 0 ? "ready" : "warning",
296+ auth: {
297+ status: connectedCount > 0 ? "authenticated" : "unknown",
298+ type: "opencode",
299+ },
300+ message:
301+ connectedCount > 0
302+ ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.`
303+ : isExternalServer
304+ ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers."
305+ : "OpenCode is available, but it did not report any connected upstream providers.",
306+ },
307+ });
308+ });
309+}
310+
311+export function makeOpenCodeProviderLive() {
312+ return Layer.effect(
313+ OpenCodeProvider,
314+ Effect.gen(function* () {
315+ const serverSettings = yield* ServerSettingsService;
316+ const serverConfig = yield* ServerConfig;
317+
318+ const getProviderSettings = serverSettings.getSettings.pipe(
319+ Effect.map((settings) => settings.providers.opencode),
320+ );
321+
322+ return yield* makeManagedServerProvider<OpenCodeSettings>({
323+ getSettings: getProviderSettings.pipe(Effect.orDie),
324+ streamSettings: serverSettings.streamChanges.pipe(
325+ Stream.map((settings) => settings.providers.opencode),
326+ ),
327+ haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
328+ initialSnapshot: makePendingOpenCodeProvider,
329+ checkProvider: getProviderSettings.pipe(
330+ Effect.flatMap((settings) =>
331+ checkOpenCodeProviderStatus({
332+ settings,
333+ cwd: serverConfig.cwd,
334+ }),
335+ ),
336+ ),
337+ });
338+ }),
339+ );
340+}
341+
342+export const OpenCodeProviderLive = makeOpenCodeProviderLive();
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+23−1
@@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; · 7 unmodified lines
88 import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
99 import { CodexAdapter } from "../Services/CodexAdapter.ts";
1010 import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
11+import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
12+import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
1113 import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
1214 import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
1315 import { ProviderUnsupportedError } from "../Errors.ts";
@@ -47,6 +49,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { · 33 unmodified lines
4749 streamEvents: Stream.empty,
4850 };
4951
52+const fakeOpenCodeAdapter: OpenCodeAdapterShape = {
53+ provider: "opencode",
54+ capabilities: { sessionModelSwitch: "in-session" },
55+ startSession: vi.fn(),
56+ sendTurn: vi.fn(),
57+ interruptTurn: vi.fn(),
58+ respondToRequest: vi.fn(),
59+ respondToUserInput: vi.fn(),
60+ stopSession: vi.fn(),
61+ listSessions: vi.fn(),
62+ hasSession: vi.fn(),
63+ readThread: vi.fn(),
64+ rollbackThread: vi.fn(),
65+ stopAll: vi.fn(),
66+ streamEvents: Stream.empty,
67+};
68+
5069 const layer = it.layer(
5170 Layer.mergeAll(
5271 Layer.provide(
@@ -54,6 +73,7 @@ const layer = it.layer( · 1 unmodified line
5473 Layer.mergeAll(
5574 Layer.succeed(CodexAdapter, fakeCodexAdapter),
5675 Layer.succeed(ClaudeAdapter, fakeClaudeAdapter),
76+ Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter),
5777 ),
5878 ),
5979 NodeServices.layer,
@@ -66,11 +86,13 @@ layer("ProviderAdapterRegistryLive", (it) => { · 6 unmodified lines
6686 const registry = yield* ProviderAdapterRegistry;
6787 const codex = yield* registry.getByProvider("codex");
6888 const claude = yield* registry.getByProvider("claudeAgent");
89+ const openCode = yield* registry.getByProvider("opencode");
6990 assert.equal(codex, fakeCodexAdapter);
7091 assert.equal(claude, fakeClaudeAdapter);
92+ assert.equal(openCode, fakeOpenCodeAdapter);
7193
7294 const providers = yield* registry.listProviders();
73− assert.deepEqual(providers, ["codex", "claudeAgent"]);
95+ assert.deepEqual(providers, ["codex", "claudeAgent", "opencode"]);
7496 }),
7597 );
7698
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+2−1
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
1717 } from "../Services/ProviderAdapterRegistry.ts";
1818 import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
1919 import { CodexAdapter } from "../Services/CodexAdapter.ts";
20+import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
2021
2122 export interface ProviderAdapterRegistryLiveOptions {
2223 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;
@@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun · 5 unmodified lines
2829 const adapters =
2930 options?.adapters !== undefined
3031 ? options.adapters
31− : [yield* CodexAdapter, yield* ClaudeAdapter];
32+ : [yield* CodexAdapter, yield* ClaudeAdapter, yield* OpenCodeAdapter];
3233 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3334
3435 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderRegistry.test.ts+27−12
@@ -31,6 +31,7 @@ import { · 30 unmodified lines
3131 } from "./CodexProvider.ts";
3232 import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts";
3333 import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts";
34+import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
3435 import { ServerConfig } from "../../config.ts";
3536 import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
3637 import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
@@ -38,6 +39,19 @@ import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; · 1 unmodified line
3839 // ── Test helpers ────────────────────────────────────────────────────
3940
4041 const encoder = new TextEncoder();
42+const fakeOpenCodeSnapshot: ServerProvider = {
43+ provider: "opencode",
44+ status: "warning",
45+ enabled: true,
46+ installed: false,
47+ auth: { status: "unknown" },
48+ checkedAt: "2026-03-25T00:00:00.000Z",
49+ version: null,
50+ models: [],
51+ slashCommands: [],
52+ skills: [],
53+ message: "OpenCode test stub",
54+};
4155
4256 function mockHandle(result: { stdout: string; stderr: string; code: number }) {
4357 return ChildProcessSpawner.makeHandle({
@@ -596,12 +610,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 552 unmodified lines
596610 }),
597611 ),
598612 );
599− const runtimeServices = yield* Layer.build(
600− Layer.mergeAll(
601− Layer.succeed(ServerSettingsService, serverSettings),
602− providerRegistryLayer,
603− ),
604− ).pipe(Scope.provide(scope));
613+ const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
614+ Scope.provide(scope),
615+ );
605616
606617 yield* Effect.gen(function* () {
607618 const registry = yield* ProviderRegistry;
@@ -630,6 +641,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 22 unmodified lines
630641 prefix: "t3-provider-registry-",
631642 }),
632643 ),
644+ Layer.provideMerge(
645+ Layer.succeed(OpenCodeProvider, {
646+ getSnapshot: Effect.succeed(fakeOpenCodeSnapshot),
647+ refresh: Effect.succeed(fakeOpenCodeSnapshot),
648+ streamChanges: Stream.empty,
649+ }),
650+ ),
633651 Layer.provideMerge(
634652 mockCommandSpawnerLayer((command, args) => {
635653 const joined = args.join(" ");
@@ -646,12 +664,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 10 unmodified lines
646664 }),
647665 ),
648666 );
649− const runtimeServices = yield* Layer.build(
650− Layer.mergeAll(
651− Layer.succeed(ServerSettingsService, serverSettings),
652− providerRegistryLayer,
653− ),
654− ).pipe(Scope.provide(scope));
667+ const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
668+ Scope.provide(scope),
669+ );
655670
656671 yield* Effect.gen(function* () {
657672 const registry = yield* ProviderRegistry;
apps/server/src/provider/Layers/ProviderRegistry.ts+28−6
@@ -9,10 +9,13 @@ import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "eff · 8 unmodified lines
99 import { ServerConfig } from "../../config.ts";
1010 import { ClaudeProviderLive } from "./ClaudeProvider.ts";
1111 import { CodexProviderLive } from "./CodexProvider.ts";
12+import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
1213 import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts";
1314 import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
1415 import type { CodexProviderShape } from "../Services/CodexProvider.ts";
1516 import { CodexProvider } from "../Services/CodexProvider.ts";
17+import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider.ts";
18+import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
1619 import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
1720 import {
1821 hydrateCachedProvider,
@@ -26,10 +29,14 @@ import { · 7 unmodified lines
2629 const loadProviders = (
2730 codexProvider: CodexProviderShape,
2831 claudeProvider: ClaudeProviderShape,
29−): Effect.Effect<readonly [ServerProvider, ServerProvider]> =>
30− Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], {
31− concurrency: "unbounded",
32− });
32+ openCodeProvider: OpenCodeProviderShape,
33+): Effect.Effect<readonly [ServerProvider, ServerProvider, ServerProvider]> =>
34+ Effect.all(
35+ [codexProvider.getSnapshot, claudeProvider.getSnapshot, openCodeProvider.getSnapshot],
36+ {
37+ concurrency: "unbounded",
38+ },
39+ );
3340
3441 export const haveProvidersChanged = (
3542 previousProviders: ReadonlyArray<ServerProvider>,
@@ -41,6 +48,7 @@ export const ProviderRegistryLive = Layer.effect( · 5 unmodified lines
4148 Effect.gen(function* () {
4249 const codexProvider = yield* CodexProvider;
4350 const claudeProvider = yield* ClaudeProvider;
51+ const openCodeProvider = yield* OpenCodeProvider;
4452 const config = yield* ServerConfig;
4553 const fileSystem = yield* FileSystem.FileSystem;
4654 const path = yield* Path.Path;
@@ -48,7 +56,7 @@ export const ProviderRegistryLive = Layer.effect( · 1 unmodified line
4856 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),
4957 PubSub.shutdown,
5058 );
51− const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider);
59+ const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider, openCodeProvider);
5260 const cachePathByProvider = new Map(
5361 PROVIDER_CACHE_IDS.map(
5462 (provider) =>
@@ -156,6 +164,10 @@ export const ProviderRegistryLive = Layer.effect( · 101 unmodified lines
156164 return yield* claudeProvider.refresh.pipe(
157165 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
158166 );
167+ case "opencode":
168+ return yield* openCodeProvider.refresh.pipe(
169+ Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
170+ );
159171 default:
160172 return yield* Effect.all(
161173 [
@@ -165,6 +177,9 @@ export const ProviderRegistryLive = Layer.effect( · 3 unmodified lines
165177 claudeProvider.refresh.pipe(
166178 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
167179 ),
180+ openCodeProvider.refresh.pipe(
181+ Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
182+ ),
168183 ],
169184 {
170185 concurrency: "unbounded",
@@ -180,6 +195,9 @@ export const ProviderRegistryLive = Layer.effect( · 9 unmodified lines
180195 yield* Stream.runForEach(claudeProvider.streamChanges, (provider) =>
181196 syncProvider(provider),
182197 ).pipe(Effect.forkScoped);
198+ yield* Stream.runForEach(openCodeProvider.streamChanges, (provider) =>
199+ syncProvider(provider),
200+ ).pipe(Effect.forkScoped);
183201
184202 return {
185203 getProviders: Ref.get(providersRef),
@@ -193,4 +211,8 @@ export const ProviderRegistryLive = Layer.effect( · 7 unmodified lines
193211 },
194212 } satisfies ProviderRegistryShape;
195213 }),
196−).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive));
214+).pipe(
215+ Layer.provideMerge(CodexProviderLive),
216+ Layer.provideMerge(ClaudeProviderLive),
217+ Layer.provideMerge(OpenCodeProviderLive),
218+);
apps/server/src/provider/Layers/ProviderService.test.ts+56−0
@@ -333,6 +333,62 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () · 332 unmodified lines
333333 );
334334
335335 const routing = makeProviderServiceLayer();
336+
337+it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () =>
338+ Effect.gen(function* () {
339+ const codex = makeFakeCodexAdapter();
340+ const canonicalEvents: ProviderRuntimeEvent[] = [];
341+ const canonicalThreadIds: Array<string | null> = [];
342+ const registry: typeof ProviderAdapterRegistry.Service = {
343+ getByProvider: (provider) =>
344+ provider === "codex"
345+ ? Effect.succeed(codex.adapter)
346+ : Effect.fail(new ProviderUnsupportedError({ provider })),
347+ listProviders: () => Effect.succeed(["codex"]),
348+ };
349+ const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe(
350+ Layer.provide(SqlitePersistenceMemory),
351+ );
352+ const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
353+ const providerLayer = makeProviderServiceLive({
354+ canonicalEventLogger: {
355+ filePath: "memory://provider-canonical-events",
356+ write: (event, threadId) => {
357+ canonicalEvents.push(event as ProviderRuntimeEvent);
358+ canonicalThreadIds.push(threadId ?? null);
359+ return Effect.void;
360+ },
361+ close: () => Effect.void,
362+ },
363+ }).pipe(
364+ Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
365+ Layer.provide(directoryLayer),
366+ Layer.provide(defaultServerSettingsLayer),
367+ Layer.provide(AnalyticsService.layerTest),
368+ );
369+
370+ yield* Effect.gen(function* () {
371+ yield* ProviderService;
372+ yield* sleep(10);
373+ codex.emit({
374+ eventId: asEventId("evt-canonical-thread-segment"),
375+ provider: "codex",
376+ threadId: asThreadId("thread-canonical-thread-segment"),
377+ createdAt: new Date().toISOString(),
378+ type: "turn.completed",
379+ payload: {
380+ state: "completed",
381+ },
382+ });
383+ yield* sleep(20);
384+ }).pipe(Effect.provide(providerLayer));
385+
386+ assert.equal(canonicalEvents.length, 1);
387+ assert.equal(canonicalEvents[0]?.threadId, "thread-canonical-thread-segment");
388+ assert.deepEqual(canonicalThreadIds, ["thread-canonical-thread-segment"]);
389+ }).pipe(Effect.provide(NodeServices.layer)),
390+);
391+
336392 it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () =>
337393 Effect.gen(function* () {
338394 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-"));
apps/server/src/provider/Layers/ProviderService.ts+3−1
@@ -161,7 +161,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 160 unmodified lines
161161 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
162162 Effect.succeed(event).pipe(
163163 Effect.tap((canonicalEvent) =>
164− canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void,
164+ canonicalEventLogger
165+ ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId)
166+ : Effect.void,
165167 ),
166168 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),
167169 Effect.asVoid,
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+1−1
@@ -24,7 +24,7 @@ function decodeProviderKind( · 23 unmodified lines
2424 providerName: string,
2525 operation: string,
2626 ): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
27− if (providerName === "codex" || providerName === "claudeAgent") {
27+ if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") {
2828 return Effect.succeed(providerName);
2929 }
3030 return Effect.fail(
apps/server/src/provider/Services/OpenCodeAdapter.ts+12−0
@@ -0,0 +1,12 @@
1+import { Context } from "effect";
2+
3+import type { ProviderAdapterError } from "../Errors.ts";
4+import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
5+
6+export interface OpenCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
7+ readonly provider: "opencode";
8+}
9+
10+export class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()(
11+ "t3/provider/Services/OpenCodeAdapter",
12+) {}
apps/server/src/provider/Services/OpenCodeProvider.ts+9−0
@@ -0,0 +1,9 @@
1+import { Context } from "effect";
2+
3+import type { ServerProviderShape } from "./ServerProvider.ts";
4+
5+export interface OpenCodeProviderShape extends ServerProviderShape {}
6+
7+export class OpenCodeProvider extends Context.Service<OpenCodeProvider, OpenCodeProviderShape>()(
8+ "t3/provider/Services/OpenCodeProvider",
9+) {}
apps/server/src/provider/opencodeRuntime.test.ts+38−0
@@ -0,0 +1,38 @@
1+import assert from "node:assert/strict";
2+
3+import { describe, it, vi } from "vitest";
4+
5+const childProcessMock = vi.hoisted(() => ({
6+ execFileSync: vi.fn((command: string, args: ReadonlyArray<string>) => {
7+ if (command === "which" && args[0] === "opencode") {
8+ return "/opt/homebrew/bin/opencode\n";
9+ }
10+ return "";
11+ }),
12+ spawn: vi.fn(),
13+}));
14+
15+vi.mock("node:child_process", () => childProcessMock);
16+
17+describe("resolveOpenCodeBinaryPath", () => {
18+ it("returns absolute binary paths without PATH lookup", async () => {
19+ const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts");
20+
21+ assert.equal(resolveOpenCodeBinaryPath("/usr/local/bin/opencode"), "/usr/local/bin/opencode");
22+ assert.equal(childProcessMock.execFileSync.mock.calls.length, 0);
23+ });
24+
25+ it("resolves command names through PATH", async () => {
26+ const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts");
27+
28+ assert.equal(resolveOpenCodeBinaryPath("opencode"), "/opt/homebrew/bin/opencode");
29+ assert.deepEqual(childProcessMock.execFileSync.mock.calls[0], [
30+ "which",
31+ ["opencode"],
32+ {
33+ encoding: "utf8",
34+ timeout: 3_000,
35+ },
36+ ]);
37+ });
38+});
apps/server/src/provider/opencodeRuntime.ts+573−0
@@ -0,0 +1,573 @@
1+import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2+import * as FS from "node:fs";
3+import { createServer, type AddressInfo } from "node:net";
4+import * as OS from "node:os";
5+import * as Path from "node:path";
6+import { pathToFileURL } from "node:url";
7+
8+import type {
9+ ChatAttachment,
10+ ModelCapabilities,
11+ ProviderApprovalDecision,
12+ RuntimeMode,
13+ ServerProviderModel,
14+} from "@t3tools/contracts";
15+import {
16+ createOpencodeClient,
17+ type Agent,
18+ type FilePartInput,
19+ type OpencodeClient,
20+ type PermissionRuleset,
21+ type ProviderListResponse,
22+ type QuestionAnswer,
23+ type QuestionRequest,
24+} from "@opencode-ai/sdk/v2";
25+
26+const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
27+const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000;
28+const DEFAULT_HOSTNAME = "127.0.0.1";
29+
30+const OPENAI_VARIANTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
31+const ANTHROPIC_VARIANTS = ["high", "max"];
32+const GOOGLE_VARIANTS = ["low", "high"];
33+
34+export const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = {
35+ reasoningEffortLevels: [],
36+ supportsFastMode: false,
37+ supportsThinkingToggle: false,
38+ contextWindowOptions: [],
39+ promptInjectedEffortLevels: [],
40+};
41+
42+export interface OpenCodeServerProcess {
43+ readonly url: string;
44+ readonly process: ChildProcess;
45+ close(): void;
46+}
47+
48+export interface OpenCodeServerConnection {
49+ readonly url: string;
50+ readonly process: ChildProcess | null;
51+ readonly external: boolean;
52+ close(): void;
53+}
54+
55+function buildOpenCodeBasicAuthorizationHeader(password: string): string {
56+ return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;
57+}
58+
59+export interface OpenCodeCommandResult {
60+ readonly stdout: string;
61+ readonly stderr: string;
62+ readonly code: number;
63+}
64+
65+export interface OpenCodeInventory {
66+ readonly providerList: ProviderListResponse;
67+ readonly agents: ReadonlyArray<Agent>;
68+}
69+
70+export interface ParsedOpenCodeModelSlug {
71+ readonly providerID: string;
72+ readonly modelID: string;
73+}
74+
75+function titleCaseSlug(value: string): string {
76+ return value
77+ .split(/[-_/]+/)
78+ .filter((segment) => segment.length > 0)
79+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
80+ .join(" ");
81+}
82+
83+function parseServerUrlFromOutput(output: string): string | null {
84+ for (const line of output.split("\n")) {
85+ if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) {
86+ continue;
87+ }
88+ const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
89+ return match?.[1] ?? null;
90+ }
91+ return null;
92+}
93+
94+function isPrimaryAgent(agent: Agent): boolean {
95+ return !agent.hidden && (agent.mode === "primary" || agent.mode === "all");
96+}
97+
98+function inferVariantValues(providerID: string): ReadonlyArray<string> {
99+ if (providerID === "anthropic") {
100+ return ANTHROPIC_VARIANTS;
101+ }
102+ if (providerID === "openai" || providerID === "opencode") {
103+ return OPENAI_VARIANTS;
104+ }
105+ if (providerID.startsWith("google")) {
106+ return GOOGLE_VARIANTS;
107+ }
108+ return [];
109+}
110+
111+function inferDefaultVariant(
112+ providerID: string,
113+ variants: ReadonlyArray<string>,
114+): string | undefined {
115+ if (variants.length === 1) {
116+ return variants[0];
117+ }
118+ if (providerID === "anthropic" || providerID.startsWith("google")) {
119+ return variants.includes("high") ? "high" : undefined;
120+ }
121+ if (providerID === "openai" || providerID === "opencode") {
122+ return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined;
123+ }
124+ return undefined;
125+}
126+
127+function buildVariantOptions(
128+ providerID: string,
129+ model: ProviderListResponse["all"][number]["models"][string],
130+) {
131+ const variantValues = Object.keys(model.variants ?? {});
132+ const resolvedValues =
133+ variantValues.length > 0 ? variantValues : [...inferVariantValues(providerID)];
134+ const defaultVariant = inferDefaultVariant(providerID, resolvedValues);
135+
136+ return resolvedValues.map((value) => {
137+ const option: { value: string; label: string; isDefault?: boolean } = {
138+ value,
139+ label: titleCaseSlug(value),
140+ };
141+ if (defaultVariant === value) {
142+ option.isDefault = true;
143+ }
144+ return option;
145+ });
146+}
147+
148+function buildAgentOptions(agents: ReadonlyArray<Agent>) {
149+ const primaryAgents = agents.filter(isPrimaryAgent);
150+ const defaultAgent =
151+ primaryAgents.find((agent) => agent.name === "build")?.name ??
152+ primaryAgents[0]?.name ??
153+ undefined;
154+ return primaryAgents.map((agent) => {
155+ const option: { value: string; label: string; isDefault?: boolean } = {
156+ value: agent.name,
157+ label: titleCaseSlug(agent.name),
158+ };
159+ if (defaultAgent === agent.name) {
160+ option.isDefault = true;
161+ }
162+ return option;
163+ });
164+}
165+
166+function openCodeCapabilitiesForModel(input: {
167+ readonly providerID: string;
168+ readonly model: ProviderListResponse["all"][number]["models"][string];
169+ readonly agents: ReadonlyArray<Agent>;
170+}): ModelCapabilities {
171+ const variantOptions = buildVariantOptions(input.providerID, input.model);
172+ const agentOptions = buildAgentOptions(input.agents);
173+ return {
174+ ...DEFAULT_OPENCODE_MODEL_CAPABILITIES,
175+ ...(variantOptions.length > 0 ? { variantOptions } : {}),
176+ ...(agentOptions.length > 0 ? { agentOptions } : {}),
177+ };
178+}
179+
180+export function parseOpenCodeModelSlug(
181+ slug: string | null | undefined,
182+): ParsedOpenCodeModelSlug | null {
183+ if (typeof slug !== "string") {
184+ return null;
185+ }
186+
187+ const trimmed = slug.trim();
188+ const separator = trimmed.indexOf("/");
189+ if (separator <= 0 || separator === trimmed.length - 1) {
190+ return null;
191+ }
192+
193+ return {
194+ providerID: trimmed.slice(0, separator),
195+ modelID: trimmed.slice(separator + 1),
196+ };
197+}
198+
199+export function toOpenCodeModelSlug(providerID: string, modelID: string): string {
200+ return `${providerID}/${modelID}`;
201+}
202+
203+export function openCodeQuestionId(
204+ index: number,
205+ question: QuestionRequest["questions"][number],
206+): string {
207+ const header = question.header
208+ .trim()
209+ .toLowerCase()
210+ .replace(/[^a-z0-9_-]+/g, "-");
211+ return header.length > 0 ? `question-${index}-${header}` : `question-${index}`;
212+}
213+
214+export function toOpenCodeFileParts(input: {
215+ readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
216+ readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;
217+}): Array<FilePartInput> {
218+ const parts: Array<FilePartInput> = [];
219+
220+ for (const attachment of input.attachments ?? []) {
221+ const attachmentPath = input.resolveAttachmentPath(attachment);
222+ if (!attachmentPath) {
223+ continue;
224+ }
225+
226+ parts.push({
227+ type: "file",
228+ mime: attachment.mimeType,
229+ filename: attachment.name,
230+ url: pathToFileURL(attachmentPath).href,
231+ });
232+ }
233+
234+ return parts;
235+}
236+
237+export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset {
238+ if (runtimeMode === "full-access") {
239+ return [{ permission: "*", pattern: "*", action: "allow" }];
240+ }
241+
242+ return [
243+ { permission: "*", pattern: "*", action: "ask" },
244+ { permission: "bash", pattern: "*", action: "ask" },
245+ { permission: "edit", pattern: "*", action: "ask" },
246+ { permission: "webfetch", pattern: "*", action: "ask" },
247+ { permission: "websearch", pattern: "*", action: "ask" },
248+ { permission: "codesearch", pattern: "*", action: "ask" },
249+ { permission: "external_directory", pattern: "*", action: "ask" },
250+ { permission: "doom_loop", pattern: "*", action: "ask" },
251+ { permission: "question", pattern: "*", action: "allow" },
252+ ];
253+}
254+
255+export function toOpenCodePermissionReply(
256+ decision: ProviderApprovalDecision,
257+): "once" | "always" | "reject" {
258+ switch (decision) {
259+ case "accept":
260+ return "once";
261+ case "acceptForSession":
262+ return "always";
263+ case "decline":
264+ case "cancel":
265+ default:
266+ return "reject";
267+ }
268+}
269+
270+export function toOpenCodeQuestionAnswers(
271+ request: QuestionRequest,
272+ answers: Record<string, unknown>,
273+): Array<QuestionAnswer> {
274+ return request.questions.map((question, index) => {
275+ const raw =
276+ answers[openCodeQuestionId(index, question)] ??
277+ answers[question.header] ??
278+ answers[question.question];
279+ if (Array.isArray(raw)) {
280+ return raw.filter((value): value is string => typeof value === "string");
281+ }
282+ if (typeof raw === "string") {
283+ return raw.trim().length > 0 ? [raw] : [];
284+ }
285+ return [];
286+ });
287+}
288+
289+export async function findAvailablePort(): Promise<number> {
290+ const server = createServer();
291+ await new Promise<void>((resolve, reject) => {
292+ server.once("error", reject);
293+ server.listen(0, DEFAULT_HOSTNAME, () => resolve());
294+ });
295+ const address = server.address() as AddressInfo;
296+ const port = address.port;
297+ await new Promise<void>((resolve, reject) => {
298+ server.close((error) => (error ? reject(error) : resolve()));
299+ });
300+ return port;
301+}
302+
303+export function resolveOpenCodeBinaryPath(binaryPath: string): string {
304+ if (Path.isAbsolute(binaryPath)) {
305+ return binaryPath;
306+ }
307+ return execFileSync("which", [binaryPath], {
308+ encoding: "utf8",
309+ timeout: 3_000,
310+ }).trim();
311+}
312+
313+export function detectMacosSigkillHint(binaryPath: string): string | null {
314+ try {
315+ // Check for quarantine xattr first.
316+ const resolvedPath = resolveOpenCodeBinaryPath(binaryPath);
317+ const xattr = execFileSync("xattr", ["-l", resolvedPath], {
318+ encoding: "utf8",
319+ timeout: 3_000,
320+ });
321+ if (xattr.includes("com.apple.quarantine")) {
322+ return (
323+ `macOS quarantine is blocking the OpenCode binary. ` +
324+ `Run: xattr -d com.apple.quarantine ${resolvedPath}`
325+ );
326+ }
327+
328+ // Look for a recent crash report with the termination reason.
329+ const crashDir = Path.join(OS.homedir(), "Library/Logs/DiagnosticReports");
330+ const binaryName = Path.basename(resolvedPath);
331+ const recentReports = FS.readdirSync(crashDir)
332+ .filter((f) => f.startsWith(binaryName) && f.endsWith(".ips"))
333+ .toSorted()
334+ .toReversed()
335+ .slice(0, 1);
336+
337+ for (const report of recentReports) {
338+ const content = FS.readFileSync(Path.join(crashDir, report), "utf8");
339+ if (content.includes('"namespace":"CODESIGNING"')) {
340+ return (
341+ "macOS killed the process due to an invalid code signature. " +
342+ "The binary may be corrupted — try reinstalling OpenCode."
343+ );
344+ }
345+ }
346+ } catch {
347+ // Best-effort detection — don't fail the original error path.
348+ }
349+ return null;
350+}
351+
352+export async function startOpenCodeServerProcess(input: {
353+ readonly binaryPath: string;
354+ readonly port?: number;
355+ readonly hostname?: string;
356+ readonly timeoutMs?: number;
357+}): Promise<OpenCodeServerProcess> {
358+ const hostname = input.hostname ?? DEFAULT_HOSTNAME;
359+ const port = input.port ?? (await findAvailablePort());
360+ const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;
361+ const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];
362+ const child = spawn(input.binaryPath, args, {
363+ stdio: ["ignore", "pipe", "pipe"],
364+ env: {
365+ ...process.env,
366+ OPENCODE_CONFIG_CONTENT: JSON.stringify({}),
367+ },
368+ });
369+
370+ child.stdout.setEncoding("utf8");
371+ child.stderr.setEncoding("utf8");
372+
373+ let stdout = "";
374+ let stderr = "";
375+ let closed = false;
376+ const close = () => {
377+ if (closed) {
378+ return;
379+ }
380+ closed = true;
381+ child.kill();
382+ };
383+
384+ const url = await new Promise<string>((resolve, reject) => {
385+ const timeout = setTimeout(() => {
386+ close();
387+ reject(new Error(`Timed out waiting for OpenCode server start after ${timeoutMs}ms.`));
388+ }, timeoutMs);
389+
390+ const cleanup = () => {
391+ clearTimeout(timeout);
392+ child.stdout.off("data", onStdout);
393+ child.stderr.off("data", onStderr);
394+ child.off("error", onError);
395+ child.off("close", onClose);
396+ };
397+
398+ const onStdout = (chunk: string) => {
399+ stdout += chunk;
400+ const parsed = parseServerUrlFromOutput(stdout);
401+ if (!parsed) {
402+ return;
403+ }
404+ cleanup();
405+ resolve(parsed);
406+ };
407+
408+ const onStderr = (chunk: string) => {
409+ stderr += chunk;
410+ };
411+
412+ const onError = (error: Error) => {
413+ cleanup();
414+ reject(error);
415+ };
416+
417+ const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
418+ cleanup();
419+ const exitReason = signal ? `signal: ${signal}` : `code: ${code ?? "unknown"}`;
420+ const hint =
421+ signal === "SIGKILL" && process.platform === "darwin"
422+ ? detectMacosSigkillHint(input.binaryPath)
423+ : null;
424+ reject(
425+ new Error(
426+ [
427+ `OpenCode server exited before startup completed (${exitReason}).`,
428+ hint,
429+ stdout.trim() ? `stdout:\n${stdout.trim()}` : null,
430+ stderr.trim() ? `stderr:\n${stderr.trim()}` : null,
431+ ]
432+ .filter(Boolean)
433+ .join("\n\n"),
434+ ),
435+ );
436+ };
437+
438+ child.stdout.on("data", onStdout);
439+ child.stderr.on("data", onStderr);
440+ child.once("error", onError);
441+ child.once("close", onClose);
442+ });
443+
444+ return {
445+ url,
446+ process: child,
447+ close,
448+ };
449+}
450+
451+export async function connectToOpenCodeServer(input: {
452+ readonly binaryPath: string;
453+ readonly serverUrl?: string | null;
454+ readonly port?: number;
455+ readonly hostname?: string;
456+ readonly timeoutMs?: number;
457+}): Promise<OpenCodeServerConnection> {
458+ const serverUrl = input.serverUrl?.trim();
459+ if (serverUrl) {
460+ return {
461+ url: serverUrl,
462+ process: null,
463+ external: true,
464+ close() {},
465+ };
466+ }
467+
468+ const server = await startOpenCodeServerProcess({
469+ binaryPath: input.binaryPath,
470+ ...(input.port !== undefined ? { port: input.port } : {}),
471+ ...(input.hostname !== undefined ? { hostname: input.hostname } : {}),
472+ ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
473+ });
474+
475+ return {
476+ url: server.url,
477+ process: server.process,
478+ external: false,
479+ close: () => server.close(),
480+ };
481+}
482+
483+export async function runOpenCodeCommand(input: {
484+ readonly binaryPath: string;
485+ readonly args: ReadonlyArray<string>;
486+}): Promise<OpenCodeCommandResult> {
487+ const child = spawn(input.binaryPath, [...input.args], {
488+ stdio: ["ignore", "pipe", "pipe"],
489+ shell: process.platform === "win32",
490+ env: process.env,
491+ });
492+
493+ child.stdout?.setEncoding("utf8");
494+ child.stderr?.setEncoding("utf8");
495+
496+ const stdoutChunks: Array<string> = [];
497+ const stderrChunks: Array<string> = [];
498+
499+ child.stdout?.on("data", (chunk: string) => stdoutChunks.push(chunk));
500+ child.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk));
501+
502+ const code = await new Promise<number>((resolve, reject) => {
503+ child.once("error", reject);
504+ child.once("exit", (exitCode) => resolve(exitCode ?? 0));
505+ });
506+
507+ return {
508+ stdout: stdoutChunks.join(""),
509+ stderr: stderrChunks.join(""),
510+ code,
511+ };
512+}
513+
514+export function createOpenCodeSdkClient(input: {
515+ readonly baseUrl: string;
516+ readonly directory: string;
517+ readonly serverPassword?: string;
518+}): OpencodeClient {
519+ return createOpencodeClient({
520+ baseUrl: input.baseUrl,
521+ directory: input.directory,
522+ ...(input.serverPassword
523+ ? {
524+ headers: {
525+ Authorization: buildOpenCodeBasicAuthorizationHeader(input.serverPassword),
526+ },
527+ }
528+ : {}),
529+ throwOnError: true,
530+ });
531+}
532+
533+export async function loadOpenCodeInventory(client: OpencodeClient): Promise<OpenCodeInventory> {
534+ const [providerListResult, agentsResult] = await Promise.all([
535+ client.provider.list(),
536+ client.app.agents(),
537+ ]);
538+ if (!providerListResult.data) {
539+ throw new Error("OpenCode provider inventory was empty.");
540+ }
541+ return {
542+ providerList: providerListResult.data,
543+ agents: agentsResult.data ?? [],
544+ };
545+}
546+
547+export function flattenOpenCodeModels(
548+ input: OpenCodeInventory,
549+): ReadonlyArray<ServerProviderModel> {
550+ const connected = new Set(input.providerList.connected);
551+ const models: Array<ServerProviderModel> = [];
552+
553+ for (const provider of input.providerList.all) {
554+ if (!connected.has(provider.id)) {
555+ continue;
556+ }
557+
558+ for (const model of Object.values(provider.models)) {
559+ models.push({
560+ slug: toOpenCodeModelSlug(provider.id, model.id),
561+ name: `${provider.name} · ${model.name}`,
562+ isCustom: false,
563+ capabilities: openCodeCapabilitiesForModel({
564+ providerID: provider.id,
565+ model,
566+ agents: input.agents,
567+ }),
568+ });
569+ }
570+ }
571+
572+ return models.toSorted((left, right) => left.name.localeCompare(right.name));
573+}
apps/server/src/provider/providerSnapshot.test.ts+34−0
@@ -0,0 +1,34 @@
1+import { describe, expect, it } from "vitest";
2+import type { ModelCapabilities } from "@t3tools/contracts";
3+
4+import { providerModelsFromSettings } from "./providerSnapshot.ts";
5+
6+const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = {
7+ reasoningEffortLevels: [],
8+ supportsFastMode: false,
9+ supportsThinkingToggle: false,
10+ contextWindowOptions: [],
11+ promptInjectedEffortLevels: [],
12+ variantOptions: [{ value: "medium", label: "Medium", isDefault: true }],
13+ agentOptions: [{ value: "build", label: "Build", isDefault: true }],
14+};
15+
16+describe("providerModelsFromSettings", () => {
17+ it("applies the provided capabilities to custom models", () => {
18+ const models = providerModelsFromSettings(
19+ [],
20+ "opencode",
21+ ["openai/gpt-5"],
22+ OPENCODE_CUSTOM_MODEL_CAPABILITIES,
23+ );
24+
25+ expect(models).toEqual([
26+ {
27+ slug: "openai/gpt-5",
28+ name: "openai/gpt-5",
29+ isCustom: true,
30+ capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES,
31+ },
32+ ]);
33+ });
34+});
apps/server/src/provider/providerStatusCache.test.ts+13−0
@@ -37,6 +37,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 36 unmodified lines
3737 status: "warning",
3838 auth: { status: "unknown" },
3939 });
40+ const openCodeProvider = makeProvider("opencode", {
41+ status: "warning",
42+ auth: { status: "unknown", type: "opencode" },
43+ });
4044 const codexPath = resolveProviderStatusCachePath({
4145 cacheDir: tempDir,
4246 provider: "codex",
@@ -45,6 +49,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 2 unmodified lines
4549 cacheDir: tempDir,
4650 provider: "claudeAgent",
4751 });
52+ const openCodePath = resolveProviderStatusCachePath({
53+ cacheDir: tempDir,
54+ provider: "opencode",
55+ });
4856
4957 yield* writeProviderStatusCache({
5058 filePath: codexPath,
@@ -54,9 +62,14 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 3 unmodified lines
5462 filePath: claudePath,
5563 provider: claudeProvider,
5664 });
65+ yield* writeProviderStatusCache({
66+ filePath: openCodePath,
67+ provider: openCodeProvider,
68+ });
5769
5870 assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider);
5971 assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider);
72+ assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider);
6073 }),
6174 );
6275
apps/server/src/provider/providerStatusCache.ts+5−3
@@ -2,9 +2,11 @@ import * as nodePath from "node:path"; · 1 unmodified line
22 import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts";
33 import { Cause, Effect, FileSystem, Path, Schema } from "effect";
44
5−export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray<
6− ServerProvider["provider"]
7−>;
5+export const PROVIDER_CACHE_IDS = [
6+ "codex",
7+ "claudeAgent",
8+ "opencode",
9+] as const satisfies ReadonlyArray<ServerProvider["provider"]>;
810
911 const decodeProviderStatusCache = Schema.decodeUnknownEffect(
1012 Schema.fromJsonString(ServerProviderSchema),
apps/server/src/server.ts+5−0
@@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD · 20 unmodified lines
2121 import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts";
2222 import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts";
2323 import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts";
24+import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts";
2425 import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
2526 import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts";
2627 import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
@@ -154,9 +155,13 @@ const ProviderLayerLive = Layer.unwrap( · 127 unmodified lines
154155 const claudeAdapterLayer = makeClaudeAdapterLive(
155156 nativeEventLogger ? { nativeEventLogger } : undefined,
156157 );
158+ const openCodeAdapterLayer = makeOpenCodeAdapterLive(
159+ nativeEventLogger ? { nativeEventLogger } : undefined,
160+ );
157161 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
158162 Layer.provide(codexAdapterLayer),
159163 Layer.provide(claudeAdapterLayer),
164+ Layer.provide(openCodeAdapterLayer),
160165 Layer.provideMerge(ProviderSessionDirectoryLayerLive),
161166 );
162167 return makeProviderServiceLive(
apps/server/src/serverSettings.test.ts+20−0
@@ -184,6 +184,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 183 unmodified lines
184184 claudeAgent: {
185185 binaryPath: " /opt/homebrew/bin/claude ",
186186 },
187+ opencode: {
188+ binaryPath: " /opt/homebrew/bin/opencode ",
189+ serverUrl: " http://127.0.0.1:4096 ",
190+ serverPassword: " secret-password ",
191+ },
187192 },
188193 });
189194
@@ -199,6 +204,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 9 unmodified lines
199204 customModels: [],
200205 launchArgs: "",
201206 });
207+ assert.deepEqual(next.providers.opencode, {
208+ enabled: true,
209+ binaryPath: "/opt/homebrew/bin/opencode",
210+ serverUrl: "http://127.0.0.1:4096",
211+ serverPassword: "secret-password",
212+ customModels: [],
213+ });
202214 }).pipe(Effect.provide(makeServerSettingsLayer())),
203215 );
204216
@@ -257,6 +269,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 52 unmodified lines
257269 codex: {
258270 binaryPath: "/opt/homebrew/bin/codex",
259271 },
272+ opencode: {
273+ serverUrl: "http://127.0.0.1:4096",
274+ serverPassword: "secret-password",
275+ },
260276 },
261277 });
262278
@@ -273,6 +289,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 10 unmodified lines
273289 codex: {
274290 binaryPath: "/opt/homebrew/bin/codex",
275291 },
292+ opencode: {
293+ serverUrl: "http://127.0.0.1:4096",
294+ serverPassword: "secret-password",
295+ },
276296 },
277297 });
278298 }).pipe(Effect.provide(makeServerSettingsLayer())),
apps/server/src/serverSettings.ts+1−1
@@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< · 104 unmodified lines
105105
106106 const ServerSettingsJson = fromLenientJson(ServerSettings);
107107
108−const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"];
108+const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode"];
109109
110110 /**
111111 * Ensure the `textGenerationModelSelection` points to an enabled provider.
apps/web/src/components/ChatView.tsx+6−9
@@ -26,7 +26,7 @@ import { · 25 unmodified lines
2626 scopeProjectRef,
2727 scopeThreadRef,
2828 } from "@t3tools/client-runtime";
29−import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";
29+import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model";
3030 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
3131 import { truncate } from "@t3tools/shared/String";
3232 import { Debouncer } from "@tanstack/react-pacer";
@@ -2534,16 +2534,13 @@ export default function ChatView(props: ChatViewProps) { · 2501 unmodified lines
25342534 }
25352535 }
25362536 const title = truncate(titleSeed);
2537− const threadCreateModelSelection: ModelSelection = {
2538− provider: ctxSelectedProvider,
2539− model:
2540− ctxSelectedModel ||
2537+ const threadCreateModelSelection = createModelSelection(
2538+ ctxSelectedProvider,
2539+ ctxSelectedModel ||
25412540 activeProject.defaultModelSelection?.model ||
25422541 DEFAULT_MODEL_BY_PROVIDER.codex,
2543− ...(ctxSelectedModelSelection.options
2544− ? { options: ctxSelectedModelSelection.options }
2545− : {}),
2546− };
2542+ ctxSelectedModelSelection.options,
2543+ );
25472544
25482545 // Auto-title from first message
25492546 if (isFirstMessage && isServerThread) {
apps/web/src/components/Icons.tsx+4−2
@@ -553,8 +553,10 @@ export const IntelliJIdeaIcon: Icon = (props) => { · 552 unmodified lines
553553 export const OpenCodeIcon: Icon = (props) => (
554554 <svg {...props} viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
555555 <g clipPath="url(#opencode__clip0_1311_94969)">
556− <path d="M24 32H8V16H24V32Z" fill="#BCBBBB" />
557− <path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E" />
556+ <path className="dark:hidden" d="M24 32H8V16H24V32Z" fill="#CFCECD" />
557+ <path className="dark:hidden" d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E" />
558+ <path className="hidden dark:block" d="M24 32H8V16H24V32Z" fill="#4B4646" />
559+ <path className="hidden dark:block" d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC" />
558560 </g>
559561 <defs>
560562 <clipPath id="opencode__clip0_1311_94969">
apps/web/src/components/KeybindingsToast.browser.tsx+7−0
@@ -99,6 +99,13 @@ function createBaseServerConfig(): ServerConfig { · 98 unmodified lines
9999 providers: {
100100 codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] },
101101 claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" },
102+ opencode: {
103+ enabled: true,
104+ binaryPath: "",
105+ serverUrl: "",
106+ serverPassword: "",
107+ customModels: [],
108+ },
102109 },
103110 },
104111 };
apps/web/src/components/chat/ChatComposer.tsx+36−24
@@ -16,7 +16,7 @@ import { · 15 unmodified lines
1616 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1717 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1818 } from "@t3tools/contracts";
19−import { normalizeModelSlug } from "@t3tools/shared/model";
19+import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2020 import {
2121 forwardRef,
2222 memo,
@@ -70,6 +70,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; · 47 unmodified lines
7070 import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
7171 import { searchSlashCommandItems } from "./composerSlashCommandSearch";
7272 import {
73+ getComposerProviderControls,
7374 getComposerProviderState,
7475 renderProviderTraitsMenuContent,
7576 renderProviderTraitsPicker,
@@ -159,6 +160,7 @@ const terminalContextIdListsEqual = ( · 83 unmodified lines
159160 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
160161
161162 const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
163+ showInteractionModeToggle: boolean;
162164 interactionMode: ProviderInteractionMode;
163165 runtimeMode: RuntimeMode;
164166 showPlanToggle: boolean;
@@ -175,25 +177,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop · 10 unmodified lines
175177 <>
176178 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
177179
178− <Button
179− variant="ghost"
180− className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
181− size="sm"
182− type="button"
183− onClick={props.onToggleInteractionMode}
184− title={
185− props.interactionMode === "plan"
186− ? "Plan mode — click to return to normal build mode"
187− : "Default mode — click to enter plan mode"
188− }
189− >
190− <BotIcon />
191− <span className="sr-only sm:not-sr-only">
192− {props.interactionMode === "plan" ? "Plan" : "Build"}
193− </span>
194− </Button>
180+ {props.showInteractionModeToggle ? (
181+ <>
182+ <Button
183+ variant="ghost"
184+ className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
185+ size="sm"
186+ type="button"
187+ onClick={props.onToggleInteractionMode}
188+ title={
189+ props.interactionMode === "plan"
190+ ? "Plan mode — click to return to normal build mode"
191+ : "Default mode — click to enter plan mode"
192+ }
193+ >
194+ <BotIcon />
195+ <span className="sr-only sm:not-sr-only">
196+ {props.interactionMode === "plan" ? "Plan" : "Build"}
197+ </span>
198+ </Button>
195199
196− <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
200+ <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
201+ </>
202+ ) : null}
197203
198204 <Select
199205 value={props.runtimeMode}
@@ -588,12 +594,12 @@ export const ChatComposer = memo( · 388 unmodified lines
588594
589595 const selectedPromptEffort = composerProviderState.promptEffort;
590596 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
597+ const composerProviderControls = useMemo(
598+ () => getComposerProviderControls(selectedProvider),
599+ [selectedProvider],
600+ );
591601 const selectedModelSelection = useMemo<ModelSelection>(
592− () => ({
593− provider: selectedProvider,
594− model: selectedModel,
595− ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}),
596− }),
602+ () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch),
597603 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
598604 );
599605 const selectedModelForPicker = selectedModel;
@@ -604,6 +610,8 @@ export const ChatComposer = memo( · 4 unmodified lines
604610 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],
605611 claudeAgent:
606612 providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [],
613+ opencode:
614+ providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [],
607615 }),
608616 [providerStatuses],
609617 );
@@ -1933,6 +1941,7 @@ export const ChatComposer = memo( · 1323 unmodified lines
19331941 planSidebarLabel={planSidebarLabel}
19341942 planSidebarOpen={planSidebarOpen}
19351943 runtimeMode={runtimeMode}
1944+ showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
19361945 traitsMenuContent={providerTraitsMenuContent}
19371946 onToggleInteractionMode={toggleInteractionMode}
19381947 onTogglePlanSidebar={togglePlanSidebar}
@@ -1950,6 +1959,9 @@ export const ChatComposer = memo( · 11 unmodified lines
19501959 </>
19511960 ) : null}
19521961 <ComposerFooterModeControls
1962+ showInteractionModeToggle={
1963+ composerProviderControls.showInteractionModeToggle
1964+ }
19531965 interactionMode={interactionMode}
19541966 runtimeMode={runtimeMode}
19551967 showPlanToggle={showPlanSidebarToggle}
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+35−0
@@ -126,6 +126,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str · 125 unmodified lines
126126 planSidebarLabel="Plan"
127127 planSidebarOpen={false}
128128 runtimeMode="approval-required"
129+ showInteractionModeToggle
129130 traitsMenuContent={
130131 <TraitsMenuContent
131132 provider={provider}
@@ -267,4 +268,38 @@ describe("CompactComposerControlsMenu", () => { · 135 unmodified lines
267268 );
268269 });
269270 });
271+
272+ it("can hide the interaction mode section", async () => {
273+ const host = document.createElement("div");
274+ document.body.append(host);
275+ const screen = await render(
276+ <CompactComposerControlsMenu
277+ activePlan={false}
278+ interactionMode="default"
279+ planSidebarLabel="Plan"
280+ planSidebarOpen={false}
281+ runtimeMode="approval-required"
282+ showInteractionModeToggle={false}
283+ onToggleInteractionMode={vi.fn()}
284+ onTogglePlanSidebar={vi.fn()}
285+ onRuntimeModeChange={vi.fn()}
286+ />,
287+ { container: host },
288+ );
289+
290+ await page.getByLabelText("More composer controls").click();
291+
292+ await vi.waitFor(() => {
293+ const text = document.body.textContent ?? "";
294+ expect(text).not.toContain("Mode");
295+ expect(text).not.toContain("Chat");
296+ expect(text).not.toContain("Plan");
297+ expect(text).toContain("Access");
298+ expect(text).toContain("Supervised");
299+ expect(text).toContain("Full access");
300+ });
301+
302+ await screen.unmount();
303+ host.remove();
304+ });
270305 });
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+17−12
@@ -18,6 +18,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls · 17 unmodified lines
1818 planSidebarLabel: string;
1919 planSidebarOpen: boolean;
2020 runtimeMode: RuntimeMode;
21+ showInteractionModeToggle: boolean;
2122 traitsMenuContent?: ReactNode;
2223 onToggleInteractionMode: () => void;
2324 onTogglePlanSidebar: () => void;
@@ -44,18 +45,22 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls · 20 unmodified lines
4445 <MenuDivider />
4546 </>
4647 ) : null}
47− <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div>
48− <MenuRadioGroup
49− value={props.interactionMode}
50− onValueChange={(value) => {
51− if (!value || value === props.interactionMode) return;
52− props.onToggleInteractionMode();
53− }}
54− >
55− <MenuRadioItem value="default">Chat</MenuRadioItem>
56− <MenuRadioItem value="plan">Plan</MenuRadioItem>
57− </MenuRadioGroup>
58− <MenuDivider />
48+ {props.showInteractionModeToggle ? (
49+ <>
50+ <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div>
51+ <MenuRadioGroup
52+ value={props.interactionMode}
53+ onValueChange={(value) => {
54+ if (!value || value === props.interactionMode) return;
55+ props.onToggleInteractionMode();
56+ }}
57+ >
58+ <MenuRadioItem value="default">Chat</MenuRadioItem>
59+ <MenuRadioItem value="plan">Plan</MenuRadioItem>
60+ </MenuRadioGroup>
61+ <MenuDivider />
62+ </>
63+ ) : null}
5964 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div>
6065 <MenuRadioGroup
6166 value={props.runtimeMode}
apps/web/src/components/chat/ProviderModelPicker.tsx+2−4
@@ -33,15 +33,13 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o · 32 unmodified lines
3333 const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
3434 codex: OpenAI,
3535 claudeAgent: ClaudeAI,
36+ opencode: OpenCodeIcon,
3637 cursor: CursorIcon,
3738 };
3839
3940 export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);
4041 const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);
41−const COMING_SOON_PROVIDER_OPTIONS = [
42− { id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
43− { id: "gemini", label: "Gemini", icon: Gemini },
44−] as const;
42+const COMING_SOON_PROVIDER_OPTIONS = [{ id: "gemini", label: "Gemini", icon: Gemini }] as const;
4543
4644 function providerIconClassName(
4745 provider: ProviderKind | ProviderPickerKind,
apps/web/src/components/chat/TraitsPicker.browser.tsx+128−1
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 CodexModelOptions,
77 DEFAULT_MODEL_BY_PROVIDER,
88 DEFAULT_SERVER_SETTINGS,
9+ OpenCodeModelOptions,
910 EnvironmentId,
1011 type ServerProvider,
1112 ThreadId,
@@ -64,6 +65,39 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ · 52 unmodified lines
6465 },
6566 ],
6667 },
68+ {
69+ provider: "opencode",
70+ enabled: true,
71+ installed: true,
72+ version: "0.1.0",
73+ status: "ready",
74+ auth: { status: "authenticated" },
75+ checkedAt: "2026-01-01T00:00:00.000Z",
76+ slashCommands: [],
77+ skills: [],
78+ models: [
79+ {
80+ slug: "openai/gpt-5",
81+ name: "GPT-5",
82+ isCustom: false,
83+ capabilities: {
84+ reasoningEffortLevels: [],
85+ supportsFastMode: false,
86+ supportsThinkingToggle: false,
87+ contextWindowOptions: [],
88+ promptInjectedEffortLevels: [],
89+ variantOptions: [
90+ { value: "low", label: "Low" },
91+ { value: "medium", label: "Medium", isDefault: true },
92+ ],
93+ agentOptions: [
94+ { value: "build", label: "Build", isDefault: true },
95+ { value: "plan", label: "Plan" },
96+ ],
97+ },
98+ },
99+ ],
100+ },
67101 {
68102 provider: "claudeAgent",
69103 enabled: true,
@@ -125,6 +159,13 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ · 55 unmodified lines
125159 ],
126160 },
127161 ];
162+const findTestProvider = (provider: ServerProvider["provider"]) => {
163+ const testProvider = TEST_PROVIDERS.find((candidate) => candidate.provider === provider);
164+ if (!testProvider) {
165+ throw new Error(`Missing test provider fixture: ${provider}`);
166+ }
167+ return testProvider;
168+};
128169
129170 function ClaudeTraitsPickerHarness(props: {
130171 model: string;
@@ -154,7 +195,7 @@ function ClaudeTraitsPickerHarness(props: { · 23 unmodified lines
154195 return (
155196 <TraitsPicker
156197 provider="claudeAgent"
157− models={TEST_PROVIDERS[1]!.models}
198+ models={TEST_PROVIDERS[2]!.models}
158199 threadRef={CLAUDE_THREAD_REF}
159200 model={selectedModel ?? props.model}
160201 prompt={prompt}
@@ -501,3 +542,89 @@ describe("TraitsPicker (Codex)", () => { · 340 unmodified lines
501542 });
502543 });
503544 });
545+
546+// ── OpenCode TraitsPicker tests ───────────────────────────────────────
547+
548+async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeModelOptions }) {
549+ const threadId = ThreadId.make("thread-opencode-traits");
550+ const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
551+ const threadKey = scopedThreadKey(threadRef);
552+ const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.opencode;
553+ const draftsByThreadKey: Record<string, ComposerThreadDraftState> = {
554+ [threadKey]: {
555+ prompt: "",
556+ images: [],
557+ nonPersistedImageIds: [],
558+ persistedAttachments: [],
559+ terminalContexts: [],
560+ modelSelectionByProvider: {
561+ opencode: {
562+ provider: "opencode",
563+ model,
564+ ...(props.options ? { options: props.options } : {}),
565+ },
566+ },
567+ activeProvider: "opencode",
568+ runtimeMode: null,
569+ interactionMode: null,
570+ },
571+ };
572+
573+ useComposerDraftStore.setState({
574+ draftsByThreadKey,
575+ draftThreadsByThreadKey: {},
576+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
577+ });
578+ const host = document.createElement("div");
579+ document.body.append(host);
580+ const screen = await render(
581+ <TraitsPicker
582+ provider="opencode"
583+ models={findTestProvider("opencode").models}
584+ threadRef={threadRef}
585+ model={model}
586+ prompt=""
587+ modelOptions={props.options}
588+ onPromptChange={() => {}}
589+ />,
590+ { container: host },
591+ );
592+
593+ const cleanup = async () => {
594+ await screen.unmount();
595+ host.remove();
596+ };
597+
598+ return {
599+ [Symbol.asyncDispose]: cleanup,
600+ cleanup,
601+ };
602+}
603+
604+describe("TraitsPicker (OpenCode)", () => {
605+ afterEach(() => {
606+ document.body.innerHTML = "";
607+ localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);
608+ useComposerDraftStore.setState({
609+ draftsByThreadKey: {},
610+ draftThreadsByThreadKey: {},
611+ logicalProjectDraftThreadKeyByLogicalProjectKey: {},
612+ stickyModelSelectionByProvider: {},
613+ });
614+ });
615+
616+ it("shows the selected agent label with capitalization in the trigger", async () => {
617+ await using _ = await mountOpenCodePicker({
618+ options: {
619+ variant: "medium",
620+ agent: "plan",
621+ },
622+ });
623+
624+ await vi.waitFor(() => {
625+ const text = document.body.textContent ?? "";
626+ expect(text).toContain("Medium · Plan");
627+ expect(text).not.toContain("Medium · plan");
628+ });
629+ });
630+});
apps/web/src/components/chat/TraitsPicker.tsx+93−9
@@ -1,6 +1,7 @@
11 import {
22 type ClaudeModelOptions,
33 type CodexModelOptions,
4+ type OpenCodeModelOptions,
45 type ProviderKind,
56 type ProviderModelOptions,
67 type ScopedThreadRef,
@@ -33,6 +34,12 @@ import { getProviderModelCapabilities } from "../../providerModels"; · 26 unmodified lines
3334 import { cn } from "~/lib/utils";
3435
3536 type ProviderOptions = ProviderModelOptions[ProviderKind];
37+type NamedOption = {
38+ value: string;
39+ label: string;
40+ isDefault?: boolean | undefined;
41+};
42+
3643 type TraitsPersistence =
3744 | {
3845 threadRef?: ScopedThreadRef;
@@ -53,9 +60,29 @@ function getRawEffort( · 14 unmodified lines
5360 if (provider === "codex") {
5461 return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort);
5562 }
63+ if (provider === "opencode") {
64+ return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant);
65+ }
5666 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);
5767 }
5868
69+function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null {
70+ return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent);
71+}
72+
73+function resolveNamedOption(
74+ options: ReadonlyArray<NamedOption>,
75+ raw: string | null,
76+): NamedOption | null {
77+ if (raw) {
78+ const matchingOption = options.find((option) => option.value === raw);
79+ if (matchingOption) {
80+ return matchingOption;
81+ }
82+ }
83+ return options.find((option) => option.isDefault) ?? null;
84+}
85+
5986 function getRawContextWindow(
6087 provider: ProviderKind,
6188 modelOptions: ProviderOptions | null | undefined,
@@ -74,6 +101,12 @@ function buildNextOptions( · 12 unmodified lines
74101 if (provider === "codex") {
75102 return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions;
76103 }
104+ if (provider === "opencode") {
105+ return {
106+ ...(modelOptions as OpenCodeModelOptions | undefined),
107+ ...patch,
108+ } as OpenCodeModelOptions;
109+ }
77110 return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions;
78111 }
79112
@@ -86,15 +119,21 @@ function getSelectedTraits( · 6 unmodified lines
86119 allowPromptInjectedEffort: boolean,
87120 ) {
88121 const caps = getProviderModelCapabilities(models, model, provider);
89− const effortLevels = allowPromptInjectedEffort
90− ? caps.reasoningEffortLevels
91− : caps.reasoningEffortLevels.filter(
92− (option) => !caps.promptInjectedEffortLevels.includes(option.value),
93− );
122+ const effortLevels =
123+ provider === "opencode"
124+ ? (caps.variantOptions ?? [])
125+ : allowPromptInjectedEffort
126+ ? caps.reasoningEffortLevels
127+ : caps.reasoningEffortLevels.filter(
128+ (option) => !caps.promptInjectedEffortLevels.includes(option.value),
129+ );
94130
95131 // Resolve effort from options (provider-specific key)
96132 const rawEffort = getRawEffort(provider, modelOptions);
97− const effort = resolveEffort(caps, rawEffort) ?? null;
133+ const effort =
134+ provider === "opencode"
135+ ? (resolveNamedOption(effortLevels, rawEffort)?.value ?? null)
136+ : (resolveEffort(caps, rawEffort) ?? null);
98137
99138 // Thinking toggle (only for models that support it)
100139 const thinkingEnabled = caps.supportsThinkingToggle
@@ -125,6 +164,10 @@ function getSelectedTraits( · 24 unmodified lines
125164 const ultrathinkInBodyText =
126165 ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, ""));
127166
167+ const agentOptions = caps.agentOptions ?? [];
168+ const selectedAgentOption =
169+ provider === "opencode" ? resolveNamedOption(agentOptions, getRawAgent(modelOptions)) : null;
170+
128171 return {
129172 caps,
130173 effort,
@@ -136,6 +179,9 @@ function getSelectedTraits( · 5 unmodified lines
136179 defaultContextWindow,
137180 ultrathinkPromptControlled,
138181 ultrathinkInBodyText,
182+ agentOptions,
183+ selectedAgent: selectedAgentOption?.value ?? null,
184+ selectedAgentLabel: selectedAgentOption?.label ?? null,
139185 };
140186 }
141187
@@ -189,6 +235,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 47 unmodified lines
189235 defaultContextWindow,
190236 ultrathinkPromptControlled,
191237 ultrathinkInBodyText,
238+ agentOptions,
239+ selectedAgent,
192240 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
193241 const defaultEffort = getDefaultEffort(caps);
194242
@@ -197,6 +245,10 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 2 unmodified lines
197245 if (!value) return;
198246 const nextOption = effortLevels.find((option) => option.value === value);
199247 if (!nextOption) return;
248+ if (provider === "opencode") {
249+ updateModelOptions(buildNextOptions(provider, modelOptions, { variant: nextOption.value }));
250+ return;
251+ }
200252 if (caps.promptInjectedEffortLevels.includes(nextOption.value)) {
201253 const nextPrompt =
202254 prompt.trim().length === 0
@@ -228,7 +280,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 25 unmodified lines
228280 ],
229281 );
230282
231− if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) {
283+ if (
284+ effort === null &&
285+ thinkingEnabled === null &&
286+ contextWindowOptions.length <= 1 &&
287+ agentOptions.length === 0
288+ ) {
232289 return null;
233290 }
234291
@@ -237,7 +294,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 2 unmodified lines
237294 {effort ? (
238295 <>
239296 <MenuGroup>
240− <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">Effort</div>
297+ <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">
298+ {provider === "opencode" ? "Variant" : "Effort"}
299+ </div>
241300 {ultrathinkInBodyText ? (
242301 <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs">
243302 Your prompt contains &quot;ultrathink&quot; in the text. Remove it to change effort.
@@ -254,7 +313,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 10 unmodified lines
254313 disabled={ultrathinkInBodyText}
255314 >
256315 {option.label}
257− {option.value === defaultEffort ? " (default)" : ""}
316+ {(provider === "opencode" ? option.isDefault : option.value === defaultEffort)
317+ ? " (default)"
318+ : ""}
258319 </MenuRadioItem>
259320 ))}
260321 </MenuRadioGroup>
@@ -322,6 +383,27 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 61 unmodified lines
322383 </MenuGroup>
323384 </>
324385 ) : null}
386+ {agentOptions.length > 0 ? (
387+ <>
388+ <MenuDivider />
389+ <MenuGroup>
390+ <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Agent</div>
391+ <MenuRadioGroup
392+ value={selectedAgent ?? ""}
393+ onValueChange={(value) => {
394+ updateModelOptions(buildNextOptions(provider, modelOptions, { agent: value }));
395+ }}
396+ >
397+ {agentOptions.map((option) => (
398+ <MenuRadioItem key={option.value} value={option.value}>
399+ {option.label}
400+ {option.isDefault ? " (default)" : ""}
401+ </MenuRadioItem>
402+ ))}
403+ </MenuRadioGroup>
404+ </MenuGroup>
405+ </>
406+ ) : null}
325407 </>
326408 );
327409 });
@@ -349,6 +431,7 @@ export const TraitsPicker = memo(function TraitsPicker({ · 21 unmodified lines
349431 contextWindow,
350432 defaultContextWindow,
351433 ultrathinkPromptControlled,
434+ selectedAgentLabel,
352435 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
353436
354437 const effortLabel = effort
@@ -368,6 +451,7 @@ export const TraitsPicker = memo(function TraitsPicker({ · 13 unmodified lines
368451 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,
369452 ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []),
370453 ...(contextWindowLabel ? [contextWindowLabel] : []),
454+ ...(selectedAgentLabel ? [selectedAgentLabel] : []),
371455 ]
372456 .filter(Boolean)
373457 .join(" · ");
apps/web/src/components/chat/composerProviderRegistry.test.tsx+65−0
@@ -1,6 +1,7 @@
11 import { describe, expect, it } from "vitest";
22 import type { ServerProviderModel } from "@t3tools/contracts";
33 import {
4+ getComposerProviderControls,
45 getComposerProviderState,
56 renderProviderTraitsMenuContent,
67 renderProviderTraitsPicker,
@@ -110,6 +111,29 @@ const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray<ServerProviderModel> = [ · 103 unmodified lines
110111 },
111112 ];
112113
114+const OPENCODE_MODELS: ReadonlyArray<ServerProviderModel> = [
115+ {
116+ slug: "openai/gpt-5",
117+ name: "GPT-5",
118+ isCustom: false,
119+ capabilities: {
120+ reasoningEffortLevels: [],
121+ supportsFastMode: false,
122+ supportsThinkingToggle: false,
123+ contextWindowOptions: [],
124+ promptInjectedEffortLevels: [],
125+ variantOptions: [
126+ { value: "low", label: "Low" },
127+ { value: "medium", label: "Medium", isDefault: true },
128+ ],
129+ agentOptions: [
130+ { value: "build", label: "Build", isDefault: true },
131+ { value: "plan", label: "Plan" },
132+ ],
133+ },
134+ },
135+];
136+
113137 describe("getComposerProviderState", () => {
114138 it("returns codex defaults when no codex draft options exist", () => {
115139 const state = getComposerProviderState({
@@ -420,6 +444,47 @@ describe("getComposerProviderState", () => { · 304 unmodified lines
420444
421445 expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode");
422446 });
447+
448+ it("preserves OpenCode variant and agent options for dispatch", () => {
449+ const state = getComposerProviderState({
450+ provider: "opencode",
451+ model: "openai/gpt-5",
452+ models: OPENCODE_MODELS,
453+ prompt: "",
454+ modelOptions: {
455+ opencode: {
456+ variant: "medium",
457+ agent: "plan",
458+ },
459+ },
460+ });
461+
462+ expect(state).toEqual({
463+ provider: "opencode",
464+ promptEffort: "medium",
465+ modelOptionsForDispatch: {
466+ variant: "medium",
467+ agent: "plan",
468+ },
469+ });
470+ });
471+});
472+
473+describe("getComposerProviderControls", () => {
474+ it("hides the interaction mode toggle for OpenCode", () => {
475+ expect(getComposerProviderControls("opencode")).toEqual({
476+ showInteractionModeToggle: false,
477+ });
478+ });
479+
480+ it("keeps the interaction mode toggle for Codex and Claude", () => {
481+ expect(getComposerProviderControls("codex")).toEqual({
482+ showInteractionModeToggle: true,
483+ });
484+ expect(getComposerProviderControls("claudeAgent")).toEqual({
485+ showInteractionModeToggle: true,
486+ });
487+ });
423488 });
424489
425490 describe("provider traits render guards", () => {
apps/web/src/components/chat/composerProviderRegistry.tsx+54−68
@@ -4,15 +4,17 @@ import { · 3 unmodified lines
44 type ScopedThreadRef,
55 type ServerProviderModel,
66 } from "@t3tools/contracts";
7−import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";
7+import {
8+ isClaudeUltrathinkPrompt,
9+ normalizeProviderModelOptionsWithCapabilities,
10+ resolveEffort,
11+ trimOrNull,
12+} from "@t3tools/shared/model";
813 import type { ReactNode } from "react";
14+
915 import type { DraftId } from "../../composerDraftStore";
1016 import { getProviderModelCapabilities } from "../../providerModels";
1117 import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
12−import {
13− normalizeClaudeModelOptionsWithCapabilities,
14− normalizeCodexModelOptionsWithCapabilities,
15−} from "@t3tools/shared/model";
1618
1719 export type ComposerProviderStateInput = {
1820 provider: ProviderKind;
@@ -31,7 +33,12 @@ export type ComposerProviderState = { · 12 unmodified lines
3133 modelPickerIconClassName?: string;
3234 };
3335
36+export type ComposerProviderControls = {
37+ showInteractionModeToggle: boolean;
38+};
39+
3440 type ProviderRegistryEntry = {
41+ controls: ComposerProviderControls;
3542 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
3643 renderTraitsMenuContent: (input: {
3744 threadRef?: ScopedThreadRef;
@@ -66,25 +73,26 @@ function getProviderStateFromCapabilities( · 28 unmodified lines
6673 const { provider, model, models, prompt, modelOptions } = input;
6774 const caps = getProviderModelCapabilities(models, model, provider);
6875 const providerOptions = modelOptions?.[provider];
69−
70− // Resolve effort
7176 const rawEffort = providerOptions
7277 ? "effort" in providerOptions
7378 ? providerOptions.effort
7479 : "reasoningEffort" in providerOptions
7580 ? providerOptions.reasoningEffort
76− : null
81+ : "variant" in providerOptions
82+ ? providerOptions.variant
83+ : null
7784 : null;
78−
79− const promptEffort = resolveEffort(caps, rawEffort) ?? null;
80−
81− // Normalize options for dispatch
82− const normalizedOptions =
83− provider === "codex"
84− ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions)
85− : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions);
86−
87− // Ultrathink styling (driven by capabilities data, not provider identity)
85+ const normalizedOptions = normalizeProviderModelOptionsWithCapabilities(
86+ provider,
87+ caps,
88+ providerOptions,
89+ );
90+ const promptEffort =
91+ provider === "opencode"
92+ ? (trimOrNull(
93+ normalizedOptions && "variant" in normalizedOptions ? normalizedOptions.variant : null,
94+ ) ?? null)
95+ : (resolveEffort(caps, rawEffort) ?? null);
8896 const ultrathinkActive =
8997 caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt);
9098
@@ -100,53 +108,19 @@ function getProviderStateFromCapabilities( · 9 unmodified lines
100108 };
101109 }
102110
103−const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
104− codex: {
105− getState: (input) => getProviderStateFromCapabilities(input),
106− renderTraitsMenuContent: ({
107− threadRef,
108− draftId,
109− model,
110− models,
111− modelOptions,
112− prompt,
113− onPromptChange,
114− }) =>
115− !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
116− <TraitsMenuContent
117− provider="codex"
118− models={models}
119− {...(threadRef ? { threadRef } : {})}
120− {...(draftId ? { draftId } : {})}
121− model={model}
122− modelOptions={modelOptions}
123− prompt={prompt}
124− onPromptChange={onPromptChange}
125− />
126− ),
127− renderTraitsPicker: ({
128− threadRef,
129− draftId,
130− model,
131− models,
132− modelOptions,
133− prompt,
134− onPromptChange,
135− }) =>
136− !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
137− <TraitsPicker
138− provider="codex"
139− models={models}
140− {...(threadRef ? { threadRef } : {})}
141− {...(draftId ? { draftId } : {})}
142− model={model}
143− modelOptions={modelOptions}
144− prompt={prompt}
145− onPromptChange={onPromptChange}
146− />
147− ),
148− },
149− claudeAgent: {
111+const DEFAULT_PROVIDER_CONTROLS: ComposerProviderControls = {
112+ showInteractionModeToggle: true,
113+};
114+
115+function createProviderRegistryEntry(
116+ provider: ProviderKind,
117+ controls?: Partial<ComposerProviderControls>,
118+): ProviderRegistryEntry {
119+ return {
120+ controls: {
121+ ...DEFAULT_PROVIDER_CONTROLS,
122+ ...controls,
123+ },
150124 getState: (input) => getProviderStateFromCapabilities(input),
151125 renderTraitsMenuContent: ({
152126 threadRef,
@@ -159,7 +133,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 6 unmodified lines
159133 }) =>
160134 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
161135 <TraitsMenuContent
162− provider="claudeAgent"
136+ provider={provider}
163137 models={models}
164138 {...(threadRef ? { threadRef } : {})}
165139 {...(draftId ? { draftId } : {})}
@@ -180,7 +154,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 14 unmodified lines
180154 }) =>
181155 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
182156 <TraitsPicker
183− provider="claudeAgent"
157+ provider={provider}
184158 models={models}
185159 {...(threadRef ? { threadRef } : {})}
186160 {...(draftId ? { draftId } : {})}
@@ -190,13 +164,25 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 3 unmodified lines
190164 onPromptChange={onPromptChange}
191165 />
192166 ),
193− },
167+ };
168+}
169+
170+const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
171+ codex: createProviderRegistryEntry("codex"),
172+ claudeAgent: createProviderRegistryEntry("claudeAgent"),
173+ opencode: createProviderRegistryEntry("opencode", {
174+ showInteractionModeToggle: false,
175+ }),
194176 };
195177
196178 export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
197179 return composerProviderRegistry[input.provider].getState(input);
198180 }
199181
182+export function getComposerProviderControls(provider: ProviderKind): ComposerProviderControls {
183+ return composerProviderRegistry[provider].controls;
184+}
185+
200186 export function renderProviderTraitsMenuContent(input: {
201187 provider: ProviderKind;
202188 threadRef?: ScopedThreadRef;
apps/web/src/components/settings/SettingsPanels.browser.tsx+17−0
@@ -701,4 +701,21 @@ describe("GeneralSettingsPanel observability", () => { · 700 unmodified lines
701701
702702 expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor");
703703 });
704+
705+ it("shows an OpenCode server URL field in provider settings", async () => {
706+ setServerConfigSnapshot(createBaseServerConfig());
707+
708+ mounted = await render(
709+ <AppAtomRegistryProvider>
710+ <GeneralSettingsPanel />
711+ </AppAtomRegistryProvider>,
712+ );
713+
714+ await page.getByLabelText("Toggle OpenCode details").click();
715+
716+ await expect.element(page.getByText("OpenCode server URL")).toBeInTheDocument();
717+ await expect.element(page.getByPlaceholder("http://127.0.0.1:4096")).toBeInTheDocument();
718+ await expect.element(page.getByText("OpenCode server password")).toBeInTheDocument();
719+ await expect.element(page.getByPlaceholder("Server password")).toBeInTheDocument();
720+ });
704721 });
apps/web/src/components/settings/SettingsPanels.tsx+119−7
@@ -21,6 +21,7 @@ import { · 20 unmodified lines
2121 import { scopeThreadRef } from "@t3tools/client-runtime";
2222 import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
2323 import { normalizeModelSlug } from "@t3tools/shared/model";
24+import { createModelSelection } from "@t3tools/shared/model";
2425 import { Equal } from "effect";
2526 import { APP_VERSION } from "../../branding";
2627 import {
@@ -104,6 +105,10 @@ type InstallProviderSettings = { · 77 unmodified lines
104105 title: string;
105106 binaryPlaceholder: string;
106107 binaryDescription: ReactNode;
108+ serverUrlPlaceholder?: string;
109+ serverUrlDescription?: ReactNode;
110+ serverPasswordPlaceholder?: string;
111+ serverPasswordDescription?: ReactNode;
107112 homePathKey?: "codexHomePath";
108113 homePlaceholder?: string;
109114 homeDescription?: ReactNode;
@@ -125,6 +130,17 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ · 15 unmodified lines
125130 binaryPlaceholder: "Claude binary path",
126131 binaryDescription: "Path to the Claude binary",
127132 },
133+ {
134+ provider: "opencode",
135+ title: "OpenCode",
136+ binaryPlaceholder: "OpenCode binary path",
137+ binaryDescription: "Path to the OpenCode binary",
138+ serverUrlPlaceholder: "http://127.0.0.1:4096",
139+ serverUrlDescription: "Leave blank to let T3 Code spawn the server when needed",
140+ serverPasswordPlaceholder: "Server password (optional)",
141+ serverPasswordDescription:
142+ "If your OpenCode server requires authentication, enter the password here. NOTE: Stored in plain text on disk",
143+ },
128144 ] as const;
129145
130146 const PROVIDER_STATUS_STYLES = {
@@ -514,12 +530,22 @@ export function GeneralSettingsPanel() { · 383 unmodified lines
514530 settings.providers.claudeAgent.customModels.length > 0 ||
515531 settings.providers.claudeAgent.launchArgs !== "",
516532 ),
533+ opencode: Boolean(
534+ settings.providers.opencode.binaryPath !==
535+ DEFAULT_UNIFIED_SETTINGS.providers.opencode.binaryPath ||
536+ settings.providers.opencode.serverUrl !==
537+ DEFAULT_UNIFIED_SETTINGS.providers.opencode.serverUrl ||
538+ settings.providers.opencode.serverPassword !==
539+ DEFAULT_UNIFIED_SETTINGS.providers.opencode.serverPassword ||
540+ settings.providers.opencode.customModels.length > 0,
541+ ),
517542 });
518543 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
519544 Record<ProviderKind, string>
520545 >({
521546 codex: "",
522547 claudeAgent: "",
548+ opencode: "",
523549 });
524550 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
525551 Partial<Record<ProviderKind, string | null>>
@@ -732,10 +758,16 @@ export function GeneralSettingsPanel() { · 206 unmodified lines
732758 title: providerSettings.title,
733759 binaryPlaceholder: providerSettings.binaryPlaceholder,
734760 binaryDescription: providerSettings.binaryDescription,
761+ serverUrlPlaceholder: providerSettings.serverUrlPlaceholder,
762+ serverUrlDescription: providerSettings.serverUrlDescription,
763+ serverPasswordPlaceholder: providerSettings.serverPasswordPlaceholder,
764+ serverPasswordDescription: providerSettings.serverPasswordDescription,
735765 homePathKey: providerSettings.homePathKey,
736766 homePlaceholder: providerSettings.homePlaceholder,
737767 homeDescription: providerSettings.homeDescription,
738768 binaryPathValue: providerConfig.binaryPath,
769+ serverUrlValue: "serverUrl" in providerConfig ? providerConfig.serverUrl : "",
770+ serverPasswordValue: "serverPassword" in providerConfig ? providerConfig.serverPassword : "",
739771 isDirty: !Equal.equals(providerConfig, defaultProviderConfig),
740772 liveProvider,
741773 models,
@@ -1035,7 +1067,7 @@ export function GeneralSettingsPanel() { · 293 unmodified lines
10351067 textGenerationModelSelection: resolveAppModelSelectionState(
10361068 {
10371069 ...settings,
1038− textGenerationModelSelection: { provider, model },
1070+ textGenerationModelSelection: createModelSelection(provider, model),
10391071 },
10401072 serverProviders,
10411073 ),
@@ -1060,11 +1092,11 @@ export function GeneralSettingsPanel() { · 18 unmodified lines
10601092 textGenerationModelSelection: resolveAppModelSelectionState(
10611093 {
10621094 ...settings,
1063− textGenerationModelSelection: {
1064− provider: textGenProvider,
1065− model: textGenModel,
1066− ...(nextOptions ? { options: nextOptions } : {}),
1067− },
1095+ textGenerationModelSelection: createModelSelection(
1096+ textGenProvider,
1097+ textGenModel,
1098+ nextOptions,
1099+ ),
10681100 },
10691101 serverProviders,
10701102 ),
@@ -1243,6 +1275,84 @@ export function GeneralSettingsPanel() { · 172 unmodified lines
12431275 </label>
12441276 </div>
12451277
1278+ {providerCard.serverUrlPlaceholder ? (
1279+ <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1280+ <label
1281+ htmlFor={`provider-install-${providerCard.provider}-server-url`}
1282+ className="block"
1283+ >
1284+ <span className="text-xs font-medium text-foreground">
1285+ {providerDisplayName} server URL
1286+ </span>
1287+ <Input
1288+ id={`provider-install-${providerCard.provider}-server-url`}
1289+ className="mt-1.5"
1290+ value={providerCard.serverUrlValue}
1291+ onChange={(event) =>
1292+ updateSettings({
1293+ providers: {
1294+ ...settings.providers,
1295+ [providerCard.provider]: {
1296+ ...settings.providers[providerCard.provider],
1297+ ...(providerCard.provider === "opencode"
1298+ ? { serverUrl: event.target.value }
1299+ : {}),
1300+ },
1301+ },
1302+ })
1303+ }
1304+ placeholder={providerCard.serverUrlPlaceholder}
1305+ spellCheck={false}
1306+ />
1307+ {providerCard.serverUrlDescription ? (
1308+ <span className="mt-1 block text-xs text-muted-foreground">
1309+ {providerCard.serverUrlDescription}
1310+ </span>
1311+ ) : null}
1312+ </label>
1313+ </div>
1314+ ) : null}
1315+
1316+ {providerCard.serverPasswordPlaceholder ? (
1317+ <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1318+ <label
1319+ htmlFor={`provider-install-${providerCard.provider}-server-password`}
1320+ className="block"
1321+ >
1322+ <span className="text-xs font-medium text-foreground">
1323+ {providerDisplayName} server password
1324+ </span>
1325+ <Input
1326+ id={`provider-install-${providerCard.provider}-server-password`}
1327+ className="mt-1.5"
1328+ type="password"
1329+ autoComplete="off"
1330+ value={providerCard.serverPasswordValue}
1331+ onChange={(event) =>
1332+ updateSettings({
1333+ providers: {
1334+ ...settings.providers,
1335+ [providerCard.provider]: {
1336+ ...settings.providers[providerCard.provider],
1337+ ...(providerCard.provider === "opencode"
1338+ ? { serverPassword: event.target.value }
1339+ : {}),
1340+ },
1341+ },
1342+ })
1343+ }
1344+ placeholder={providerCard.serverPasswordPlaceholder}
1345+ spellCheck={false}
1346+ />
1347+ {providerCard.serverPasswordDescription ? (
1348+ <span className="mt-1 block text-xs text-muted-foreground">
1349+ {providerCard.serverPasswordDescription}
1350+ </span>
1351+ ) : null}
1352+ </label>
1353+ </div>
1354+ ) : null}
1355+
12461356 {providerCard.homePathKey ? (
12471357 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
12481358 <label
@@ -1422,7 +1532,9 @@ export function GeneralSettingsPanel() { · 173 unmodified lines
14221532 placeholder={
14231533 providerCard.provider === "codex"
14241534 ? "gpt-6.7-codex-ultra-preview"
1425− : "claude-sonnet-5-0"
1535+ : providerCard.provider === "opencode"
1536+ ? "openai/gpt-5"
1537+ : "claude-sonnet-5-0"
14261538 }
14271539 spellCheck={false}
14281540 />
apps/web/src/composerDraftStore.ts+56−45
@@ -25,7 +25,7 @@ import { · 24 unmodified lines
2525 import * as Schema from "effect/Schema";
2626 import * as Equal from "effect/Equal";
2727 import { DeepMutable } from "effect/Types";
28−import { normalizeModelSlug } from "@t3tools/shared/model";
28+import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
2929 import { useMemo } from "react";
3030 import { getLocalStorageItem } from "./hooks/useLocalStorage";
3131 import { resolveAppModelSelection } from "./modelSelection";
@@ -527,7 +527,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { · 495 unmodified lines
527527 }
528528
529529 function normalizeProviderKind(value: unknown): ProviderKind | null {
530− return value === "codex" || value === "claudeAgent" ? value : null;
530+ return value === "codex" || value === "claudeAgent" || value === "opencode" ? value : null;
531531 }
532532
533533 function normalizeProviderModelOptions(
@@ -544,6 +544,10 @@ function normalizeProviderModelOptions( · 10 unmodified lines
544544 candidate?.claudeAgent && typeof candidate.claudeAgent === "object"
545545 ? (candidate.claudeAgent as Record<string, unknown>)
546546 : null;
547+ const openCodeCandidate =
548+ candidate?.opencode && typeof candidate.opencode === "object"
549+ ? (candidate.opencode as Record<string, unknown>)
550+ : null;
547551
548552 const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort)
549553 ? codexCandidate.reasoningEffort
@@ -600,13 +604,29 @@ function normalizeProviderModelOptions( · 50 unmodified lines
600604 ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}),
601605 }
602606 : undefined;
607+ const openCodeVariant =
608+ typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0
609+ ? openCodeCandidate.variant
610+ : undefined;
611+ const openCodeAgent =
612+ typeof openCodeCandidate?.agent === "string" && openCodeCandidate.agent.length > 0
613+ ? openCodeCandidate.agent
614+ : undefined;
615+ const opencode =
616+ openCodeVariant !== undefined || openCodeAgent !== undefined
617+ ? {
618+ ...(openCodeVariant !== undefined ? { variant: openCodeVariant } : {}),
619+ ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}),
620+ }
621+ : undefined;
603622
604− if (!codex && !claude) {
623+ if (!codex && !claude && !opencode) {
605624 return null;
606625 }
607626 return {
608627 ...(codex ? { codex } : {}),
609628 ...(claude ? { claudeAgent: claude } : {}),
629+ ...(opencode ? { opencode } : {}),
610630 };
611631 }
612632
@@ -637,12 +657,8 @@ function normalizeModelSelection( · 24 unmodified lines
637657 provider,
638658 provider === "codex" ? legacy?.legacyCodex : undefined,
639659 );
640− const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent;
641− return {
642− provider,
643− model,
644− ...(options ? { options } : {}),
645− };
660+ const options = modelOptions?.[provider];
661+ return createModelSelection(provider, model, options);
646662 }
647663
648664 // ── Legacy sync helpers (used only during migration from v2 storage) ──
@@ -655,11 +671,7 @@ function legacySyncModelSelectionOptions( · 6 unmodified lines
655671 return null;
656672 }
657673 const options = modelOptions?.[modelSelection.provider];
658− return {
659− provider: modelSelection.provider,
660− model: modelSelection.model,
661− ...(options ? { options } : {}),
662− };
674+ return createModelSelection(modelSelection.provider, modelSelection.model, options);
663675 }
664676
665677 function legacyMergeModelSelectionIntoProviderModelOptions(
@@ -703,17 +715,16 @@ function legacyToModelSelectionByProvider( · 37 unmodified lines
703715 const result: Partial<Record<ProviderKind, ModelSelection>> = {};
704716 // Add entries from the options bag (for non-active providers)
705717 if (modelOptions) {
706− for (const provider of ["codex", "claudeAgent"] as const) {
718+ for (const provider of ["codex", "claudeAgent", "opencode"] as const) {
707719 const options = modelOptions[provider];
708720 if (options && Object.keys(options).length > 0) {
709− result[provider] = {
721+ result[provider] = createModelSelection(
710722 provider,
711− model:
712− modelSelection?.provider === provider
713− ? modelSelection.model
714− : DEFAULT_MODEL_BY_PROVIDER[provider],
723+ modelSelection?.provider === provider
724+ ? modelSelection.model
725+ : DEFAULT_MODEL_BY_PROVIDER[provider],
715726 options,
716− };
727+ );
717728 }
718729 }
719730 }
@@ -2205,11 +2216,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 1485 unmodified lines
22052216 nextMap[normalized.provider] = normalized;
22062217 } else {
22072218 // No options in selection → preserve existing options, update provider+model
2208− nextMap[normalized.provider] = {
2209− provider: normalized.provider,
2210− model: normalized.model,
2211− ...(current?.options ? { options: current.options } : {}),
2212− };
2219+ nextMap[normalized.provider] = createModelSelection(
2220+ normalized.provider,
2221+ normalized.model,
2222+ current?.options,
2223+ );
22132224 }
22142225 }
22152226 const nextActiveProvider = normalized?.provider ?? base.activeProvider;
@@ -2246,17 +2257,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 30 unmodified lines
22462257 }
22472258 const base = existing ?? createEmptyThreadDraft();
22482259 const nextMap = { ...base.modelSelectionByProvider };
2249− for (const provider of ["codex", "claudeAgent"] as const) {
2260+ for (const provider of ["codex", "claudeAgent", "opencode"] as const) {
22502261 // Only touch providers explicitly present in the input
22512262 if (!normalizedOpts || !(provider in normalizedOpts)) continue;
22522263 const opts = normalizedOpts[provider];
22532264 const current = nextMap[provider];
22542265 if (opts) {
2255− nextMap[provider] = {
2266+ nextMap[provider] = createModelSelection(
22562267 provider,
2257− model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
2258− options: opts,
2259− };
2268+ current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
2269+ opts,
2270+ );
22602271 } else if (current?.options) {
22612272 // Remove options but keep the selection
22622273 const { options: _, ...rest } = current;
@@ -2303,11 +2314,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 40 unmodified lines
23032314 const nextMap = { ...base.modelSelectionByProvider };
23042315 const currentForProvider = nextMap[normalizedProvider];
23052316 if (providerOpts) {
2306− nextMap[normalizedProvider] = {
2307− provider: normalizedProvider,
2308− model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2309− options: providerOpts,
2310− };
2317+ nextMap[normalizedProvider] = createModelSelection(
2318+ normalizedProvider,
2319+ currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2320+ providerOpts,
2321+ );
23112322 } else if (currentForProvider?.options) {
23122323 const { options: _, ...rest } = currentForProvider;
23132324 nextMap[normalizedProvider] = rest as ModelSelection;
@@ -2321,16 +2332,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 7 unmodified lines
23212332 const stickyBase =
23222333 nextStickyMap[normalizedProvider] ??
23232334 base.modelSelectionByProvider[normalizedProvider] ??
2324− ({
2325− provider: normalizedProvider,
2326− model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2327− } as ModelSelection);
2335+ createModelSelection(
2336+ normalizedProvider,
2337+ DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2338+ );
23282339 if (providerOpts) {
2329− nextStickyMap[normalizedProvider] = {
2330− ...stickyBase,
2331− provider: normalizedProvider,
2332− options: providerOpts,
2333− };
2340+ nextStickyMap[normalizedProvider] = createModelSelection(
2341+ normalizedProvider,
2342+ stickyBase.model,
2343+ providerOpts,
2344+ );
23342345 } else if (stickyBase.options) {
23352346 const { options: _, ...rest } = stickyBase;
23362347 nextStickyMap[normalizedProvider] = rest as ModelSelection;
apps/web/src/modelSelection.ts+19−6
@@ -4,7 +4,11 @@ import { · 3 unmodified lines
44 type ProviderKind,
55 type ServerProvider,
66 } from "@t3tools/contracts";
7−import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model";
7+import {
8+ createModelSelection,
9+ normalizeModelSlug,
10+ resolveSelectableModel,
11+} from "@t3tools/shared/model";
812 import { getComposerProviderState } from "./components/chat/composerProviderRegistry";
913 import { UnifiedSettings } from "@t3tools/contracts/settings";
1014 import {
@@ -45,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf · 34 unmodified lines
4549 placeholder: "your-claude-model-slug",
4650 example: "claude-sonnet-5-0",
4751 },
52+ opencode: {
53+ provider: "opencode",
54+ title: "OpenCode",
55+ description: "Save additional OpenCode model slugs in `provider/model` format.",
56+ placeholder: "openai/gpt-5",
57+ example: "anthropic/claude-sonnet-4-5-20250929",
58+ },
4859 };
4960
5061 export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
@@ -165,6 +176,12 @@ export function getCustomModelOptionsByProvider( · 114 unmodified lines
165176 "claudeAgent",
166177 selectedProvider === "claudeAgent" ? selectedModel : undefined,
167178 ),
179+ opencode: getAppModelOptions(
180+ settings,
181+ providers,
182+ "opencode",
183+ selectedProvider === "opencode" ? selectedModel : undefined,
184+ ),
168185 };
169186 }
170187
@@ -192,9 +209,5 @@ export function resolveAppModelSelectionState( · 21 unmodified lines
192209 },
193210 });
194211
195− return {
196− provider,
197− model,
198− ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}),
199− };
212+ return createModelSelection(provider, model, modelOptionsForDispatch);
200213 }
apps/web/src/session-logic.test.ts+8−1
@@ -1326,12 +1326,14 @@ describe("deriveActiveWorkStartedAt", () => { · 1325 unmodified lines
13261326 });
13271327
13281328 describe("PROVIDER_OPTIONS", () => {
1329− it("advertises Claude as available while keeping Cursor as a placeholder", () => {
1329+ it("advertises available providers while keeping Cursor as a placeholder", () => {
13301330 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent");
1331+ const opencode = PROVIDER_OPTIONS.find((option) => option.value === "opencode");
13311332 const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor");
13321333 expect(PROVIDER_OPTIONS).toEqual([
13331334 { value: "codex", label: "Codex", available: true },
13341335 { value: "claudeAgent", label: "Claude", available: true },
1336+ { value: "opencode", label: "OpenCode", available: true },
13351337 { value: "cursor", label: "Cursor", available: false },
13361338 ]);
13371339 expect(claude).toEqual({
@@ -1339,6 +1341,11 @@ describe("PROVIDER_OPTIONS", () => { · 1 unmodified line
13391341 label: "Claude",
13401342 available: true,
13411343 });
1344+ expect(opencode).toEqual({
1345+ value: "opencode",
1346+ label: "OpenCode",
1347+ available: true,
1348+ });
13421349 expect(cursor).toEqual({
13431350 value: "cursor",
13441351 label: "Cursor",
apps/web/src/session-logic.ts+1−0
@@ -29,6 +29,7 @@ export const PROVIDER_OPTIONS: Array<{ · 28 unmodified lines
2929 }> = [
3030 { value: "codex", label: "Codex", available: true },
3131 { value: "claudeAgent", label: "Claude", available: true },
32+ { value: "opencode", label: "OpenCode", available: true },
3233 { value: "cursor", label: "Cursor", available: false },
3334 ];
3435
apps/web/src/store.ts+2−2
@@ -129,7 +129,7 @@ function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { · 128 unmodified lines
129129 return left.length === right.length && left.every((value, index) => value === right[index]);
130130 }
131131
132−function normalizeModelSelection<T extends { provider: "codex" | "claudeAgent"; model: string }>(
132+function normalizeModelSelection<T extends { provider: ProviderKind; model: string }>(
133133 selection: T,
134134 ): T {
135135 return {
@@ -1001,7 +1001,7 @@ function toLegacySessionStatus( · 865 unmodified lines
10011001 }
10021002
10031003 function toLegacyProvider(providerName: string | null): ProviderKind {
1004− if (providerName === "codex" || providerName === "claudeAgent") {
1004+ if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") {
10051005 return providerName;
10061006 }
10071007 return "codex";
bun.lock+3−0
@@ -51,6 +51,7 @@ · 50 unmodified lines
5151 "@effect/platform-bun": "catalog:",
5252 "@effect/platform-node": "catalog:",
5353 "@effect/sql-sqlite-bun": "catalog:",
54+ "@opencode-ai/sdk": "^1.3.15",
5455 "@pierre/diffs": "^1.1.0-beta.16",
5556 "effect": "catalog:",
5657 "node-pty": "^1.1.0",
@@ -536,6 +537,8 @@ · 479 unmodified lines
536537
537538 "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
538539
540+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.15", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Uk59C7wsK20wpdr277yx7Xz7TqG5jGqlZUpSW3wDH/7a2K2iBg0lXc2wskHuCXLRXMhXpPZtb4a3SOpPENkkbg=="],
541+
539542 "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
540543
541544 "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
packages/contracts/src/model.ts+13−0
@@ -29,9 +29,16 @@ export const ClaudeModelOptions = Schema.Struct({ · 28 unmodified lines
2929 });
3030 export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;
3131
32+export const OpenCodeModelOptions = Schema.Struct({
33+ variant: Schema.optional(TrimmedNonEmptyString),
34+ agent: Schema.optional(TrimmedNonEmptyString),
35+});
36+export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type;
37+
3238 export const ProviderModelOptions = Schema.Struct({
3339 codex: Schema.optional(CodexModelOptions),
3440 claudeAgent: Schema.optional(ClaudeModelOptions),
41+ opencode: Schema.optional(OpenCodeModelOptions),
3542 });
3643 export type ProviderModelOptions = typeof ProviderModelOptions.Type;
3744
@@ -55,12 +62,15 @@ export const ModelCapabilities = Schema.Struct({ · 17 unmodified lines
5562 supportsThinkingToggle: Schema.Boolean,
5663 contextWindowOptions: Schema.Array(ContextWindowOption),
5764 promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString),
65+ variantOptions: Schema.optional(Schema.Array(EffortOption)),
66+ agentOptions: Schema.optional(Schema.Array(EffortOption)),
5867 });
5968 export type ModelCapabilities = typeof ModelCapabilities.Type;
6069
6170 export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
6271 codex: "gpt-5.4",
6372 claudeAgent: "claude-sonnet-4-6",
73+ opencode: "openai/gpt-5",
6474 };
6575
6676 export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
@@ -69,6 +79,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; · 2 unmodified lines
6979 export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
7080 codex: "gpt-5.4-mini",
7181 claudeAgent: "claude-haiku-4-5",
82+ opencode: "openai/gpt-5",
7283 };
7384
7485 export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = {
@@ -96,6 +107,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, · 21 unmodified lines
96107 "claude-haiku-4.5": "claude-haiku-4-5",
97108 "claude-haiku-4-5-20251001": "claude-haiku-4-5",
98109 },
110+ opencode: {},
99111 };
100112
101113 // ── Provider display names ────────────────────────────────────────────
@@ -103,4 +115,5 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, · 1 unmodified line
103115 export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {
104116 codex: "Codex",
105117 claudeAgent: "Claude",
118+ opencode: "OpenCode",
106119 };
packages/contracts/src/orchestration.ts+14−3
@@ -1,5 +1,5 @@
11 import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";
2−import { ClaudeModelOptions, CodexModelOptions } from "./model.ts";
2+import { ClaudeModelOptions, CodexModelOptions, OpenCodeModelOptions } from "./model.ts";
33 import { RepositoryIdentity } from "./environment.ts";
44 import {
55 ApprovalRequestId,
@@ -25,7 +25,7 @@ export const ORCHESTRATION_WS_METHODS = { · 19 unmodified lines
2525 subscribeThread: "orchestration.subscribeThread",
2626 } as const;
2727
28−export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]);
28+export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "opencode"]);
2929 export type ProviderKind = typeof ProviderKind.Type;
3030 export const ProviderApprovalPolicy = Schema.Literals([
3131 "untrusted",
@@ -57,7 +57,18 @@ export const ClaudeModelSelection = Schema.Struct({ · 25 unmodified lines
5757 });
5858 export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
5959
60−export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]);
60+export const OpenCodeModelSelection = Schema.Struct({
61+ provider: Schema.Literal("opencode"),
62+ model: TrimmedNonEmptyString,
63+ options: Schema.optionalKey(OpenCodeModelOptions),
64+});
65+export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type;
66+
67+export const ModelSelection = Schema.Union([
68+ CodexModelSelection,
69+ ClaudeModelSelection,
70+ OpenCodeModelSelection,
71+]);
6172 export type ModelSelection = typeof ModelSelection.Type;
6273
6374 export const RuntimeMode = Schema.Literals([
packages/contracts/src/providerRuntime.ts+1−0
@@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Literals([ · 23 unmodified lines
2424 "claude.sdk.message",
2525 "claude.sdk.permission",
2626 "codex.sdk.thread-event",
27+ "opencode.sdk.event",
2728 ]);
2829 export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;
2930
packages/contracts/src/settings.ts+30−0
@@ -6,6 +6,7 @@ import { · 5 unmodified lines
66 ClaudeModelOptions,
77 CodexModelOptions,
88 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
9+ OpenCodeModelOptions,
910 } from "./model.ts";
1011 import { ModelSelection } from "./orchestration.ts";
1112
@@ -89,6 +90,15 @@ export const ClaudeSettings = Schema.Struct({ · 77 unmodified lines
8990 });
9091 export type ClaudeSettings = typeof ClaudeSettings.Type;
9192
93+export const OpenCodeSettings = Schema.Struct({
94+ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
95+ binaryPath: makeBinaryPathSetting("opencode"),
96+ serverUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
97+ serverPassword: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
98+ customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
99+});
100+export type OpenCodeSettings = typeof OpenCodeSettings.Type;
101+
92102 export const ObservabilitySettings = Schema.Struct({
93103 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
94104 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
@@ -114,6 +124,7 @@ export const ServerSettings = Schema.Struct({ · 19 unmodified lines
114124 providers: Schema.Struct({
115125 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
116126 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
127+ opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
117128 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
118129 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
119130 });
@@ -156,6 +167,11 @@ const ClaudeModelOptionsPatch = Schema.Struct({ · 36 unmodified lines
156167 contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow),
157168 });
158169
170+const OpenCodeModelOptionsPatch = Schema.Struct({
171+ variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant),
172+ agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent),
173+});
174+
159175 const ModelSelectionPatch = Schema.Union([
160176 Schema.Struct({
161177 provider: Schema.optionalKey(Schema.Literal("codex")),
@@ -167,6 +183,11 @@ const ModelSelectionPatch = Schema.Union([ · 5 unmodified lines
167183 model: Schema.optionalKey(TrimmedNonEmptyString),
168184 options: Schema.optionalKey(ClaudeModelOptionsPatch),
169185 }),
186+ Schema.Struct({
187+ provider: Schema.optionalKey(Schema.Literal("opencode")),
188+ model: Schema.optionalKey(TrimmedNonEmptyString),
189+ options: Schema.optionalKey(OpenCodeModelOptionsPatch),
190+ }),
170191 ]);
171192
172193 const CodexSettingsPatch = Schema.Struct({
@@ -183,6 +204,14 @@ const ClaudeSettingsPatch = Schema.Struct({ · 10 unmodified lines
183204 launchArgs: Schema.optionalKey(Schema.String),
184205 });
185206
207+const OpenCodeSettingsPatch = Schema.Struct({
208+ enabled: Schema.optionalKey(Schema.Boolean),
209+ binaryPath: Schema.optionalKey(Schema.String),
210+ serverUrl: Schema.optionalKey(Schema.String),
211+ serverPassword: Schema.optionalKey(Schema.String),
212+ customModels: Schema.optionalKey(Schema.Array(Schema.String)),
213+});
214+
186215 export const ServerSettingsPatch = Schema.Struct({
187216 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
188217 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
@@ -198,6 +227,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 9 unmodified lines
198227 Schema.Struct({
199228 codex: Schema.optionalKey(CodexSettingsPatch),
200229 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
230+ opencode: Schema.optionalKey(OpenCodeSettingsPatch),
201231 }),
202232 ),
203233 });
packages/shared/src/model.ts+73−0
@@ -6,7 +6,9 @@ import { · 5 unmodified lines
66 type CodexModelOptions,
77 type ModelCapabilities,
88 type ModelSelection,
9+ type OpenCodeModelOptions,
910 type ProviderKind,
11+ type ProviderModelOptions,
1012 } from "@t3tools/contracts";
1113
1214 export interface SelectableModelOption {
@@ -117,6 +119,50 @@ export function normalizeClaudeModelOptionsWithCapabilities( · 104 unmodified lines
117119 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
118120 }
119121
122+function resolveLabeledOption(
123+ options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined,
124+ raw: string | null | undefined,
125+): string | undefined {
126+ if (!options || options.length === 0) {
127+ return raw ?? undefined;
128+ }
129+ if (raw && options.some((option) => option.value === raw)) {
130+ return raw;
131+ }
132+ return options.find((option) => option.isDefault)?.value;
133+}
134+
135+export function normalizeOpenCodeModelOptionsWithCapabilities(
136+ caps: ModelCapabilities,
137+ modelOptions: OpenCodeModelOptions | null | undefined,
138+): OpenCodeModelOptions | undefined {
139+ const variant = resolveLabeledOption(caps.variantOptions, trimOrNull(modelOptions?.variant));
140+ const agent = resolveLabeledOption(caps.agentOptions, trimOrNull(modelOptions?.agent));
141+ const nextOptions: OpenCodeModelOptions = {
142+ ...(variant ? { variant } : {}),
143+ ...(agent ? { agent } : {}),
144+ };
145+ return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
146+}
147+
148+export function normalizeProviderModelOptionsWithCapabilities(
149+ provider: ProviderKind,
150+ caps: ModelCapabilities,
151+ modelOptions: ProviderModelOptions[ProviderKind] | null | undefined,
152+): ProviderModelOptions[ProviderKind] | undefined {
153+ switch (provider) {
154+ case "codex":
155+ return normalizeCodexModelOptionsWithCapabilities(caps, modelOptions as CodexModelOptions);
156+ case "claudeAgent":
157+ return normalizeClaudeModelOptionsWithCapabilities(caps, modelOptions as ClaudeModelOptions);
158+ case "opencode":
159+ return normalizeOpenCodeModelOptionsWithCapabilities(
160+ caps,
161+ modelOptions as OpenCodeModelOptions,
162+ );
163+ }
164+}
165+
120166 export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
121167 return typeof text === "string" && /\bultrathink\b/i.test(text);
122168 }
@@ -196,6 +242,33 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n · 73 unmodified lines
196242 return trimmed || null;
197243 }
198244
245+export function createModelSelection(
246+ provider: ProviderKind,
247+ model: string,
248+ options?: ProviderModelOptions[ProviderKind] | undefined,
249+): ModelSelection {
250+ switch (provider) {
251+ case "codex":
252+ return {
253+ provider,
254+ model,
255+ ...(options ? { options: options as CodexModelOptions } : {}),
256+ };
257+ case "claudeAgent":
258+ return {
259+ provider,
260+ model,
261+ ...(options ? { options: options as ClaudeModelOptions } : {}),
262+ };
263+ case "opencode":
264+ return {
265+ provider,
266+ model,
267+ ...(options ? { options: options as OpenCodeModelOptions } : {}),
268+ };
269+ }
270+}
271+
199272 /**
200273 * Resolve the actual API model identifier from a model selection.
201274 *
packages/shared/src/serverSettings.test.ts+26−0
@@ -109,4 +109,30 @@ describe("serverSettings helpers", () => { · 108 unmodified lines
109109 },
110110 });
111111 });
112+
113+ it("replaces text generation selection across providers without leaking stale options", () => {
114+ const current = {
115+ ...DEFAULT_SERVER_SETTINGS,
116+ textGenerationModelSelection: {
117+ provider: "codex" as const,
118+ model: "gpt-5.4-mini",
119+ options: {
120+ reasoningEffort: "high" as const,
121+ fastMode: true,
122+ },
123+ },
124+ };
125+
126+ expect(
127+ applyServerSettingsPatch(current, {
128+ textGenerationModelSelection: {
129+ provider: "opencode",
130+ model: "openai/gpt-5",
131+ },
132+ }).textGenerationModelSelection,
133+ ).toEqual({
134+ provider: "opencode",
135+ model: "openai/gpt-5",
136+ });
137+ });
112138 });
packages/shared/src/serverSettings.ts+35−6
@@ -1,4 +1,10 @@
1−import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";
1+import {
2+ ServerSettings,
3+ type ClaudeModelOptions,
4+ type CodexModelOptions,
5+ type OpenCodeModelOptions,
6+ type ServerSettingsPatch,
7+} from "@t3tools/contracts";
28 import { Schema } from "effect";
39 import { deepMerge } from "./Struct.ts";
410 import { fromLenientJson } from "./schemaJson.ts";
@@ -46,6 +52,9 @@ function shouldReplaceTextGenerationModelSelection( · 41 unmodified lines
4652 return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined));
4753 }
4854
55+const withModelSelectionOptions = <Options>(options: Options | undefined) =>
56+ options ? { options } : {};
57+
4958 /**
5059 * Applies a server settings patch while treating textGenerationModelSelection as
5160 * replace-on-provider/model updates. This prevents stale nested options from
@@ -61,12 +70,32 @@ export function applyServerSettingsPatch( · 9 unmodified lines
6170 return next;
6271 }
6372
73+ const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider;
74+ const model = selectionPatch.model ?? current.textGenerationModelSelection.model;
75+
6476 return {
6577 ...next,
66− textGenerationModelSelection: {
67− provider: selectionPatch.provider ?? current.textGenerationModelSelection.provider,
68− model: selectionPatch.model ?? current.textGenerationModelSelection.model,
69− ...(selectionPatch.options ? { options: selectionPatch.options } : {}),
70− },
78+ textGenerationModelSelection:
79+ provider === "codex"
80+ ? {
81+ provider,
82+ model,
83+ ...withModelSelectionOptions(selectionPatch.options as CodexModelOptions | undefined),
84+ }
85+ : provider === "claudeAgent"
86+ ? {
87+ provider,
88+ model,
89+ ...withModelSelectionOptions(
90+ selectionPatch.options as ClaudeModelOptions | undefined,
91+ ),
92+ }
93+ : {
94+ provider,
95+ model,
96+ ...withModelSelectionOptions(
97+ selectionPatch.options as OpenCodeModelOptions | undefined,
98+ ),
99+ },
71100 };
72101 }