authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-19 22:56:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-19 22:56:44-07:00
log306ec4bb842fac22099192e964334fbf5368ad84
tree6913984707ba0fbe83ce93f6a749b3d8a19a1fb4
parent3b98fe3548831c536cf8c22d4b8a63e03e0a7d58
signature Signed by PGP key B5690EEEBB952194

Refactor OpenCode lifecycle and structured output handling (#2218)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

14 files changed, 1723 insertions(+), 1454 deletions(-)

apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts+73-57
......@@ -1,18 +1,22 @@
1import type { ChildProcess } from "node:child_process";
2
31import * as NodeServices from "@effect/platform-node/NodeServices";
42import { it } from "@effect/vitest";
53import { Duration, Effect, Layer } from "effect";
64import { TestClock } from "effect/testing";
7import { beforeEach, expect, vi } from "vitest";
5import { NetService } from "@t3tools/shared/Net";
6import { beforeEach, expect } from "vitest";
87
98import { ServerConfig } from "../../config.ts";
109import { ServerSettingsService } from "../../serverSettings.ts";
10import {
11 OpenCodeRuntime,
12 OpenCodeRuntimeError,
13 type OpenCodeRuntimeShape,
14} from "../../provider/opencodeRuntime.ts";
1115import { TextGeneration } from "../Services/TextGeneration.ts";
1216import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
1317
14const runtimeMock = vi.hoisted(() => {
15 const state = {
18const runtimeMock = {
19 state: {
1620 startCalls: [] as string[],
1721 promptUrls: [] as string[],
1822 authHeaders: [] as Array<string | null>,
......@@ -20,69 +24,77 @@ const runtimeMock = vi.hoisted(() => {
2024 promptResult: undefined as
2125 | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } }
2226 | undefined,
23 };
24
25 return {
26 state,
27 reset() {
28 state.startCalls.length = 0;
29 state.promptUrls.length = 0;
30 state.authHeaders.length = 0;
31 state.closeCalls.length = 0;
32 state.promptResult = undefined;
33 },
34 };
35});
36
37vi.mock("../../provider/opencodeRuntime.ts", async () => {
38 const actual = await vi.importActual<typeof import("../../provider/opencodeRuntime.ts")>(
39 "../../provider/opencodeRuntime.ts",
40 );
27 },
28 reset() {
29 this.state.startCalls.length = 0;
30 this.state.promptUrls.length = 0;
31 this.state.authHeaders.length = 0;
32 this.state.closeCalls.length = 0;
33 this.state.promptResult = undefined;
34 },
35};
4136
42 return {
43 ...actual,
44 startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => {
37const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
38 startOpenCodeServerProcess: ({ binaryPath }) =>
39 Effect.gen(function* () {
4540 const index = runtimeMock.state.startCalls.length + 1;
4641 const url = `http://127.0.0.1:${4_300 + index}`;
4742 runtimeMock.state.startCalls.push(binaryPath);
43 // The production runtime binds server lifetime to the caller's scope.
44 // Mirror that here so the closeCalls probe observes scope close.
45 yield* Effect.addFinalizer(() =>
46 Effect.sync(() => {
47 runtimeMock.state.closeCalls.push(url);
48 }),
49 );
4850 return {
4951 url,
50 process: {} as ChildProcess,
51 close: () => {
52 runtimeMock.state.closeCalls.push(url);
53 },
52 exitCode: Effect.never,
5453 };
5554 }),
56 createOpenCodeSdkClient: vi.fn(
57 ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({
58 session: {
59 create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })),
60 prompt: vi.fn(async () => {
61 runtimeMock.state.promptUrls.push(baseUrl);
62 runtimeMock.state.authHeaders.push(
63 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
64 );
65 return (
66 runtimeMock.state.promptResult ?? {
67 data: {
68 parts: [
69 {
70 type: "text",
71 text: JSON.stringify({
72 subject: "Improve OpenCode reuse",
73 body: "Reuse one server for the full action.",
74 }),
75 },
76 ],
77 },
78 }
79 );
80 }),
55 connectToOpenCodeServer: ({ serverUrl }) =>
56 Effect.succeed({
57 url: serverUrl ?? "http://127.0.0.1:4301",
58 exitCode: null,
59 external: Boolean(serverUrl),
60 }),
61 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
62 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
63 ({
64 session: {
65 create: async () => ({ data: { id: `${baseUrl}/session` } }),
66 prompt: async () => {
67 runtimeMock.state.promptUrls.push(baseUrl);
68 runtimeMock.state.authHeaders.push(
69 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
70 );
71 return (
72 runtimeMock.state.promptResult ?? {
73 data: {
74 parts: [
75 {
76 type: "text",
77 text: JSON.stringify({
78 subject: "Improve OpenCode reuse",
79 body: "Reuse one server for the full action.",
80 }),
81 },
82 ],
83 },
84 }
85 );
8186 },
87 },
88 }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
89 loadOpenCodeInventory: () =>
90 Effect.fail(
91 new OpenCodeRuntimeError({
92 operation: "loadOpenCodeInventory",
93 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
94 cause: null,
8295 }),
8396 ),
84 };
85});
97};
8698
8799const DEFAULT_TEST_MODEL_SELECTION = {
88100 provider: "opencode" as const,
......@@ -92,6 +104,7 @@ const DEFAULT_TEST_MODEL_SELECTION = {
92104const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
93105
94106const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(
107 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
95108 Layer.provideMerge(
96109 ServerSettingsService.layerTest({
97110 providers: {
......@@ -106,10 +119,12 @@ const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(
106119 prefix: "t3code-opencode-text-generation-test-",
107120 }),
108121 ),
122 Layer.provideMerge(NetService.layer),
109123 Layer.provideMerge(NodeServices.layer),
110124);
111125
112126const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe(
127 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
113128 Layer.provideMerge(
114129 ServerSettingsService.layerTest({
115130 providers: {
......@@ -126,6 +141,7 @@ const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive
126141 prefix: "t3code-opencode-text-generation-existing-server-test-",
127142 }),
128143 ),
144 Layer.provideMerge(NetService.layer),
129145 Layer.provideMerge(NodeServices.layer),
130146);
131147
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+77-36
......@@ -1,4 +1,4 @@
1import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";
1import { Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";
22import * as Semaphore from "effect/Semaphore";
33
44import {
......@@ -25,15 +25,15 @@ import {
2525 sanitizeThreadTitle,
2626} from "../Utils.ts";
2727import {
28 createOpenCodeSdkClient,
28 OpenCodeRuntime,
2929 type OpenCodeServerConnection,
3030 type OpenCodeServerProcess,
31 openCodeRuntimeErrorDetail,
3132 parseOpenCodeModelSlug,
32 startOpenCodeServerProcess,
3333 toOpenCodeFileParts,
3434} from "../../provider/opencodeRuntime.ts";
3535
36const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
36const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds";
3737
3838function getOpenCodePromptErrorMessage(error: unknown): string | null {
3939 if (!error || typeof error !== "object") {
......@@ -80,6 +80,13 @@ function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): str
8080
8181interface SharedOpenCodeTextGenerationServerState {
8282 server: OpenCodeServerProcess | null;
83 /**
84 * The scope that owns the shared server's lifetime. Closing this scope
85 * terminates the OpenCode child process and interrupts any fibers the
86 * runtime forked during startup. We don't hold a `close()` function on
87 * the server handle anymore — the scope is the only lifecycle handle.
88 */
89 serverScope: Scope.Closeable | null;
8390 binaryPath: string | null;
8491 activeRequests: number;
8592 idleCloseFiber: Fiber.Fiber<void, never> | null;
......@@ -88,24 +95,28 @@ interface SharedOpenCodeTextGenerationServerState {
8895const makeOpenCodeTextGeneration = Effect.gen(function* () {
8996 const serverConfig = yield* ServerConfig;
9097 const serverSettingsService = yield* ServerSettingsService;
98 const openCodeRuntime = yield* OpenCodeRuntime;
9199 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
92100 Scope.close(scope, Exit.void),
93101 );
94102 const sharedServerMutex = yield* Semaphore.make(1);
95103 const sharedServerState: SharedOpenCodeTextGenerationServerState = {
96104 server: null,
105 serverScope: null,
97106 binaryPath: null,
98107 activeRequests: 0,
99108 idleCloseFiber: null,
100109 };
101110
102 const closeSharedServer = (server: OpenCodeServerProcess) => {
103 if (sharedServerState.server === server) {
104 sharedServerState.server = null;
105 sharedServerState.binaryPath = null;
111 const closeSharedServer = Effect.fn("closeSharedServer")(function* () {
112 const scope = sharedServerState.serverScope;
113 sharedServerState.server = null;
114 sharedServerState.serverScope = null;
115 sharedServerState.binaryPath = null;
116 if (scope !== null) {
117 yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
106118 }
107 server.close();
108 };
119 });
109120
110121 const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () {
111122 const idleCloseFiber = sharedServerState.idleCloseFiber;
......@@ -119,15 +130,15 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
119130 server: OpenCodeServerProcess,
120131 ) {
121132 yield* cancelIdleCloseFiber();
122 const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe(
133 const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe(
123134 Effect.andThen(
124135 sharedServerMutex.withPermit(
125 Effect.sync(() => {
136 Effect.gen(function* () {
126137 if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) {
127138 return;
128139 }
129140 sharedServerState.idleCloseFiber = null;
130 closeSharedServer(server);
141 yield* closeSharedServer();
131142 }),
132143 ),
133144 ),
......@@ -154,7 +165,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
154165 sharedServerState.binaryPath !== input.binaryPath &&
155166 sharedServerState.activeRequests === 0
156167 ) {
157 closeSharedServer(existingServer);
168 yield* closeSharedServer();
158169 } else {
159170 if (sharedServerState.binaryPath !== input.binaryPath) {
160171 yield* Effect.logWarning(
......@@ -170,20 +181,53 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
170181 }
171182 }
172183
173 const server = yield* Effect.tryPromise({
174 try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }),
175 catch: (cause) =>
176 new TextGenerationError({
177 operation: input.operation,
178 detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.",
179 cause,
180 }),
181 });
184 // Create a fresh scope that owns this shared server. The runtime
185 // will attach its child-process and fiber finalizers to this scope;
186 // closing it kills the server and interrupts those fibers.
187 //
188 // The `Scope.make` / spawn / record-or-close transitions run inside
189 // `uninterruptibleMask` so an interrupt arriving between any two
190 // steps can't orphan the scope (and the child process attached to
191 // it) before we either close it on failure or hand ownership to
192 // `sharedServerState`. `restore` keeps the actual spawn
193 // interruptible; an interrupt during the spawn is captured by
194 // `Effect.exit` and drives us through the failure branch that
195 // closes the fresh scope.
196 return yield* Effect.uninterruptibleMask((restore) =>
197 Effect.gen(function* () {
198 const serverScope = yield* Scope.make();
199 const startedExit = yield* Effect.exit(
200 restore(
201 openCodeRuntime
202 .startOpenCodeServerProcess({
203 binaryPath: input.binaryPath,
204 })
205 .pipe(
206 Effect.provideService(Scope.Scope, serverScope),
207 Effect.mapError(
208 (cause) =>
209 new TextGenerationError({
210 operation: input.operation,
211 detail: openCodeRuntimeErrorDetail(cause),
212 cause,
213 }),
214 ),
215 ),
216 ),
217 );
218 if (startedExit._tag === "Failure") {
219 yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore);
220 return yield* Effect.failCause(startedExit.cause);
221 }
182222
183 sharedServerState.server = server;
184 sharedServerState.binaryPath = input.binaryPath;
185 sharedServerState.activeRequests = 1;
186 return server;
223 const server = startedExit.value;
224 sharedServerState.server = server;
225 sharedServerState.serverScope = serverScope;
226 sharedServerState.binaryPath = input.binaryPath;
227 sharedServerState.activeRequests = 1;
228 return server;
229 }),
230 );
187231 }),
188232 );
189233
......@@ -200,17 +244,15 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
200244 }),
201245 );
202246
247 // Module-level finalizer: on layer shutdown, cancel the idle close fiber
248 // and close the shared server scope. Consumers therefore cannot leak
249 // the shared OpenCode server by forgetting to call anything.
203250 yield* Effect.addFinalizer(() =>
204251 sharedServerMutex.withPermit(
205252 Effect.gen(function* () {
206253 yield* cancelIdleCloseFiber();
207 const server = sharedServerState.server;
208 sharedServerState.server = null;
209 sharedServerState.binaryPath = null;
210254 sharedServerState.activeRequests = 0;
211 if (server !== null) {
212 server.close();
213 }
255 yield* closeSharedServer();
214256 }),
215257 ),
216258 );
......@@ -264,7 +306,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
264306 const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) =>
265307 Effect.tryPromise({
266308 try: async () => {
267 const client = createOpenCodeSdkClient({
309 const client = openCodeRuntime.createOpenCodeSdkClient({
268310 baseUrl: server.url,
269311 directory: input.cwd,
270312 ...(settings.serverUrl.length > 0 && settings.serverPassword
......@@ -304,8 +346,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
304346 catch: (cause) =>
305347 new TextGenerationError({
306348 operation: input.operation,
307 detail:
308 cause instanceof Error ? cause.message : "OpenCode text generation request failed.",
349 detail: openCodeRuntimeErrorDetail(cause),
309350 cause,
310351 }),
311352 });
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+146-109
......@@ -3,13 +3,18 @@ import assert from "node:assert/strict";
33import * as NodeServices from "@effect/platform-node/NodeServices";
44import { it } from "@effect/vitest";
55import { Effect, Layer, Option } from "effect";
6import { beforeEach, vi } from "vitest";
6import { beforeEach } from "vitest";
77
88import { ThreadId } from "@t3tools/contracts";
99import { ServerConfig } from "../../config.ts";
1010import { ServerSettingsService } from "../../serverSettings.ts";
1111import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
1212import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
13import {
14 OpenCodeRuntime,
15 OpenCodeRuntimeError,
16 type OpenCodeRuntimeShape,
17} from "../opencodeRuntime.ts";
1318import {
1419 appendOpenCodeAssistantTextDelta,
1520 makeOpenCodeAdapterLive,
......@@ -18,16 +23,16 @@ import {
1823
1924const asThreadId = (value: string): ThreadId => ThreadId.make(value);
2025
21const runtimeMock = vi.hoisted(() => {
22 type MessageEntry = {
23 info: {
24 id: string;
25 role: "user" | "assistant";
26 };
27 parts: Array<unknown>;
26type MessageEntry = {
27 info: {
28 id: string;
29 role: "user" | "assistant";
2830 };
31 parts: Array<unknown>;
32};
2933
30 const state = {
34const runtimeMock = {
35 state: {
3136 startCalls: [] as string[],
3237 sessionCreateUrls: [] as string[],
3338 authHeaders: [] as Array<string | null>,
......@@ -38,105 +43,118 @@ const runtimeMock = vi.hoisted(() => {
3843 closeError: null as Error | null,
3944 messages: [] as MessageEntry[],
4045 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 }) => {
46 },
47 reset() {
48 this.state.startCalls.length = 0;
49 this.state.sessionCreateUrls.length = 0;
50 this.state.authHeaders.length = 0;
51 this.state.abortCalls.length = 0;
52 this.state.closeCalls.length = 0;
53 this.state.revertCalls.length = 0;
54 this.state.promptAsyncError = null;
55 this.state.closeError = null;
56 this.state.messages = [];
57 this.state.subscribedEvents = [];
58 },
59};
60
61const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
62 startOpenCodeServerProcess: ({ binaryPath }) =>
63 Effect.gen(function* () {
6764 runtimeMock.state.startCalls.push(binaryPath);
65 const url = "http://127.0.0.1:4301";
66 yield* Effect.addFinalizer(() =>
67 Effect.sync(() => {
68 runtimeMock.state.closeCalls.push(url);
69 if (runtimeMock.state.closeError) {
70 throw runtimeMock.state.closeError;
71 }
72 }),
73 );
6874 return {
69 url: "http://127.0.0.1:4301",
70 process: {
71 once() {},
72 },
73 close() {},
75 url,
76 exitCode: Effect.never,
7477 };
7578 }),
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 ),
79 connectToOpenCodeServer: ({ serverUrl }) =>
80 Effect.gen(function* () {
81 const url = serverUrl ?? "http://127.0.0.1:4301";
82 // Unconditionally register a scope finalizer for test observability —
83 // preserves the `closeCalls` / `closeError` probes that the existing
84 // suites rely on. Production code never attaches a finalizer to an
85 // external server (it simply returns `Effect.succeed(...)`).
86 yield* Effect.addFinalizer(() =>
87 Effect.sync(() => {
88 runtimeMock.state.closeCalls.push(url);
89 if (runtimeMock.state.closeError) {
90 throw runtimeMock.state.closeError;
91 }
92 }),
93 );
94 return {
95 url,
96 exitCode: null,
97 external: Boolean(serverUrl),
98 };
99 }),
100 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
101 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
102 ({
103 session: {
104 create: async () => {
105 runtimeMock.state.sessionCreateUrls.push(baseUrl);
106 runtimeMock.state.authHeaders.push(
107 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
108 );
109 return { data: { id: `${baseUrl}/session` } };
110 },
111 abort: async ({ sessionID }: { sessionID: string }) => {
112 runtimeMock.state.abortCalls.push(sessionID);
126113 },
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 })),
114 promptAsync: async () => {
115 if (runtimeMock.state.promptAsyncError) {
116 throw runtimeMock.state.promptAsyncError;
117 }
135118 },
119 messages: async () => ({ data: runtimeMock.state.messages }),
120 revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => {
121 runtimeMock.state.revertCalls.push({
122 sessionID,
123 ...(messageID ? { messageID } : {}),
124 });
125 if (!messageID) {
126 runtimeMock.state.messages = [];
127 return;
128 }
129
130 const targetIndex = runtimeMock.state.messages.findIndex(
131 (entry) => entry.info.id === messageID,
132 );
133 runtimeMock.state.messages =
134 targetIndex >= 0
135 ? runtimeMock.state.messages.slice(0, targetIndex + 1)
136 : runtimeMock.state.messages;
137 },
138 },
139 event: {
140 subscribe: async () => ({
141 stream: (async function* () {
142 for (const event of runtimeMock.state.subscribedEvents) {
143 yield event;
144 }
145 })(),
146 }),
147 },
148 }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
149 loadOpenCodeInventory: () =>
150 Effect.fail(
151 new OpenCodeRuntimeError({
152 operation: "loadOpenCodeInventory",
153 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
154 cause: null,
136155 }),
137156 ),
138 };
139});
157};
140158
141159const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
142160 upsert: () => Effect.void,
......@@ -148,6 +166,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory
148166});
149167
150168const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe(
169 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
151170 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
152171 Layer.provideMerge(
153172 ServerSettingsService.layerTest({
......@@ -211,7 +230,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
211230 }),
212231 );
213232
214 it.effect("clears session state when stopAll cleanup fails", () =>
233 it.effect("clears session state even when cleanup finalizers throw", () =>
215234 Effect.gen(function* () {
216235 const adapter = yield* OpenCodeAdapter;
217236 yield* adapter.startSession({
......@@ -226,11 +245,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
226245 });
227246
228247 runtimeMock.state.closeError = new Error("close failed");
229 const error = yield* adapter.stopAll().pipe(Effect.flip);
248 // `stopAll` relies on `stopOpenCodeContext`, which is typed as
249 // never-failing. A throwing finalizer surfaces as a defect — `Effect.exit`
250 // captures it so the assertions can still run. The key invariant we're
251 // validating is "the sessions map and close-call probes reflect cleanup
252 // attempts regardless of finalizer outcome".
253 yield* Effect.exit(adapter.stopAll());
230254 const sessions = yield* adapter.listSessions();
231255
232 assert.equal(error._tag, "ProviderAdapterProcessError");
233 assert.equal(error.detail, "Failed to stop 2 OpenCode sessions.");
234256 assert.deepEqual(runtimeMock.state.closeCalls, [
235257 "http://127.0.0.1:9999",
236258 "http://127.0.0.1:9999",
......@@ -374,7 +396,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
374396 close: () => Effect.void,
375397 };
376398
377 const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe(
399 const adapterLayer = makeOpenCodeAdapterLive({
400 nativeEventLogger,
401 }).pipe(
402 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
378403 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
379404 Layer.provideMerge(
380405 ServerSettingsService.layerTest({
......@@ -450,7 +475,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
450475 close: () => Effect.void,
451476 };
452477
453 const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe(
478 const adapterLayer = makeOpenCodeAdapterLive({
479 nativeEventLogger,
480 }).pipe(
481 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
454482 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
455483 Layer.provideMerge(
456484 ServerSettingsService.layerTest({
......@@ -467,7 +495,13 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
467495 Layer.provideMerge(NodeServices.layer),
468496 );
469497
470 const sessions = yield* Effect.gen(function* () {
498 // Capture closeCalls *inside* the provided layer scope: the adapter's
499 // layer finalizer now tears down any live sessions when the layer
500 // closes (which is exactly what we want for leak prevention), so
501 // inspecting closeCalls after `Effect.provide` completes would observe
502 // the teardown — not the behavior under test. We care that the event
503 // pump kept the session alive while logging was failing.
504 const { sessions, closeCallsDuringRun } = yield* Effect.gen(function* () {
471505 const adapter = yield* OpenCodeAdapter;
472506 yield* adapter.startSession({
473507 provider: "opencode",
......@@ -475,12 +509,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
475509 runtimeMode: "full-access",
476510 });
477511 yield* sleep(10);
478 return yield* adapter.listSessions();
512 return {
513 sessions: yield* adapter.listSessions(),
514 closeCallsDuringRun: [...runtimeMock.state.closeCalls],
515 };
479516 }).pipe(Effect.provide(adapterLayer));
480517
481518 assert.equal(sessions.length, 1);
482519 assert.equal(sessions[0]?.threadId, "thread-native-log-failure");
483 assert.deepEqual(runtimeMock.state.closeCalls, []);
520 assert.deepEqual(closeCallsDuringRun, []);
484521 }),
485522 );
486523});
apps/server/src/provider/Layers/OpenCodeAdapter.ts+623-579
......@@ -11,7 +11,7 @@ import {
1111 TurnId,
1212 type UserInputQuestion,
1313} from "@t3tools/contracts";
14import { Cause, Effect, Layer, Queue, Stream } from "effect";
14import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect";
1515import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
1616
1717import { resolveAttachmentPath } from "../../attachmentStore.ts";
......@@ -28,10 +28,12 @@ import {
2828import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
2929import {
3030 buildOpenCodePermissionRules,
31 connectToOpenCodeServer,
32 createOpenCodeSdkClient,
31 OpenCodeRuntime,
32 OpenCodeRuntimeError,
3333 openCodeQuestionId,
34 openCodeRuntimeErrorDetail,
3435 parseOpenCodeModelSlug,
36 runOpenCodeSdk,
3537 toOpenCodeFileParts,
3638 toOpenCodePermissionReply,
3739 toOpenCodeQuestionAnswers,
......@@ -45,6 +47,13 @@ interface OpenCodeTurnSnapshot {
4547 readonly items: Array<unknown>;
4648}
4749
50type OpenCodeSubscribedEvent =
51 Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>> extends {
52 readonly stream: AsyncIterable<infer TEvent>;
53 }
54 ? TEvent
55 : never;
56
4857interface OpenCodeSessionContext {
4958 session: ProviderSession;
5059 readonly client: OpencodeClient;
......@@ -61,8 +70,21 @@ interface OpenCodeSessionContext {
6170 activeTurnId: TurnId | undefined;
6271 activeAgent: string | undefined;
6372 activeVariant: string | undefined;
64 stopped: boolean;
65 readonly eventsAbortController: AbortController;
73 /**
74 * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`.
75 * The session lifecycle is owned by `sessionScope`; this Ref exists only
76 * so concurrent callers can race the transition safely via `getAndSet`.
77 */
78 readonly stopped: Ref.Ref<boolean>;
79 /**
80 * Sole lifecycle handle for the session. Closing this scope:
81 * - aborts the `AbortController` registered as a finalizer
82 * (cancels the in-flight `event.subscribe` fetch),
83 * - interrupts the event-pump and server-exit fibers forked
84 * via `Effect.forkIn(sessionScope)`,
85 * - tears down the OpenCode server process for scope-owned servers.
86 */
87 readonly sessionScope: Scope.Closeable;
6688}
6789
6890export interface OpenCodeAdapterLiveOptions {
......@@ -74,14 +96,33 @@ function nowIso(): string {
7496 return new Date().toISOString();
7597}
7698
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}
99/**
100 * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into
101 * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call
102 * sites pipe through this in `Effect.mapError` so they never build the error
103 * shape by hand.
104 */
105const toRequestError = (cause: OpenCodeRuntimeError): ProviderAdapterRequestError =>
106 new ProviderAdapterRequestError({
107 provider: PROVIDER,
108 method: cause.operation,
109 detail: cause.detail,
110 cause: cause.cause,
111 });
112
113/**
114 * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The
115 * typed cause is usually an `OpenCodeRuntimeError` (from {@link runOpenCodeSdk}),
116 * in which case we preserve its `detail`; otherwise we fall back to
117 * {@link openCodeRuntimeErrorDetail} for unknown causes (defects, etc.).
118 */
119const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError =>
120 new ProviderAdapterProcessError({
121 provider: PROVIDER,
122 threadId,
123 detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause),
124 cause,
125 });
85126
86127function buildEventBase(input: {
87128 readonly threadId: ThreadId;
......@@ -205,7 +246,10 @@ function ensureSessionContext(
205246 if (!session) {
206247 throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId });
207248 }
208 if (session.stopped) {
249 // `ensureSessionContext` is a sync gate used from both sync helpers and
250 // Effect bodies. `Ref.getUnsafe` is an atomic read of the backing cell —
251 // no fiber suspension required, which keeps this callable everywhere.
252 if (Ref.getUnsafe(session.stopped)) {
209253 throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId });
210254 }
211255 return session;
......@@ -369,73 +413,119 @@ function updateProviderSession(
369413 return nextSession;
370414}
371415
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}
416const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* (
417 context: OpenCodeSessionContext,
418) {
419 // Race-safe one-shot: first caller flips the flag, everyone else no-ops.
420 if (yield* Ref.getAndSet(context.stopped, true)) {
421 return;
422 }
423
424 // Best-effort remote abort. The scope close below tears down the local
425 // handles (event-pump fiber, server-exit fiber, event-subscribe fetch),
426 // but we still want to tell OpenCode that this session is done.
427 yield* runOpenCodeSdk("session.abort", () =>
428 context.client.session.abort({ sessionID: context.openCodeSessionId }),
429 ).pipe(Effect.ignore({ log: true }));
382430
383export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
431 // Closing the session scope interrupts every fiber forked into it and
432 // runs each finalizer we registered — the `AbortController.abort()` call,
433 // the child-process termination, etc.
434 yield* Scope.close(context.sessionScope, Exit.void);
435});
436
437export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
384438 return Layer.effect(
385439 OpenCodeAdapter,
386440 Effect.gen(function* () {
387441 const serverConfig = yield* ServerConfig;
388442 const serverSettings = yield* ServerSettingsService;
389 const services = yield* Effect.context<never>();
443 const openCodeRuntime = yield* OpenCodeRuntime;
390444 const nativeEventLogger =
391 _options?.nativeEventLogger ??
392 (_options?.nativeEventLogPath !== undefined
393 ? yield* makeEventNdjsonLogger(_options.nativeEventLogPath, {
445 options?.nativeEventLogger ??
446 (options?.nativeEventLogPath !== undefined
447 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
394448 stream: "native",
395449 })
396450 : undefined);
451 // Only close loggers we created. If the caller passed one in via
452 // `options.nativeEventLogger`, they own its lifecycle.
453 const managedNativeEventLogger =
454 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
397455 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
398456 const sessions = new Map<ThreadId, OpenCodeSessionContext>();
399457
458 // Layer-level finalizer: when the adapter layer shuts down, stop every
459 // session. Each session's `Scope.close` tears down its spawned OpenCode
460 // server (via the `ChildProcessSpawner` finalizer installed in
461 // `startOpenCodeServerProcess`) and interrupts the forked event/exit
462 // fibers. Consumers that can't reason about Effect scopes therefore
463 // cannot leak OpenCode child processes by forgetting to call `stopAll`.
464 yield* Effect.addFinalizer(() =>
465 Effect.gen(function* () {
466 const contexts = [...sessions.values()];
467 sessions.clear();
468 // `ignoreCause` swallows both typed failures (none here) and defects
469 // from throwing scope finalizers so a sibling's death can't interrupt
470 // the remaining cleanups.
471 yield* Effect.forEach(
472 contexts,
473 (context) => Effect.ignoreCause(stopOpenCodeContext(context)),
474 { concurrency: "unbounded", discard: true },
475 );
476 // Close the logger AFTER session teardown so any final lifecycle
477 // events emitted during shutdown still get written. `close` flushes
478 // the `Logger.batched` window and closes each per-thread
479 // `RotatingFileSink` handle owned by the logger's internal scope.
480 if (managedNativeEventLogger !== undefined) {
481 yield* managedNativeEventLogger.close();
482 }
483 }),
484 );
485
400486 const emit = (event: ProviderRuntimeEvent) =>
401487 Queue.offer(runtimeEvents, event).pipe(Effect.asVoid);
402 const emitPromise = (event: ProviderRuntimeEvent) =>
403 emit(event).pipe(Effect.runPromiseWith(services));
404 const writeNativeEventPromise = (
488 const writeNativeEvent = (
405489 threadId: ThreadId,
406490 event: {
407491 readonly observedAt: string;
408492 readonly event: Record<string, unknown>;
409493 },
410 ) =>
411 (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void).pipe(
412 Effect.runPromiseWith(services),
413 );
494 ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void);
414495 const writeNativeEventBestEffort = (
415496 threadId: ThreadId,
416497 event: {
417498 readonly observedAt: string;
418499 readonly event: Record<string, unknown>;
419500 },
420 ) => writeNativeEventPromise(threadId, event).catch(() => undefined);
501 ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void));
421502
422 const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => {
423 if (context.stopped) {
503 const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* (
504 context: OpenCodeSessionContext,
505 message: string,
506 ) {
507 // Atomic one-shot: two fibers can race here (the event-pump on stream
508 // failure and the server-exit watcher). `getAndSet` flips the flag in
509 // a single step so the loser observes `true` and returns; a plain
510 // `Ref.get` would let both racers slip past and emit duplicates.
511 if (yield* Ref.getAndSet(context.stopped, true)) {
424512 return;
425513 }
426 context.stopped = true;
427 sessions.delete(context.session.threadId);
428 context.server.close();
429514 const turnId = context.activeTurnId;
430 void emitPromise({
515 sessions.delete(context.session.threadId);
516 // Emit lifecycle events BEFORE tearing down the scope. Both call sites
517 // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`;
518 // closing that scope triggers the fiber-interrupt finalizer, so any
519 // subsequent yield point would unwind and silently drop these emits.
520 yield* emit({
431521 ...buildEventBase({ threadId: context.session.threadId, turnId }),
432522 type: "runtime.error",
433523 payload: {
434524 message,
435525 class: "transport_error",
436526 },
437 }).catch(() => undefined);
438 void emitPromise({
527 }).pipe(Effect.ignore);
528 yield* emit({
439529 ...buildEventBase({ threadId: context.session.threadId, turnId }),
440530 type: "session.exited",
441531 payload: {
......@@ -443,16 +533,23 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
443533 recoverable: false,
444534 exitKind: "error",
445535 },
446 }).catch(() => undefined);
447 };
536 }).pipe(Effect.ignore);
537 // Inline the teardown that `stopOpenCodeContext` would do; we can't
538 // delegate to it because our `getAndSet` above already flipped the
539 // one-shot guard, so the call would no-op.
540 yield* runOpenCodeSdk("session.abort", () =>
541 context.client.session.abort({ sessionID: context.openCodeSessionId }),
542 ).pipe(Effect.ignore({ log: true }));
543 yield* Scope.close(context.sessionScope, Exit.void);
544 });
448545
449546 /** Emit content.delta and item.completed events for an assistant text part. */
450 const emitAssistantTextDelta = async (
547 const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* (
451548 context: OpenCodeSessionContext,
452549 part: Part,
453550 turnId: TurnId | undefined,
454551 raw: unknown,
455 ): Promise<void> => {
552 ) {
456553 const text = textFromPart(part);
457554 if (text === undefined) {
458555 return;
......@@ -469,7 +566,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
469566 );
470567 }
471568 if (deltaToEmit.length > 0) {
472 await emitPromise({
569 yield* emit({
473570 ...buildEventBase({
474571 threadId: context.session.threadId,
475572 turnId,
......@@ -494,7 +591,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
494591 !context.completedAssistantPartIds.has(part.id)
495592 ) {
496593 context.completedAssistantPartIds.add(part.id);
497 await emitPromise({
594 yield* emit({
498595 ...buildEventBase({
499596 threadId: context.session.threadId,
500597 turnId,
......@@ -511,346 +608,383 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
511608 },
512609 });
513610 }
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 });
611 });
522612
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 }
613 const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* (
614 context: OpenCodeSessionContext,
615 event: OpenCodeSubscribedEvent,
616 ) {
617 const payloadSessionId =
618 "properties" in event
619 ? (event.properties as { sessionID?: unknown }).sessionID
620 : undefined;
621 if (payloadSessionId !== context.openCodeSessionId) {
622 return;
623 }
531624
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 });
625 const turnId = context.activeTurnId;
626 yield* writeNativeEventBestEffort(context.session.threadId, {
627 observedAt: nowIso(),
628 event: {
629 provider: PROVIDER,
630 threadId: context.session.threadId,
631 providerThreadId: context.openCodeSessionId,
632 type: event.type,
633 ...(turnId ? { turnId } : {}),
634 payload: event,
635 },
636 });
544637
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;
638 switch (event.type) {
639 case "message.updated": {
640 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);
641 if (event.properties.info.role === "assistant") {
642 for (const part of context.partById.values()) {
643 if (part.messageID !== event.properties.info.id) {
644 continue;
557645 }
646 yield* emitAssistantTextDelta(context, part, turnId, event);
647 }
648 }
649 break;
650 }
558651
559 case "message.removed": {
560 context.messageRoleById.delete(event.properties.messageID);
561 break;
562 }
652 case "message.removed": {
653 context.messageRoleById.delete(event.properties.messageID);
654 break;
655 }
563656
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 }
657 case "message.part.delta": {
658 const existingPart = context.partById.get(event.properties.partID);
659 if (!existingPart) {
660 break;
661 }
662 const role = messageRoleForPart(context, existingPart);
663 if (role !== "assistant") {
664 break;
665 }
666 const streamKind = resolveTextStreamKind(existingPart);
667 const delta = event.properties.delta;
668 if (delta.length === 0) {
669 break;
670 }
671 const previousText =
672 context.emittedTextByPartId.get(event.properties.partID) ??
673 textFromPart(existingPart) ??
674 "";
675 const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta);
676 if (deltaToEmit.length === 0) {
677 break;
678 }
679 context.emittedTextByPartId.set(event.properties.partID, nextText);
680 if (existingPart.type === "text" || existingPart.type === "reasoning") {
681 context.partById.set(event.properties.partID, {
682 ...existingPart,
683 text: nextText,
684 });
685 }
686 yield* emit({
687 ...buildEventBase({
688 threadId: context.session.threadId,
689 turnId,
690 itemId: event.properties.partID,
691 raw: event,
692 }),
693 type: "content.delta",
694 payload: {
695 streamKind,
696 delta: deltaToEmit,
697 },
698 });
699 break;
700 }
611701
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 }
702 case "message.part.updated": {
703 const part = event.properties.part;
704 context.partById.set(part.id, part);
705 const messageRole = messageRoleForPart(context, part);
661706
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 }
707 if (messageRole === "assistant") {
708 yield* emitAssistantTextDelta(context, part, turnId, event);
709 }
683710
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 }
711 if (part.type === "tool") {
712 const itemType = toToolLifecycleItemType(part.tool);
713 const title =
714 part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool;
715 const detail = detailFromToolPart(part);
716 const payload = {
717 itemType,
718 ...(part.state.status === "error"
719 ? { status: "failed" as const }
720 : part.state.status === "completed"
721 ? { status: "completed" as const }
722 : { status: "inProgress" as const }),
723 ...(title ? { title } : {}),
724 ...(detail ? { detail } : {}),
725 data: {
726 tool: part.tool,
727 state: part.state,
728 },
729 };
730 const runtimeEvent: ProviderRuntimeEvent = {
731 ...buildEventBase({
732 threadId: context.session.threadId,
733 turnId,
734 itemId: part.callID,
735 createdAt: toolStateCreatedAt(part),
736 raw: event,
737 }),
738 type:
739 part.state.status === "pending"
740 ? "item.started"
741 : part.state.status === "completed" || part.state.status === "error"
742 ? "item.completed"
743 : "item.updated",
744 payload,
745 };
746 appendTurnItem(context, turnId, part);
747 yield* emit(runtimeEvent);
748 }
749 break;
750 }
701751
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 }
752 case "permission.asked": {
753 context.pendingPermissions.set(event.properties.id, event.properties);
754 yield* emit({
755 ...buildEventBase({
756 threadId: context.session.threadId,
757 turnId,
758 requestId: event.properties.id,
759 raw: event,
760 }),
761 type: "request.opened",
762 payload: {
763 requestType: mapPermissionToRequestType(event.properties.permission),
764 detail:
765 event.properties.patterns.length > 0
766 ? event.properties.patterns.join("\n")
767 : event.properties.permission,
768 args: event.properties.metadata,
769 },
770 });
771 break;
772 }
718773
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 }
774 case "permission.replied": {
775 context.pendingPermissions.delete(event.properties.requestID);
776 yield* emit({
777 ...buildEventBase({
778 threadId: context.session.threadId,
779 turnId,
780 requestId: event.properties.requestID,
781 raw: event,
782 }),
783 type: "request.resolved",
784 payload: {
785 requestType: "unknown",
786 decision: mapPermissionDecision(event.properties.reply),
787 },
788 });
789 break;
790 }
740791
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 }
792 case "question.asked": {
793 context.pendingQuestions.set(event.properties.id, event.properties);
794 yield* emit({
795 ...buildEventBase({
796 threadId: context.session.threadId,
797 turnId,
798 requestId: event.properties.id,
799 raw: event,
800 }),
801 type: "user-input.requested",
802 payload: {
803 questions: normalizeQuestionRequest(event.properties),
804 },
805 });
806 break;
807 }
755808
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 }
809 case "question.replied": {
810 const request = context.pendingQuestions.get(event.properties.requestID);
811 context.pendingQuestions.delete(event.properties.requestID);
812 const answers = Object.fromEntries(
813 (request?.questions ?? []).map((question, index) => [
814 openCodeQuestionId(index, question),
815 event.properties.answers[index]?.join(", ") ?? "",
816 ]),
817 );
818 yield* emit({
819 ...buildEventBase({
820 threadId: context.session.threadId,
821 turnId,
822 requestId: event.properties.requestID,
823 raw: event,
824 }),
825 type: "user-input.resolved",
826 payload: { answers },
827 });
828 break;
829 }
790830
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 }
831 case "question.rejected": {
832 context.pendingQuestions.delete(event.properties.requestID);
833 yield* emit({
834 ...buildEventBase({
835 threadId: context.session.threadId,
836 turnId,
837 requestId: event.properties.requestID,
838 raw: event,
839 }),
840 type: "user-input.resolved",
841 payload: { answers: {} },
842 });
843 break;
844 }
828845
829 default:
830 break;
831 }
846 case "session.status": {
847 if (event.properties.status.type === "busy") {
848 updateProviderSession(context, { status: "running", activeTurnId: turnId });
849 }
850
851 if (event.properties.status.type === "retry") {
852 yield* emit({
853 ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }),
854 type: "runtime.warning",
855 payload: {
856 message: event.properties.status.message,
857 detail: event.properties.status,
858 },
859 });
860 break;
832861 }
833 } catch (error) {
834 if (context.eventsAbortController.signal.aborted || context.stopped) {
835 return;
862
863 if (event.properties.status.type === "idle" && turnId) {
864 context.activeTurnId = undefined;
865 updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true });
866 yield* emit({
867 ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }),
868 type: "turn.completed",
869 payload: {
870 state: "completed",
871 },
872 });
836873 }
837 emitUnexpectedExit(
874 break;
875 }
876
877 case "session.error": {
878 const message = sessionErrorMessage(event.properties.error);
879 const activeTurnId = context.activeTurnId;
880 context.activeTurnId = undefined;
881 updateProviderSession(
838882 context,
839 error instanceof Error ? error.message : "OpenCode event stream failed.",
883 {
884 status: "error",
885 lastError: message,
886 },
887 { clearActiveTurnId: true },
840888 );
889 if (activeTurnId) {
890 yield* emit({
891 ...buildEventBase({
892 threadId: context.session.threadId,
893 turnId: activeTurnId,
894 raw: event,
895 }),
896 type: "turn.completed",
897 payload: {
898 state: "failed",
899 errorMessage: message,
900 },
901 });
902 }
903 yield* emit({
904 ...buildEventBase({ threadId: context.session.threadId, raw: event }),
905 type: "runtime.error",
906 payload: {
907 message,
908 class: "provider_error",
909 detail: event.properties.error,
910 },
911 });
912 break;
841913 }
842 })();
843914
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"}).`,
915 default:
916 break;
917 }
918 });
919
920 const startEventPump = Effect.fn("startEventPump")(function* (
921 context: OpenCodeSessionContext,
922 ) {
923 // One AbortController per session scope. The finalizer fires when
924 // the scope closes (explicit stop, unexpected exit, or layer
925 // shutdown) and cancels the in-flight `event.subscribe` fetch so
926 // the async iterable unwinds cleanly.
927 const eventsAbortController = new AbortController();
928 yield* Scope.addFinalizer(
929 context.sessionScope,
930 Effect.sync(() => eventsAbortController.abort()),
931 );
932
933 // Fibers forked into `context.sessionScope` are interrupted
934 // automatically when the scope closes — no bookkeeping required.
935 yield* Effect.flatMap(
936 runOpenCodeSdk("event.subscribe", () =>
937 context.client.event.subscribe(undefined, {
938 signal: eventsAbortController.signal,
939 }),
940 ),
941 (subscription) =>
942 Stream.fromAsyncIterable(
943 subscription.stream,
944 (cause) =>
945 new OpenCodeRuntimeError({
946 operation: "event.subscribe",
947 detail: openCodeRuntimeErrorDetail(cause),
948 cause,
949 }),
950 ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))),
951 ).pipe(
952 Effect.exit,
953 Effect.flatMap((exit) =>
954 Effect.gen(function* () {
955 // Expected paths: caller aborted the fetch or the session
956 // has already been marked stopped. Treat as a clean exit.
957 if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) {
958 return;
959 }
960 if (Exit.isFailure(exit)) {
961 yield* emitUnexpectedExit(
962 context,
963 openCodeRuntimeErrorDetail(Cause.squash(exit.cause)),
964 );
965 }
966 }),
967 ),
968 Effect.forkIn(context.sessionScope),
969 );
970
971 if (!context.server.external && context.server.exitCode !== null) {
972 yield* context.server.exitCode.pipe(
973 Effect.flatMap((code) =>
974 Effect.gen(function* () {
975 if (yield* Ref.get(context.stopped)) {
976 return;
977 }
978 yield* emitUnexpectedExit(
979 context,
980 `OpenCode server exited unexpectedly (${code}).`,
981 );
982 }),
983 ),
984 Effect.forkIn(context.sessionScope),
851985 );
852 });
853 };
986 }
987 });
854988
855989 const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")(
856990 function* (input) {
......@@ -871,44 +1005,46 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
8711005 const directory = input.cwd ?? serverConfig.cwd;
8721006 const existing = sessions.get(input.threadId);
8731007 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 });
1008 yield* stopOpenCodeContext(existing);
8841009 sessions.delete(input.threadId);
8851010 }
8861011
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 }),
1012 const started = yield* Effect.gen(function* () {
1013 const sessionScope = yield* Scope.make();
1014 const startedExit = yield* Effect.exit(
1015 Effect.gen(function* () {
1016 // The runtime binds the server's lifetime to the Scope.Scope
1017 // we provide below — closing `sessionScope` kills the child
1018 // process automatically. No manual `server.close()` needed.
1019 const server = yield* openCodeRuntime.connectToOpenCodeServer({
1020 binaryPath,
1021 serverUrl,
1022 });
1023 const client = openCodeRuntime.createOpenCodeSdkClient({
1024 baseUrl: server.url,
1025 directory,
1026 ...(server.external && serverPassword ? { serverPassword } : {}),
1027 });
1028 const openCodeSession = yield* runOpenCodeSdk("session.create", () =>
1029 client.session.create({
1030 title: `T3 Code ${input.threadId}`,
1031 permission: buildOpenCodePermissionRules(input.runtimeMode),
1032 }),
1033 );
1034 if (!openCodeSession.data) {
1035 return yield* new OpenCodeRuntimeError({
1036 operation: "session.create",
1037 detail: "OpenCode session.create returned no session payload.",
1038 });
1039 }
1040 return { sessionScope, server, client, openCodeSession: openCodeSession.data };
1041 }).pipe(Effect.provideService(Scope.Scope, sessionScope)),
1042 );
1043 if (Exit.isFailure(startedExit)) {
1044 yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore);
1045 return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause));
1046 }
1047 return startedExit.value;
9121048 });
9131049
9141050 // Guard against a concurrent startSession call that may have raced
......@@ -917,14 +1053,10 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
9171053 if (raceWinner) {
9181054 // Another call won the race – clean up the session we just created
9191055 // (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();
1056 yield* runOpenCodeSdk("session.abort", () =>
1057 started.client.session.abort({ sessionID: started.openCodeSession.id }),
1058 ).pipe(Effect.ignore);
1059 yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore);
9281060 return raceWinner.session;
9291061 }
9301062
......@@ -956,11 +1088,11 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
9561088 activeTurnId: undefined,
9571089 activeAgent: undefined,
9581090 activeVariant: undefined,
959 stopped: false,
960 eventsAbortController: new AbortController(),
1091 stopped: yield* Ref.make(false),
1092 sessionScope: started.sessionScope,
9611093 };
9621094 sessions.set(input.threadId, context);
963 startEventPump(context);
1095 yield* startEventPump(context);
9641096
9651097 yield* emit({
9661098 ...buildEventBase({ threadId: input.threadId }),
......@@ -1043,59 +1175,44 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
10431175 },
10441176 });
10451177
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 }),
1178 yield* runOpenCodeSdk("session.promptAsync", () =>
1179 context.client.session.promptAsync({
1180 sessionID: context.openCodeSessionId,
1181 model: parsedModel,
1182 ...(context.activeAgent ? { agent: context.activeAgent } : {}),
1183 ...(context.activeVariant ? { variant: context.activeVariant } : {}),
1184 parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
10641185 }),
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,
1186 ).pipe(
1187 Effect.mapError(toRequestError),
1188 // On failure: clear active-turn state, flip the session back to ready
1189 // with lastError set, emit turn.aborted, then let the typed error
1190 // propagate. We don't need to rebuild the error here — `toRequestError`
1191 // already produced the right shape.
1192 Effect.tapError((requestError) =>
1193 Effect.gen(function* () {
1194 context.activeTurnId = undefined;
1195 context.activeAgent = undefined;
1196 context.activeVariant = undefined;
1197 updateProviderSession(
1198 context,
1199 {
1200 status: "ready",
1201 model: modelSelection?.model ?? context.session.model,
1202 lastError: requestError.detail,
1203 },
1204 { clearActiveTurnId: true },
1205 );
1206 yield* emit({
1207 ...buildEventBase({ threadId: input.threadId, turnId }),
1208 type: "turn.aborted",
1209 payload: {
1210 reason: requestError.detail,
1211 },
10761212 });
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 }
1213 }),
1214 ),
1215 );
10991216
11001217 return {
11011218 threadId: input.threadId,
......@@ -1106,16 +1223,9 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
11061223 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
11071224 function* (threadId, turnId) {
11081225 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 });
1226 yield* runOpenCodeSdk("session.abort", () =>
1227 context.client.session.abort({ sessionID: context.openCodeSessionId }),
1228 ).pipe(Effect.mapError(toRequestError));
11191229 if (turnId ?? context.activeTurnId) {
11201230 yield* emit({
11211231 ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }),
......@@ -1140,23 +1250,12 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
11401250 });
11411251 }
11421252
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 });
1253 yield* runOpenCodeSdk("permission.reply", () =>
1254 context.client.permission.reply({
1255 requestID: requestId,
1256 reply: toOpenCodePermissionReply(decision),
1257 }),
1258 ).pipe(Effect.mapError(toRequestError));
11601259 });
11611260
11621261 const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn(
......@@ -1172,35 +1271,18 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
11721271 });
11731272 }
11741273
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 });
1274 yield* runOpenCodeSdk("question.reply", () =>
1275 context.client.question.reply({
1276 requestID: requestId,
1277 answers: toOpenCodeQuestionAnswers(request, answers),
1278 }),
1279 ).pipe(Effect.mapError(toRequestError));
11891280 });
11901281
11911282 const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")(
11921283 function* (threadId) {
11931284 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 });
1285 yield* stopOpenCodeContext(context);
12041286 sessions.delete(threadId);
12051287 yield* emit({
12061288 ...buildEventBase({ threadId }),
......@@ -1223,16 +1305,9 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
12231305 const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")(
12241306 function* (threadId) {
12251307 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 });
1308 const messages = yield* runOpenCodeSdk("session.messages", () =>
1309 context.client.session.messages({ sessionID: context.openCodeSessionId }),
1310 ).pipe(Effect.mapError(toRequestError));
12361311
12371312 const turns = (messages.data ?? [])
12381313 .filter((entry) => entry.info.role === "assistant")
......@@ -1251,70 +1326,39 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
12511326 const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
12521327 function* (threadId, numTurns) {
12531328 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 });
1329 const messages = yield* runOpenCodeSdk("session.messages", () =>
1330 context.client.session.messages({ sessionID: context.openCodeSessionId }),
1331 ).pipe(Effect.mapError(toRequestError));
12651332
12661333 const assistantMessages = (messages.data ?? []).filter(
12671334 (entry) => entry.info.role === "assistant",
12681335 );
12691336 const targetIndex = assistantMessages.length - numTurns - 1;
12701337 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 });
1338 yield* runOpenCodeSdk("session.revert", () =>
1339 context.client.session.revert({
1340 sessionID: context.openCodeSessionId,
1341 ...(target ? { messageID: target.info.id } : {}),
1342 }),
1343 ).pipe(Effect.mapError(toRequestError));
12851344
12861345 return yield* readThread(threadId);
12871346 },
12881347 );
12891348
12901349 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 }),
1350 Effect.gen(function* () {
1351 const contexts = [...sessions.values()];
1352 sessions.clear();
1353 // `stopOpenCodeContext` is typed as never-failing — SDK aborts are
1354 // already `Effect.ignore`'d inside it. `ignoreCause` here also
1355 // swallows defects from throwing finalizers so one bad close can't
1356 // interrupt the sibling fibers. Same pattern as the layer finalizer.
1357 yield* Effect.forEach(
1358 contexts,
1359 (context) => Effect.ignoreCause(stopOpenCodeContext(context)),
1360 { concurrency: "unbounded", discard: true },
1361 );
13181362 });
13191363
13201364 return {
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+107-44
......@@ -3,66 +3,81 @@ import assert from "node:assert/strict";
33import * as NodeServices from "@effect/platform-node/NodeServices";
44import { it } from "@effect/vitest";
55import { Effect, Layer } from "effect";
6import { beforeEach, vi } from "vitest";
6import { beforeEach } from "vitest";
77
88import { ServerConfig } from "../../config.ts";
99import { ServerSettingsService } from "../../serverSettings.ts";
1010import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
11import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts";
12
13const runtimeMock = vi.hoisted(() => {
14 const state = {
11import {
12 OpenCodeRuntime,
13 OpenCodeRuntimeError,
14 type OpenCodeRuntimeShape,
15} from "../opencodeRuntime.ts";
16import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
17import type { OpenCodeInventory } from "../opencodeRuntime.ts";
18
19const runtimeMock = {
20 state: {
1521 runVersionError: null as Error | null,
1622 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 };
23 inventory: {
24 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
25 agents: [] as unknown[],
26 } as unknown,
27 },
28 reset() {
29 this.state.runVersionError = null;
30 this.state.inventoryError = null;
31 this.state.inventory = {
32 providerList: { connected: [], all: [] as unknown[], default: {} },
33 agents: [] as unknown[],
34 };
35 },
36};
37
38const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
39 startOpenCodeServerProcess: () =>
40 Effect.succeed({
41 url: "http://127.0.0.1:4301",
42 exitCode: Effect.never,
3943 }),
40 connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({
44 connectToOpenCodeServer: ({ serverUrl }) =>
45 Effect.succeed({
4146 url: serverUrl ?? "http://127.0.0.1:4301",
42 process: null,
47 exitCode: null,
4348 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 };
5549 }),
56 flattenOpenCodeModels: vi.fn(() => []),
57 };
58});
50 runOpenCodeCommand: () =>
51 runtimeMock.state.runVersionError
52 ? Effect.fail(
53 new OpenCodeRuntimeError({
54 operation: "runOpenCodeCommand",
55 detail: runtimeMock.state.runVersionError.message,
56 cause: runtimeMock.state.runVersionError,
57 }),
58 )
59 : Effect.succeed({ stdout: "opencode 1.0.0\n", stderr: "", code: 0 }),
60 createOpenCodeSdkClient: () =>
61 ({}) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
62 loadOpenCodeInventory: () =>
63 runtimeMock.state.inventoryError
64 ? Effect.fail(
65 new OpenCodeRuntimeError({
66 operation: "loadOpenCodeInventory",
67 detail: runtimeMock.state.inventoryError.message,
68 cause: runtimeMock.state.inventoryError,
69 }),
70 )
71 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),
72};
5973
6074beforeEach(() => {
6175 runtimeMock.reset();
6276});
6377
6478const makeTestLayer = (settingsOverrides?: Parameters<typeof ServerSettingsService.layerTest>[0]) =>
65 makeOpenCodeProviderLive().pipe(
79 OpenCodeProviderLive.pipe(
80 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
6681 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
6782 Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)),
6883 Layer.provideMerge(NodeServices.layer),
......@@ -92,6 +107,54 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
92107 assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check.");
93108 }),
94109 );
110
111 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
112 Effect.gen(function* () {
113 runtimeMock.state.inventory = {
114 providerList: {
115 connected: ["openai"],
116 all: [
117 {
118 id: "openai",
119 name: "OpenAI",
120 models: {
121 "gpt-5.4": {
122 id: "gpt-5.4",
123 name: "GPT-5.4",
124 variants: {
125 none: {},
126 low: {},
127 medium: {},
128 high: {},
129 xhigh: {},
130 },
131 },
132 },
133 },
134 ],
135 default: {},
136 },
137 agents: [
138 { name: "build", hidden: false, mode: "primary" },
139 { name: "plan", hidden: false, mode: "primary" },
140 ],
141 };
142
143 const provider = yield* OpenCodeProvider;
144 const snapshot = yield* provider.refresh;
145 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
146
147 assert.ok(model);
148 assert.equal(
149 model.capabilities?.variantOptions?.find((option) => option.isDefault)?.value,
150 "medium",
151 );
152 assert.equal(
153 model.capabilities?.agentOptions?.find((option) => option.isDefault)?.value,
154 "build",
155 );
156 }),
157 );
95158});
96159
97160it.layer(
apps/server/src/provider/Layers/OpenCodeProvider.ts+286-188
......@@ -1,57 +1,58 @@
1import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts";
2import { Cause, Effect, Equal, Layer, Stream } from "effect";
1import type {
2 ModelCapabilities,
3 OpenCodeSettings,
4 ServerProvider,
5 ServerProviderModel,
6} from "@t3tools/contracts";
7import { Cause, Data, Effect, Equal, Layer, Stream } from "effect";
38
49import { ServerConfig } from "../../config.ts";
510import { ServerSettingsService } from "../../serverSettings.ts";
611import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
712import {
813 buildServerProvider,
9 isCommandMissingCause,
1014 parseGenericCliVersion,
1115 providerModelsFromSettings,
1216} from "../providerSnapshot.ts";
1317import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
1418import {
15 connectToOpenCodeServer,
16 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
17 createOpenCodeSdkClient,
18 flattenOpenCodeModels,
19 loadOpenCodeInventory,
20 runOpenCodeCommand,
19 OpenCodeRuntime,
20 openCodeRuntimeErrorDetail,
21 type OpenCodeInventory,
2122} from "../opencodeRuntime.ts";
23import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
2224
2325const PROVIDER = "opencode" as const;
2426
25class OpenCodeProbePromiseError extends Error {
26 override readonly cause: unknown;
27class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
28 readonly cause: unknown;
29 readonly detail: string;
30}> {}
2731
28 constructor(cause: unknown) {
29 super(cause instanceof Error ? cause.message : String(cause));
30 this.cause = cause;
31 this.name = "OpenCodeProbePromiseError";
32function normalizeProbeMessage(message: string): string | undefined {
33 const trimmed = message.trim();
34 if (trimmed.length === 0) {
35 return undefined;
3236 }
33}
34
35function toOpenCodeProbeError(cause: unknown): OpenCodeProbePromiseError {
36 return new OpenCodeProbePromiseError(cause);
37 if (
38 trimmed === "An error occurred in Effect.tryPromise" ||
39 trimmed === "An error occurred in Effect.try"
40 ) {
41 return undefined;
42 }
43 return trimmed;
3744}
3845
3946function normalizedErrorMessage(cause: unknown): string | undefined {
40 if (!(cause instanceof Error)) {
41 return undefined;
47 if (cause instanceof OpenCodeProbeError) {
48 return normalizeProbeMessage(cause.detail);
4249 }
4350
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 ) {
51 if (!(cause instanceof Error)) {
5252 return undefined;
5353 }
54 return message;
54
55 return normalizeProbeMessage(cause.message);
5556}
5657
5758function formatOpenCodeProbeError(input: {
......@@ -59,8 +60,8 @@ function formatOpenCodeProbeError(input: {
5960 readonly isExternalServer: boolean;
6061 readonly serverUrl: string;
6162}): { readonly installed: boolean; readonly message: string } {
62 const lower = input.cause instanceof Error ? input.cause.message.toLowerCase() : "";
6363 const detail = normalizedErrorMessage(input.cause);
64 const lower = detail?.toLowerCase() ?? "";
6465
6566 if (input.isExternalServer) {
6667 if (
......@@ -96,7 +97,7 @@ function formatOpenCodeProbeError(input: {
9697 };
9798 }
9899
99 if (input.cause instanceof Error && isCommandMissingCause(input.cause)) {
100 if (lower.includes("enoent") || lower.includes("notfound")) {
100101 return {
101102 installed: false,
102103 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
......@@ -127,6 +128,99 @@ function formatOpenCodeProbeError(input: {
127128 };
128129}
129130
131function titleCaseSlug(value: string): string {
132 return value
133 .split(/[-_/]+/)
134 .filter((segment) => segment.length > 0)
135 .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
136 .join(" ");
137}
138
139function inferDefaultVariant(
140 providerID: string,
141 variants: ReadonlyArray<string>,
142): string | undefined {
143 if (variants.length === 1) {
144 return variants[0];
145 }
146 if (providerID === "anthropic" || providerID.startsWith("google")) {
147 return variants.includes("high") ? "high" : undefined;
148 }
149 if (providerID === "openai" || providerID === "opencode") {
150 return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined;
151 }
152 return undefined;
153}
154
155function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined {
156 return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined;
157}
158
159const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = {
160 reasoningEffortLevels: [],
161 supportsFastMode: false,
162 supportsThinkingToggle: false,
163 contextWindowOptions: [],
164 promptInjectedEffortLevels: [],
165};
166
167function openCodeCapabilitiesForModel(input: {
168 readonly providerID: string;
169 readonly model: ProviderListResponse["all"][number]["models"][string];
170 readonly agents: ReadonlyArray<Agent>;
171}): ModelCapabilities {
172 const variantValues = Object.keys(input.model.variants ?? {});
173 const defaultVariant = inferDefaultVariant(input.providerID, variantValues);
174 const variantOptions: ModelCapabilities["variantOptions"] = variantValues.map((value) =>
175 Object.assign(
176 { value, label: titleCaseSlug(value) },
177 defaultVariant === value ? { isDefault: true } : {},
178 ),
179 );
180 const primaryAgents = input.agents.filter(
181 (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"),
182 );
183 const defaultAgent = inferDefaultAgent(primaryAgents);
184 const agentOptions: ModelCapabilities["agentOptions"] = primaryAgents.map((agent) =>
185 Object.assign(
186 { value: agent.name, label: titleCaseSlug(agent.name) },
187 defaultAgent === agent.name ? { isDefault: true } : {},
188 ),
189 );
190 return {
191 ...DEFAULT_OPENCODE_MODEL_CAPABILITIES,
192 ...(variantOptions.length > 0 ? { variantOptions } : {}),
193 ...(agentOptions.length > 0 ? { agentOptions } : {}),
194 };
195}
196
197function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray<ServerProviderModel> {
198 const connected = new Set(input.providerList.connected);
199 const models: Array<ServerProviderModel> = [];
200
201 for (const provider of input.providerList.all) {
202 if (!connected.has(provider.id)) {
203 continue;
204 }
205
206 for (const model of Object.values(provider.models)) {
207 models.push({
208 slug: `${provider.id}/${model.id}`,
209 name: model.name,
210 subProvider: provider.name,
211 isCustom: false,
212 capabilities: openCodeCapabilitiesForModel({
213 providerID: provider.id,
214 model,
215 agents: input.agents,
216 }),
217 });
218 }
219 }
220
221 return models.toSorted((left, right) => left.name.localeCompare(right.name));
222}
223
130224const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => {
131225 const checkedAt = new Date().toISOString();
132226 const models = providerModelsFromSettings(
......@@ -170,173 +264,177 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server
170264 });
171265};
172266
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 [],
267export const OpenCodeProviderLive = Layer.effect(
268 OpenCodeProvider,
269 Effect.gen(function* () {
270 const serverSettings = yield* ServerSettingsService;
271 const serverConfig = yield* ServerConfig;
272 const openCodeRuntime = yield* OpenCodeRuntime;
273
274 const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (input: {
275 readonly settings: OpenCodeSettings;
276 readonly cwd: string;
277 }): Effect.fn.Return<ServerProvider, never> {
278 const checkedAt = new Date().toISOString();
279 const customModels = input.settings.customModels;
280 const isExternalServer = input.settings.serverUrl.trim().length > 0;
281
282 const fallback = (cause: unknown, version: string | null = null) => {
283 const failure = formatOpenCodeProbeError({
284 cause,
285 isExternalServer,
286 serverUrl: input.settings.serverUrl,
287 });
288 return buildServerProvider({
289 provider: PROVIDER,
290 enabled: input.settings.enabled,
291 checkedAt,
292 models: providerModelsFromSettings(
293 [],
294 PROVIDER,
295 customModels,
296 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
297 ),
298 probe: {
299 installed: failure.installed,
300 version,
301 status: "error",
302 auth: { status: "unknown" },
303 message: failure.message,
304 },
305 });
306 };
307
308 if (!input.settings.enabled) {
309 return buildServerProvider({
310 provider: PROVIDER,
311 enabled: false,
312 checkedAt,
313 models: providerModelsFromSettings(
314 [],
315 PROVIDER,
316 customModels,
317 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
318 ),
319 probe: {
320 installed: false,
321 version: null,
322 status: "warning",
323 auth: { status: "unknown" },
324 message: isExternalServer
325 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."
326 : "OpenCode is disabled in T3 Code settings.",
327 },
328 });
329 }
330
331 let version: string | null = null;
332 if (!isExternalServer) {
333 const versionExit = yield* Effect.exit(
334 openCodeRuntime
335 .runOpenCodeCommand({
336 binaryPath: input.settings.binaryPath,
337 args: ["--version"],
338 })
339 .pipe(
340 Effect.mapError(
341 (cause) =>
342 new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }),
343 ),
344 ),
345 );
346 if (versionExit._tag === "Failure") {
347 return fallback(Cause.squash(versionExit.cause));
348 }
349 version = parseGenericCliVersion(versionExit.value.stdout) ?? null;
350 }
351
352 const inventoryExit = yield* Effect.exit(
353 Effect.scoped(
354 Effect.gen(function* () {
355 const server = yield* openCodeRuntime
356 .connectToOpenCodeServer({
357 binaryPath: input.settings.binaryPath,
358 serverUrl: input.settings.serverUrl,
359 })
360 .pipe(
361 Effect.mapError(
362 (cause) =>
363 new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }),
364 ),
365 );
366 return yield* openCodeRuntime
367 .loadOpenCodeInventory(
368 openCodeRuntime.createOpenCodeSdkClient({
369 baseUrl: server.url,
370 directory: input.cwd,
371 ...(isExternalServer && input.settings.serverPassword
372 ? { serverPassword: input.settings.serverPassword }
373 : {}),
374 }),
375 )
376 .pipe(
377 Effect.mapError(
378 (cause) =>
379 new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }),
380 ),
381 );
382 }),
383 ),
384 );
385 if (inventoryExit._tag === "Failure") {
386 return fallback(Cause.squash(inventoryExit.cause), version);
387 }
388
389 const models = providerModelsFromSettings(
390 flattenOpenCodeModels(inventoryExit.value),
193391 PROVIDER,
194392 customModels,
195393 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) {
394 );
395 const connectedCount = inventoryExit.value.providerList.connected.length;
209396 return buildServerProvider({
210397 provider: PROVIDER,
211 enabled: false,
398 enabled: true,
212399 checkedAt,
213 models: providerModelsFromSettings(
214 [],
215 PROVIDER,
216 customModels,
217 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
218 ),
400 models,
219401 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.",
402 installed: true,
403 version,
404 status: connectedCount > 0 ? "ready" : "warning",
405 auth: {
406 status: connectedCount > 0 ? "authenticated" : "unknown",
407 type: "opencode",
408 },
409 message:
410 connectedCount > 0
411 ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.`
412 : isExternalServer
413 ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers."
414 : "OpenCode is available, but it did not report any connected upstream providers.",
227415 },
228416 });
229 }
417 });
230418
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 }
419 const getProviderSettings = serverSettings.getSettings.pipe(
420 Effect.map((settings) => settings.providers.opencode),
421 );
248422
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,
423 return yield* makeManagedServerProvider<OpenCodeSettings>({
424 getSettings: getProviderSettings.pipe(Effect.orDie),
425 streamSettings: serverSettings.streamChanges.pipe(
426 Stream.map((settings) => settings.providers.opencode),
427 ),
428 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
429 initialSnapshot: makePendingOpenCodeProvider,
430 checkProvider: getProviderSettings.pipe(
431 Effect.flatMap((settings) =>
432 checkOpenCodeProviderStatus({
433 settings,
434 cwd: serverConfig.cwd,
272435 }),
273 (server) => Effect.sync(() => server.close()),
436 ),
274437 ),
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 },
307438 });
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();
439 }),
440);
apps/server/src/provider/Layers/ProviderRegistry.ts+2
......@@ -16,6 +16,7 @@ import { CodexProvider } from "../Services/CodexProvider.ts";
1616import { CursorProvider } from "../Services/CursorProvider.ts";
1717import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
1818import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
19import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
1920import {
2021 hydrateCachedProvider,
2122 PROVIDER_CACHE_IDS,
......@@ -287,6 +288,7 @@ export const ProviderRegistryLive = Layer.unwrap(
287288 Layer.provideMerge(CodexProviderLive),
288289 Layer.provideMerge(ClaudeProviderLive),
289290 Layer.provideMerge(OpenCodeProviderLive),
291 Layer.provideMerge(OpenCodeRuntimeLive),
290292 ),
291293 ),
292294);
apps/server/src/provider/opencodeRuntime.test.ts deleted-37
......@@ -1,37 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { DEFAULT_OPENCODE_MODEL_CAPABILITIES, flattenOpenCodeModels } from "./opencodeRuntime.ts";
4
5describe("flattenOpenCodeModels", () => {
6 it("keeps the canonical model name separate from the subprovider label", () => {
7 const models = flattenOpenCodeModels({
8 providerList: {
9 connected: ["github-copilot"],
10 all: [
11 {
12 id: "github-copilot",
13 name: "GitHub Copilot",
14 models: {
15 "claude-opus-4.5": {
16 id: "claude-opus-4.5",
17 name: "Claude Opus 4.5",
18 variants: {},
19 },
20 },
21 },
22 ],
23 },
24 agents: [],
25 } as unknown as Parameters<typeof flattenOpenCodeModels>[0]);
26
27 expect(models).toEqual([
28 {
29 slug: "github-copilot/claude-opus-4.5",
30 name: "Claude Opus 4.5",
31 subProvider: "GitHub Copilot",
32 isCustom: false,
33 capabilities: DEFAULT_OPENCODE_MODEL_CAPABILITIES,
34 },
35 ]);
36 });
37});
apps/server/src/provider/opencodeRuntime.ts+341-392
......@@ -1,17 +1,6 @@
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";
61import { pathToFileURL } from "node:url";
72
8import type {
9 ChatAttachment,
10 ModelCapabilities,
11 ProviderApprovalDecision,
12 RuntimeMode,
13 ServerProviderModel,
14} from "@t3tools/contracts";
3import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";
154import {
165 createOpencodeClient,
176 type Agent,
......@@ -22,40 +11,80 @@ import {
2211 type QuestionAnswer,
2312 type QuestionRequest,
2413} from "@opencode-ai/sdk/v2";
14import {
15 Cause,
16 Context,
17 Data,
18 Deferred,
19 Effect,
20 Exit,
21 Fiber,
22 Layer,
23 Option,
24 Predicate as P,
25 Ref,
26 Result,
27 Scope,
28 Stream,
29} from "effect";
30import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
31
32import { isWindowsCommandNotFound } from "../processRunner.ts";
33import { collectStreamAsString } from "./providerSnapshot.ts";
34import { NetService } from "@t3tools/shared/Net";
2535
2636const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
2737const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000;
2838const DEFAULT_HOSTNAME = "127.0.0.1";
2939
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
4240export interface OpenCodeServerProcess {
4341 readonly url: string;
44 readonly process: ChildProcess;
45 close(): void;
42 readonly exitCode: Effect.Effect<number, never>;
4643}
4744
4845export interface OpenCodeServerConnection {
4946 readonly url: string;
50 readonly process: ChildProcess | null;
47 readonly exitCode: Effect.Effect<number, never> | null;
5148 readonly external: boolean;
52 close(): void;
5349}
5450
55function buildOpenCodeBasicAuthorizationHeader(password: string): string {
56 return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;
51const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError";
52export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{
53 readonly operation: string;
54 readonly cause?: unknown;
55 readonly detail: string;
56}> {
57 static readonly is = (u: unknown): u is OpenCodeRuntimeError =>
58 P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);
59}
60
61export function openCodeRuntimeErrorDetail(cause: unknown): string {
62 if (OpenCodeRuntimeError.is(cause)) return cause.detail;
63 if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim();
64 if (cause && typeof cause === "object") {
65 // SDK v2 throws { response, request, error? } shapes — extract what's useful
66 const anyCause = cause as Record<string, unknown>;
67 const status = (anyCause.response as { status?: number } | undefined)?.status;
68 const body = anyCause.error ?? anyCause.data ?? anyCause.body;
69 try {
70 return `status=${status ?? "?"} body=${JSON.stringify(body ?? cause)}`;
71 } catch {
72 /* fall through */
73 }
74 }
75 return String(cause);
5776}
5877
78export const runOpenCodeSdk = <A>(
79 operation: string,
80 fn: () => Promise<A>,
81): Effect.Effect<A, OpenCodeRuntimeError> =>
82 Effect.tryPromise({
83 try: fn,
84 catch: (cause) =>
85 new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }),
86 }).pipe(Effect.withSpan(`opencode.${operation}`));
87
5988export interface OpenCodeCommandResult {
6089 readonly stdout: string;
6190 readonly stderr: string;
......@@ -72,12 +101,43 @@ export interface ParsedOpenCodeModelSlug {
72101 readonly modelID: string;
73102}
74103
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(" ");
104export interface OpenCodeRuntimeShape {
105 /**
106 * Spawns a local OpenCode server process. Its lifetime is bound to the caller's
107 * `Scope.Scope` — the child is killed automatically when that scope closes.
108 * Consumers that want a long-lived server must create and hold a scope explicitly
109 * (see {@link Scope.make}) and close it when done.
110 */
111 readonly startOpenCodeServerProcess: (input: {
112 readonly binaryPath: string;
113 readonly port?: number;
114 readonly hostname?: string;
115 readonly timeoutMs?: number;
116 }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>;
117 /**
118 * Returns a handle to either an externally-managed OpenCode server (when
119 * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a
120 * freshly spawned local server whose lifetime is bound to the caller's scope.
121 */
122 readonly connectToOpenCodeServer: (input: {
123 readonly binaryPath: string;
124 readonly serverUrl?: string | null;
125 readonly port?: number;
126 readonly hostname?: string;
127 readonly timeoutMs?: number;
128 }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>;
129 readonly runOpenCodeCommand: (input: {
130 readonly binaryPath: string;
131 readonly args: ReadonlyArray<string>;
132 }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;
133 readonly createOpenCodeSdkClient: (input: {
134 readonly baseUrl: string;
135 readonly directory: string;
136 readonly serverPassword?: string;
137 }) => OpencodeClient;
138 readonly loadOpenCodeInventory: (
139 client: OpencodeClient,
140 ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
81141}
82142
83143function parseServerUrlFromOutput(output: string): string | null {
......@@ -91,92 +151,6 @@ function parseServerUrlFromOutput(output: string): string | null {
91151 return null;
92152}
93153
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
180154export function parseOpenCodeModelSlug(
181155 slug: string | null | undefined,
182156): ParsedOpenCodeModelSlug | null {
......@@ -196,10 +170,6 @@ export function parseOpenCodeModelSlug(
196170 };
197171}
198172
199export function toOpenCodeModelSlug(providerID: string, modelID: string): string {
200 return `${providerID}/${modelID}`;
201}
202
203173export function openCodeQuestionId(
204174 index: number,
205175 question: QuestionRequest["questions"][number],
......@@ -286,289 +256,268 @@ export function toOpenCodeQuestionAnswers(
286256 });
287257}
288258
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;
259function ensureRuntimeError(
260 operation: OpenCodeRuntimeError["operation"],
261 detail: string,
262 cause: unknown,
263): OpenCodeRuntimeError {
264 return OpenCodeRuntimeError.is(cause)
265 ? cause
266 : new OpenCodeRuntimeError({ operation, detail, cause });
301267}
302268
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}
269const makeOpenCodeRuntime = Effect.gen(function* () {
270 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
271 const netService = yield* NetService;
312272
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}`
273 const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) =>
274 Effect.gen(function* () {
275 const child = yield* spawner.spawn(
276 ChildProcess.make(input.binaryPath, [...input.args], {
277 shell: process.platform === "win32",
278 env: process.env,
279 }),
325280 );
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 );
281 const [stdout, stderr, code] = yield* Effect.all(
282 [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode],
283 { concurrency: "unbounded" },
284 );
285 const exitCode = Number(code);
286 if (isWindowsCommandNotFound(exitCode, stderr)) {
287 return yield* new OpenCodeRuntimeError({
288 operation: "runOpenCodeCommand",
289 detail: `spawn ${input.binaryPath} ENOENT`,
290 });
344291 }
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 });
292 return {
293 stdout,
294 stderr,
295 code: exitCode,
296 } satisfies OpenCodeCommandResult;
297 }).pipe(
298 Effect.scoped,
299 Effect.mapError((cause) =>
300 ensureRuntimeError(
301 "runOpenCodeCommand",
302 `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`,
303 cause,
304 ),
305 ),
306 );
307
308 const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) =>
309 Effect.gen(function* () {
310 // Bind this server's lifetime to the caller's scope. When the caller's
311 // scope closes, the spawned child is killed and all associated fibers
312 // are interrupted automatically — no `close()` method needed.
313 const runtimeScope = yield* Scope.Scope;
314
315 const hostname = input.hostname ?? DEFAULT_HOSTNAME;
316 const port =
317 input.port ??
318 (yield* netService.findAvailablePort(0).pipe(
319 Effect.mapError(
320 (cause) =>
321 new OpenCodeRuntimeError({
322 operation: "startOpenCodeServerProcess",
323 detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`,
324 cause,
325 }),
326 ),
327 ));
328 const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;
329 const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];
330
331 const child = yield* spawner
332 .spawn(
333 ChildProcess.make(input.binaryPath, args, {
334 env: {
335 ...process.env,
336 OPENCODE_CONFIG_CONTENT: JSON.stringify({}),
337 },
338 }),
339 )
340 .pipe(
341 Effect.provideService(Scope.Scope, runtimeScope),
342 Effect.mapError(
343 (cause) =>
344 new OpenCodeRuntimeError({
345 operation: "startOpenCodeServerProcess",
346 detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`,
347 cause,
348 }),
349 ),
350 );
369351
370 child.stdout.setEncoding("utf8");
371 child.stderr.setEncoding("utf8");
352 const stdoutRef = yield* Ref.make("");
353 const stderrRef = yield* Ref.make("");
354 const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();
355
356 const setReadyFromStdoutChunk = (chunk: string) =>
357 Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe(
358 Effect.flatMap((nextStdout) => {
359 const parsed = parseServerUrlFromOutput(nextStdout);
360 return parsed
361 ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore)
362 : Effect.void;
363 }),
364 );
372365
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 };
366 const stdoutFiber = yield* child.stdout.pipe(
367 Stream.decodeText(),
368 Stream.runForEach(setReadyFromStdoutChunk),
369 Effect.ignore,
370 Effect.forkIn(runtimeScope),
371 );
372 const stderrFiber = yield* child.stderr.pipe(
373 Stream.decodeText(),
374 Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)),
375 Effect.ignore,
376 Effect.forkIn(runtimeScope),
377 );
383378
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"),
379 const exitFiber = yield* child.exitCode.pipe(
380 Effect.flatMap((code) =>
381 Effect.gen(function* () {
382 const stdout = yield* Ref.get(stdoutRef);
383 const stderr = yield* Ref.get(stderrRef);
384 const exitCode = Number(code);
385 yield* Deferred.fail(
386 readyDeferred,
387 new OpenCodeRuntimeError({
388 operation: "startOpenCodeServerProcess",
389 detail: [
390 `OpenCode server exited before startup completed (code: ${String(exitCode)}).`,
391 stdout.trim() ? `stdout:\n${stdout.trim()}` : null,
392 stderr.trim() ? `stderr:\n${stderr.trim()}` : null,
393 ]
394 .filter(Boolean)
395 .join("\n\n"),
396 cause: { exitCode, stdout, stderr },
397 }),
398 ).pipe(Effect.ignore);
399 }),
434400 ),
401 Effect.ignore,
402 Effect.forkIn(runtimeScope),
435403 );
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 }
467404
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> = [];
405 const readyExit = yield* Effect.exit(
406 Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)),
407 );
498408
499 child.stdout?.on("data", (chunk: string) => stdoutChunks.push(chunk));
500 child.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk));
409 // Startup-time fibers are no longer needed once ready has resolved (either
410 // way). The exit fiber is only interrupted on failure; on success it keeps
411 // the caller's `exitCode` effect observable until the scope closes.
412 yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore);
413 yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore);
414
415 if (Exit.isFailure(readyExit)) {
416 yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);
417 const squashed = Cause.squash(readyExit.cause);
418 return yield* ensureRuntimeError(
419 "startOpenCodeServerProcess",
420 `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`,
421 squashed,
422 );
423 }
501424
502 const code = await new Promise<number>((resolve, reject) => {
503 child.once("error", reject);
504 child.once("exit", (exitCode) => resolve(exitCode ?? 0));
505 });
425 const readyOption = readyExit.value;
426 if (Option.isNone(readyOption)) {
427 yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);
428 return yield* new OpenCodeRuntimeError({
429 operation: "startOpenCodeServerProcess",
430 detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`,
431 });
432 }
506433
507 return {
508 stdout: stdoutChunks.join(""),
509 stderr: stderrChunks.join(""),
510 code,
511 };
512}
434 return {
435 url: readyOption.value,
436 exitCode: child.exitCode.pipe(
437 Effect.map(Number),
438 Effect.orElseSucceed(() => 0),
439 ),
440 } satisfies OpenCodeServerProcess;
441 });
513442
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}
443 const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => {
444 const serverUrl = input.serverUrl?.trim();
445 if (serverUrl) {
446 // We don't own externally-configured servers — no scope interaction.
447 return Effect.succeed({
448 url: serverUrl,
449 exitCode: null,
450 external: true,
451 });
452 }
532453
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 ?? [],
454 return startOpenCodeServerProcess({
455 binaryPath: input.binaryPath,
456 ...(input.port !== undefined ? { port: input.port } : {}),
457 ...(input.hostname !== undefined ? { hostname: input.hostname } : {}),
458 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
459 }).pipe(
460 Effect.map((server) => ({
461 url: server.url,
462 exitCode: server.exitCode,
463 external: false,
464 })),
465 );
544466 };
545}
546467
547export function flattenOpenCodeModels(
548 input: OpenCodeInventory,
549): ReadonlyArray<ServerProviderModel> {
550 const connected = new Set(input.providerList.connected);
551 const models: Array<ServerProviderModel> = [];
468 const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) =>
469 createOpencodeClient({
470 baseUrl: input.baseUrl,
471 directory: input.directory,
472 ...(input.serverPassword
473 ? {
474 headers: {
475 Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`,
476 },
477 }
478 : {}),
479 throwOnError: true,
480 });
552481
553 for (const provider of input.providerList.all) {
554 if (!connected.has(provider.id)) {
555 continue;
556 }
482 const loadProviders = (client: OpencodeClient) =>
483 runOpenCodeSdk("provider.list", () => client.provider.list()).pipe(
484 Effect.filterMapOrFail(
485 (list) =>
486 list.data
487 ? Result.succeed(list.data)
488 : Result.fail(
489 new OpenCodeRuntimeError({
490 operation: "provider.list",
491 detail: "OpenCode provider list was empty.",
492 }),
493 ),
494 (result) => result,
495 ),
496 );
497
498 const loadAgents = (client: OpencodeClient) =>
499 runOpenCodeSdk("app.agents", () => client.app.agents()).pipe(
500 Effect.map((result) => result.data ?? []),
501 );
502
503 const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) =>
504 Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe(
505 Effect.map(([providerList, agents]) => ({ providerList, agents })),
506 );
557507
558 for (const model of Object.values(provider.models)) {
559 models.push({
560 slug: toOpenCodeModelSlug(provider.id, model.id),
561 name: model.name,
562 subProvider: provider.name,
563 isCustom: false,
564 capabilities: openCodeCapabilitiesForModel({
565 providerID: provider.id,
566 model,
567 agents: input.agents,
568 }),
569 });
570 }
571 }
572
573 return models.toSorted((left, right) => left.name.localeCompare(right.name));
574}
508 return {
509 startOpenCodeServerProcess,
510 connectToOpenCodeServer,
511 runOpenCodeCommand,
512 createOpenCodeSdkClient,
513 loadOpenCodeInventory,
514 } satisfies OpenCodeRuntimeShape;
515});
516
517export class OpenCodeRuntime extends Context.Service<OpenCodeRuntime, OpenCodeRuntimeShape>()(
518 "t3/provider/OpenCodeRuntime",
519) {}
520
521export const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe(
522 Layer.provide(NetService.layer),
523);
apps/server/src/server.test.ts+8-1
......@@ -1830,7 +1830,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
18301830 );
18311831
18321832 assertTrue(result._tag === "Failure");
1833 assertInclude(String(result.failure), "SocketOpenError");
1833 const failureMessage = String(result.failure);
1834 assertTrue(
1835 failureMessage.includes("SocketOpenError") || failureMessage.includes("SocketCloseError"),
1836 );
1837 assertTrue(
1838 failureMessage.includes("Unauthorized") ||
1839 failureMessage.includes("An error occurred during Open"),
1840 );
18341841 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
18351842 );
18361843
apps/server/src/server.ts+2
......@@ -76,6 +76,7 @@ import {
7676 orchestrationDispatchRouteLayer,
7777 orchestrationSnapshotRouteLayer,
7878} from "./orchestration/http.ts";
79import { NetService } from "@t3tools/shared/Net";
7980
8081const PtyAdapterLive = Layer.unwrap(
8182 Effect.gen(function* () {
......@@ -243,6 +244,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe(
243244 Layer.provideMerge(AnalyticsServiceLayerLive),
244245 Layer.provideMerge(OpenLive),
245246 Layer.provideMerge(ServerLifecycleEventsLive),
247 Layer.provide(NetService.layer),
246248);
247249
248250const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
apps/web/src/components/chat/TraitsPicker.browser.tsx+41-2
......@@ -630,7 +630,11 @@ describe("TraitsPicker (Codex)", () => {
630630
631631// ── OpenCode TraitsPicker tests ───────────────────────────────────────
632632
633async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeModelOptions }) {
633async function mountOpenCodePicker(props: {
634 model?: string;
635 options?: OpenCodeModelOptions;
636 models?: ServerProvider["models"];
637}) {
634638 const threadId = ThreadId.make("thread-opencode-traits");
635639 const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
636640 const threadKey = scopedThreadKey(threadRef);
......@@ -665,7 +669,7 @@ async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeMo
665669 const screen = await render(
666670 <TraitsPicker
667671 provider="opencode"
668 models={findTestProvider("opencode").models}
672 models={props.models ?? findTestProvider("opencode").models}
669673 threadRef={threadRef}
670674 model={model}
671675 prompt=""
......@@ -712,6 +716,41 @@ describe("TraitsPicker (OpenCode)", () => {
712716 expect(text).not.toContain("Medium · plan");
713717 });
714718 });
719
720 it("does not show a leading separator when only agent options are available", async () => {
721 await using _ = await mountOpenCodePicker({
722 model: "openai/gpt-5.4",
723 models: [
724 {
725 slug: "openai/gpt-5.4",
726 name: "OpenAI · GPT-5.4",
727 isCustom: false,
728 capabilities: {
729 reasoningEffortLevels: [],
730 supportsFastMode: false,
731 supportsThinkingToggle: false,
732 contextWindowOptions: [],
733 promptInjectedEffortLevels: [],
734 variantOptions: [],
735 agentOptions: [
736 { value: "build", label: "Build", isDefault: true },
737 { value: "plan", label: "Plan" },
738 ],
739 },
740 },
741 ],
742 });
743
744 await page.getByRole("button").click();
745
746 await vi.waitFor(() => {
747 const text = document.body.textContent ?? "";
748 expect(text).toContain("Agent");
749 expect(text).toContain("Build (default)");
750 expect(text).toContain("Plan");
751 expect(document.querySelectorAll('[data-slot="menu-separator"]')).toHaveLength(0);
752 });
753 });
715754});
716755
717756describe("TraitsPicker (Cursor)", () => {
apps/web/src/components/chat/TraitsPicker.tsx+16-8
......@@ -91,7 +91,7 @@ function resolveNamedOption(
9191 return matchingOption;
9292 }
9393 }
94 return options.find((option) => option.isDefault) ?? null;
94 return options.find((option) => option.isDefault) ?? options[0] ?? null;
9595}
9696
9797function getRawContextWindow(
......@@ -301,6 +301,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
301301 allowPromptInjectedEffort,
302302 });
303303 const defaultEffort = getDefaultEffort(caps);
304 const showsEffortSection = showEffort;
305 const showsThinkingSection = !showEffort && showThinking;
306 const showsFastModeSection = showFastMode;
307 const showsContextWindowSection = showContextWindow;
308 const hasSectionsBeforeAgent =
309 showsEffortSection || showsThinkingSection || showsFastModeSection || showsContextWindowSection;
304310
305311 const handleEffortChange = useCallback(
306312 (value: string) => {
......@@ -348,7 +354,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
348354
349355 return (
350356 <>
351 {showEffort ? (
357 {showsEffortSection ? (
352358 <>
353359 <MenuGroup>
354360 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">
......@@ -378,7 +384,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
378384 </MenuRadioGroup>
379385 </MenuGroup>
380386 </>
381 ) : showThinking ? (
387 ) : showsThinkingSection ? (
382388 <MenuGroup>
383389 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
384390 <MenuRadioGroup
......@@ -394,9 +400,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
394400 </MenuRadioGroup>
395401 </MenuGroup>
396402 ) : null}
397 {showFastMode ? (
403 {showsFastModeSection ? (
398404 <>
399 {showEffort || showThinking ? <MenuDivider /> : null}
405 {showsEffortSection || showsThinkingSection ? <MenuDivider /> : null}
400406 <MenuGroup>
401407 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
402408 <MenuRadioGroup
......@@ -413,9 +419,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
413419 </MenuGroup>
414420 </>
415421 ) : null}
416 {showContextWindow ? (
422 {showsContextWindowSection ? (
417423 <>
418 {showEffort || showThinking || showFastMode ? <MenuDivider /> : null}
424 {showsEffortSection || showsThinkingSection || showsFastModeSection ? (
425 <MenuDivider />
426 ) : null}
419427 <MenuGroup>
420428 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
421429 Context Window
......@@ -442,7 +450,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
442450 ) : null}
443451 {agentOptions.length > 0 ? (
444452 <>
445 <MenuDivider />
453 {hasSectionsBeforeAgent ? <MenuDivider /> : null}
446454 <MenuGroup>
447455 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Agent</div>
448456 <MenuRadioGroup
packages/shared/src/model.ts+1-1
......@@ -134,7 +134,7 @@ function resolveLabeledOption(
134134 if (raw && options.some((option) => option.value === raw)) {
135135 return raw;
136136 }
137 return options.find((option) => option.isDefault)?.value;
137 return options.find((option) => option.isDefault)?.value ?? options[0]?.value;
138138}
139139
140140export function normalizeOpenCodeModelOptionsWithCapabilities(