authorgravatar for shoubhit2005@gmail.comShoubhit Dash <shoubhit2005@gmail.com> 2026-04-18 02:01:41+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-17 13:31:41-07:00
logce94feeea156e77f465c0330e89c0cb77eb0196f
tree21bfefb5c61776273846f16ad62a81f72c5a7b46
parent39ca3ee858e764dba3293aa9cb69452c2265440d
signature Signed by PGP key B5690EEEBB952194

feat: add opencode provider support (#1758)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

52 files changed, 4727 insertions(+), 228 deletions(-)

apps/server/package.json+1
...@@ -29,6 +29,7 @@...@@ -29,6 +29,7 @@
29 "@effect/platform-bun": "catalog:",29 "@effect/platform-bun": "catalog:",
30 "@effect/platform-node": "catalog:",30 "@effect/platform-node": "catalog:",
31 "@effect/sql-sqlite-bun": "catalog:",31 "@effect/sql-sqlite-bun": "catalog:",
32 "@opencode-ai/sdk": "^1.3.15",
32 "@pierre/diffs": "^1.1.0-beta.16",33 "@pierre/diffs": "^1.1.0-beta.16",
33 "effect": "catalog:",34 "effect": "catalog:",
34 "node-pty": "^1.1.0",35 "node-pty": "^1.1.0",
apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts created+259
...@@ -0,0 +1,259 @@
1import type { ChildProcess } from "node:child_process";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import { Duration, Effect, Layer } from "effect";
6import { TestClock } from "effect/testing";
7import { beforeEach, expect, vi } from "vitest";
8
9import { ServerConfig } from "../../config.ts";
10import { ServerSettingsService } from "../../serverSettings.ts";
11import { TextGeneration } from "../Services/TextGeneration.ts";
12import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
13
14const 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
35vi.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
82const DEFAULT_TEST_MODEL_SELECTION = {
83 provider: "opencode" as const,
84 model: "openai/gpt-5",
85};
86
87const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
88
89const 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
107const 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
127beforeEach(() => {
128 runtimeMock.reset();
129});
130
131const 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
137it.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
221it.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 created+422
...@@ -0,0 +1,422 @@
1import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";
2import * as Semaphore from "effect/Semaphore";
3
4import {
5 TextGenerationError,
6 type ChatAttachment,
7 type OpenCodeModelSelection,
8} from "@t3tools/contracts";
9import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
10
11import { ServerConfig } from "../../config.ts";
12import { resolveAttachmentPath } from "../../attachmentStore.ts";
13import { ServerSettingsService } from "../../serverSettings.ts";
14import {
15 buildBranchNamePrompt,
16 buildCommitMessagePrompt,
17 buildPrContentPrompt,
18 buildThreadTitlePrompt,
19} from "../Prompts.ts";
20import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
21import {
22 sanitizeCommitSubject,
23 sanitizePrTitle,
24 sanitizeThreadTitle,
25 toJsonSchemaObject,
26} from "../Utils.ts";
27import {
28 createOpenCodeSdkClient,
29 type OpenCodeServerConnection,
30 type OpenCodeServerProcess,
31 parseOpenCodeModelSlug,
32 startOpenCodeServerProcess,
33 toOpenCodeFileParts,
34} from "../../provider/opencodeRuntime.ts";
35
36const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
37
38interface SharedOpenCodeTextGenerationServerState {
39 server: OpenCodeServerProcess | null;
40 binaryPath: string | null;
41 activeRequests: number;
42 idleCloseFiber: Fiber.Fiber<void, never> | null;
43}
44
45const 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
422export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration);
apps/server/src/git/Layers/RoutingTextGeneration.ts+20-2
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18} from "../Services/TextGeneration.ts";18} from "../Services/TextGeneration.ts";
19import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";19import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
20import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";20import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts";
21import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
2122
22// ---------------------------------------------------------------------------23// ---------------------------------------------------------------------------
23// Internal service tags so both concrete layers can coexist.24// Internal service tags so both concrete layers can coexist.
...@@ -31,6 +32,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>(...@@ -31,6 +32,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>(
31 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",32 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
32) {}33) {}
3334
35class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()(
36 "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen",
37) {}
38
34// ---------------------------------------------------------------------------39// ---------------------------------------------------------------------------
35// Routing implementation40// Routing implementation
36// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
...@@ -38,9 +43,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>(...@@ -38,9 +43,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>(
38const makeRoutingTextGeneration = Effect.gen(function* () {43const makeRoutingTextGeneration = Effect.gen(function* () {
39 const codex = yield* CodexTextGen;44 const codex = yield* CodexTextGen;
40 const claude = yield* ClaudeTextGen;45 const claude = yield* ClaudeTextGen;
46 const openCode = yield* OpenCodeTextGen;
4147
42 const route = (provider?: TextGenerationProvider): TextGenerationShape =>48 const route = (provider?: TextGenerationProvider): TextGenerationShape =>
43 provider === "claudeAgent" ? claude : codex;49 provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex;
4450
45 return {51 return {
46 generateCommitMessage: (input) =>52 generateCommitMessage: (input) =>
...@@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect(...@@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect(
67 }),73 }),
68).pipe(Layer.provide(ClaudeTextGenerationLive));74).pipe(Layer.provide(ClaudeTextGenerationLive));
6975
76const InternalOpenCodeLayer = Layer.effect(
77 OpenCodeTextGen,
78 Effect.gen(function* () {
79 const svc = yield* TextGeneration;
80 return svc;
81 }),
82).pipe(Layer.provide(OpenCodeTextGenerationLive));
83
70export const RoutingTextGenerationLive = Layer.effect(84export const RoutingTextGenerationLive = Layer.effect(
71 TextGeneration,85 TextGeneration,
72 makeRoutingTextGeneration,86 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";...@@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
13import type { TextGenerationError } from "@t3tools/contracts";13import type { TextGenerationError } from "@t3tools/contracts";
1414
15/** Providers that support git text generation (commit messages, PR content, branch names). */15/** Providers that support git text generation (commit messages, PR content, branch names). */
16export type TextGenerationProvider = "codex" | "claudeAgent";16export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode";
1717
18export interface CommitMessageGenerationInput {18export interface CommitMessageGenerationInput {
19 cwd: string;19 cwd: string;
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts created+486
...@@ -0,0 +1,486 @@
1import assert from "node:assert/strict";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import { Effect, Layer, Option } from "effect";
6import { beforeEach, vi } from "vitest";
7
8import { ThreadId } from "@t3tools/contracts";
9import { ServerConfig } from "../../config.ts";
10import { ServerSettingsService } from "../../serverSettings.ts";
11import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
12import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
13import {
14 appendOpenCodeAssistantTextDelta,
15 makeOpenCodeAdapterLive,
16 mergeOpenCodeAssistantText,
17} from "./OpenCodeAdapter.ts";
18
19const asThreadId = (value: string): ThreadId => ThreadId.make(value);
20
21const 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
60vi.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
141const 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
150const 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
167beforeEach(() => {
168 runtimeMock.reset();
169});
170
171const sleep = (ms: number) =>
172 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
173
174it.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 created+1344
...@@ -0,0 +1,1344 @@
1import { randomUUID } from "node:crypto";
2
3import {
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";
14import { Cause, Effect, Layer, Queue, Stream } from "effect";
15import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
16
17import { resolveAttachmentPath } from "../../attachmentStore.ts";
18import { ServerConfig } from "../../config.ts";
19import { ServerSettingsService } from "../../serverSettings.ts";
20import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
21import {
22 ProviderAdapterProcessError,
23 ProviderAdapterRequestError,
24 ProviderAdapterSessionClosedError,
25 ProviderAdapterSessionNotFoundError,
26 ProviderAdapterValidationError,
27} from "../Errors.ts";
28import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
29import {
30 buildOpenCodePermissionRules,
31 connectToOpenCodeServer,
32 createOpenCodeSdkClient,
33 openCodeQuestionId,
34 parseOpenCodeModelSlug,
35 toOpenCodeFileParts,
36 toOpenCodePermissionReply,
37 toOpenCodeQuestionAnswers,
38 type OpenCodeServerConnection,
39} from "../opencodeRuntime.ts";
40
41const PROVIDER = "opencode" as const;
42
43interface OpenCodeTurnSnapshot {
44 readonly id: TurnId;
45 readonly items: Array<unknown>;
46}
47
48interface 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
68export interface OpenCodeAdapterLiveOptions {
69 readonly nativeEventLogPath?: string;
70 readonly nativeEventLogger?: EventNdjsonLogger;
71}
72
73function nowIso(): string {
74 return new Date().toISOString();
75}
76
77function 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
86function 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
116function 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
148function 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
163function 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
175function 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
189function 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
200function 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
214function 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
227function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" {
228 return part?.type === "reasoning" ? "reasoning_text" : "assistant_text";
229}
230
231function 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
241function 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
249function 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
259function 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
266export 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
280export 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
294function 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
301function 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
312function 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
325function 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
337function 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
348function 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
372async 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
383export 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
1344export const OpenCodeAdapterLive = makeOpenCodeAdapterLive();
apps/server/src/provider/Layers/OpenCodeProvider.test.ts created+138
...@@ -0,0 +1,138 @@
1import assert from "node:assert/strict";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import { Effect, Layer } from "effect";
6import { beforeEach, vi } from "vitest";
7
8import { ServerConfig } from "../../config.ts";
9import { ServerSettingsService } from "../../serverSettings.ts";
10import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
11import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts";
12
13const 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
28vi.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
60beforeEach(() => {
61 runtimeMock.reset();
62});
63
64const 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
71it.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
97it.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 created+342
...@@ -0,0 +1,342 @@
1import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts";
2import { Cause, Effect, Equal, Layer, Stream } from "effect";
3
4import { ServerConfig } from "../../config.ts";
5import { ServerSettingsService } from "../../serverSettings.ts";
6import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
7import {
8 buildServerProvider,
9 isCommandMissingCause,
10 parseGenericCliVersion,
11 providerModelsFromSettings,
12} from "../providerSnapshot.ts";
13import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
14import {
15 connectToOpenCodeServer,
16 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
17 createOpenCodeSdkClient,
18 flattenOpenCodeModels,
19 loadOpenCodeInventory,
20 runOpenCodeCommand,
21} from "../opencodeRuntime.ts";
22
23const PROVIDER = "opencode" as const;
24
25class 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
35function toOpenCodeProbeError(cause: unknown): OpenCodeProbePromiseError {
36 return new OpenCodeProbePromiseError(cause);
37}
38
39function 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
57function 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
130const 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
173export 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
311export 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
342export const OpenCodeProviderLive = makeOpenCodeProviderLive();
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+23-1
...@@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";...@@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
8import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";8import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
9import { CodexAdapter } from "../Services/CodexAdapter.ts";9import { CodexAdapter } from "../Services/CodexAdapter.ts";
10import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";10import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
11import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
12import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
11import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";13import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
12import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";14import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
13import { ProviderUnsupportedError } from "../Errors.ts";15import { ProviderUnsupportedError } from "../Errors.ts";
...@@ -47,6 +49,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = {...@@ -47,6 +49,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = {
47 streamEvents: Stream.empty,49 streamEvents: Stream.empty,
48};50};
4951
52const 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
50const layer = it.layer(69const layer = it.layer(
51 Layer.mergeAll(70 Layer.mergeAll(
52 Layer.provide(71 Layer.provide(
...@@ -54,6 +73,7 @@ const layer = it.layer(...@@ -54,6 +73,7 @@ const layer = it.layer(
54 Layer.mergeAll(73 Layer.mergeAll(
55 Layer.succeed(CodexAdapter, fakeCodexAdapter),74 Layer.succeed(CodexAdapter, fakeCodexAdapter),
56 Layer.succeed(ClaudeAdapter, fakeClaudeAdapter),75 Layer.succeed(ClaudeAdapter, fakeClaudeAdapter),
76 Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter),
57 ),77 ),
58 ),78 ),
59 NodeServices.layer,79 NodeServices.layer,
...@@ -66,11 +86,13 @@ layer("ProviderAdapterRegistryLive", (it) => {...@@ -66,11 +86,13 @@ layer("ProviderAdapterRegistryLive", (it) => {
66 const registry = yield* ProviderAdapterRegistry;86 const registry = yield* ProviderAdapterRegistry;
67 const codex = yield* registry.getByProvider("codex");87 const codex = yield* registry.getByProvider("codex");
68 const claude = yield* registry.getByProvider("claudeAgent");88 const claude = yield* registry.getByProvider("claudeAgent");
89 const openCode = yield* registry.getByProvider("opencode");
69 assert.equal(codex, fakeCodexAdapter);90 assert.equal(codex, fakeCodexAdapter);
70 assert.equal(claude, fakeClaudeAdapter);91 assert.equal(claude, fakeClaudeAdapter);
92 assert.equal(openCode, fakeOpenCodeAdapter);
7193
72 const providers = yield* registry.listProviders();94 const providers = yield* registry.listProviders();
73 assert.deepEqual(providers, ["codex", "claudeAgent"]);95 assert.deepEqual(providers, ["codex", "claudeAgent", "opencode"]);
74 }),96 }),
75 );97 );
7698
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+2-1
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17} from "../Services/ProviderAdapterRegistry.ts";17} from "../Services/ProviderAdapterRegistry.ts";
18import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";18import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts";
19import { CodexAdapter } from "../Services/CodexAdapter.ts";19import { CodexAdapter } from "../Services/CodexAdapter.ts";
20import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
2021
21export interface ProviderAdapterRegistryLiveOptions {22export interface ProviderAdapterRegistryLiveOptions {
22 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;23 readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>;
...@@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun...@@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun
28 const adapters =29 const adapters =
29 options?.adapters !== undefined30 options?.adapters !== undefined
30 ? options.adapters31 ? options.adapters
31 : [yield* CodexAdapter, yield* ClaudeAdapter];32 : [yield* CodexAdapter, yield* ClaudeAdapter, yield* OpenCodeAdapter];
32 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));33 const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
3334
34 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {35 const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
apps/server/src/provider/Layers/ProviderRegistry.test.ts+27-12
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31} from "./CodexProvider.ts";31} from "./CodexProvider.ts";
32import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts";32import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts";
33import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts";33import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts";
34import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
34import { ServerConfig } from "../../config.ts";35import { ServerConfig } from "../../config.ts";
35import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";36import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts";
36import { ProviderRegistry } from "../Services/ProviderRegistry.ts";37import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
...@@ -38,6 +39,19 @@ import { ProviderRegistry } from "../Services/ProviderRegistry.ts";...@@ -38,6 +39,19 @@ import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
38// ── Test helpers ────────────────────────────────────────────────────39// ── Test helpers ────────────────────────────────────────────────────
3940
40const encoder = new TextEncoder();41const encoder = new TextEncoder();
42const 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
42function mockHandle(result: { stdout: string; stderr: string; code: number }) {56function mockHandle(result: { stdout: string; stderr: string; code: number }) {
43 return ChildProcessSpawner.makeHandle({57 return ChildProcessSpawner.makeHandle({
...@@ -596,12 +610,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -596,12 +610,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
596 }),610 }),
597 ),611 ),
598 );612 );
599 const runtimeServices = yield* Layer.build(613 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
600 Layer.mergeAll(614 Scope.provide(scope),
601 Layer.succeed(ServerSettingsService, serverSettings),615 );
602 providerRegistryLayer,
603 ),
604 ).pipe(Scope.provide(scope));
605616
606 yield* Effect.gen(function* () {617 yield* Effect.gen(function* () {
607 const registry = yield* ProviderRegistry;618 const registry = yield* ProviderRegistry;
...@@ -630,6 +641,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -630,6 +641,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
630 prefix: "t3-provider-registry-",641 prefix: "t3-provider-registry-",
631 }),642 }),
632 ),643 ),
644 Layer.provideMerge(
645 Layer.succeed(OpenCodeProvider, {
646 getSnapshot: Effect.succeed(fakeOpenCodeSnapshot),
647 refresh: Effect.succeed(fakeOpenCodeSnapshot),
648 streamChanges: Stream.empty,
649 }),
650 ),
633 Layer.provideMerge(651 Layer.provideMerge(
634 mockCommandSpawnerLayer((command, args) => {652 mockCommandSpawnerLayer((command, args) => {
635 const joined = args.join(" ");653 const joined = args.join(" ");
...@@ -646,12 +664,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -646,12 +664,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
646 }),664 }),
647 ),665 ),
648 );666 );
649 const runtimeServices = yield* Layer.build(667 const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe(
650 Layer.mergeAll(668 Scope.provide(scope),
651 Layer.succeed(ServerSettingsService, serverSettings),669 );
652 providerRegistryLayer,
653 ),
654 ).pipe(Scope.provide(scope));
655670
656 yield* Effect.gen(function* () {671 yield* Effect.gen(function* () {
657 const registry = yield* ProviderRegistry;672 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...@@ -9,10 +9,13 @@ import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "eff
9import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
10import { ClaudeProviderLive } from "./ClaudeProvider.ts";10import { ClaudeProviderLive } from "./ClaudeProvider.ts";
11import { CodexProviderLive } from "./CodexProvider.ts";11import { CodexProviderLive } from "./CodexProvider.ts";
12import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
12import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts";13import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts";
13import { ClaudeProvider } from "../Services/ClaudeProvider.ts";14import { ClaudeProvider } from "../Services/ClaudeProvider.ts";
14import type { CodexProviderShape } from "../Services/CodexProvider.ts";15import type { CodexProviderShape } from "../Services/CodexProvider.ts";
15import { CodexProvider } from "../Services/CodexProvider.ts";16import { CodexProvider } from "../Services/CodexProvider.ts";
17import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider.ts";
18import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
16import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";19import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
17import {20import {
18 hydrateCachedProvider,21 hydrateCachedProvider,
...@@ -26,10 +29,14 @@ import {...@@ -26,10 +29,14 @@ import {
26const loadProviders = (29const loadProviders = (
27 codexProvider: CodexProviderShape,30 codexProvider: CodexProviderShape,
28 claudeProvider: ClaudeProviderShape,31 claudeProvider: ClaudeProviderShape,
29): Effect.Effect<readonly [ServerProvider, ServerProvider]> =>32 openCodeProvider: OpenCodeProviderShape,
30 Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], {33): Effect.Effect<readonly [ServerProvider, ServerProvider, ServerProvider]> =>
31 concurrency: "unbounded",34 Effect.all(
32 });35 [codexProvider.getSnapshot, claudeProvider.getSnapshot, openCodeProvider.getSnapshot],
36 {
37 concurrency: "unbounded",
38 },
39 );
3340
34export const haveProvidersChanged = (41export const haveProvidersChanged = (
35 previousProviders: ReadonlyArray<ServerProvider>,42 previousProviders: ReadonlyArray<ServerProvider>,
...@@ -41,6 +48,7 @@ export const ProviderRegistryLive = Layer.effect(...@@ -41,6 +48,7 @@ export const ProviderRegistryLive = Layer.effect(
41 Effect.gen(function* () {48 Effect.gen(function* () {
42 const codexProvider = yield* CodexProvider;49 const codexProvider = yield* CodexProvider;
43 const claudeProvider = yield* ClaudeProvider;50 const claudeProvider = yield* ClaudeProvider;
51 const openCodeProvider = yield* OpenCodeProvider;
44 const config = yield* ServerConfig;52 const config = yield* ServerConfig;
45 const fileSystem = yield* FileSystem.FileSystem;53 const fileSystem = yield* FileSystem.FileSystem;
46 const path = yield* Path.Path;54 const path = yield* Path.Path;
...@@ -48,7 +56,7 @@ export const ProviderRegistryLive = Layer.effect(...@@ -48,7 +56,7 @@ export const ProviderRegistryLive = Layer.effect(
48 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),56 PubSub.unbounded<ReadonlyArray<ServerProvider>>(),
49 PubSub.shutdown,57 PubSub.shutdown,
50 );58 );
51 const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider);59 const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider, openCodeProvider);
52 const cachePathByProvider = new Map(60 const cachePathByProvider = new Map(
53 PROVIDER_CACHE_IDS.map(61 PROVIDER_CACHE_IDS.map(
54 (provider) =>62 (provider) =>
...@@ -156,6 +164,10 @@ export const ProviderRegistryLive = Layer.effect(...@@ -156,6 +164,10 @@ export const ProviderRegistryLive = Layer.effect(
156 return yield* claudeProvider.refresh.pipe(164 return yield* claudeProvider.refresh.pipe(
157 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),165 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
158 );166 );
167 case "opencode":
168 return yield* openCodeProvider.refresh.pipe(
169 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
170 );
159 default:171 default:
160 return yield* Effect.all(172 return yield* Effect.all(
161 [173 [
...@@ -165,6 +177,9 @@ export const ProviderRegistryLive = Layer.effect(...@@ -165,6 +177,9 @@ export const ProviderRegistryLive = Layer.effect(
165 claudeProvider.refresh.pipe(177 claudeProvider.refresh.pipe(
166 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),178 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
167 ),179 ),
180 openCodeProvider.refresh.pipe(
181 Effect.flatMap((nextProvider) => syncProvider(nextProvider)),
182 ),
168 ],183 ],
169 {184 {
170 concurrency: "unbounded",185 concurrency: "unbounded",
...@@ -180,6 +195,9 @@ export const ProviderRegistryLive = Layer.effect(...@@ -180,6 +195,9 @@ export const ProviderRegistryLive = Layer.effect(
180 yield* Stream.runForEach(claudeProvider.streamChanges, (provider) =>195 yield* Stream.runForEach(claudeProvider.streamChanges, (provider) =>
181 syncProvider(provider),196 syncProvider(provider),
182 ).pipe(Effect.forkScoped);197 ).pipe(Effect.forkScoped);
198 yield* Stream.runForEach(openCodeProvider.streamChanges, (provider) =>
199 syncProvider(provider),
200 ).pipe(Effect.forkScoped);
183201
184 return {202 return {
185 getProviders: Ref.get(providersRef),203 getProviders: Ref.get(providersRef),
...@@ -193,4 +211,8 @@ export const ProviderRegistryLive = Layer.effect(...@@ -193,4 +211,8 @@ export const ProviderRegistryLive = Layer.effect(
193 },211 },
194 } satisfies ProviderRegistryShape;212 } satisfies ProviderRegistryShape;
195 }),213 }),
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
...@@ -333,6 +333,62 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()...@@ -333,6 +333,62 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", ()
333);333);
334334
335const routing = makeProviderServiceLayer();335const routing = makeProviderServiceLayer();
336
337it.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
336it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () =>392it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () =>
337 Effect.gen(function* () {393 Effect.gen(function* () {
338 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-"));394 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* (...@@ -161,7 +161,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
161 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>161 const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
162 Effect.succeed(event).pipe(162 Effect.succeed(event).pipe(
163 Effect.tap((canonicalEvent) =>163 Effect.tap((canonicalEvent) =>
164 canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void,164 canonicalEventLogger
165 ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId)
166 : Effect.void,
165 ),167 ),
166 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),168 Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)),
167 Effect.asVoid,169 Effect.asVoid,
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+1-1
...@@ -24,7 +24,7 @@ function decodeProviderKind(...@@ -24,7 +24,7 @@ function decodeProviderKind(
24 providerName: string,24 providerName: string,
25 operation: string,25 operation: string,
26): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {26): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
27 if (providerName === "codex" || providerName === "claudeAgent") {27 if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") {
28 return Effect.succeed(providerName);28 return Effect.succeed(providerName);
29 }29 }
30 return Effect.fail(30 return Effect.fail(
apps/server/src/provider/Services/OpenCodeAdapter.ts created+12
...@@ -0,0 +1,12 @@
1import { Context } from "effect";
2
3import type { ProviderAdapterError } from "../Errors.ts";
4import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
5
6export interface OpenCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> {
7 readonly provider: "opencode";
8}
9
10export class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()(
11 "t3/provider/Services/OpenCodeAdapter",
12) {}
apps/server/src/provider/Services/OpenCodeProvider.ts created+9
...@@ -0,0 +1,9 @@
1import { Context } from "effect";
2
3import type { ServerProviderShape } from "./ServerProvider.ts";
4
5export interface OpenCodeProviderShape extends ServerProviderShape {}
6
7export class OpenCodeProvider extends Context.Service<OpenCodeProvider, OpenCodeProviderShape>()(
8 "t3/provider/Services/OpenCodeProvider",
9) {}
apps/server/src/provider/opencodeRuntime.test.ts created+38
...@@ -0,0 +1,38 @@
1import assert from "node:assert/strict";
2
3import { describe, it, vi } from "vitest";
4
5const 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
15vi.mock("node:child_process", () => childProcessMock);
16
17describe("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 created+573
...@@ -0,0 +1,573 @@
1import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2import * as FS from "node:fs";
3import { createServer, type AddressInfo } from "node:net";
4import * as OS from "node:os";
5import * as Path from "node:path";
6import { pathToFileURL } from "node:url";
7
8import type {
9 ChatAttachment,
10 ModelCapabilities,
11 ProviderApprovalDecision,
12 RuntimeMode,
13 ServerProviderModel,
14} from "@t3tools/contracts";
15import {
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
26const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
27const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000;
28const DEFAULT_HOSTNAME = "127.0.0.1";
29
30const OPENAI_VARIANTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
31const ANTHROPIC_VARIANTS = ["high", "max"];
32const GOOGLE_VARIANTS = ["low", "high"];
33
34export const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = {
35 reasoningEffortLevels: [],
36 supportsFastMode: false,
37 supportsThinkingToggle: false,
38 contextWindowOptions: [],
39 promptInjectedEffortLevels: [],
40};
41
42export interface OpenCodeServerProcess {
43 readonly url: string;
44 readonly process: ChildProcess;
45 close(): void;
46}
47
48export interface OpenCodeServerConnection {
49 readonly url: string;
50 readonly process: ChildProcess | null;
51 readonly external: boolean;
52 close(): void;
53}
54
55function buildOpenCodeBasicAuthorizationHeader(password: string): string {
56 return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;
57}
58
59export interface OpenCodeCommandResult {
60 readonly stdout: string;
61 readonly stderr: string;
62 readonly code: number;
63}
64
65export interface OpenCodeInventory {
66 readonly providerList: ProviderListResponse;
67 readonly agents: ReadonlyArray<Agent>;
68}
69
70export interface ParsedOpenCodeModelSlug {
71 readonly providerID: string;
72 readonly modelID: string;
73}
74
75function 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
83function 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
94function isPrimaryAgent(agent: Agent): boolean {
95 return !agent.hidden && (agent.mode === "primary" || agent.mode === "all");
96}
97
98function 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
111function 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
127function 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
148function 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
166function 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
180export 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
199export function toOpenCodeModelSlug(providerID: string, modelID: string): string {
200 return `${providerID}/${modelID}`;
201}
202
203export 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
214export 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
237export 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
255export 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
270export 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
289export 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
303export 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
313export 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
352export 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
451export 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
483export 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
514export 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
533export 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
547export 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 created+34
...@@ -0,0 +1,34 @@
1import { describe, expect, it } from "vitest";
2import type { ModelCapabilities } from "@t3tools/contracts";
3
4import { providerModelsFromSettings } from "./providerSnapshot.ts";
5
6const 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
16describe("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
...@@ -37,6 +37,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {...@@ -37,6 +37,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
37 status: "warning",37 status: "warning",
38 auth: { status: "unknown" },38 auth: { status: "unknown" },
39 });39 });
40 const openCodeProvider = makeProvider("opencode", {
41 status: "warning",
42 auth: { status: "unknown", type: "opencode" },
43 });
40 const codexPath = resolveProviderStatusCachePath({44 const codexPath = resolveProviderStatusCachePath({
41 cacheDir: tempDir,45 cacheDir: tempDir,
42 provider: "codex",46 provider: "codex",
...@@ -45,6 +49,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {...@@ -45,6 +49,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
45 cacheDir: tempDir,49 cacheDir: tempDir,
46 provider: "claudeAgent",50 provider: "claudeAgent",
47 });51 });
52 const openCodePath = resolveProviderStatusCachePath({
53 cacheDir: tempDir,
54 provider: "opencode",
55 });
4856
49 yield* writeProviderStatusCache({57 yield* writeProviderStatusCache({
50 filePath: codexPath,58 filePath: codexPath,
...@@ -54,9 +62,14 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {...@@ -54,9 +62,14 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
54 filePath: claudePath,62 filePath: claudePath,
55 provider: claudeProvider,63 provider: claudeProvider,
56 });64 });
65 yield* writeProviderStatusCache({
66 filePath: openCodePath,
67 provider: openCodeProvider,
68 });
5769
58 assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider);70 assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider);
59 assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider);71 assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider);
72 assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider);
60 }),73 }),
61 );74 );
6275
apps/server/src/provider/providerStatusCache.ts+5-3
...@@ -2,9 +2,11 @@ import * as nodePath from "node:path";...@@ -2,9 +2,11 @@ import * as nodePath from "node:path";
2import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts";2import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts";
3import { Cause, Effect, FileSystem, Path, Schema } from "effect";3import { Cause, Effect, FileSystem, Path, Schema } from "effect";
44
5export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray<5export const PROVIDER_CACHE_IDS = [
6 ServerProvider["provider"]6 "codex",
7>;7 "claudeAgent",
8 "opencode",
9] as const satisfies ReadonlyArray<ServerProvider["provider"]>;
810
9const decodeProviderStatusCache = Schema.decodeUnknownEffect(11const decodeProviderStatusCache = Schema.decodeUnknownEffect(
10 Schema.fromJsonString(ServerProviderSchema),12 Schema.fromJsonString(ServerProviderSchema),
apps/server/src/server.ts+5
...@@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD...@@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
21import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts";21import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts";
22import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts";22import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts";
23import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts";23import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts";
24import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts";
24import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";25import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
25import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts";26import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts";
26import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";27import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
...@@ -154,9 +155,13 @@ const ProviderLayerLive = Layer.unwrap(...@@ -154,9 +155,13 @@ const ProviderLayerLive = Layer.unwrap(
154 const claudeAdapterLayer = makeClaudeAdapterLive(155 const claudeAdapterLayer = makeClaudeAdapterLive(
155 nativeEventLogger ? { nativeEventLogger } : undefined,156 nativeEventLogger ? { nativeEventLogger } : undefined,
156 );157 );
158 const openCodeAdapterLayer = makeOpenCodeAdapterLive(
159 nativeEventLogger ? { nativeEventLogger } : undefined,
160 );
157 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(161 const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
158 Layer.provide(codexAdapterLayer),162 Layer.provide(codexAdapterLayer),
159 Layer.provide(claudeAdapterLayer),163 Layer.provide(claudeAdapterLayer),
164 Layer.provide(openCodeAdapterLayer),
160 Layer.provideMerge(ProviderSessionDirectoryLayerLive),165 Layer.provideMerge(ProviderSessionDirectoryLayerLive),
161 );166 );
162 return makeProviderServiceLive(167 return makeProviderServiceLive(
apps/server/src/serverSettings.test.ts+20
...@@ -184,6 +184,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -184,6 +184,11 @@ it.layer(NodeServices.layer)("server settings", (it) => {
184 claudeAgent: {184 claudeAgent: {
185 binaryPath: " /opt/homebrew/bin/claude ",185 binaryPath: " /opt/homebrew/bin/claude ",
186 },186 },
187 opencode: {
188 binaryPath: " /opt/homebrew/bin/opencode ",
189 serverUrl: " http://127.0.0.1:4096 ",
190 serverPassword: " secret-password ",
191 },
187 },192 },
188 });193 });
189194
...@@ -199,6 +204,13 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -199,6 +204,13 @@ it.layer(NodeServices.layer)("server settings", (it) => {
199 customModels: [],204 customModels: [],
200 launchArgs: "",205 launchArgs: "",
201 });206 });
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 });
202 }).pipe(Effect.provide(makeServerSettingsLayer())),214 }).pipe(Effect.provide(makeServerSettingsLayer())),
203 );215 );
204216
...@@ -257,6 +269,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -257,6 +269,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {
257 codex: {269 codex: {
258 binaryPath: "/opt/homebrew/bin/codex",270 binaryPath: "/opt/homebrew/bin/codex",
259 },271 },
272 opencode: {
273 serverUrl: "http://127.0.0.1:4096",
274 serverPassword: "secret-password",
275 },
260 },276 },
261 });277 });
262278
...@@ -273,6 +289,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -273,6 +289,10 @@ it.layer(NodeServices.layer)("server settings", (it) => {
273 codex: {289 codex: {
274 binaryPath: "/opt/homebrew/bin/codex",290 binaryPath: "/opt/homebrew/bin/codex",
275 },291 },
292 opencode: {
293 serverUrl: "http://127.0.0.1:4096",
294 serverPassword: "secret-password",
295 },
276 },296 },
277 });297 });
278 }).pipe(Effect.provide(makeServerSettingsLayer())),298 }).pipe(Effect.provide(makeServerSettingsLayer())),
apps/server/src/serverSettings.ts+1-1
...@@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service<...@@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service<
105105
106const ServerSettingsJson = fromLenientJson(ServerSettings);106const ServerSettingsJson = fromLenientJson(ServerSettings);
107107
108const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"];108const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode"];
109109
110/**110/**
111 * Ensure the `textGenerationModelSelection` points to an enabled provider.111 * Ensure the `textGenerationModelSelection` points to an enabled provider.
apps/web/src/components/ChatView.tsx+6-9
...@@ -26,7 +26,7 @@ import {...@@ -26,7 +26,7 @@ import {
26 scopeProjectRef,26 scopeProjectRef,
27 scopeThreadRef,27 scopeThreadRef,
28} from "@t3tools/client-runtime";28} from "@t3tools/client-runtime";
29import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model";29import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model";
30import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";30import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
31import { truncate } from "@t3tools/shared/String";31import { truncate } from "@t3tools/shared/String";
32import { Debouncer } from "@tanstack/react-pacer";32import { Debouncer } from "@tanstack/react-pacer";
...@@ -2534,16 +2534,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -2534,16 +2534,13 @@ export default function ChatView(props: ChatViewProps) {
2534 }2534 }
2535 }2535 }
2536 const title = truncate(titleSeed);2536 const title = truncate(titleSeed);
2537 const threadCreateModelSelection: ModelSelection = {2537 const threadCreateModelSelection = createModelSelection(
2538 provider: ctxSelectedProvider,2538 ctxSelectedProvider,
2539 model:2539 ctxSelectedModel ||
2540 ctxSelectedModel ||
2541 activeProject.defaultModelSelection?.model ||2540 activeProject.defaultModelSelection?.model ||
2542 DEFAULT_MODEL_BY_PROVIDER.codex,2541 DEFAULT_MODEL_BY_PROVIDER.codex,
2543 ...(ctxSelectedModelSelection.options2542 ctxSelectedModelSelection.options,
2544 ? { options: ctxSelectedModelSelection.options }2543 );
2545 : {}),
2546 };
25472544
2548 // Auto-title from first message2545 // Auto-title from first message
2549 if (isFirstMessage && isServerThread) {2546 if (isFirstMessage && isServerThread) {
apps/web/src/components/Icons.tsx+4-2
...@@ -553,8 +553,10 @@ export const IntelliJIdeaIcon: Icon = (props) => {...@@ -553,8 +553,10 @@ export const IntelliJIdeaIcon: Icon = (props) => {
553export const OpenCodeIcon: Icon = (props) => (553export const OpenCodeIcon: Icon = (props) => (
554 <svg {...props} viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">554 <svg {...props} viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
555 <g clipPath="url(#opencode__clip0_1311_94969)">555 <g clipPath="url(#opencode__clip0_1311_94969)">
556 <path d="M24 32H8V16H24V32Z" fill="#BCBBBB" />556 <path className="dark:hidden" d="M24 32H8V16H24V32Z" fill="#CFCECD" />
557 <path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E" />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" />
558 </g>560 </g>
559 <defs>561 <defs>
560 <clipPath id="opencode__clip0_1311_94969">562 <clipPath id="opencode__clip0_1311_94969">
apps/web/src/components/KeybindingsToast.browser.tsx+7
...@@ -99,6 +99,13 @@ function createBaseServerConfig(): ServerConfig {...@@ -99,6 +99,13 @@ function createBaseServerConfig(): ServerConfig {
99 providers: {99 providers: {
100 codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] },100 codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] },
101 claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" },101 claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" },
102 opencode: {
103 enabled: true,
104 binaryPath: "",
105 serverUrl: "",
106 serverPassword: "",
107 customModels: [],
108 },
102 },109 },
103 },110 },
104 };111 };
apps/web/src/components/chat/ChatComposer.tsx+36-24
...@@ -16,7 +16,7 @@ import {...@@ -16,7 +16,7 @@ import {
16 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,16 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
17 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,17 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import { normalizeModelSlug } from "@t3tools/shared/model";19import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
20import {20import {
21 forwardRef,21 forwardRef,
22 memo,22 memo,
...@@ -70,6 +70,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";...@@ -70,6 +70,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
70import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";70import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
71import { searchSlashCommandItems } from "./composerSlashCommandSearch";71import { searchSlashCommandItems } from "./composerSlashCommandSearch";
72import {72import {
73 getComposerProviderControls,
73 getComposerProviderState,74 getComposerProviderState,
74 renderProviderTraitsMenuContent,75 renderProviderTraitsMenuContent,
75 renderProviderTraitsPicker,76 renderProviderTraitsPicker,
...@@ -159,6 +160,7 @@ const terminalContextIdListsEqual = (...@@ -159,6 +160,7 @@ const terminalContextIdListsEqual = (
159 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);160 contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]);
160161
161const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {162const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: {
163 showInteractionModeToggle: boolean;
162 interactionMode: ProviderInteractionMode;164 interactionMode: ProviderInteractionMode;
163 runtimeMode: RuntimeMode;165 runtimeMode: RuntimeMode;
164 showPlanToggle: boolean;166 showPlanToggle: boolean;
...@@ -175,25 +177,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -175,25 +177,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
175 <>177 <>
176 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />178 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
177179
178 <Button180 {props.showInteractionModeToggle ? (
179 variant="ghost"181 <>
180 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"182 <Button
181 size="sm"183 variant="ghost"
182 type="button"184 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
183 onClick={props.onToggleInteractionMode}185 size="sm"
184 title={186 type="button"
185 props.interactionMode === "plan"187 onClick={props.onToggleInteractionMode}
186 ? "Plan mode — click to return to normal build mode"188 title={
187 : "Default mode — click to enter plan mode"189 props.interactionMode === "plan"
188 }190 ? "Plan mode — click to return to normal build mode"
189 >191 : "Default mode — click to enter plan mode"
190 <BotIcon />192 }
191 <span className="sr-only sm:not-sr-only">193 >
192 {props.interactionMode === "plan" ? "Plan" : "Build"}194 <BotIcon />
193 </span>195 <span className="sr-only sm:not-sr-only">
194 </Button>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
198 <Select204 <Select
199 value={props.runtimeMode}205 value={props.runtimeMode}
...@@ -588,12 +594,12 @@ export const ChatComposer = memo(...@@ -588,12 +594,12 @@ export const ChatComposer = memo(
588594
589 const selectedPromptEffort = composerProviderState.promptEffort;595 const selectedPromptEffort = composerProviderState.promptEffort;
590 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;596 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
597 const composerProviderControls = useMemo(
598 () => getComposerProviderControls(selectedProvider),
599 [selectedProvider],
600 );
591 const selectedModelSelection = useMemo<ModelSelection>(601 const selectedModelSelection = useMemo<ModelSelection>(
592 () => ({602 () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch),
593 provider: selectedProvider,
594 model: selectedModel,
595 ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}),
596 }),
597 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],603 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
598 );604 );
599 const selectedModelForPicker = selectedModel;605 const selectedModelForPicker = selectedModel;
...@@ -604,6 +610,8 @@ export const ChatComposer = memo(...@@ -604,6 +610,8 @@ export const ChatComposer = memo(
604 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],610 codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [],
605 claudeAgent:611 claudeAgent:
606 providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [],612 providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [],
613 opencode:
614 providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [],
607 }),615 }),
608 [providerStatuses],616 [providerStatuses],
609 );617 );
...@@ -1933,6 +1941,7 @@ export const ChatComposer = memo(...@@ -1933,6 +1941,7 @@ export const ChatComposer = memo(
1933 planSidebarLabel={planSidebarLabel}1941 planSidebarLabel={planSidebarLabel}
1934 planSidebarOpen={planSidebarOpen}1942 planSidebarOpen={planSidebarOpen}
1935 runtimeMode={runtimeMode}1943 runtimeMode={runtimeMode}
1944 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
1936 traitsMenuContent={providerTraitsMenuContent}1945 traitsMenuContent={providerTraitsMenuContent}
1937 onToggleInteractionMode={toggleInteractionMode}1946 onToggleInteractionMode={toggleInteractionMode}
1938 onTogglePlanSidebar={togglePlanSidebar}1947 onTogglePlanSidebar={togglePlanSidebar}
...@@ -1950,6 +1959,9 @@ export const ChatComposer = memo(...@@ -1950,6 +1959,9 @@ export const ChatComposer = memo(
1950 </>1959 </>
1951 ) : null}1960 ) : null}
1952 <ComposerFooterModeControls1961 <ComposerFooterModeControls
1962 showInteractionModeToggle={
1963 composerProviderControls.showInteractionModeToggle
1964 }
1953 interactionMode={interactionMode}1965 interactionMode={interactionMode}
1954 runtimeMode={runtimeMode}1966 runtimeMode={runtimeMode}
1955 showPlanToggle={showPlanSidebarToggle}1967 showPlanToggle={showPlanSidebarToggle}
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+35
...@@ -126,6 +126,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str...@@ -126,6 +126,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
126 planSidebarLabel="Plan"126 planSidebarLabel="Plan"
127 planSidebarOpen={false}127 planSidebarOpen={false}
128 runtimeMode="approval-required"128 runtimeMode="approval-required"
129 showInteractionModeToggle
129 traitsMenuContent={130 traitsMenuContent={
130 <TraitsMenuContent131 <TraitsMenuContent
131 provider={provider}132 provider={provider}
...@@ -267,4 +268,38 @@ describe("CompactComposerControlsMenu", () => {...@@ -267,4 +268,38 @@ describe("CompactComposerControlsMenu", () => {
267 );268 );
268 });269 });
269 });270 });
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 });
270});305});
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+17-12
...@@ -18,6 +18,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls...@@ -18,6 +18,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
18 planSidebarLabel: string;18 planSidebarLabel: string;
19 planSidebarOpen: boolean;19 planSidebarOpen: boolean;
20 runtimeMode: RuntimeMode;20 runtimeMode: RuntimeMode;
21 showInteractionModeToggle: boolean;
21 traitsMenuContent?: ReactNode;22 traitsMenuContent?: ReactNode;
22 onToggleInteractionMode: () => void;23 onToggleInteractionMode: () => void;
23 onTogglePlanSidebar: () => void;24 onTogglePlanSidebar: () => void;
...@@ -44,18 +45,22 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls...@@ -44,18 +45,22 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
44 <MenuDivider />45 <MenuDivider />
45 </>46 </>
46 ) : null}47 ) : null}
47 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div>48 {props.showInteractionModeToggle ? (
48 <MenuRadioGroup49 <>
49 value={props.interactionMode}50 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div>
50 onValueChange={(value) => {51 <MenuRadioGroup
51 if (!value || value === props.interactionMode) return;52 value={props.interactionMode}
52 props.onToggleInteractionMode();53 onValueChange={(value) => {
53 }}54 if (!value || value === props.interactionMode) return;
54 >55 props.onToggleInteractionMode();
55 <MenuRadioItem value="default">Chat</MenuRadioItem>56 }}
56 <MenuRadioItem value="plan">Plan</MenuRadioItem>57 >
57 </MenuRadioGroup>58 <MenuRadioItem value="default">Chat</MenuRadioItem>
58 <MenuDivider />59 <MenuRadioItem value="plan">Plan</MenuRadioItem>
60 </MenuRadioGroup>
61 <MenuDivider />
62 </>
63 ) : null}
59 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div>64 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div>
60 <MenuRadioGroup65 <MenuRadioGroup
61 value={props.runtimeMode}66 value={props.runtimeMode}
apps/web/src/components/chat/ProviderModelPicker.tsx+2-4
...@@ -33,15 +33,13 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o...@@ -33,15 +33,13 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o
33const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {33const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
34 codex: OpenAI,34 codex: OpenAI,
35 claudeAgent: ClaudeAI,35 claudeAgent: ClaudeAI,
36 opencode: OpenCodeIcon,
36 cursor: CursorIcon,37 cursor: CursorIcon,
37};38};
3839
39export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);40export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);
40const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);41const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);
41const COMING_SOON_PROVIDER_OPTIONS = [42const COMING_SOON_PROVIDER_OPTIONS = [{ id: "gemini", label: "Gemini", icon: Gemini }] as const;
42 { id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
43 { id: "gemini", label: "Gemini", icon: Gemini },
44] as const;
4543
46function providerIconClassName(44function providerIconClassName(
47 provider: ProviderKind | ProviderPickerKind,45 provider: ProviderKind | ProviderPickerKind,
apps/web/src/components/chat/TraitsPicker.browser.tsx+128-1
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 CodexModelOptions,6 CodexModelOptions,
7 DEFAULT_MODEL_BY_PROVIDER,7 DEFAULT_MODEL_BY_PROVIDER,
8 DEFAULT_SERVER_SETTINGS,8 DEFAULT_SERVER_SETTINGS,
9 OpenCodeModelOptions,
9 EnvironmentId,10 EnvironmentId,
10 type ServerProvider,11 type ServerProvider,
11 ThreadId,12 ThreadId,
...@@ -64,6 +65,39 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -64,6 +65,39 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
64 },65 },
65 ],66 ],
66 },67 },
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 },
67 {101 {
68 provider: "claudeAgent",102 provider: "claudeAgent",
69 enabled: true,103 enabled: true,
...@@ -125,6 +159,13 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -125,6 +159,13 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
125 ],159 ],
126 },160 },
127];161];
162const 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
129function ClaudeTraitsPickerHarness(props: {170function ClaudeTraitsPickerHarness(props: {
130 model: string;171 model: string;
...@@ -154,7 +195,7 @@ function ClaudeTraitsPickerHarness(props: {...@@ -154,7 +195,7 @@ function ClaudeTraitsPickerHarness(props: {
154 return (195 return (
155 <TraitsPicker196 <TraitsPicker
156 provider="claudeAgent"197 provider="claudeAgent"
157 models={TEST_PROVIDERS[1]!.models}198 models={TEST_PROVIDERS[2]!.models}
158 threadRef={CLAUDE_THREAD_REF}199 threadRef={CLAUDE_THREAD_REF}
159 model={selectedModel ?? props.model}200 model={selectedModel ?? props.model}
160 prompt={prompt}201 prompt={prompt}
...@@ -501,3 +542,89 @@ describe("TraitsPicker (Codex)", () => {...@@ -501,3 +542,89 @@ describe("TraitsPicker (Codex)", () => {
501 });542 });
502 });543 });
503});544});
545
546// ── OpenCode TraitsPicker tests ───────────────────────────────────────
547
548async 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
604describe("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 @@...@@ -1,6 +1,7 @@
1import {1import {
2 type ClaudeModelOptions,2 type ClaudeModelOptions,
3 type CodexModelOptions,3 type CodexModelOptions,
4 type OpenCodeModelOptions,
4 type ProviderKind,5 type ProviderKind,
5 type ProviderModelOptions,6 type ProviderModelOptions,
6 type ScopedThreadRef,7 type ScopedThreadRef,
...@@ -33,6 +34,12 @@ import { getProviderModelCapabilities } from "../../providerModels";...@@ -33,6 +34,12 @@ import { getProviderModelCapabilities } from "../../providerModels";
33import { cn } from "~/lib/utils";34import { cn } from "~/lib/utils";
3435
35type ProviderOptions = ProviderModelOptions[ProviderKind];36type ProviderOptions = ProviderModelOptions[ProviderKind];
37type NamedOption = {
38 value: string;
39 label: string;
40 isDefault?: boolean | undefined;
41};
42
36type TraitsPersistence =43type TraitsPersistence =
37 | {44 | {
38 threadRef?: ScopedThreadRef;45 threadRef?: ScopedThreadRef;
...@@ -53,9 +60,29 @@ function getRawEffort(...@@ -53,9 +60,29 @@ function getRawEffort(
53 if (provider === "codex") {60 if (provider === "codex") {
54 return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort);61 return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort);
55 }62 }
63 if (provider === "opencode") {
64 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant);
65 }
56 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);66 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);
57}67}
5868
69function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null {
70 return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent);
71}
72
73function 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
59function getRawContextWindow(86function getRawContextWindow(
60 provider: ProviderKind,87 provider: ProviderKind,
61 modelOptions: ProviderOptions | null | undefined,88 modelOptions: ProviderOptions | null | undefined,
...@@ -74,6 +101,12 @@ function buildNextOptions(...@@ -74,6 +101,12 @@ function buildNextOptions(
74 if (provider === "codex") {101 if (provider === "codex") {
75 return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions;102 return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions;
76 }103 }
104 if (provider === "opencode") {
105 return {
106 ...(modelOptions as OpenCodeModelOptions | undefined),
107 ...patch,
108 } as OpenCodeModelOptions;
109 }
77 return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions;110 return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions;
78}111}
79112
...@@ -86,15 +119,21 @@ function getSelectedTraits(...@@ -86,15 +119,21 @@ function getSelectedTraits(
86 allowPromptInjectedEffort: boolean,119 allowPromptInjectedEffort: boolean,
87) {120) {
88 const caps = getProviderModelCapabilities(models, model, provider);121 const caps = getProviderModelCapabilities(models, model, provider);
89 const effortLevels = allowPromptInjectedEffort122 const effortLevels =
90 ? caps.reasoningEffortLevels123 provider === "opencode"
91 : caps.reasoningEffortLevels.filter(124 ? (caps.variantOptions ?? [])
92 (option) => !caps.promptInjectedEffortLevels.includes(option.value),125 : allowPromptInjectedEffort
93 );126 ? caps.reasoningEffortLevels
127 : caps.reasoningEffortLevels.filter(
128 (option) => !caps.promptInjectedEffortLevels.includes(option.value),
129 );
94130
95 // Resolve effort from options (provider-specific key)131 // Resolve effort from options (provider-specific key)
96 const rawEffort = getRawEffort(provider, modelOptions);132 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
99 // Thinking toggle (only for models that support it)138 // Thinking toggle (only for models that support it)
100 const thinkingEnabled = caps.supportsThinkingToggle139 const thinkingEnabled = caps.supportsThinkingToggle
...@@ -125,6 +164,10 @@ function getSelectedTraits(...@@ -125,6 +164,10 @@ function getSelectedTraits(
125 const ultrathinkInBodyText =164 const ultrathinkInBodyText =
126 ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, ""));165 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
128 return {171 return {
129 caps,172 caps,
130 effort,173 effort,
...@@ -136,6 +179,9 @@ function getSelectedTraits(...@@ -136,6 +179,9 @@ function getSelectedTraits(
136 defaultContextWindow,179 defaultContextWindow,
137 ultrathinkPromptControlled,180 ultrathinkPromptControlled,
138 ultrathinkInBodyText,181 ultrathinkInBodyText,
182 agentOptions,
183 selectedAgent: selectedAgentOption?.value ?? null,
184 selectedAgentLabel: selectedAgentOption?.label ?? null,
139 };185 };
140}186}
141187
...@@ -189,6 +235,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -189,6 +235,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
189 defaultContextWindow,235 defaultContextWindow,
190 ultrathinkPromptControlled,236 ultrathinkPromptControlled,
191 ultrathinkInBodyText,237 ultrathinkInBodyText,
238 agentOptions,
239 selectedAgent,
192 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);240 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
193 const defaultEffort = getDefaultEffort(caps);241 const defaultEffort = getDefaultEffort(caps);
194242
...@@ -197,6 +245,10 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -197,6 +245,10 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
197 if (!value) return;245 if (!value) return;
198 const nextOption = effortLevels.find((option) => option.value === value);246 const nextOption = effortLevels.find((option) => option.value === value);
199 if (!nextOption) return;247 if (!nextOption) return;
248 if (provider === "opencode") {
249 updateModelOptions(buildNextOptions(provider, modelOptions, { variant: nextOption.value }));
250 return;
251 }
200 if (caps.promptInjectedEffortLevels.includes(nextOption.value)) {252 if (caps.promptInjectedEffortLevels.includes(nextOption.value)) {
201 const nextPrompt =253 const nextPrompt =
202 prompt.trim().length === 0254 prompt.trim().length === 0
...@@ -228,7 +280,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -228,7 +280,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
228 ],280 ],
229 );281 );
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 ) {
232 return null;289 return null;
233 }290 }
234291
...@@ -237,7 +294,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -237,7 +294,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
237 {effort ? (294 {effort ? (
238 <>295 <>
239 <MenuGroup>296 <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>
241 {ultrathinkInBodyText ? (300 {ultrathinkInBodyText ? (
242 <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs">301 <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs">
243 Your prompt contains &quot;ultrathink&quot; in the text. Remove it to change effort.302 Your prompt contains &quot;ultrathink&quot; in the text. Remove it to change effort.
...@@ -254,7 +313,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -254,7 +313,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
254 disabled={ultrathinkInBodyText}313 disabled={ultrathinkInBodyText}
255 >314 >
256 {option.label}315 {option.label}
257 {option.value === defaultEffort ? " (default)" : ""}316 {(provider === "opencode" ? option.isDefault : option.value === defaultEffort)
317 ? " (default)"
318 : ""}
258 </MenuRadioItem>319 </MenuRadioItem>
259 ))}320 ))}
260 </MenuRadioGroup>321 </MenuRadioGroup>
...@@ -322,6 +383,27 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -322,6 +383,27 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
322 </MenuGroup>383 </MenuGroup>
323 </>384 </>
324 ) : null}385 ) : 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}
325 </>407 </>
326 );408 );
327});409});
...@@ -349,6 +431,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -349,6 +431,7 @@ export const TraitsPicker = memo(function TraitsPicker({
349 contextWindow,431 contextWindow,
350 defaultContextWindow,432 defaultContextWindow,
351 ultrathinkPromptControlled,433 ultrathinkPromptControlled,
434 selectedAgentLabel,
352 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);435 } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort);
353436
354 const effortLabel = effort437 const effortLabel = effort
...@@ -368,6 +451,7 @@ export const TraitsPicker = memo(function TraitsPicker({...@@ -368,6 +451,7 @@ export const TraitsPicker = memo(function TraitsPicker({
368 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,451 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,
369 ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []),452 ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []),
370 ...(contextWindowLabel ? [contextWindowLabel] : []),453 ...(contextWindowLabel ? [contextWindowLabel] : []),
454 ...(selectedAgentLabel ? [selectedAgentLabel] : []),
371 ]455 ]
372 .filter(Boolean)456 .filter(Boolean)
373 .join(" · ");457 .join(" · ");
apps/web/src/components/chat/composerProviderRegistry.test.tsx+65
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import type { ServerProviderModel } from "@t3tools/contracts";2import type { ServerProviderModel } from "@t3tools/contracts";
3import {3import {
4 getComposerProviderControls,
4 getComposerProviderState,5 getComposerProviderState,
5 renderProviderTraitsMenuContent,6 renderProviderTraitsMenuContent,
6 renderProviderTraitsPicker,7 renderProviderTraitsPicker,
...@@ -110,6 +111,29 @@ const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray<ServerProviderModel> = [...@@ -110,6 +111,29 @@ const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray<ServerProviderModel> = [
110 },111 },
111];112];
112113
114const 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
113describe("getComposerProviderState", () => {137describe("getComposerProviderState", () => {
114 it("returns codex defaults when no codex draft options exist", () => {138 it("returns codex defaults when no codex draft options exist", () => {
115 const state = getComposerProviderState({139 const state = getComposerProviderState({
...@@ -420,6 +444,47 @@ describe("getComposerProviderState", () => {...@@ -420,6 +444,47 @@ describe("getComposerProviderState", () => {
420444
421 expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode");445 expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode");
422 });446 });
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
473describe("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 });
423});488});
424489
425describe("provider traits render guards", () => {490describe("provider traits render guards", () => {
apps/web/src/components/chat/composerProviderRegistry.tsx+54-68
...@@ -4,15 +4,17 @@ import {...@@ -4,15 +4,17 @@ import {
4 type ScopedThreadRef,4 type ScopedThreadRef,
5 type ServerProviderModel,5 type ServerProviderModel,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";7import {
8 isClaudeUltrathinkPrompt,
9 normalizeProviderModelOptionsWithCapabilities,
10 resolveEffort,
11 trimOrNull,
12} from "@t3tools/shared/model";
8import type { ReactNode } from "react";13import type { ReactNode } from "react";
14
9import type { DraftId } from "../../composerDraftStore";15import type { DraftId } from "../../composerDraftStore";
10import { getProviderModelCapabilities } from "../../providerModels";16import { getProviderModelCapabilities } from "../../providerModels";
11import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";17import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
12import {
13 normalizeClaudeModelOptionsWithCapabilities,
14 normalizeCodexModelOptionsWithCapabilities,
15} from "@t3tools/shared/model";
1618
17export type ComposerProviderStateInput = {19export type ComposerProviderStateInput = {
18 provider: ProviderKind;20 provider: ProviderKind;
...@@ -31,7 +33,12 @@ export type ComposerProviderState = {...@@ -31,7 +33,12 @@ export type ComposerProviderState = {
31 modelPickerIconClassName?: string;33 modelPickerIconClassName?: string;
32};34};
3335
36export type ComposerProviderControls = {
37 showInteractionModeToggle: boolean;
38};
39
34type ProviderRegistryEntry = {40type ProviderRegistryEntry = {
41 controls: ComposerProviderControls;
35 getState: (input: ComposerProviderStateInput) => ComposerProviderState;42 getState: (input: ComposerProviderStateInput) => ComposerProviderState;
36 renderTraitsMenuContent: (input: {43 renderTraitsMenuContent: (input: {
37 threadRef?: ScopedThreadRef;44 threadRef?: ScopedThreadRef;
...@@ -66,25 +73,26 @@ function getProviderStateFromCapabilities(...@@ -66,25 +73,26 @@ function getProviderStateFromCapabilities(
66 const { provider, model, models, prompt, modelOptions } = input;73 const { provider, model, models, prompt, modelOptions } = input;
67 const caps = getProviderModelCapabilities(models, model, provider);74 const caps = getProviderModelCapabilities(models, model, provider);
68 const providerOptions = modelOptions?.[provider];75 const providerOptions = modelOptions?.[provider];
69
70 // Resolve effort
71 const rawEffort = providerOptions76 const rawEffort = providerOptions
72 ? "effort" in providerOptions77 ? "effort" in providerOptions
73 ? providerOptions.effort78 ? providerOptions.effort
74 : "reasoningEffort" in providerOptions79 : "reasoningEffort" in providerOptions
75 ? providerOptions.reasoningEffort80 ? providerOptions.reasoningEffort
76 : null81 : "variant" in providerOptions
82 ? providerOptions.variant
83 : null
77 : null;84 : null;
7885 const normalizedOptions = normalizeProviderModelOptionsWithCapabilities(
79 const promptEffort = resolveEffort(caps, rawEffort) ?? null;86 provider,
8087 caps,
81 // Normalize options for dispatch88 providerOptions,
82 const normalizedOptions =89 );
83 provider === "codex"90 const promptEffort =
84 ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions)91 provider === "opencode"
85 : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions);92 ? (trimOrNull(
8693 normalizedOptions && "variant" in normalizedOptions ? normalizedOptions.variant : null,
87 // Ultrathink styling (driven by capabilities data, not provider identity)94 ) ?? null)
95 : (resolveEffort(caps, rawEffort) ?? null);
88 const ultrathinkActive =96 const ultrathinkActive =
89 caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt);97 caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt);
9098
...@@ -100,53 +108,19 @@ function getProviderStateFromCapabilities(...@@ -100,53 +108,19 @@ function getProviderStateFromCapabilities(
100 };108 };
101}109}
102110
103const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {111const DEFAULT_PROVIDER_CONTROLS: ComposerProviderControls = {
104 codex: {112 showInteractionModeToggle: true,
105 getState: (input) => getProviderStateFromCapabilities(input),113};
106 renderTraitsMenuContent: ({114
107 threadRef,115function createProviderRegistryEntry(
108 draftId,116 provider: ProviderKind,
109 model,117 controls?: Partial<ComposerProviderControls>,
110 models,118): ProviderRegistryEntry {
111 modelOptions,119 return {
112 prompt,120 controls: {
113 onPromptChange,121 ...DEFAULT_PROVIDER_CONTROLS,
114 }) =>122 ...controls,
115 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (123 },
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: {
150 getState: (input) => getProviderStateFromCapabilities(input),124 getState: (input) => getProviderStateFromCapabilities(input),
151 renderTraitsMenuContent: ({125 renderTraitsMenuContent: ({
152 threadRef,126 threadRef,
...@@ -159,7 +133,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {...@@ -159,7 +133,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
159 }) =>133 }) =>
160 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (134 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
161 <TraitsMenuContent135 <TraitsMenuContent
162 provider="claudeAgent"136 provider={provider}
163 models={models}137 models={models}
164 {...(threadRef ? { threadRef } : {})}138 {...(threadRef ? { threadRef } : {})}
165 {...(draftId ? { draftId } : {})}139 {...(draftId ? { draftId } : {})}
...@@ -180,7 +154,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {...@@ -180,7 +154,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
180 }) =>154 }) =>
181 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (155 !hasComposerTraitsTarget({ threadRef, draftId }) ? null : (
182 <TraitsPicker156 <TraitsPicker
183 provider="claudeAgent"157 provider={provider}
184 models={models}158 models={models}
185 {...(threadRef ? { threadRef } : {})}159 {...(threadRef ? { threadRef } : {})}
186 {...(draftId ? { draftId } : {})}160 {...(draftId ? { draftId } : {})}
...@@ -190,13 +164,25 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {...@@ -190,13 +164,25 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
190 onPromptChange={onPromptChange}164 onPromptChange={onPromptChange}
191 />165 />
192 ),166 ),
193 },167 };
168}
169
170const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
171 codex: createProviderRegistryEntry("codex"),
172 claudeAgent: createProviderRegistryEntry("claudeAgent"),
173 opencode: createProviderRegistryEntry("opencode", {
174 showInteractionModeToggle: false,
175 }),
194};176};
195177
196export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {178export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState {
197 return composerProviderRegistry[input.provider].getState(input);179 return composerProviderRegistry[input.provider].getState(input);
198}180}
199181
182export function getComposerProviderControls(provider: ProviderKind): ComposerProviderControls {
183 return composerProviderRegistry[provider].controls;
184}
185
200export function renderProviderTraitsMenuContent(input: {186export function renderProviderTraitsMenuContent(input: {
201 provider: ProviderKind;187 provider: ProviderKind;
202 threadRef?: ScopedThreadRef;188 threadRef?: ScopedThreadRef;
apps/web/src/components/settings/SettingsPanels.browser.tsx+17
...@@ -701,4 +701,21 @@ describe("GeneralSettingsPanel observability", () => {...@@ -701,4 +701,21 @@ describe("GeneralSettingsPanel observability", () => {
701701
702 expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor");702 expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor");
703 });703 });
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 });
704});721});
apps/web/src/components/settings/SettingsPanels.tsx+119-7
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21import { scopeThreadRef } from "@t3tools/client-runtime";21import { scopeThreadRef } from "@t3tools/client-runtime";
22import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";22import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
23import { normalizeModelSlug } from "@t3tools/shared/model";23import { normalizeModelSlug } from "@t3tools/shared/model";
24import { createModelSelection } from "@t3tools/shared/model";
24import { Equal } from "effect";25import { Equal } from "effect";
25import { APP_VERSION } from "../../branding";26import { APP_VERSION } from "../../branding";
26import {27import {
...@@ -104,6 +105,10 @@ type InstallProviderSettings = {...@@ -104,6 +105,10 @@ type InstallProviderSettings = {
104 title: string;105 title: string;
105 binaryPlaceholder: string;106 binaryPlaceholder: string;
106 binaryDescription: ReactNode;107 binaryDescription: ReactNode;
108 serverUrlPlaceholder?: string;
109 serverUrlDescription?: ReactNode;
110 serverPasswordPlaceholder?: string;
111 serverPasswordDescription?: ReactNode;
107 homePathKey?: "codexHomePath";112 homePathKey?: "codexHomePath";
108 homePlaceholder?: string;113 homePlaceholder?: string;
109 homeDescription?: ReactNode;114 homeDescription?: ReactNode;
...@@ -125,6 +130,17 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [...@@ -125,6 +130,17 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
125 binaryPlaceholder: "Claude binary path",130 binaryPlaceholder: "Claude binary path",
126 binaryDescription: "Path to the Claude binary",131 binaryDescription: "Path to the Claude binary",
127 },132 },
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 },
128] as const;144] as const;
129145
130const PROVIDER_STATUS_STYLES = {146const PROVIDER_STATUS_STYLES = {
...@@ -514,12 +530,22 @@ export function GeneralSettingsPanel() {...@@ -514,12 +530,22 @@ export function GeneralSettingsPanel() {
514 settings.providers.claudeAgent.customModels.length > 0 ||530 settings.providers.claudeAgent.customModels.length > 0 ||
515 settings.providers.claudeAgent.launchArgs !== "",531 settings.providers.claudeAgent.launchArgs !== "",
516 ),532 ),
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 ),
517 });542 });
518 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<543 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
519 Record<ProviderKind, string>544 Record<ProviderKind, string>
520 >({545 >({
521 codex: "",546 codex: "",
522 claudeAgent: "",547 claudeAgent: "",
548 opencode: "",
523 });549 });
524 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<550 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
525 Partial<Record<ProviderKind, string | null>>551 Partial<Record<ProviderKind, string | null>>
...@@ -732,10 +758,16 @@ export function GeneralSettingsPanel() {...@@ -732,10 +758,16 @@ export function GeneralSettingsPanel() {
732 title: providerSettings.title,758 title: providerSettings.title,
733 binaryPlaceholder: providerSettings.binaryPlaceholder,759 binaryPlaceholder: providerSettings.binaryPlaceholder,
734 binaryDescription: providerSettings.binaryDescription,760 binaryDescription: providerSettings.binaryDescription,
761 serverUrlPlaceholder: providerSettings.serverUrlPlaceholder,
762 serverUrlDescription: providerSettings.serverUrlDescription,
763 serverPasswordPlaceholder: providerSettings.serverPasswordPlaceholder,
764 serverPasswordDescription: providerSettings.serverPasswordDescription,
735 homePathKey: providerSettings.homePathKey,765 homePathKey: providerSettings.homePathKey,
736 homePlaceholder: providerSettings.homePlaceholder,766 homePlaceholder: providerSettings.homePlaceholder,
737 homeDescription: providerSettings.homeDescription,767 homeDescription: providerSettings.homeDescription,
738 binaryPathValue: providerConfig.binaryPath,768 binaryPathValue: providerConfig.binaryPath,
769 serverUrlValue: "serverUrl" in providerConfig ? providerConfig.serverUrl : "",
770 serverPasswordValue: "serverPassword" in providerConfig ? providerConfig.serverPassword : "",
739 isDirty: !Equal.equals(providerConfig, defaultProviderConfig),771 isDirty: !Equal.equals(providerConfig, defaultProviderConfig),
740 liveProvider,772 liveProvider,
741 models,773 models,
...@@ -1035,7 +1067,7 @@ export function GeneralSettingsPanel() {...@@ -1035,7 +1067,7 @@ export function GeneralSettingsPanel() {
1035 textGenerationModelSelection: resolveAppModelSelectionState(1067 textGenerationModelSelection: resolveAppModelSelectionState(
1036 {1068 {
1037 ...settings,1069 ...settings,
1038 textGenerationModelSelection: { provider, model },1070 textGenerationModelSelection: createModelSelection(provider, model),
1039 },1071 },
1040 serverProviders,1072 serverProviders,
1041 ),1073 ),
...@@ -1060,11 +1092,11 @@ export function GeneralSettingsPanel() {...@@ -1060,11 +1092,11 @@ export function GeneralSettingsPanel() {
1060 textGenerationModelSelection: resolveAppModelSelectionState(1092 textGenerationModelSelection: resolveAppModelSelectionState(
1061 {1093 {
1062 ...settings,1094 ...settings,
1063 textGenerationModelSelection: {1095 textGenerationModelSelection: createModelSelection(
1064 provider: textGenProvider,1096 textGenProvider,
1065 model: textGenModel,1097 textGenModel,
1066 ...(nextOptions ? { options: nextOptions } : {}),1098 nextOptions,
1067 },1099 ),
1068 },1100 },
1069 serverProviders,1101 serverProviders,
1070 ),1102 ),
...@@ -1243,6 +1275,84 @@ export function GeneralSettingsPanel() {...@@ -1243,6 +1275,84 @@ export function GeneralSettingsPanel() {
1243 </label>1275 </label>
1244 </div>1276 </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
1246 {providerCard.homePathKey ? (1356 {providerCard.homePathKey ? (
1247 <div className="border-t border-border/60 px-4 py-3 sm:px-5">1357 <div className="border-t border-border/60 px-4 py-3 sm:px-5">
1248 <label1358 <label
...@@ -1422,7 +1532,9 @@ export function GeneralSettingsPanel() {...@@ -1422,7 +1532,9 @@ export function GeneralSettingsPanel() {
1422 placeholder={1532 placeholder={
1423 providerCard.provider === "codex"1533 providerCard.provider === "codex"
1424 ? "gpt-6.7-codex-ultra-preview"1534 ? "gpt-6.7-codex-ultra-preview"
1425 : "claude-sonnet-5-0"1535 : providerCard.provider === "opencode"
1536 ? "openai/gpt-5"
1537 : "claude-sonnet-5-0"
1426 }1538 }
1427 spellCheck={false}1539 spellCheck={false}
1428 />1540 />
apps/web/src/composerDraftStore.ts+56-45
...@@ -25,7 +25,7 @@ import {...@@ -25,7 +25,7 @@ import {
25import * as Schema from "effect/Schema";25import * as Schema from "effect/Schema";
26import * as Equal from "effect/Equal";26import * as Equal from "effect/Equal";
27import { DeepMutable } from "effect/Types";27import { DeepMutable } from "effect/Types";
28import { normalizeModelSlug } from "@t3tools/shared/model";28import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";
29import { useMemo } from "react";29import { useMemo } from "react";
30import { getLocalStorageItem } from "./hooks/useLocalStorage";30import { getLocalStorageItem } from "./hooks/useLocalStorage";
31import { resolveAppModelSelection } from "./modelSelection";31import { resolveAppModelSelection } from "./modelSelection";
...@@ -527,7 +527,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {...@@ -527,7 +527,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
527}527}
528528
529function normalizeProviderKind(value: unknown): ProviderKind | null {529function normalizeProviderKind(value: unknown): ProviderKind | null {
530 return value === "codex" || value === "claudeAgent" ? value : null;530 return value === "codex" || value === "claudeAgent" || value === "opencode" ? value : null;
531}531}
532532
533function normalizeProviderModelOptions(533function normalizeProviderModelOptions(
...@@ -544,6 +544,10 @@ function normalizeProviderModelOptions(...@@ -544,6 +544,10 @@ function normalizeProviderModelOptions(
544 candidate?.claudeAgent && typeof candidate.claudeAgent === "object"544 candidate?.claudeAgent && typeof candidate.claudeAgent === "object"
545 ? (candidate.claudeAgent as Record<string, unknown>)545 ? (candidate.claudeAgent as Record<string, unknown>)
546 : null;546 : null;
547 const openCodeCandidate =
548 candidate?.opencode && typeof candidate.opencode === "object"
549 ? (candidate.opencode as Record<string, unknown>)
550 : null;
547551
548 const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort)552 const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort)
549 ? codexCandidate.reasoningEffort553 ? codexCandidate.reasoningEffort
...@@ -600,13 +604,29 @@ function normalizeProviderModelOptions(...@@ -600,13 +604,29 @@ function normalizeProviderModelOptions(
600 ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}),604 ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}),
601 }605 }
602 : undefined;606 : 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) {
605 return null;624 return null;
606 }625 }
607 return {626 return {
608 ...(codex ? { codex } : {}),627 ...(codex ? { codex } : {}),
609 ...(claude ? { claudeAgent: claude } : {}),628 ...(claude ? { claudeAgent: claude } : {}),
629 ...(opencode ? { opencode } : {}),
610 };630 };
611}631}
612632
...@@ -637,12 +657,8 @@ function normalizeModelSelection(...@@ -637,12 +657,8 @@ function normalizeModelSelection(
637 provider,657 provider,
638 provider === "codex" ? legacy?.legacyCodex : undefined,658 provider === "codex" ? legacy?.legacyCodex : undefined,
639 );659 );
640 const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent;660 const options = modelOptions?.[provider];
641 return {661 return createModelSelection(provider, model, options);
642 provider,
643 model,
644 ...(options ? { options } : {}),
645 };
646}662}
647663
648// ── Legacy sync helpers (used only during migration from v2 storage) ──664// ── Legacy sync helpers (used only during migration from v2 storage) ──
...@@ -655,11 +671,7 @@ function legacySyncModelSelectionOptions(...@@ -655,11 +671,7 @@ function legacySyncModelSelectionOptions(
655 return null;671 return null;
656 }672 }
657 const options = modelOptions?.[modelSelection.provider];673 const options = modelOptions?.[modelSelection.provider];
658 return {674 return createModelSelection(modelSelection.provider, modelSelection.model, options);
659 provider: modelSelection.provider,
660 model: modelSelection.model,
661 ...(options ? { options } : {}),
662 };
663}675}
664676
665function legacyMergeModelSelectionIntoProviderModelOptions(677function legacyMergeModelSelectionIntoProviderModelOptions(
...@@ -703,17 +715,16 @@ function legacyToModelSelectionByProvider(...@@ -703,17 +715,16 @@ function legacyToModelSelectionByProvider(
703 const result: Partial<Record<ProviderKind, ModelSelection>> = {};715 const result: Partial<Record<ProviderKind, ModelSelection>> = {};
704 // Add entries from the options bag (for non-active providers)716 // Add entries from the options bag (for non-active providers)
705 if (modelOptions) {717 if (modelOptions) {
706 for (const provider of ["codex", "claudeAgent"] as const) {718 for (const provider of ["codex", "claudeAgent", "opencode"] as const) {
707 const options = modelOptions[provider];719 const options = modelOptions[provider];
708 if (options && Object.keys(options).length > 0) {720 if (options && Object.keys(options).length > 0) {
709 result[provider] = {721 result[provider] = createModelSelection(
710 provider,722 provider,
711 model:723 modelSelection?.provider === provider
712 modelSelection?.provider === provider724 ? modelSelection.model
713 ? modelSelection.model725 : DEFAULT_MODEL_BY_PROVIDER[provider],
714 : DEFAULT_MODEL_BY_PROVIDER[provider],
715 options,726 options,
716 };727 );
717 }728 }
718 }729 }
719 }730 }
...@@ -2205,11 +2216,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2205,11 +2216,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2205 nextMap[normalized.provider] = normalized;2216 nextMap[normalized.provider] = normalized;
2206 } else {2217 } else {
2207 // No options in selection → preserve existing options, update provider+model2218 // No options in selection → preserve existing options, update provider+model
2208 nextMap[normalized.provider] = {2219 nextMap[normalized.provider] = createModelSelection(
2209 provider: normalized.provider,2220 normalized.provider,
2210 model: normalized.model,2221 normalized.model,
2211 ...(current?.options ? { options: current.options } : {}),2222 current?.options,
2212 };2223 );
2213 }2224 }
2214 }2225 }
2215 const nextActiveProvider = normalized?.provider ?? base.activeProvider;2226 const nextActiveProvider = normalized?.provider ?? base.activeProvider;
...@@ -2246,17 +2257,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2246,17 +2257,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2246 }2257 }
2247 const base = existing ?? createEmptyThreadDraft();2258 const base = existing ?? createEmptyThreadDraft();
2248 const nextMap = { ...base.modelSelectionByProvider };2259 const nextMap = { ...base.modelSelectionByProvider };
2249 for (const provider of ["codex", "claudeAgent"] as const) {2260 for (const provider of ["codex", "claudeAgent", "opencode"] as const) {
2250 // Only touch providers explicitly present in the input2261 // Only touch providers explicitly present in the input
2251 if (!normalizedOpts || !(provider in normalizedOpts)) continue;2262 if (!normalizedOpts || !(provider in normalizedOpts)) continue;
2252 const opts = normalizedOpts[provider];2263 const opts = normalizedOpts[provider];
2253 const current = nextMap[provider];2264 const current = nextMap[provider];
2254 if (opts) {2265 if (opts) {
2255 nextMap[provider] = {2266 nextMap[provider] = createModelSelection(
2256 provider,2267 provider,
2257 model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],2268 current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider],
2258 options: opts,2269 opts,
2259 };2270 );
2260 } else if (current?.options) {2271 } else if (current?.options) {
2261 // Remove options but keep the selection2272 // Remove options but keep the selection
2262 const { options: _, ...rest } = current;2273 const { options: _, ...rest } = current;
...@@ -2303,11 +2314,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2303,11 +2314,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2303 const nextMap = { ...base.modelSelectionByProvider };2314 const nextMap = { ...base.modelSelectionByProvider };
2304 const currentForProvider = nextMap[normalizedProvider];2315 const currentForProvider = nextMap[normalizedProvider];
2305 if (providerOpts) {2316 if (providerOpts) {
2306 nextMap[normalizedProvider] = {2317 nextMap[normalizedProvider] = createModelSelection(
2307 provider: normalizedProvider,2318 normalizedProvider,
2308 model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],2319 currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2309 options: providerOpts,2320 providerOpts,
2310 };2321 );
2311 } else if (currentForProvider?.options) {2322 } else if (currentForProvider?.options) {
2312 const { options: _, ...rest } = currentForProvider;2323 const { options: _, ...rest } = currentForProvider;
2313 nextMap[normalizedProvider] = rest as ModelSelection;2324 nextMap[normalizedProvider] = rest as ModelSelection;
...@@ -2321,16 +2332,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2321,16 +2332,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2321 const stickyBase =2332 const stickyBase =
2322 nextStickyMap[normalizedProvider] ??2333 nextStickyMap[normalizedProvider] ??
2323 base.modelSelectionByProvider[normalizedProvider] ??2334 base.modelSelectionByProvider[normalizedProvider] ??
2324 ({2335 createModelSelection(
2325 provider: normalizedProvider,2336 normalizedProvider,
2326 model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],2337 DEFAULT_MODEL_BY_PROVIDER[normalizedProvider],
2327 } as ModelSelection);2338 );
2328 if (providerOpts) {2339 if (providerOpts) {
2329 nextStickyMap[normalizedProvider] = {2340 nextStickyMap[normalizedProvider] = createModelSelection(
2330 ...stickyBase,2341 normalizedProvider,
2331 provider: normalizedProvider,2342 stickyBase.model,
2332 options: providerOpts,2343 providerOpts,
2333 };2344 );
2334 } else if (stickyBase.options) {2345 } else if (stickyBase.options) {
2335 const { options: _, ...rest } = stickyBase;2346 const { options: _, ...rest } = stickyBase;
2336 nextStickyMap[normalizedProvider] = rest as ModelSelection;2347 nextStickyMap[normalizedProvider] = rest as ModelSelection;
apps/web/src/modelSelection.ts+19-6
...@@ -4,7 +4,11 @@ import {...@@ -4,7 +4,11 @@ import {
4 type ProviderKind,4 type ProviderKind,
5 type ServerProvider,5 type ServerProvider,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model";7import {
8 createModelSelection,
9 normalizeModelSlug,
10 resolveSelectableModel,
11} from "@t3tools/shared/model";
8import { getComposerProviderState } from "./components/chat/composerProviderRegistry";12import { getComposerProviderState } from "./components/chat/composerProviderRegistry";
9import { UnifiedSettings } from "@t3tools/contracts/settings";13import { UnifiedSettings } from "@t3tools/contracts/settings";
10import {14import {
...@@ -45,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf...@@ -45,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf
45 placeholder: "your-claude-model-slug",49 placeholder: "your-claude-model-slug",
46 example: "claude-sonnet-5-0",50 example: "claude-sonnet-5-0",
47 },51 },
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 },
48};59};
4960
50export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);61export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG);
...@@ -165,6 +176,12 @@ export function getCustomModelOptionsByProvider(...@@ -165,6 +176,12 @@ export function getCustomModelOptionsByProvider(
165 "claudeAgent",176 "claudeAgent",
166 selectedProvider === "claudeAgent" ? selectedModel : undefined,177 selectedProvider === "claudeAgent" ? selectedModel : undefined,
167 ),178 ),
179 opencode: getAppModelOptions(
180 settings,
181 providers,
182 "opencode",
183 selectedProvider === "opencode" ? selectedModel : undefined,
184 ),
168 };185 };
169}186}
170187
...@@ -192,9 +209,5 @@ export function resolveAppModelSelectionState(...@@ -192,9 +209,5 @@ export function resolveAppModelSelectionState(
192 },209 },
193 });210 });
194211
195 return {212 return createModelSelection(provider, model, modelOptionsForDispatch);
196 provider,
197 model,
198 ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}),
199 };
200}213}
apps/web/src/session-logic.test.ts+8-1
...@@ -1326,12 +1326,14 @@ describe("deriveActiveWorkStartedAt", () => {...@@ -1326,12 +1326,14 @@ describe("deriveActiveWorkStartedAt", () => {
1326});1326});
13271327
1328describe("PROVIDER_OPTIONS", () => {1328describe("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", () => {
1330 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent");1330 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent");
1331 const opencode = PROVIDER_OPTIONS.find((option) => option.value === "opencode");
1331 const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor");1332 const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor");
1332 expect(PROVIDER_OPTIONS).toEqual([1333 expect(PROVIDER_OPTIONS).toEqual([
1333 { value: "codex", label: "Codex", available: true },1334 { value: "codex", label: "Codex", available: true },
1334 { value: "claudeAgent", label: "Claude", available: true },1335 { value: "claudeAgent", label: "Claude", available: true },
1336 { value: "opencode", label: "OpenCode", available: true },
1335 { value: "cursor", label: "Cursor", available: false },1337 { value: "cursor", label: "Cursor", available: false },
1336 ]);1338 ]);
1337 expect(claude).toEqual({1339 expect(claude).toEqual({
...@@ -1339,6 +1341,11 @@ describe("PROVIDER_OPTIONS", () => {...@@ -1339,6 +1341,11 @@ describe("PROVIDER_OPTIONS", () => {
1339 label: "Claude",1341 label: "Claude",
1340 available: true,1342 available: true,
1341 });1343 });
1344 expect(opencode).toEqual({
1345 value: "opencode",
1346 label: "OpenCode",
1347 available: true,
1348 });
1342 expect(cursor).toEqual({1349 expect(cursor).toEqual({
1343 value: "cursor",1350 value: "cursor",
1344 label: "Cursor",1351 label: "Cursor",
apps/web/src/session-logic.ts+1
...@@ -29,6 +29,7 @@ export const PROVIDER_OPTIONS: Array<{...@@ -29,6 +29,7 @@ export const PROVIDER_OPTIONS: Array<{
29}> = [29}> = [
30 { value: "codex", label: "Codex", available: true },30 { value: "codex", label: "Codex", available: true },
31 { value: "claudeAgent", label: "Claude", available: true },31 { value: "claudeAgent", label: "Claude", available: true },
32 { value: "opencode", label: "OpenCode", available: true },
32 { value: "cursor", label: "Cursor", available: false },33 { value: "cursor", label: "Cursor", available: false },
33];34];
3435
apps/web/src/store.ts+2-2
...@@ -129,7 +129,7 @@ function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean {...@@ -129,7 +129,7 @@ function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean {
129 return left.length === right.length && left.every((value, index) => value === right[index]);129 return left.length === right.length && left.every((value, index) => value === right[index]);
130}130}
131131
132function normalizeModelSelection<T extends { provider: "codex" | "claudeAgent"; model: string }>(132function normalizeModelSelection<T extends { provider: ProviderKind; model: string }>(
133 selection: T,133 selection: T,
134): T {134): T {
135 return {135 return {
...@@ -1001,7 +1001,7 @@ function toLegacySessionStatus(...@@ -1001,7 +1001,7 @@ function toLegacySessionStatus(
1001}1001}
10021002
1003function toLegacyProvider(providerName: string | null): ProviderKind {1003function toLegacyProvider(providerName: string | null): ProviderKind {
1004 if (providerName === "codex" || providerName === "claudeAgent") {1004 if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") {
1005 return providerName;1005 return providerName;
1006 }1006 }
1007 return "codex";1007 return "codex";
bun.lock+3
...@@ -51,6 +51,7 @@...@@ -51,6 +51,7 @@
51 "@effect/platform-bun": "catalog:",51 "@effect/platform-bun": "catalog:",
52 "@effect/platform-node": "catalog:",52 "@effect/platform-node": "catalog:",
53 "@effect/sql-sqlite-bun": "catalog:",53 "@effect/sql-sqlite-bun": "catalog:",
54 "@opencode-ai/sdk": "^1.3.15",
54 "@pierre/diffs": "^1.1.0-beta.16",55 "@pierre/diffs": "^1.1.0-beta.16",
55 "effect": "catalog:",56 "effect": "catalog:",
56 "node-pty": "^1.1.0",57 "node-pty": "^1.1.0",
...@@ -536,6 +537,8 @@...@@ -536,6 +537,8 @@
536537
537 "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],538 "@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
539 "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],542 "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
540543
541 "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],544 "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
packages/contracts/src/model.ts+13
...@@ -29,9 +29,16 @@ export const ClaudeModelOptions = Schema.Struct({...@@ -29,9 +29,16 @@ export const ClaudeModelOptions = Schema.Struct({
29});29});
30export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;30export type ClaudeModelOptions = typeof ClaudeModelOptions.Type;
3131
32export const OpenCodeModelOptions = Schema.Struct({
33 variant: Schema.optional(TrimmedNonEmptyString),
34 agent: Schema.optional(TrimmedNonEmptyString),
35});
36export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type;
37
32export const ProviderModelOptions = Schema.Struct({38export const ProviderModelOptions = Schema.Struct({
33 codex: Schema.optional(CodexModelOptions),39 codex: Schema.optional(CodexModelOptions),
34 claudeAgent: Schema.optional(ClaudeModelOptions),40 claudeAgent: Schema.optional(ClaudeModelOptions),
41 opencode: Schema.optional(OpenCodeModelOptions),
35});42});
36export type ProviderModelOptions = typeof ProviderModelOptions.Type;43export type ProviderModelOptions = typeof ProviderModelOptions.Type;
3744
...@@ -55,12 +62,15 @@ export const ModelCapabilities = Schema.Struct({...@@ -55,12 +62,15 @@ export const ModelCapabilities = Schema.Struct({
55 supportsThinkingToggle: Schema.Boolean,62 supportsThinkingToggle: Schema.Boolean,
56 contextWindowOptions: Schema.Array(ContextWindowOption),63 contextWindowOptions: Schema.Array(ContextWindowOption),
57 promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString),64 promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString),
65 variantOptions: Schema.optional(Schema.Array(EffortOption)),
66 agentOptions: Schema.optional(Schema.Array(EffortOption)),
58});67});
59export type ModelCapabilities = typeof ModelCapabilities.Type;68export type ModelCapabilities = typeof ModelCapabilities.Type;
6069
61export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {70export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
62 codex: "gpt-5.4",71 codex: "gpt-5.4",
63 claudeAgent: "claude-sonnet-4-6",72 claudeAgent: "claude-sonnet-4-6",
73 opencode: "openai/gpt-5",
64};74};
6575
66export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;76export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
...@@ -69,6 +79,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;...@@ -69,6 +79,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
69export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {79export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = {
70 codex: "gpt-5.4-mini",80 codex: "gpt-5.4-mini",
71 claudeAgent: "claude-haiku-4-5",81 claudeAgent: "claude-haiku-4-5",
82 opencode: "openai/gpt-5",
72};83};
7384
74export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = {85export 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,...@@ -96,6 +107,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
96 "claude-haiku-4.5": "claude-haiku-4-5",107 "claude-haiku-4.5": "claude-haiku-4-5",
97 "claude-haiku-4-5-20251001": "claude-haiku-4-5",108 "claude-haiku-4-5-20251001": "claude-haiku-4-5",
98 },109 },
110 opencode: {},
99};111};
100112
101// ── Provider display names ────────────────────────────────────────────113// ── Provider display names ────────────────────────────────────────────
...@@ -103,4 +115,5 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,...@@ -103,4 +115,5 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
103export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {115export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {
104 codex: "Codex",116 codex: "Codex",
105 claudeAgent: "Claude",117 claudeAgent: "Claude",
118 opencode: "OpenCode",
106};119};
packages/contracts/src/orchestration.ts+14-3
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";1import { Effect, Option, Schema, SchemaIssue, Struct } from "effect";
2import { ClaudeModelOptions, CodexModelOptions } from "./model.ts";2import { ClaudeModelOptions, CodexModelOptions, OpenCodeModelOptions } from "./model.ts";
3import { RepositoryIdentity } from "./environment.ts";3import { RepositoryIdentity } from "./environment.ts";
4import {4import {
5 ApprovalRequestId,5 ApprovalRequestId,
...@@ -25,7 +25,7 @@ export const ORCHESTRATION_WS_METHODS = {...@@ -25,7 +25,7 @@ export const ORCHESTRATION_WS_METHODS = {
25 subscribeThread: "orchestration.subscribeThread",25 subscribeThread: "orchestration.subscribeThread",
26} as const;26} as const;
2727
28export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]);28export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "opencode"]);
29export type ProviderKind = typeof ProviderKind.Type;29export type ProviderKind = typeof ProviderKind.Type;
30export const ProviderApprovalPolicy = Schema.Literals([30export const ProviderApprovalPolicy = Schema.Literals([
31 "untrusted",31 "untrusted",
...@@ -57,7 +57,18 @@ export const ClaudeModelSelection = Schema.Struct({...@@ -57,7 +57,18 @@ export const ClaudeModelSelection = Schema.Struct({
57});57});
58export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;58export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
5959
60export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]);60export const OpenCodeModelSelection = Schema.Struct({
61 provider: Schema.Literal("opencode"),
62 model: TrimmedNonEmptyString,
63 options: Schema.optionalKey(OpenCodeModelOptions),
64});
65export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type;
66
67export const ModelSelection = Schema.Union([
68 CodexModelSelection,
69 ClaudeModelSelection,
70 OpenCodeModelSelection,
71]);
61export type ModelSelection = typeof ModelSelection.Type;72export type ModelSelection = typeof ModelSelection.Type;
6273
63export const RuntimeMode = Schema.Literals([74export const RuntimeMode = Schema.Literals([
packages/contracts/src/providerRuntime.ts+1
...@@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Literals([...@@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Literals([
24 "claude.sdk.message",24 "claude.sdk.message",
25 "claude.sdk.permission",25 "claude.sdk.permission",
26 "codex.sdk.thread-event",26 "codex.sdk.thread-event",
27 "opencode.sdk.event",
27]);28]);
28export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;29export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;
2930
packages/contracts/src/settings.ts+30
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 ClaudeModelOptions,6 ClaudeModelOptions,
7 CodexModelOptions,7 CodexModelOptions,
8 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,8 DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER,
9 OpenCodeModelOptions,
9} from "./model.ts";10} from "./model.ts";
10import { ModelSelection } from "./orchestration.ts";11import { ModelSelection } from "./orchestration.ts";
1112
...@@ -89,6 +90,15 @@ export const ClaudeSettings = Schema.Struct({...@@ -89,6 +90,15 @@ export const ClaudeSettings = Schema.Struct({
89});90});
90export type ClaudeSettings = typeof ClaudeSettings.Type;91export type ClaudeSettings = typeof ClaudeSettings.Type;
9192
93export 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});
100export type OpenCodeSettings = typeof OpenCodeSettings.Type;
101
92export const ObservabilitySettings = Schema.Struct({102export const ObservabilitySettings = Schema.Struct({
93 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),103 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
94 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),104 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
...@@ -114,6 +124,7 @@ export const ServerSettings = Schema.Struct({...@@ -114,6 +124,7 @@ export const ServerSettings = Schema.Struct({
114 providers: Schema.Struct({124 providers: Schema.Struct({
115 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),125 codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
116 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),126 claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
127 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
117 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),128 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
118 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),129 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
119});130});
...@@ -156,6 +167,11 @@ const ClaudeModelOptionsPatch = Schema.Struct({...@@ -156,6 +167,11 @@ const ClaudeModelOptionsPatch = Schema.Struct({
156 contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow),167 contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow),
157});168});
158169
170const OpenCodeModelOptionsPatch = Schema.Struct({
171 variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant),
172 agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent),
173});
174
159const ModelSelectionPatch = Schema.Union([175const ModelSelectionPatch = Schema.Union([
160 Schema.Struct({176 Schema.Struct({
161 provider: Schema.optionalKey(Schema.Literal("codex")),177 provider: Schema.optionalKey(Schema.Literal("codex")),
...@@ -167,6 +183,11 @@ const ModelSelectionPatch = Schema.Union([...@@ -167,6 +183,11 @@ const ModelSelectionPatch = Schema.Union([
167 model: Schema.optionalKey(TrimmedNonEmptyString),183 model: Schema.optionalKey(TrimmedNonEmptyString),
168 options: Schema.optionalKey(ClaudeModelOptionsPatch),184 options: Schema.optionalKey(ClaudeModelOptionsPatch),
169 }),185 }),
186 Schema.Struct({
187 provider: Schema.optionalKey(Schema.Literal("opencode")),
188 model: Schema.optionalKey(TrimmedNonEmptyString),
189 options: Schema.optionalKey(OpenCodeModelOptionsPatch),
190 }),
170]);191]);
171192
172const CodexSettingsPatch = Schema.Struct({193const CodexSettingsPatch = Schema.Struct({
...@@ -183,6 +204,14 @@ const ClaudeSettingsPatch = Schema.Struct({...@@ -183,6 +204,14 @@ const ClaudeSettingsPatch = Schema.Struct({
183 launchArgs: Schema.optionalKey(Schema.String),204 launchArgs: Schema.optionalKey(Schema.String),
184});205});
185206
207const 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
186export const ServerSettingsPatch = Schema.Struct({215export const ServerSettingsPatch = Schema.Struct({
187 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),216 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
188 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),217 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
...@@ -198,6 +227,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -198,6 +227,7 @@ export const ServerSettingsPatch = Schema.Struct({
198 Schema.Struct({227 Schema.Struct({
199 codex: Schema.optionalKey(CodexSettingsPatch),228 codex: Schema.optionalKey(CodexSettingsPatch),
200 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),229 claudeAgent: Schema.optionalKey(ClaudeSettingsPatch),
230 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
201 }),231 }),
202 ),232 ),
203});233});
packages/shared/src/model.ts+73
...@@ -6,7 +6,9 @@ import {...@@ -6,7 +6,9 @@ import {
6 type CodexModelOptions,6 type CodexModelOptions,
7 type ModelCapabilities,7 type ModelCapabilities,
8 type ModelSelection,8 type ModelSelection,
9 type OpenCodeModelOptions,
9 type ProviderKind,10 type ProviderKind,
11 type ProviderModelOptions,
10} from "@t3tools/contracts";12} from "@t3tools/contracts";
1113
12export interface SelectableModelOption {14export interface SelectableModelOption {
...@@ -117,6 +119,50 @@ export function normalizeClaudeModelOptionsWithCapabilities(...@@ -117,6 +119,50 @@ export function normalizeClaudeModelOptionsWithCapabilities(
117 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;119 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
118}120}
119121
122function 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
135export 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
148export 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
120export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {166export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
121 return typeof text === "string" && /\bultrathink\b/i.test(text);167 return typeof text === "string" && /\bultrathink\b/i.test(text);
122}168}
...@@ -196,6 +242,33 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n...@@ -196,6 +242,33 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n
196 return trimmed || null;242 return trimmed || null;
197}243}
198244
245export 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
199/**272/**
200 * Resolve the actual API model identifier from a model selection.273 * Resolve the actual API model identifier from a model selection.
201 *274 *
packages/shared/src/serverSettings.test.ts+26
...@@ -109,4 +109,30 @@ describe("serverSettings helpers", () => {...@@ -109,4 +109,30 @@ describe("serverSettings helpers", () => {
109 },109 },
110 });110 });
111 });111 });
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 });
112});138});
packages/shared/src/serverSettings.ts+35-6
...@@ -1,4 +1,10 @@...@@ -1,4 +1,10 @@
1import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";1import {
2 ServerSettings,
3 type ClaudeModelOptions,
4 type CodexModelOptions,
5 type OpenCodeModelOptions,
6 type ServerSettingsPatch,
7} from "@t3tools/contracts";
2import { Schema } from "effect";8import { Schema } from "effect";
3import { deepMerge } from "./Struct.ts";9import { deepMerge } from "./Struct.ts";
4import { fromLenientJson } from "./schemaJson.ts";10import { fromLenientJson } from "./schemaJson.ts";
...@@ -46,6 +52,9 @@ function shouldReplaceTextGenerationModelSelection(...@@ -46,6 +52,9 @@ function shouldReplaceTextGenerationModelSelection(
46 return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined));52 return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined));
47}53}
4854
55const withModelSelectionOptions = <Options>(options: Options | undefined) =>
56 options ? { options } : {};
57
49/**58/**
50 * Applies a server settings patch while treating textGenerationModelSelection as59 * Applies a server settings patch while treating textGenerationModelSelection as
51 * replace-on-provider/model updates. This prevents stale nested options from60 * replace-on-provider/model updates. This prevents stale nested options from
...@@ -61,12 +70,32 @@ export function applyServerSettingsPatch(...@@ -61,12 +70,32 @@ export function applyServerSettingsPatch(
61 return next;70 return next;
62 }71 }
6372
73 const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider;
74 const model = selectionPatch.model ?? current.textGenerationModelSelection.model;
75
64 return {76 return {
65 ...next,77 ...next,
66 textGenerationModelSelection: {78 textGenerationModelSelection:
67 provider: selectionPatch.provider ?? current.textGenerationModelSelection.provider,79 provider === "codex"
68 model: selectionPatch.model ?? current.textGenerationModelSelection.model,80 ? {
69 ...(selectionPatch.options ? { options: selectionPatch.options } : {}),81 provider,
70 },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 },
71 };100 };
72}101}