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 @@...@@ -1,18 +1,22 @@
1import type { ChildProcess } from "node:child_process";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";2import { it } from "@effect/vitest";
5import { Duration, Effect, Layer } from "effect";3import { Duration, Effect, Layer } from "effect";
6import { TestClock } from "effect/testing";4import { TestClock } from "effect/testing";
7import { beforeEach, expect, vi } from "vitest";5import { NetService } from "@t3tools/shared/Net";
6import { beforeEach, expect } from "vitest";
87
9import { ServerConfig } from "../../config.ts";8import { ServerConfig } from "../../config.ts";
10import { ServerSettingsService } from "../../serverSettings.ts";9import { ServerSettingsService } from "../../serverSettings.ts";
10import {
11 OpenCodeRuntime,
12 OpenCodeRuntimeError,
13 type OpenCodeRuntimeShape,
14} from "../../provider/opencodeRuntime.ts";
11import { TextGeneration } from "../Services/TextGeneration.ts";15import { TextGeneration } from "../Services/TextGeneration.ts";
12import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";16import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts";
1317
14const runtimeMock = vi.hoisted(() => {18const runtimeMock = {
15 const state = {19 state: {
16 startCalls: [] as string[],20 startCalls: [] as string[],
17 promptUrls: [] as string[],21 promptUrls: [] as string[],
18 authHeaders: [] as Array<string | null>,22 authHeaders: [] as Array<string | null>,
...@@ -20,69 +24,77 @@ const runtimeMock = vi.hoisted(() => {...@@ -20,69 +24,77 @@ const runtimeMock = vi.hoisted(() => {
20 promptResult: undefined as24 promptResult: undefined as
21 | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } }25 | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } }
22 | undefined,26 | undefined,
23 };27 },
2428 reset() {
25 return {29 this.state.startCalls.length = 0;
26 state,30 this.state.promptUrls.length = 0;
27 reset() {31 this.state.authHeaders.length = 0;
28 state.startCalls.length = 0;32 this.state.closeCalls.length = 0;
29 state.promptUrls.length = 0;33 this.state.promptResult = undefined;
30 state.authHeaders.length = 0;34 },
31 state.closeCalls.length = 0;35};
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 );
4136
42 return {37const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
43 ...actual,38 startOpenCodeServerProcess: ({ binaryPath }) =>
44 startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => {39 Effect.gen(function* () {
45 const index = runtimeMock.state.startCalls.length + 1;40 const index = runtimeMock.state.startCalls.length + 1;
46 const url = `http://127.0.0.1:${4_300 + index}`;41 const url = `http://127.0.0.1:${4_300 + index}`;
47 runtimeMock.state.startCalls.push(binaryPath);42 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 );
48 return {50 return {
49 url,51 url,
50 process: {} as ChildProcess,52 exitCode: Effect.never,
51 close: () => {
52 runtimeMock.state.closeCalls.push(url);
53 },
54 };53 };
55 }),54 }),
56 createOpenCodeSdkClient: vi.fn(55 connectToOpenCodeServer: ({ serverUrl }) =>
57 ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({56 Effect.succeed({
58 session: {57 url: serverUrl ?? "http://127.0.0.1:4301",
59 create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })),58 exitCode: null,
60 prompt: vi.fn(async () => {59 external: Boolean(serverUrl),
61 runtimeMock.state.promptUrls.push(baseUrl);60 }),
62 runtimeMock.state.authHeaders.push(61 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
63 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,62 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
64 );63 ({
65 return (64 session: {
66 runtimeMock.state.promptResult ?? {65 create: async () => ({ data: { id: `${baseUrl}/session` } }),
67 data: {66 prompt: async () => {
68 parts: [67 runtimeMock.state.promptUrls.push(baseUrl);
69 {68 runtimeMock.state.authHeaders.push(
70 type: "text",69 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
71 text: JSON.stringify({70 );
72 subject: "Improve OpenCode reuse",71 return (
73 body: "Reuse one server for the full action.",72 runtimeMock.state.promptResult ?? {
74 }),73 data: {
75 },74 parts: [
76 ],75 {
77 },76 type: "text",
78 }77 text: JSON.stringify({
79 );78 subject: "Improve OpenCode reuse",
80 }),79 body: "Reuse one server for the full action.",
80 }),
81 },
82 ],
83 },
84 }
85 );
81 },86 },
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,
82 }),95 }),
83 ),96 ),
84 };97};
85});
8698
87const DEFAULT_TEST_MODEL_SELECTION = {99const DEFAULT_TEST_MODEL_SELECTION = {
88 provider: "opencode" as const,100 provider: "opencode" as const,
...@@ -92,6 +104,7 @@ const DEFAULT_TEST_MODEL_SELECTION = {...@@ -92,6 +104,7 @@ const DEFAULT_TEST_MODEL_SELECTION = {
92const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;104const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
93105
94const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(106const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(
107 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
95 Layer.provideMerge(108 Layer.provideMerge(
96 ServerSettingsService.layerTest({109 ServerSettingsService.layerTest({
97 providers: {110 providers: {
...@@ -106,10 +119,12 @@ const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(...@@ -106,10 +119,12 @@ const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe(
106 prefix: "t3code-opencode-text-generation-test-",119 prefix: "t3code-opencode-text-generation-test-",
107 }),120 }),
108 ),121 ),
122 Layer.provideMerge(NetService.layer),
109 Layer.provideMerge(NodeServices.layer),123 Layer.provideMerge(NodeServices.layer),
110);124);
111125
112const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe(126const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe(
127 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
113 Layer.provideMerge(128 Layer.provideMerge(
114 ServerSettingsService.layerTest({129 ServerSettingsService.layerTest({
115 providers: {130 providers: {
...@@ -126,6 +141,7 @@ const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive...@@ -126,6 +141,7 @@ const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive
126 prefix: "t3code-opencode-text-generation-existing-server-test-",141 prefix: "t3code-opencode-text-generation-existing-server-test-",
127 }),142 }),
128 ),143 ),
144 Layer.provideMerge(NetService.layer),
129 Layer.provideMerge(NodeServices.layer),145 Layer.provideMerge(NodeServices.layer),
130);146);
131147
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+77-36
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";1import { Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";
2import * as Semaphore from "effect/Semaphore";2import * as Semaphore from "effect/Semaphore";
33
4import {4import {
...@@ -25,15 +25,15 @@ import {...@@ -25,15 +25,15 @@ import {
25 sanitizeThreadTitle,25 sanitizeThreadTitle,
26} from "../Utils.ts";26} from "../Utils.ts";
27import {27import {
28 createOpenCodeSdkClient,28 OpenCodeRuntime,
29 type OpenCodeServerConnection,29 type OpenCodeServerConnection,
30 type OpenCodeServerProcess,30 type OpenCodeServerProcess,
31 openCodeRuntimeErrorDetail,
31 parseOpenCodeModelSlug,32 parseOpenCodeModelSlug,
32 startOpenCodeServerProcess,
33 toOpenCodeFileParts,33 toOpenCodeFileParts,
34} from "../../provider/opencodeRuntime.ts";34} from "../../provider/opencodeRuntime.ts";
3535
36const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;36const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds";
3737
38function getOpenCodePromptErrorMessage(error: unknown): string | null {38function getOpenCodePromptErrorMessage(error: unknown): string | null {
39 if (!error || typeof error !== "object") {39 if (!error || typeof error !== "object") {
...@@ -80,6 +80,13 @@ function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): str...@@ -80,6 +80,13 @@ function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): str
8080
81interface SharedOpenCodeTextGenerationServerState {81interface SharedOpenCodeTextGenerationServerState {
82 server: OpenCodeServerProcess | null;82 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;
83 binaryPath: string | null;90 binaryPath: string | null;
84 activeRequests: number;91 activeRequests: number;
85 idleCloseFiber: Fiber.Fiber<void, never> | null;92 idleCloseFiber: Fiber.Fiber<void, never> | null;
...@@ -88,24 +95,28 @@ interface SharedOpenCodeTextGenerationServerState {...@@ -88,24 +95,28 @@ interface SharedOpenCodeTextGenerationServerState {
88const makeOpenCodeTextGeneration = Effect.gen(function* () {95const makeOpenCodeTextGeneration = Effect.gen(function* () {
89 const serverConfig = yield* ServerConfig;96 const serverConfig = yield* ServerConfig;
90 const serverSettingsService = yield* ServerSettingsService;97 const serverSettingsService = yield* ServerSettingsService;
98 const openCodeRuntime = yield* OpenCodeRuntime;
91 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>99 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
92 Scope.close(scope, Exit.void),100 Scope.close(scope, Exit.void),
93 );101 );
94 const sharedServerMutex = yield* Semaphore.make(1);102 const sharedServerMutex = yield* Semaphore.make(1);
95 const sharedServerState: SharedOpenCodeTextGenerationServerState = {103 const sharedServerState: SharedOpenCodeTextGenerationServerState = {
96 server: null,104 server: null,
105 serverScope: null,
97 binaryPath: null,106 binaryPath: null,
98 activeRequests: 0,107 activeRequests: 0,
99 idleCloseFiber: null,108 idleCloseFiber: null,
100 };109 };
101110
102 const closeSharedServer = (server: OpenCodeServerProcess) => {111 const closeSharedServer = Effect.fn("closeSharedServer")(function* () {
103 if (sharedServerState.server === server) {112 const scope = sharedServerState.serverScope;
104 sharedServerState.server = null;113 sharedServerState.server = null;
105 sharedServerState.binaryPath = null;114 sharedServerState.serverScope = null;
115 sharedServerState.binaryPath = null;
116 if (scope !== null) {
117 yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
106 }118 }
107 server.close();119 });
108 };
109120
110 const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () {121 const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () {
111 const idleCloseFiber = sharedServerState.idleCloseFiber;122 const idleCloseFiber = sharedServerState.idleCloseFiber;
...@@ -119,15 +130,15 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {...@@ -119,15 +130,15 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
119 server: OpenCodeServerProcess,130 server: OpenCodeServerProcess,
120 ) {131 ) {
121 yield* cancelIdleCloseFiber();132 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(
123 Effect.andThen(134 Effect.andThen(
124 sharedServerMutex.withPermit(135 sharedServerMutex.withPermit(
125 Effect.sync(() => {136 Effect.gen(function* () {
126 if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) {137 if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) {
127 return;138 return;
128 }139 }
129 sharedServerState.idleCloseFiber = null;140 sharedServerState.idleCloseFiber = null;
130 closeSharedServer(server);141 yield* closeSharedServer();
131 }),142 }),
132 ),143 ),
133 ),144 ),
...@@ -154,7 +165,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {...@@ -154,7 +165,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
154 sharedServerState.binaryPath !== input.binaryPath &&165 sharedServerState.binaryPath !== input.binaryPath &&
155 sharedServerState.activeRequests === 0166 sharedServerState.activeRequests === 0
156 ) {167 ) {
157 closeSharedServer(existingServer);168 yield* closeSharedServer();
158 } else {169 } else {
159 if (sharedServerState.binaryPath !== input.binaryPath) {170 if (sharedServerState.binaryPath !== input.binaryPath) {
160 yield* Effect.logWarning(171 yield* Effect.logWarning(
...@@ -170,20 +181,53 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {...@@ -170,20 +181,53 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
170 }181 }
171 }182 }
172183
173 const server = yield* Effect.tryPromise({184 // Create a fresh scope that owns this shared server. The runtime
174 try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }),185 // will attach its child-process and fiber finalizers to this scope;
175 catch: (cause) =>186 // closing it kills the server and interrupts those fibers.
176 new TextGenerationError({187 //
177 operation: input.operation,188 // The `Scope.make` / spawn / record-or-close transitions run inside
178 detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.",189 // `uninterruptibleMask` so an interrupt arriving between any two
179 cause,190 // steps can't orphan the scope (and the child process attached to
180 }),191 // it) before we either close it on failure or hand ownership to
181 });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;223 const server = startedExit.value;
184 sharedServerState.binaryPath = input.binaryPath;224 sharedServerState.server = server;
185 sharedServerState.activeRequests = 1;225 sharedServerState.serverScope = serverScope;
186 return server;226 sharedServerState.binaryPath = input.binaryPath;
227 sharedServerState.activeRequests = 1;
228 return server;
229 }),
230 );
187 }),231 }),
188 );232 );
189233
...@@ -200,17 +244,15 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {...@@ -200,17 +244,15 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
200 }),244 }),
201 );245 );
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.
203 yield* Effect.addFinalizer(() =>250 yield* Effect.addFinalizer(() =>
204 sharedServerMutex.withPermit(251 sharedServerMutex.withPermit(
205 Effect.gen(function* () {252 Effect.gen(function* () {
206 yield* cancelIdleCloseFiber();253 yield* cancelIdleCloseFiber();
207 const server = sharedServerState.server;
208 sharedServerState.server = null;
209 sharedServerState.binaryPath = null;
210 sharedServerState.activeRequests = 0;254 sharedServerState.activeRequests = 0;
211 if (server !== null) {255 yield* closeSharedServer();
212 server.close();
213 }
214 }),256 }),
215 ),257 ),
216 );258 );
...@@ -264,7 +306,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {...@@ -264,7 +306,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
264 const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) =>306 const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) =>
265 Effect.tryPromise({307 Effect.tryPromise({
266 try: async () => {308 try: async () => {
267 const client = createOpenCodeSdkClient({309 const client = openCodeRuntime.createOpenCodeSdkClient({
268 baseUrl: server.url,310 baseUrl: server.url,
269 directory: input.cwd,311 directory: input.cwd,
270 ...(settings.serverUrl.length > 0 && settings.serverPassword312 ...(settings.serverUrl.length > 0 && settings.serverPassword
...@@ -304,8 +346,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {...@@ -304,8 +346,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () {
304 catch: (cause) =>346 catch: (cause) =>
305 new TextGenerationError({347 new TextGenerationError({
306 operation: input.operation,348 operation: input.operation,
307 detail:349 detail: openCodeRuntimeErrorDetail(cause),
308 cause instanceof Error ? cause.message : "OpenCode text generation request failed.",
309 cause,350 cause,
310 }),351 }),
311 });352 });
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+146-109
...@@ -3,13 +3,18 @@ import assert from "node:assert/strict";...@@ -3,13 +3,18 @@ import assert from "node:assert/strict";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";4import { it } from "@effect/vitest";
5import { Effect, Layer, Option } from "effect";5import { Effect, Layer, Option } from "effect";
6import { beforeEach, vi } from "vitest";6import { beforeEach } from "vitest";
77
8import { ThreadId } from "@t3tools/contracts";8import { ThreadId } from "@t3tools/contracts";
9import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
10import { ServerSettingsService } from "../../serverSettings.ts";10import { ServerSettingsService } from "../../serverSettings.ts";
11import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";11import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
12import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";12import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts";
13import {
14 OpenCodeRuntime,
15 OpenCodeRuntimeError,
16 type OpenCodeRuntimeShape,
17} from "../opencodeRuntime.ts";
13import {18import {
14 appendOpenCodeAssistantTextDelta,19 appendOpenCodeAssistantTextDelta,
15 makeOpenCodeAdapterLive,20 makeOpenCodeAdapterLive,
...@@ -18,16 +23,16 @@ import {...@@ -18,16 +23,16 @@ import {
1823
19const asThreadId = (value: string): ThreadId => ThreadId.make(value);24const asThreadId = (value: string): ThreadId => ThreadId.make(value);
2025
21const runtimeMock = vi.hoisted(() => {26type MessageEntry = {
22 type MessageEntry = {27 info: {
23 info: {28 id: string;
24 id: string;29 role: "user" | "assistant";
25 role: "user" | "assistant";
26 };
27 parts: Array<unknown>;
28 };30 };
31 parts: Array<unknown>;
32};
2933
30 const state = {34const runtimeMock = {
35 state: {
31 startCalls: [] as string[],36 startCalls: [] as string[],
32 sessionCreateUrls: [] as string[],37 sessionCreateUrls: [] as string[],
33 authHeaders: [] as Array<string | null>,38 authHeaders: [] as Array<string | null>,
...@@ -38,105 +43,118 @@ const runtimeMock = vi.hoisted(() => {...@@ -38,105 +43,118 @@ const runtimeMock = vi.hoisted(() => {
38 closeError: null as Error | null,43 closeError: null as Error | null,
39 messages: [] as MessageEntry[],44 messages: [] as MessageEntry[],
40 subscribedEvents: [] as unknown[],45 subscribedEvents: [] as unknown[],
41 };46 },
4247 reset() {
43 return {48 this.state.startCalls.length = 0;
44 state,49 this.state.sessionCreateUrls.length = 0;
45 reset() {50 this.state.authHeaders.length = 0;
46 state.startCalls.length = 0;51 this.state.abortCalls.length = 0;
47 state.sessionCreateUrls.length = 0;52 this.state.closeCalls.length = 0;
48 state.authHeaders.length = 0;53 this.state.revertCalls.length = 0;
49 state.abortCalls.length = 0;54 this.state.promptAsyncError = null;
50 state.closeCalls.length = 0;55 this.state.closeError = null;
51 state.revertCalls.length = 0;56 this.state.messages = [];
52 state.promptAsyncError = null;57 this.state.subscribedEvents = [];
53 state.closeError = null;58 },
54 state.messages = [];59};
55 state.subscribedEvents = [];60
56 },61const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
57 };62 startOpenCodeServerProcess: ({ binaryPath }) =>
58});63 Effect.gen(function* () {
59
60vi.mock("../opencodeRuntime.ts", async () => {
61 const actual =
62 await vi.importActual<typeof import("../opencodeRuntime.ts")>("../opencodeRuntime.ts");
63
64 return {
65 ...actual,
66 startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => {
67 runtimeMock.state.startCalls.push(binaryPath);64 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 );
68 return {74 return {
69 url: "http://127.0.0.1:4301",75 url,
70 process: {76 exitCode: Effect.never,
71 once() {},
72 },
73 close() {},
74 };77 };
75 }),78 }),
76 connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({79 connectToOpenCodeServer: ({ serverUrl }) =>
77 url: serverUrl ?? "http://127.0.0.1:4301",80 Effect.gen(function* () {
78 process: null,81 const url = serverUrl ?? "http://127.0.0.1:4301";
79 external: Boolean(serverUrl),82 // Unconditionally register a scope finalizer for test observability —
80 close() {83 // preserves the `closeCalls` / `closeError` probes that the existing
81 runtimeMock.state.closeCalls.push(serverUrl ?? "http://127.0.0.1:4301");84 // suites rely on. Production code never attaches a finalizer to an
82 if (runtimeMock.state.closeError) {85 // external server (it simply returns `Effect.succeed(...)`).
83 throw runtimeMock.state.closeError;86 yield* Effect.addFinalizer(() =>
84 }87 Effect.sync(() => {
85 },88 runtimeMock.state.closeCalls.push(url);
86 })),89 if (runtimeMock.state.closeError) {
87 createOpenCodeSdkClient: vi.fn(90 throw runtimeMock.state.closeError;
88 ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({91 }
89 session: {92 }),
90 create: vi.fn(async () => {93 );
91 runtimeMock.state.sessionCreateUrls.push(baseUrl);94 return {
92 runtimeMock.state.authHeaders.push(95 url,
93 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,96 exitCode: null,
94 );97 external: Boolean(serverUrl),
95 return { data: { id: `${baseUrl}/session` } };98 };
96 }),99 }),
97 abort: vi.fn(async ({ sessionID }: { sessionID: string }) => {100 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
98 runtimeMock.state.abortCalls.push(sessionID);101 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
99 }),102 ({
100 promptAsync: vi.fn(async () => {103 session: {
101 if (runtimeMock.state.promptAsyncError) {104 create: async () => {
102 throw runtimeMock.state.promptAsyncError;105 runtimeMock.state.sessionCreateUrls.push(baseUrl);
103 }106 runtimeMock.state.authHeaders.push(
104 }),107 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
105 messages: vi.fn(async () => ({ data: runtimeMock.state.messages })),108 );
106 revert: vi.fn(109 return { data: { id: `${baseUrl}/session` } };
107 async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => {110 },
108 runtimeMock.state.revertCalls.push({111 abort: async ({ sessionID }: { sessionID: string }) => {
109 sessionID,112 runtimeMock.state.abortCalls.push(sessionID);
110 ...(messageID ? { messageID } : {}),
111 });
112 if (!messageID) {
113 runtimeMock.state.messages = [];
114 return;
115 }
116
117 const targetIndex = runtimeMock.state.messages.findIndex(
118 (entry) => entry.info.id === messageID,
119 );
120 runtimeMock.state.messages =
121 targetIndex >= 0
122 ? runtimeMock.state.messages.slice(0, targetIndex + 1)
123 : runtimeMock.state.messages;
124 },
125 ),
126 },113 },
127 event: {114 promptAsync: async () => {
128 subscribe: vi.fn(async () => ({115 if (runtimeMock.state.promptAsyncError) {
129 stream: (async function* () {116 throw runtimeMock.state.promptAsyncError;
130 for (const event of runtimeMock.state.subscribedEvents) {117 }
131 yield event;
132 }
133 })(),
134 })),
135 },118 },
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,
136 }),155 }),
137 ),156 ),
138 };157};
139});
140158
141const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {159const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
142 upsert: () => Effect.void,160 upsert: () => Effect.void,
...@@ -148,6 +166,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory...@@ -148,6 +166,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory
148});166});
149167
150const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe(168const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe(
169 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
151 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),170 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
152 Layer.provideMerge(171 Layer.provideMerge(
153 ServerSettingsService.layerTest({172 ServerSettingsService.layerTest({
...@@ -211,7 +230,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -211,7 +230,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
211 }),230 }),
212 );231 );
213232
214 it.effect("clears session state when stopAll cleanup fails", () =>233 it.effect("clears session state even when cleanup finalizers throw", () =>
215 Effect.gen(function* () {234 Effect.gen(function* () {
216 const adapter = yield* OpenCodeAdapter;235 const adapter = yield* OpenCodeAdapter;
217 yield* adapter.startSession({236 yield* adapter.startSession({
...@@ -226,11 +245,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -226,11 +245,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
226 });245 });
227246
228 runtimeMock.state.closeError = new Error("close failed");247 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());
230 const sessions = yield* adapter.listSessions();254 const sessions = yield* adapter.listSessions();
231255
232 assert.equal(error._tag, "ProviderAdapterProcessError");
233 assert.equal(error.detail, "Failed to stop 2 OpenCode sessions.");
234 assert.deepEqual(runtimeMock.state.closeCalls, [256 assert.deepEqual(runtimeMock.state.closeCalls, [
235 "http://127.0.0.1:9999",257 "http://127.0.0.1:9999",
236 "http://127.0.0.1:9999",258 "http://127.0.0.1:9999",
...@@ -374,7 +396,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -374,7 +396,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
374 close: () => Effect.void,396 close: () => Effect.void,
375 };397 };
376398
377 const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe(399 const adapterLayer = makeOpenCodeAdapterLive({
400 nativeEventLogger,
401 }).pipe(
402 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
378 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),403 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
379 Layer.provideMerge(404 Layer.provideMerge(
380 ServerSettingsService.layerTest({405 ServerSettingsService.layerTest({
...@@ -450,7 +475,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -450,7 +475,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
450 close: () => Effect.void,475 close: () => Effect.void,
451 };476 };
452477
453 const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe(478 const adapterLayer = makeOpenCodeAdapterLive({
479 nativeEventLogger,
480 }).pipe(
481 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
454 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),482 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
455 Layer.provideMerge(483 Layer.provideMerge(
456 ServerSettingsService.layerTest({484 ServerSettingsService.layerTest({
...@@ -467,7 +495,13 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -467,7 +495,13 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
467 Layer.provideMerge(NodeServices.layer),495 Layer.provideMerge(NodeServices.layer),
468 );496 );
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* () {
471 const adapter = yield* OpenCodeAdapter;505 const adapter = yield* OpenCodeAdapter;
472 yield* adapter.startSession({506 yield* adapter.startSession({
473 provider: "opencode",507 provider: "opencode",
...@@ -475,12 +509,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -475,12 +509,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
475 runtimeMode: "full-access",509 runtimeMode: "full-access",
476 });510 });
477 yield* sleep(10);511 yield* sleep(10);
478 return yield* adapter.listSessions();512 return {
513 sessions: yield* adapter.listSessions(),
514 closeCallsDuringRun: [...runtimeMock.state.closeCalls],
515 };
479 }).pipe(Effect.provide(adapterLayer));516 }).pipe(Effect.provide(adapterLayer));
480517
481 assert.equal(sessions.length, 1);518 assert.equal(sessions.length, 1);
482 assert.equal(sessions[0]?.threadId, "thread-native-log-failure");519 assert.equal(sessions[0]?.threadId, "thread-native-log-failure");
483 assert.deepEqual(runtimeMock.state.closeCalls, []);520 assert.deepEqual(closeCallsDuringRun, []);
484 }),521 }),
485 );522 );
486});523});
apps/server/src/provider/Layers/OpenCodeAdapter.ts+623-579
...@@ -11,7 +11,7 @@ import {...@@ -11,7 +11,7 @@ import {
11 TurnId,11 TurnId,
12 type UserInputQuestion,12 type UserInputQuestion,
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { Cause, Effect, Layer, Queue, Stream } from "effect";14import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect";
15import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";15import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
1616
17import { resolveAttachmentPath } from "../../attachmentStore.ts";17import { resolveAttachmentPath } from "../../attachmentStore.ts";
...@@ -28,10 +28,12 @@ import {...@@ -28,10 +28,12 @@ import {
28import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";28import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
29import {29import {
30 buildOpenCodePermissionRules,30 buildOpenCodePermissionRules,
31 connectToOpenCodeServer,31 OpenCodeRuntime,
32 createOpenCodeSdkClient,32 OpenCodeRuntimeError,
33 openCodeQuestionId,33 openCodeQuestionId,
34 openCodeRuntimeErrorDetail,
34 parseOpenCodeModelSlug,35 parseOpenCodeModelSlug,
36 runOpenCodeSdk,
35 toOpenCodeFileParts,37 toOpenCodeFileParts,
36 toOpenCodePermissionReply,38 toOpenCodePermissionReply,
37 toOpenCodeQuestionAnswers,39 toOpenCodeQuestionAnswers,
...@@ -45,6 +47,13 @@ interface OpenCodeTurnSnapshot {...@@ -45,6 +47,13 @@ interface OpenCodeTurnSnapshot {
45 readonly items: Array<unknown>;47 readonly items: Array<unknown>;
46}48}
4749
50type OpenCodeSubscribedEvent =
51 Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>> extends {
52 readonly stream: AsyncIterable<infer TEvent>;
53 }
54 ? TEvent
55 : never;
56
48interface OpenCodeSessionContext {57interface OpenCodeSessionContext {
49 session: ProviderSession;58 session: ProviderSession;
50 readonly client: OpencodeClient;59 readonly client: OpencodeClient;
...@@ -61,8 +70,21 @@ interface OpenCodeSessionContext {...@@ -61,8 +70,21 @@ interface OpenCodeSessionContext {
61 activeTurnId: TurnId | undefined;70 activeTurnId: TurnId | undefined;
62 activeAgent: string | undefined;71 activeAgent: string | undefined;
63 activeVariant: string | undefined;72 activeVariant: string | undefined;
64 stopped: boolean;73 /**
65 readonly eventsAbortController: AbortController;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;
66}88}
6789
68export interface OpenCodeAdapterLiveOptions {90export interface OpenCodeAdapterLiveOptions {
...@@ -74,14 +96,33 @@ function nowIso(): string {...@@ -74,14 +96,33 @@ function nowIso(): string {
74 return new Date().toISOString();96 return new Date().toISOString();
75}97}
7698
77function isProviderAdapterRequestError(cause: unknown): cause is ProviderAdapterRequestError {99/**
78 return (100 * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into
79 typeof cause === "object" &&101 * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call
80 cause !== null &&102 * sites pipe through this in `Effect.mapError` so they never build the error
81 "_tag" in cause &&103 * shape by hand.
82 cause._tag === "ProviderAdapterRequestError"104 */
83 );105const toRequestError = (cause: OpenCodeRuntimeError): ProviderAdapterRequestError =>
84}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
86function buildEventBase(input: {127function buildEventBase(input: {
87 readonly threadId: ThreadId;128 readonly threadId: ThreadId;
...@@ -205,7 +246,10 @@ function ensureSessionContext(...@@ -205,7 +246,10 @@ function ensureSessionContext(
205 if (!session) {246 if (!session) {
206 throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId });247 throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId });
207 }248 }
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)) {
209 throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId });253 throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId });
210 }254 }
211 return session;255 return session;
...@@ -369,73 +413,119 @@ function updateProviderSession(...@@ -369,73 +413,119 @@ function updateProviderSession(
369 return nextSession;413 return nextSession;
370}414}
371415
372async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise<void> {416const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* (
373 context.stopped = true;417 context: OpenCodeSessionContext,
374 context.eventsAbortController.abort();418) {
375 try {419 // Race-safe one-shot: first caller flips the flag, everyone else no-ops.
376 await context.client.session420 if (yield* Ref.getAndSet(context.stopped, true)) {
377 .abort({ sessionID: context.openCodeSessionId })421 return;
378 .catch(() => undefined);422 }
379 } catch {}423
380 context.server.close();424 // Best-effort remote abort. The scope close below tears down the local
381}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) {
384 return Layer.effect(438 return Layer.effect(
385 OpenCodeAdapter,439 OpenCodeAdapter,
386 Effect.gen(function* () {440 Effect.gen(function* () {
387 const serverConfig = yield* ServerConfig;441 const serverConfig = yield* ServerConfig;
388 const serverSettings = yield* ServerSettingsService;442 const serverSettings = yield* ServerSettingsService;
389 const services = yield* Effect.context<never>();443 const openCodeRuntime = yield* OpenCodeRuntime;
390 const nativeEventLogger =444 const nativeEventLogger =
391 _options?.nativeEventLogger ??445 options?.nativeEventLogger ??
392 (_options?.nativeEventLogPath !== undefined446 (options?.nativeEventLogPath !== undefined
393 ? yield* makeEventNdjsonLogger(_options.nativeEventLogPath, {447 ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
394 stream: "native",448 stream: "native",
395 })449 })
396 : undefined);450 : 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;
397 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();455 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
398 const sessions = new Map<ThreadId, OpenCodeSessionContext>();456 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
400 const emit = (event: ProviderRuntimeEvent) =>486 const emit = (event: ProviderRuntimeEvent) =>
401 Queue.offer(runtimeEvents, event).pipe(Effect.asVoid);487 Queue.offer(runtimeEvents, event).pipe(Effect.asVoid);
402 const emitPromise = (event: ProviderRuntimeEvent) =>488 const writeNativeEvent = (
403 emit(event).pipe(Effect.runPromiseWith(services));
404 const writeNativeEventPromise = (
405 threadId: ThreadId,489 threadId: ThreadId,
406 event: {490 event: {
407 readonly observedAt: string;491 readonly observedAt: string;
408 readonly event: Record<string, unknown>;492 readonly event: Record<string, unknown>;
409 },493 },
410 ) =>494 ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void);
411 (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void).pipe(
412 Effect.runPromiseWith(services),
413 );
414 const writeNativeEventBestEffort = (495 const writeNativeEventBestEffort = (
415 threadId: ThreadId,496 threadId: ThreadId,
416 event: {497 event: {
417 readonly observedAt: string;498 readonly observedAt: string;
418 readonly event: Record<string, unknown>;499 readonly event: Record<string, unknown>;
419 },500 },
420 ) => writeNativeEventPromise(threadId, event).catch(() => undefined);501 ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void));
421502
422 const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => {503 const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* (
423 if (context.stopped) {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)) {
424 return;512 return;
425 }513 }
426 context.stopped = true;
427 sessions.delete(context.session.threadId);
428 context.server.close();
429 const turnId = context.activeTurnId;514 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({
431 ...buildEventBase({ threadId: context.session.threadId, turnId }),521 ...buildEventBase({ threadId: context.session.threadId, turnId }),
432 type: "runtime.error",522 type: "runtime.error",
433 payload: {523 payload: {
434 message,524 message,
435 class: "transport_error",525 class: "transport_error",
436 },526 },
437 }).catch(() => undefined);527 }).pipe(Effect.ignore);
438 void emitPromise({528 yield* emit({
439 ...buildEventBase({ threadId: context.session.threadId, turnId }),529 ...buildEventBase({ threadId: context.session.threadId, turnId }),
440 type: "session.exited",530 type: "session.exited",
441 payload: {531 payload: {
...@@ -443,16 +533,23 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -443,16 +533,23 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
443 recoverable: false,533 recoverable: false,
444 exitKind: "error",534 exitKind: "error",
445 },535 },
446 }).catch(() => undefined);536 }).pipe(Effect.ignore);
447 };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
449 /** Emit content.delta and item.completed events for an assistant text part. */546 /** Emit content.delta and item.completed events for an assistant text part. */
450 const emitAssistantTextDelta = async (547 const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* (
451 context: OpenCodeSessionContext,548 context: OpenCodeSessionContext,
452 part: Part,549 part: Part,
453 turnId: TurnId | undefined,550 turnId: TurnId | undefined,
454 raw: unknown,551 raw: unknown,
455 ): Promise<void> => {552 ) {
456 const text = textFromPart(part);553 const text = textFromPart(part);
457 if (text === undefined) {554 if (text === undefined) {
458 return;555 return;
...@@ -469,7 +566,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -469,7 +566,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
469 );566 );
470 }567 }
471 if (deltaToEmit.length > 0) {568 if (deltaToEmit.length > 0) {
472 await emitPromise({569 yield* emit({
473 ...buildEventBase({570 ...buildEventBase({
474 threadId: context.session.threadId,571 threadId: context.session.threadId,
475 turnId,572 turnId,
...@@ -494,7 +591,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -494,7 +591,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
494 !context.completedAssistantPartIds.has(part.id)591 !context.completedAssistantPartIds.has(part.id)
495 ) {592 ) {
496 context.completedAssistantPartIds.add(part.id);593 context.completedAssistantPartIds.add(part.id);
497 await emitPromise({594 yield* emit({
498 ...buildEventBase({595 ...buildEventBase({
499 threadId: context.session.threadId,596 threadId: context.session.threadId,
500 turnId,597 turnId,
...@@ -511,346 +608,383 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -511,346 +608,383 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
511 },608 },
512 });609 });
513 }610 }
514 };611 });
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 });
522612
523 for await (const event of subscription.stream) {613 const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* (
524 const payloadSessionId =614 context: OpenCodeSessionContext,
525 "properties" in event615 event: OpenCodeSubscribedEvent,
526 ? (event.properties as { sessionID?: unknown }).sessionID616 ) {
527 : undefined;617 const payloadSessionId =
528 if (payloadSessionId !== context.openCodeSessionId) {618 "properties" in event
529 continue;619 ? (event.properties as { sessionID?: unknown }).sessionID
530 }620 : undefined;
621 if (payloadSessionId !== context.openCodeSessionId) {
622 return;
623 }
531624
532 const turnId = context.activeTurnId;625 const turnId = context.activeTurnId;
533 await writeNativeEventBestEffort(context.session.threadId, {626 yield* writeNativeEventBestEffort(context.session.threadId, {
534 observedAt: nowIso(),627 observedAt: nowIso(),
535 event: {628 event: {
536 provider: PROVIDER,629 provider: PROVIDER,
537 threadId: context.session.threadId,630 threadId: context.session.threadId,
538 providerThreadId: context.openCodeSessionId,631 providerThreadId: context.openCodeSessionId,
539 type: event.type,632 type: event.type,
540 ...(turnId ? { turnId } : {}),633 ...(turnId ? { turnId } : {}),
541 payload: event,634 payload: event,
542 },635 },
543 });636 });
544637
545 switch (event.type) {638 switch (event.type) {
546 case "message.updated": {639 case "message.updated": {
547 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);640 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);
548 if (event.properties.info.role === "assistant") {641 if (event.properties.info.role === "assistant") {
549 for (const part of context.partById.values()) {642 for (const part of context.partById.values()) {
550 if (part.messageID !== event.properties.info.id) {643 if (part.messageID !== event.properties.info.id) {
551 continue;644 continue;
552 }
553 await emitAssistantTextDelta(context, part, turnId, event);
554 }
555 }
556 break;
557 }645 }
646 yield* emitAssistantTextDelta(context, part, turnId, event);
647 }
648 }
649 break;
650 }
558651
559 case "message.removed": {652 case "message.removed": {
560 context.messageRoleById.delete(event.properties.messageID);653 context.messageRoleById.delete(event.properties.messageID);
561 break;654 break;
562 }655 }
563656
564 case "message.part.delta": {657 case "message.part.delta": {
565 const existingPart = context.partById.get(event.properties.partID);658 const existingPart = context.partById.get(event.properties.partID);
566 if (!existingPart) {659 if (!existingPart) {
567 break;660 break;
568 }661 }
569 const role = messageRoleForPart(context, existingPart);662 const role = messageRoleForPart(context, existingPart);
570 if (role !== "assistant") {663 if (role !== "assistant") {
571 break;664 break;
572 }665 }
573 const streamKind = resolveTextStreamKind(existingPart);666 const streamKind = resolveTextStreamKind(existingPart);
574 const delta = event.properties.delta;667 const delta = event.properties.delta;
575 if (delta.length === 0) {668 if (delta.length === 0) {
576 break;669 break;
577 }670 }
578 const previousText =671 const previousText =
579 context.emittedTextByPartId.get(event.properties.partID) ??672 context.emittedTextByPartId.get(event.properties.partID) ??
580 textFromPart(existingPart) ??673 textFromPart(existingPart) ??
581 "";674 "";
582 const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(675 const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta);
583 previousText,676 if (deltaToEmit.length === 0) {
584 delta,677 break;
585 );678 }
586 if (deltaToEmit.length === 0) {679 context.emittedTextByPartId.set(event.properties.partID, nextText);
587 break;680 if (existingPart.type === "text" || existingPart.type === "reasoning") {
588 }681 context.partById.set(event.properties.partID, {
589 context.emittedTextByPartId.set(event.properties.partID, nextText);682 ...existingPart,
590 if (existingPart.type === "text" || existingPart.type === "reasoning") {683 text: nextText,
591 context.partById.set(event.properties.partID, {684 });
592 ...existingPart,685 }
593 text: nextText,686 yield* emit({
594 });687 ...buildEventBase({
595 }688 threadId: context.session.threadId,
596 await emitPromise({689 turnId,
597 ...buildEventBase({690 itemId: event.properties.partID,
598 threadId: context.session.threadId,691 raw: event,
599 turnId,692 }),
600 itemId: event.properties.partID,693 type: "content.delta",
601 raw: event,694 payload: {
602 }),695 streamKind,
603 type: "content.delta",696 delta: deltaToEmit,
604 payload: {697 },
605 streamKind,698 });
606 delta: deltaToEmit,699 break;
607 },700 }
608 });
609 break;
610 }
611701
612 case "message.part.updated": {702 case "message.part.updated": {
613 const part = event.properties.part;703 const part = event.properties.part;
614 context.partById.set(part.id, part);704 context.partById.set(part.id, part);
615 const messageRole = messageRoleForPart(context, part);705 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 }
661706
662 case "permission.asked": {707 if (messageRole === "assistant") {
663 context.pendingPermissions.set(event.properties.id, event.properties);708 yield* emitAssistantTextDelta(context, part, turnId, event);
664 await emitPromise({709 }
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 }
683710
684 case "permission.replied": {711 if (part.type === "tool") {
685 context.pendingPermissions.delete(event.properties.requestID);712 const itemType = toToolLifecycleItemType(part.tool);
686 await emitPromise({713 const title =
687 ...buildEventBase({714 part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool;
688 threadId: context.session.threadId,715 const detail = detailFromToolPart(part);
689 turnId,716 const payload = {
690 requestId: event.properties.requestID,717 itemType,
691 raw: event,718 ...(part.state.status === "error"
692 }),719 ? { status: "failed" as const }
693 type: "request.resolved",720 : part.state.status === "completed"
694 payload: {721 ? { status: "completed" as const }
695 requestType: "unknown",722 : { status: "inProgress" as const }),
696 decision: mapPermissionDecision(event.properties.reply),723 ...(title ? { title } : {}),
697 },724 ...(detail ? { detail } : {}),
698 });725 data: {
699 break;726 tool: part.tool,
700 }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": {752 case "permission.asked": {
703 context.pendingQuestions.set(event.properties.id, event.properties);753 context.pendingPermissions.set(event.properties.id, event.properties);
704 await emitPromise({754 yield* emit({
705 ...buildEventBase({755 ...buildEventBase({
706 threadId: context.session.threadId,756 threadId: context.session.threadId,
707 turnId,757 turnId,
708 requestId: event.properties.id,758 requestId: event.properties.id,
709 raw: event,759 raw: event,
710 }),760 }),
711 type: "user-input.requested",761 type: "request.opened",
712 payload: {762 payload: {
713 questions: normalizeQuestionRequest(event.properties),763 requestType: mapPermissionToRequestType(event.properties.permission),
714 },764 detail:
715 });765 event.properties.patterns.length > 0
716 break;766 ? event.properties.patterns.join("\n")
717 }767 : event.properties.permission,
768 args: event.properties.metadata,
769 },
770 });
771 break;
772 }
718773
719 case "question.replied": {774 case "permission.replied": {
720 const request = context.pendingQuestions.get(event.properties.requestID);775 context.pendingPermissions.delete(event.properties.requestID);
721 context.pendingQuestions.delete(event.properties.requestID);776 yield* emit({
722 const answers = Object.fromEntries(777 ...buildEventBase({
723 (request?.questions ?? []).map((question, index) => [778 threadId: context.session.threadId,
724 openCodeQuestionId(index, question),779 turnId,
725 event.properties.answers[index]?.join(", ") ?? "",780 requestId: event.properties.requestID,
726 ]),781 raw: event,
727 );782 }),
728 await emitPromise({783 type: "request.resolved",
729 ...buildEventBase({784 payload: {
730 threadId: context.session.threadId,785 requestType: "unknown",
731 turnId,786 decision: mapPermissionDecision(event.properties.reply),
732 requestId: event.properties.requestID,787 },
733 raw: event,788 });
734 }),789 break;
735 type: "user-input.resolved",790 }
736 payload: { answers },
737 });
738 break;
739 }
740791
741 case "question.rejected": {792 case "question.asked": {
742 context.pendingQuestions.delete(event.properties.requestID);793 context.pendingQuestions.set(event.properties.id, event.properties);
743 await emitPromise({794 yield* emit({
744 ...buildEventBase({795 ...buildEventBase({
745 threadId: context.session.threadId,796 threadId: context.session.threadId,
746 turnId,797 turnId,
747 requestId: event.properties.requestID,798 requestId: event.properties.id,
748 raw: event,799 raw: event,
749 }),800 }),
750 type: "user-input.resolved",801 type: "user-input.requested",
751 payload: { answers: {} },802 payload: {
752 });803 questions: normalizeQuestionRequest(event.properties),
753 break;804 },
754 }805 });
806 break;
807 }
755808
756 case "session.status": {809 case "question.replied": {
757 if (event.properties.status.type === "busy") {810 const request = context.pendingQuestions.get(event.properties.requestID);
758 updateProviderSession(context, { status: "running", activeTurnId: turnId });811 context.pendingQuestions.delete(event.properties.requestID);
759 }812 const answers = Object.fromEntries(
760813 (request?.questions ?? []).map((question, index) => [
761 if (event.properties.status.type === "retry") {814 openCodeQuestionId(index, question),
762 await emitPromise({815 event.properties.answers[index]?.join(", ") ?? "",
763 ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }),816 ]),
764 type: "runtime.warning",817 );
765 payload: {818 yield* emit({
766 message: event.properties.status.message,819 ...buildEventBase({
767 detail: event.properties.status,820 threadId: context.session.threadId,
768 },821 turnId,
769 });822 requestId: event.properties.requestID,
770 break;823 raw: event,
771 }824 }),
772825 type: "user-input.resolved",
773 if (event.properties.status.type === "idle" && turnId) {826 payload: { answers },
774 context.activeTurnId = undefined;827 });
775 updateProviderSession(828 break;
776 context,829 }
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 }
790830
791 case "session.error": {831 case "question.rejected": {
792 const message = sessionErrorMessage(event.properties.error);832 context.pendingQuestions.delete(event.properties.requestID);
793 const activeTurnId = context.activeTurnId;833 yield* emit({
794 context.activeTurnId = undefined;834 ...buildEventBase({
795 updateProviderSession(835 threadId: context.session.threadId,
796 context,836 turnId,
797 {837 requestId: event.properties.requestID,
798 status: "error",838 raw: event,
799 lastError: message,839 }),
800 },840 type: "user-input.resolved",
801 { clearActiveTurnId: true },841 payload: { answers: {} },
802 );842 });
803 if (activeTurnId) {843 break;
804 await emitPromise({844 }
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 }
828845
829 default:846 case "session.status": {
830 break;847 if (event.properties.status.type === "busy") {
831 }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;
832 }861 }
833 } catch (error) {862
834 if (context.eventsAbortController.signal.aborted || context.stopped) {863 if (event.properties.status.type === "idle" && turnId) {
835 return;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 });
836 }873 }
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(
838 context,882 context,
839 error instanceof Error ? error.message : "OpenCode event stream failed.",883 {
884 status: "error",
885 lastError: message,
886 },
887 { clearActiveTurnId: true },
840 );888 );
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;
841 }913 }
842 })();
843914
844 context.server.process?.once("exit", (code, signal) => {915 default:
845 if (context.stopped) {916 break;
846 return;917 }
847 }918 });
848 emitUnexpectedExit(919
849 context,920 const startEventPump = Effect.fn("startEventPump")(function* (
850 `OpenCode server exited unexpectedly (${signal ?? code ?? "unknown"}).`,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),
851 );985 );
852 });986 }
853 };987 });
854988
855 const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")(989 const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")(
856 function* (input) {990 function* (input) {
...@@ -871,44 +1005,46 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -871,44 +1005,46 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
871 const directory = input.cwd ?? serverConfig.cwd;1005 const directory = input.cwd ?? serverConfig.cwd;
872 const existing = sessions.get(input.threadId);1006 const existing = sessions.get(input.threadId);
873 if (existing) {1007 if (existing) {
874 yield* Effect.tryPromise({1008 yield* stopOpenCodeContext(existing);
875 try: () => stopOpenCodeContext(existing),
876 catch: (cause) =>
877 new ProviderAdapterProcessError({
878 provider: PROVIDER,
879 threadId: input.threadId,
880 detail: "Failed to stop existing OpenCode session.",
881 cause,
882 }),
883 });
884 sessions.delete(input.threadId);1009 sessions.delete(input.threadId);
885 }1010 }
8861011
887 const started = yield* Effect.tryPromise({1012 const started = yield* Effect.gen(function* () {
888 try: async () => {1013 const sessionScope = yield* Scope.make();
889 const server = await connectToOpenCodeServer({ binaryPath, serverUrl });1014 const startedExit = yield* Effect.exit(
890 const client = createOpenCodeSdkClient({1015 Effect.gen(function* () {
891 baseUrl: server.url,1016 // The runtime binds the server's lifetime to the Scope.Scope
892 directory,1017 // we provide below — closing `sessionScope` kills the child
893 ...(server.external && serverPassword ? { serverPassword } : {}),1018 // process automatically. No manual `server.close()` needed.
894 });1019 const server = yield* openCodeRuntime.connectToOpenCodeServer({
895 const openCodeSession = await client.session.create({1020 binaryPath,
896 title: `T3 Code ${input.threadId}`,1021 serverUrl,
897 permission: buildOpenCodePermissionRules(input.runtimeMode),1022 });
898 });1023 const client = openCodeRuntime.createOpenCodeSdkClient({
899 if (!openCodeSession.data) {1024 baseUrl: server.url,
900 throw new Error("OpenCode session.create returned no session payload.");1025 directory,
901 }1026 ...(server.external && serverPassword ? { serverPassword } : {}),
902 return { server, client, openCodeSession: openCodeSession.data };1027 });
903 },1028 const openCodeSession = yield* runOpenCodeSdk("session.create", () =>
904 catch: (cause) =>1029 client.session.create({
905 new ProviderAdapterProcessError({1030 title: `T3 Code ${input.threadId}`,
906 provider: PROVIDER,1031 permission: buildOpenCodePermissionRules(input.runtimeMode),
907 threadId: input.threadId,1032 }),
908 detail:1033 );
909 cause instanceof Error ? cause.message : "Failed to start OpenCode session.",1034 if (!openCodeSession.data) {
910 cause,1035 return yield* new OpenCodeRuntimeError({
911 }),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;
912 });1048 });
9131049
914 // Guard against a concurrent startSession call that may have raced1050 // Guard against a concurrent startSession call that may have raced
...@@ -917,14 +1053,10 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -917,14 +1053,10 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
917 if (raceWinner) {1053 if (raceWinner) {
918 // Another call won the race – clean up the session we just created1054 // Another call won the race – clean up the session we just created
919 // (including the remote SDK session) and return the existing one.1055 // (including the remote SDK session) and return the existing one.
920 yield* Effect.tryPromise({1056 yield* runOpenCodeSdk("session.abort", () =>
921 try: () =>1057 started.client.session.abort({ sessionID: started.openCodeSession.id }),
922 started.client.session1058 ).pipe(Effect.ignore);
923 .abort({ sessionID: started.openCodeSession.id })1059 yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore);
924 .catch(() => undefined),
925 catch: () => undefined,
926 }).pipe(Effect.ignore);
927 started.server.close();
928 return raceWinner.session;1060 return raceWinner.session;
929 }1061 }
9301062
...@@ -956,11 +1088,11 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -956,11 +1088,11 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
956 activeTurnId: undefined,1088 activeTurnId: undefined,
957 activeAgent: undefined,1089 activeAgent: undefined,
958 activeVariant: undefined,1090 activeVariant: undefined,
959 stopped: false,1091 stopped: yield* Ref.make(false),
960 eventsAbortController: new AbortController(),1092 sessionScope: started.sessionScope,
961 };1093 };
962 sessions.set(input.threadId, context);1094 sessions.set(input.threadId, context);
963 startEventPump(context);1095 yield* startEventPump(context);
9641096
965 yield* emit({1097 yield* emit({
966 ...buildEventBase({ threadId: input.threadId }),1098 ...buildEventBase({ threadId: input.threadId }),
...@@ -1043,59 +1175,44 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -1043,59 +1175,44 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
1043 },1175 },
1044 });1176 });
10451177
1046 const promptExit = yield* Effect.exit(1178 yield* runOpenCodeSdk("session.promptAsync", () =>
1047 Effect.tryPromise({1179 context.client.session.promptAsync({
1048 try: async () => {1180 sessionID: context.openCodeSessionId,
1049 await context.client.session.promptAsync({1181 model: parsedModel,
1050 sessionID: context.openCodeSessionId,1182 ...(context.activeAgent ? { agent: context.activeAgent } : {}),
1051 model: parsedModel,1183 ...(context.activeVariant ? { variant: context.activeVariant } : {}),
1052 ...(context.activeAgent ? { agent: context.activeAgent } : {}),1184 parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
1053 ...(context.activeVariant ? { variant: context.activeVariant } : {}),
1054 parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts],
1055 });
1056 },
1057 catch: (cause) =>
1058 new ProviderAdapterRequestError({
1059 provider: PROVIDER,
1060 method: "session.promptAsync",
1061 detail: cause instanceof Error ? cause.message : "Failed to send OpenCode turn.",
1062 cause,
1063 }),
1064 }),1185 }),
1065 );1186 ).pipe(
1066 if (promptExit._tag === "Failure") {1187 Effect.mapError(toRequestError),
1067 const failure = Cause.squash(promptExit.cause);1188 // On failure: clear active-turn state, flip the session back to ready
1068 const requestError = isProviderAdapterRequestError(failure)1189 // with lastError set, emit turn.aborted, then let the typed error
1069 ? failure1190 // propagate. We don't need to rebuild the error here — `toRequestError`
1070 : new ProviderAdapterRequestError({1191 // already produced the right shape.
1071 provider: PROVIDER,1192 Effect.tapError((requestError) =>
1072 method: "session.promptAsync",1193 Effect.gen(function* () {
1073 detail:1194 context.activeTurnId = undefined;
1074 failure instanceof Error ? failure.message : "Failed to send OpenCode turn.",1195 context.activeAgent = undefined;
1075 cause: failure,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 },
1076 });1212 });
1077 const failureMessage = requestError.detail;1213 }),
1078 context.activeTurnId = undefined;1214 ),
1079 context.activeAgent = undefined;1215 );
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 }
10991216
1100 return {1217 return {
1101 threadId: input.threadId,1218 threadId: input.threadId,
...@@ -1106,16 +1223,9 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -1106,16 +1223,9 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
1106 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(1223 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
1107 function* (threadId, turnId) {1224 function* (threadId, turnId) {
1108 const context = ensureSessionContext(sessions, threadId);1225 const context = ensureSessionContext(sessions, threadId);
1109 yield* Effect.tryPromise({1226 yield* runOpenCodeSdk("session.abort", () =>
1110 try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }),1227 context.client.session.abort({ sessionID: context.openCodeSessionId }),
1111 catch: (cause) =>1228 ).pipe(Effect.mapError(toRequestError));
1112 new ProviderAdapterRequestError({
1113 provider: PROVIDER,
1114 method: "session.abort",
1115 detail: cause instanceof Error ? cause.message : "Failed to abort OpenCode turn.",
1116 cause,
1117 }),
1118 });
1119 if (turnId ?? context.activeTurnId) {1229 if (turnId ?? context.activeTurnId) {
1120 yield* emit({1230 yield* emit({
1121 ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }),1231 ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }),
...@@ -1140,23 +1250,12 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -1140,23 +1250,12 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
1140 });1250 });
1141 }1251 }
11421252
1143 yield* Effect.tryPromise({1253 yield* runOpenCodeSdk("permission.reply", () =>
1144 try: () =>1254 context.client.permission.reply({
1145 context.client.permission.reply({1255 requestID: requestId,
1146 requestID: requestId,1256 reply: toOpenCodePermissionReply(decision),
1147 reply: toOpenCodePermissionReply(decision),1257 }),
1148 }),1258 ).pipe(Effect.mapError(toRequestError));
1149 catch: (cause) =>
1150 new ProviderAdapterRequestError({
1151 provider: PROVIDER,
1152 method: "permission.reply",
1153 detail:
1154 cause instanceof Error
1155 ? cause.message
1156 : "Failed to submit OpenCode permission reply.",
1157 cause,
1158 }),
1159 });
1160 });1259 });
11611260
1162 const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn(1261 const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn(
...@@ -1172,35 +1271,18 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -1172,35 +1271,18 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
1172 });1271 });
1173 }1272 }
11741273
1175 yield* Effect.tryPromise({1274 yield* runOpenCodeSdk("question.reply", () =>
1176 try: () =>1275 context.client.question.reply({
1177 context.client.question.reply({1276 requestID: requestId,
1178 requestID: requestId,1277 answers: toOpenCodeQuestionAnswers(request, answers),
1179 answers: toOpenCodeQuestionAnswers(request, answers),1278 }),
1180 }),1279 ).pipe(Effect.mapError(toRequestError));
1181 catch: (cause) =>
1182 new ProviderAdapterRequestError({
1183 provider: PROVIDER,
1184 method: "question.reply",
1185 detail: cause instanceof Error ? cause.message : "Failed to submit OpenCode answers.",
1186 cause,
1187 }),
1188 });
1189 });1280 });
11901281
1191 const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")(1282 const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")(
1192 function* (threadId) {1283 function* (threadId) {
1193 const context = ensureSessionContext(sessions, threadId);1284 const context = ensureSessionContext(sessions, threadId);
1194 yield* Effect.tryPromise({1285 yield* stopOpenCodeContext(context);
1195 try: () => stopOpenCodeContext(context),
1196 catch: (cause) =>
1197 new ProviderAdapterProcessError({
1198 provider: PROVIDER,
1199 threadId,
1200 detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode session.",
1201 cause,
1202 }),
1203 });
1204 sessions.delete(threadId);1286 sessions.delete(threadId);
1205 yield* emit({1287 yield* emit({
1206 ...buildEventBase({ threadId }),1288 ...buildEventBase({ threadId }),
...@@ -1223,16 +1305,9 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -1223,16 +1305,9 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
1223 const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")(1305 const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")(
1224 function* (threadId) {1306 function* (threadId) {
1225 const context = ensureSessionContext(sessions, threadId);1307 const context = ensureSessionContext(sessions, threadId);
1226 const messages = yield* Effect.tryPromise({1308 const messages = yield* runOpenCodeSdk("session.messages", () =>
1227 try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }),1309 context.client.session.messages({ sessionID: context.openCodeSessionId }),
1228 catch: (cause) =>1310 ).pipe(Effect.mapError(toRequestError));
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 });
12361311
1237 const turns = (messages.data ?? [])1312 const turns = (messages.data ?? [])
1238 .filter((entry) => entry.info.role === "assistant")1313 .filter((entry) => entry.info.role === "assistant")
...@@ -1251,70 +1326,39 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {...@@ -1251,70 +1326,39 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) {
1251 const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(1326 const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
1252 function* (threadId, numTurns) {1327 function* (threadId, numTurns) {
1253 const context = ensureSessionContext(sessions, threadId);1328 const context = ensureSessionContext(sessions, threadId);
1254 const messages = yield* Effect.tryPromise({1329 const messages = yield* runOpenCodeSdk("session.messages", () =>
1255 try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }),1330 context.client.session.messages({ sessionID: context.openCodeSessionId }),
1256 catch: (cause) =>1331 ).pipe(Effect.mapError(toRequestError));
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 });
12651332
1266 const assistantMessages = (messages.data ?? []).filter(1333 const assistantMessages = (messages.data ?? []).filter(
1267 (entry) => entry.info.role === "assistant",1334 (entry) => entry.info.role === "assistant",
1268 );1335 );
1269 const targetIndex = assistantMessages.length - numTurns - 1;1336 const targetIndex = assistantMessages.length - numTurns - 1;
1270 const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null;1337 const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null;
1271 yield* Effect.tryPromise({1338 yield* runOpenCodeSdk("session.revert", () =>
1272 try: () =>1339 context.client.session.revert({
1273 context.client.session.revert({1340 sessionID: context.openCodeSessionId,
1274 sessionID: context.openCodeSessionId,1341 ...(target ? { messageID: target.info.id } : {}),
1275 ...(target ? { messageID: target.info.id } : {}),1342 }),
1276 }),1343 ).pipe(Effect.mapError(toRequestError));
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 });
12851344
1286 return yield* readThread(threadId);1345 return yield* readThread(threadId);
1287 },1346 },
1288 );1347 );
12891348
1290 const stopAll: OpenCodeAdapterShape["stopAll"] = () =>1349 const stopAll: OpenCodeAdapterShape["stopAll"] = () =>
1291 Effect.tryPromise({1350 Effect.gen(function* () {
1292 try: async () => {1351 const contexts = [...sessions.values()];
1293 const contexts = [...sessions.values()];1352 sessions.clear();
1294 sessions.clear();1353 // `stopOpenCodeContext` is typed as never-failing — SDK aborts are
1295 const results = await Promise.allSettled(1354 // already `Effect.ignore`'d inside it. `ignoreCause` here also
1296 contexts.map((context) => stopOpenCodeContext(context)),1355 // swallows defects from throwing finalizers so one bad close can't
1297 );1356 // interrupt the sibling fibers. Same pattern as the layer finalizer.
1298 const errors = results1357 yield* Effect.forEach(
1299 .filter((result): result is PromiseRejectedResult => result.status === "rejected")1358 contexts,
1300 .map((result) => result.reason);1359 (context) => Effect.ignoreCause(stopOpenCodeContext(context)),
1301 if (errors.length === 1) {1360 { concurrency: "unbounded", discard: true },
1302 throw errors[0];1361 );
1303 }
1304 if (errors.length > 1) {
1305 throw new AggregateError(
1306 errors,
1307 `Failed to stop ${errors.length} OpenCode sessions.`,
1308 );
1309 }
1310 },
1311 catch: (cause) =>
1312 new ProviderAdapterProcessError({
1313 provider: PROVIDER,
1314 threadId: "*",
1315 detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.",
1316 cause,
1317 }),
1318 });1362 });
13191363
1320 return {1364 return {
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+107-44
...@@ -3,66 +3,81 @@ import assert from "node:assert/strict";...@@ -3,66 +3,81 @@ import assert from "node:assert/strict";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";4import { it } from "@effect/vitest";
5import { Effect, Layer } from "effect";5import { Effect, Layer } from "effect";
6import { beforeEach, vi } from "vitest";6import { beforeEach } from "vitest";
77
8import { ServerConfig } from "../../config.ts";8import { ServerConfig } from "../../config.ts";
9import { ServerSettingsService } from "../../serverSettings.ts";9import { ServerSettingsService } from "../../serverSettings.ts";
10import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";10import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
11import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts";11import {
1212 OpenCodeRuntime,
13const runtimeMock = vi.hoisted(() => {13 OpenCodeRuntimeError,
14 const state = {14 type OpenCodeRuntimeShape,
15} from "../opencodeRuntime.ts";
16import { OpenCodeProviderLive } from "./OpenCodeProvider.ts";
17import type { OpenCodeInventory } from "../opencodeRuntime.ts";
18
19const runtimeMock = {
20 state: {
15 runVersionError: null as Error | null,21 runVersionError: null as Error | null,
16 inventoryError: null as Error | null,22 inventoryError: null as Error | null,
17 };23 inventory: {
1824 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
19 return {25 agents: [] as unknown[],
20 state,26 } as unknown,
21 reset() {27 },
22 state.runVersionError = null;28 reset() {
23 state.inventoryError = null;29 this.state.runVersionError = null;
24 },30 this.state.inventoryError = null;
25 };31 this.state.inventory = {
26});32 providerList: { connected: [], all: [] as unknown[], default: {} },
2733 agents: [] as unknown[],
28vi.mock("../opencodeRuntime.ts", async () => {34 };
29 const actual =35 },
30 await vi.importActual<typeof import("../opencodeRuntime.ts")>("../opencodeRuntime.ts");36};
3137
32 return {38const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
33 ...actual,39 startOpenCodeServerProcess: () =>
34 runOpenCodeCommand: vi.fn(async () => {40 Effect.succeed({
35 if (runtimeMock.state.runVersionError) {41 url: "http://127.0.0.1:4301",
36 throw runtimeMock.state.runVersionError;42 exitCode: Effect.never,
37 }
38 return { stdout: "opencode 1.0.0\n", stderr: "", code: 0 };
39 }),43 }),
40 connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({44 connectToOpenCodeServer: ({ serverUrl }) =>
45 Effect.succeed({
41 url: serverUrl ?? "http://127.0.0.1:4301",46 url: serverUrl ?? "http://127.0.0.1:4301",
42 process: null,47 exitCode: null,
43 external: Boolean(serverUrl),48 external: Boolean(serverUrl),
44 close() {},
45 })),
46 createOpenCodeSdkClient: vi.fn(() => ({})),
47 loadOpenCodeInventory: vi.fn(async () => {
48 if (runtimeMock.state.inventoryError) {
49 throw runtimeMock.state.inventoryError;
50 }
51 return {
52 providerList: { connected: [], all: [] },
53 agents: [],
54 };
55 }),49 }),
56 flattenOpenCodeModels: vi.fn(() => []),50 runOpenCodeCommand: () =>
57 };51 runtimeMock.state.runVersionError
58});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
60beforeEach(() => {74beforeEach(() => {
61 runtimeMock.reset();75 runtimeMock.reset();
62});76});
6377
64const makeTestLayer = (settingsOverrides?: Parameters<typeof ServerSettingsService.layerTest>[0]) =>78const makeTestLayer = (settingsOverrides?: Parameters<typeof ServerSettingsService.layerTest>[0]) =>
65 makeOpenCodeProviderLive().pipe(79 OpenCodeProviderLive.pipe(
80 Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
66 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),81 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
67 Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)),82 Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)),
68 Layer.provideMerge(NodeServices.layer),83 Layer.provideMerge(NodeServices.layer),
...@@ -92,6 +107,54 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {...@@ -92,6 +107,54 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => {
92 assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check.");107 assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check.");
93 }),108 }),
94 );109 );
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 );
95});158});
96159
97it.layer(160it.layer(
apps/server/src/provider/Layers/OpenCodeProvider.ts+286-188
...@@ -1,57 +1,58 @@...@@ -1,57 +1,58 @@
1import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts";1import type {
2import { Cause, Effect, Equal, Layer, Stream } from "effect";2 ModelCapabilities,
3 OpenCodeSettings,
4 ServerProvider,
5 ServerProviderModel,
6} from "@t3tools/contracts";
7import { Cause, Data, Effect, Equal, Layer, Stream } from "effect";
38
4import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
5import { ServerSettingsService } from "../../serverSettings.ts";10import { ServerSettingsService } from "../../serverSettings.ts";
6import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";11import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
7import {12import {
8 buildServerProvider,13 buildServerProvider,
9 isCommandMissingCause,
10 parseGenericCliVersion,14 parseGenericCliVersion,
11 providerModelsFromSettings,15 providerModelsFromSettings,
12} from "../providerSnapshot.ts";16} from "../providerSnapshot.ts";
13import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";17import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
14import {18import {
15 connectToOpenCodeServer,19 OpenCodeRuntime,
16 DEFAULT_OPENCODE_MODEL_CAPABILITIES,20 openCodeRuntimeErrorDetail,
17 createOpenCodeSdkClient,21 type OpenCodeInventory,
18 flattenOpenCodeModels,
19 loadOpenCodeInventory,
20 runOpenCodeCommand,
21} from "../opencodeRuntime.ts";22} from "../opencodeRuntime.ts";
23import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";
2224
23const PROVIDER = "opencode" as const;25const PROVIDER = "opencode" as const;
2426
25class OpenCodeProbePromiseError extends Error {27class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{
26 override readonly cause: unknown;28 readonly cause: unknown;
29 readonly detail: string;
30}> {}
2731
28 constructor(cause: unknown) {32function normalizeProbeMessage(message: string): string | undefined {
29 super(cause instanceof Error ? cause.message : String(cause));33 const trimmed = message.trim();
30 this.cause = cause;34 if (trimmed.length === 0) {
31 this.name = "OpenCodeProbePromiseError";35 return undefined;
32 }36 }
33}37 if (
3438 trimmed === "An error occurred in Effect.tryPromise" ||
35function toOpenCodeProbeError(cause: unknown): OpenCodeProbePromiseError {39 trimmed === "An error occurred in Effect.try"
36 return new OpenCodeProbePromiseError(cause);40 ) {
41 return undefined;
42 }
43 return trimmed;
37}44}
3845
39function normalizedErrorMessage(cause: unknown): string | undefined {46function normalizedErrorMessage(cause: unknown): string | undefined {
40 if (!(cause instanceof Error)) {47 if (cause instanceof OpenCodeProbeError) {
41 return undefined;48 return normalizeProbeMessage(cause.detail);
42 }49 }
4350
44 const message = cause.message.trim();51 if (!(cause instanceof Error)) {
45 if (message.length === 0) {
46 return undefined;
47 }
48 if (
49 message === "An error occurred in Effect.tryPromise" ||
50 message === "An error occurred in Effect.try"
51 ) {
52 return undefined;52 return undefined;
53 }53 }
54 return message;54
55 return normalizeProbeMessage(cause.message);
55}56}
5657
57function formatOpenCodeProbeError(input: {58function formatOpenCodeProbeError(input: {
...@@ -59,8 +60,8 @@ function formatOpenCodeProbeError(input: {...@@ -59,8 +60,8 @@ function formatOpenCodeProbeError(input: {
59 readonly isExternalServer: boolean;60 readonly isExternalServer: boolean;
60 readonly serverUrl: string;61 readonly serverUrl: string;
61}): { readonly installed: boolean; readonly message: string } {62}): { readonly installed: boolean; readonly message: string } {
62 const lower = input.cause instanceof Error ? input.cause.message.toLowerCase() : "";
63 const detail = normalizedErrorMessage(input.cause);63 const detail = normalizedErrorMessage(input.cause);
64 const lower = detail?.toLowerCase() ?? "";
6465
65 if (input.isExternalServer) {66 if (input.isExternalServer) {
66 if (67 if (
...@@ -96,7 +97,7 @@ function formatOpenCodeProbeError(input: {...@@ -96,7 +97,7 @@ function formatOpenCodeProbeError(input: {
96 };97 };
97 }98 }
9899
99 if (input.cause instanceof Error && isCommandMissingCause(input.cause)) {100 if (lower.includes("enoent") || lower.includes("notfound")) {
100 return {101 return {
101 installed: false,102 installed: false,
102 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",103 message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
...@@ -127,6 +128,99 @@ function formatOpenCodeProbeError(input: {...@@ -127,6 +128,99 @@ function formatOpenCodeProbeError(input: {
127 };128 };
128}129}
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
130const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => {224const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => {
131 const checkedAt = new Date().toISOString();225 const checkedAt = new Date().toISOString();
132 const models = providerModelsFromSettings(226 const models = providerModelsFromSettings(
...@@ -170,173 +264,177 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server...@@ -170,173 +264,177 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server
170 });264 });
171};265};
172266
173export function checkOpenCodeProviderStatus(input: {267export const OpenCodeProviderLive = Layer.effect(
174 readonly settings: OpenCodeSettings;268 OpenCodeProvider,
175 readonly cwd: string;269 Effect.gen(function* () {
176}): Effect.Effect<ServerProvider> {270 const serverSettings = yield* ServerSettingsService;
177 const checkedAt = new Date().toISOString();271 const serverConfig = yield* ServerConfig;
178 const customModels = input.settings.customModels;272 const openCodeRuntime = yield* OpenCodeRuntime;
179 const isExternalServer = input.settings.serverUrl.trim().length > 0;273
180274 const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (input: {
181 const fallback = (cause: unknown, version: string | null = null) => {275 readonly settings: OpenCodeSettings;
182 const failure = formatOpenCodeProbeError({276 readonly cwd: string;
183 cause,277 }): Effect.fn.Return<ServerProvider, never> {
184 isExternalServer,278 const checkedAt = new Date().toISOString();
185 serverUrl: input.settings.serverUrl,279 const customModels = input.settings.customModels;
186 });280 const isExternalServer = input.settings.serverUrl.trim().length > 0;
187 return buildServerProvider({281
188 provider: PROVIDER,282 const fallback = (cause: unknown, version: string | null = null) => {
189 enabled: input.settings.enabled,283 const failure = formatOpenCodeProbeError({
190 checkedAt,284 cause,
191 models: providerModelsFromSettings(285 isExternalServer,
192 [],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),
193 PROVIDER,391 PROVIDER,
194 customModels,392 customModels,
195 DEFAULT_OPENCODE_MODEL_CAPABILITIES,393 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
196 ),394 );
197 probe: {395 const connectedCount = inventoryExit.value.providerList.connected.length;
198 installed: failure.installed,
199 version,
200 status: "error",
201 auth: { status: "unknown" },
202 message: failure.message,
203 },
204 });
205 };
206
207 return Effect.gen(function* () {
208 if (!input.settings.enabled) {
209 return buildServerProvider({396 return buildServerProvider({
210 provider: PROVIDER,397 provider: PROVIDER,
211 enabled: false,398 enabled: true,
212 checkedAt,399 checkedAt,
213 models: providerModelsFromSettings(400 models,
214 [],
215 PROVIDER,
216 customModels,
217 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
218 ),
219 probe: {401 probe: {
220 installed: false,402 installed: true,
221 version: null,403 version,
222 status: "warning",404 status: connectedCount > 0 ? "ready" : "warning",
223 auth: { status: "unknown" },405 auth: {
224 message: isExternalServer406 status: connectedCount > 0 ? "authenticated" : "unknown",
225 ? "OpenCode is disabled in T3 Code settings. A server URL is configured."407 type: "opencode",
226 : "OpenCode is disabled in T3 Code settings.",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.",
227 },415 },
228 });416 });
229 }417 });
230418
231 let version: string | null = null;419 const getProviderSettings = serverSettings.getSettings.pipe(
232 if (!isExternalServer) {420 Effect.map((settings) => settings.providers.opencode),
233 const versionExit = yield* Effect.exit(421 );
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 }
248422
249 const inventoryExit = yield* Effect.exit(423 return yield* makeManagedServerProvider<OpenCodeSettings>({
250 Effect.acquireUseRelease(424 getSettings: getProviderSettings.pipe(Effect.orDie),
251 Effect.tryPromise({425 streamSettings: serverSettings.streamChanges.pipe(
252 try: () =>426 Stream.map((settings) => settings.providers.opencode),
253 connectToOpenCodeServer({427 ),
254 binaryPath: input.settings.binaryPath,428 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
255 serverUrl: input.settings.serverUrl,429 initialSnapshot: makePendingOpenCodeProvider,
256 }),430 checkProvider: getProviderSettings.pipe(
257 catch: toOpenCodeProbeError,431 Effect.flatMap((settings) =>
258 }),432 checkOpenCodeProviderStatus({
259 (server) =>433 settings,
260 Effect.tryPromise({434 cwd: serverConfig.cwd,
261 try: async () => {
262 const client = createOpenCodeSdkClient({
263 baseUrl: server.url,
264 directory: input.cwd,
265 ...(isExternalServer && input.settings.serverPassword
266 ? { serverPassword: input.settings.serverPassword }
267 : {}),
268 });
269 return await loadOpenCodeInventory(client);
270 },
271 catch: toOpenCodeProbeError,
272 }),435 }),
273 (server) => Effect.sync(() => server.close()),436 ),
274 ),437 ),
275 );
276 if (inventoryExit._tag === "Failure") {
277 return fallback(Cause.squash(inventoryExit.cause), version);
278 }
279
280 const models = providerModelsFromSettings(
281 flattenOpenCodeModels(inventoryExit.value),
282 PROVIDER,
283 customModels,
284 DEFAULT_OPENCODE_MODEL_CAPABILITIES,
285 );
286 const connectedCount = inventoryExit.value.providerList.connected.length;
287 return buildServerProvider({
288 provider: PROVIDER,
289 enabled: true,
290 checkedAt,
291 models,
292 probe: {
293 installed: true,
294 version,
295 status: connectedCount > 0 ? "ready" : "warning",
296 auth: {
297 status: connectedCount > 0 ? "authenticated" : "unknown",
298 type: "opencode",
299 },
300 message:
301 connectedCount > 0
302 ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.`
303 : isExternalServer
304 ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers."
305 : "OpenCode is available, but it did not report any connected upstream providers.",
306 },
307 });438 });
308 });439 }),
309}440);
310
311export function makeOpenCodeProviderLive() {
312 return Layer.effect(
313 OpenCodeProvider,
314 Effect.gen(function* () {
315 const serverSettings = yield* ServerSettingsService;
316 const serverConfig = yield* ServerConfig;
317
318 const getProviderSettings = serverSettings.getSettings.pipe(
319 Effect.map((settings) => settings.providers.opencode),
320 );
321
322 return yield* makeManagedServerProvider<OpenCodeSettings>({
323 getSettings: getProviderSettings.pipe(Effect.orDie),
324 streamSettings: serverSettings.streamChanges.pipe(
325 Stream.map((settings) => settings.providers.opencode),
326 ),
327 haveSettingsChanged: (previous, next) => !Equal.equals(previous, next),
328 initialSnapshot: makePendingOpenCodeProvider,
329 checkProvider: getProviderSettings.pipe(
330 Effect.flatMap((settings) =>
331 checkOpenCodeProviderStatus({
332 settings,
333 cwd: serverConfig.cwd,
334 }),
335 ),
336 ),
337 });
338 }),
339 );
340}
341
342export const OpenCodeProviderLive = makeOpenCodeProviderLive();
apps/server/src/provider/Layers/ProviderRegistry.ts+2
...@@ -16,6 +16,7 @@ import { CodexProvider } from "../Services/CodexProvider.ts";...@@ -16,6 +16,7 @@ import { CodexProvider } from "../Services/CodexProvider.ts";
16import { CursorProvider } from "../Services/CursorProvider.ts";16import { CursorProvider } from "../Services/CursorProvider.ts";
17import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";17import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts";
18import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";18import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
19import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
19import {20import {
20 hydrateCachedProvider,21 hydrateCachedProvider,
21 PROVIDER_CACHE_IDS,22 PROVIDER_CACHE_IDS,
...@@ -287,6 +288,7 @@ export const ProviderRegistryLive = Layer.unwrap(...@@ -287,6 +288,7 @@ export const ProviderRegistryLive = Layer.unwrap(
287 Layer.provideMerge(CodexProviderLive),288 Layer.provideMerge(CodexProviderLive),
288 Layer.provideMerge(ClaudeProviderLive),289 Layer.provideMerge(ClaudeProviderLive),
289 Layer.provideMerge(OpenCodeProviderLive),290 Layer.provideMerge(OpenCodeProviderLive),
291 Layer.provideMerge(OpenCodeRuntimeLive),
290 ),292 ),
291 ),293 ),
292);294);
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 @@...@@ -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";
6import { pathToFileURL } from "node:url";1import { pathToFileURL } from "node:url";
72
8import type {3import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";
9 ChatAttachment,
10 ModelCapabilities,
11 ProviderApprovalDecision,
12 RuntimeMode,
13 ServerProviderModel,
14} from "@t3tools/contracts";
15import {4import {
16 createOpencodeClient,5 createOpencodeClient,
17 type Agent,6 type Agent,
...@@ -22,40 +11,80 @@ import {...@@ -22,40 +11,80 @@ import {
22 type QuestionAnswer,11 type QuestionAnswer,
23 type QuestionRequest,12 type QuestionRequest,
24} from "@opencode-ai/sdk/v2";13} 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
26const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";36const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
27const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000;37const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000;
28const DEFAULT_HOSTNAME = "127.0.0.1";38const 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
42export interface OpenCodeServerProcess {40export interface OpenCodeServerProcess {
43 readonly url: string;41 readonly url: string;
44 readonly process: ChildProcess;42 readonly exitCode: Effect.Effect<number, never>;
45 close(): void;
46}43}
4744
48export interface OpenCodeServerConnection {45export interface OpenCodeServerConnection {
49 readonly url: string;46 readonly url: string;
50 readonly process: ChildProcess | null;47 readonly exitCode: Effect.Effect<number, never> | null;
51 readonly external: boolean;48 readonly external: boolean;
52 close(): void;
53}49}
5450
55function buildOpenCodeBasicAuthorizationHeader(password: string): string {51const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError";
56 return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;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);
57}76}
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
59export interface OpenCodeCommandResult {88export interface OpenCodeCommandResult {
60 readonly stdout: string;89 readonly stdout: string;
61 readonly stderr: string;90 readonly stderr: string;
...@@ -72,12 +101,43 @@ export interface ParsedOpenCodeModelSlug {...@@ -72,12 +101,43 @@ export interface ParsedOpenCodeModelSlug {
72 readonly modelID: string;101 readonly modelID: string;
73}102}
74103
75function titleCaseSlug(value: string): string {104export interface OpenCodeRuntimeShape {
76 return value105 /**
77 .split(/[-_/]+/)106 * Spawns a local OpenCode server process. Its lifetime is bound to the caller's
78 .filter((segment) => segment.length > 0)107 * `Scope.Scope` — the child is killed automatically when that scope closes.
79 .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))108 * Consumers that want a long-lived server must create and hold a scope explicitly
80 .join(" ");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>;
81}141}
82142
83function parseServerUrlFromOutput(output: string): string | null {143function parseServerUrlFromOutput(output: string): string | null {
...@@ -91,92 +151,6 @@ function parseServerUrlFromOutput(output: string): string | null {...@@ -91,92 +151,6 @@ function parseServerUrlFromOutput(output: string): string | null {
91 return null;151 return null;
92}152}
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
180export function parseOpenCodeModelSlug(154export function parseOpenCodeModelSlug(
181 slug: string | null | undefined,155 slug: string | null | undefined,
182): ParsedOpenCodeModelSlug | null {156): ParsedOpenCodeModelSlug | null {
...@@ -196,10 +170,6 @@ export function parseOpenCodeModelSlug(...@@ -196,10 +170,6 @@ export function parseOpenCodeModelSlug(
196 };170 };
197}171}
198172
199export function toOpenCodeModelSlug(providerID: string, modelID: string): string {
200 return `${providerID}/${modelID}`;
201}
202
203export function openCodeQuestionId(173export function openCodeQuestionId(
204 index: number,174 index: number,
205 question: QuestionRequest["questions"][number],175 question: QuestionRequest["questions"][number],
...@@ -286,289 +256,268 @@ export function toOpenCodeQuestionAnswers(...@@ -286,289 +256,268 @@ export function toOpenCodeQuestionAnswers(
286 });256 });
287}257}
288258
289export async function findAvailablePort(): Promise<number> {259function ensureRuntimeError(
290 const server = createServer();260 operation: OpenCodeRuntimeError["operation"],
291 await new Promise<void>((resolve, reject) => {261 detail: string,
292 server.once("error", reject);262 cause: unknown,
293 server.listen(0, DEFAULT_HOSTNAME, () => resolve());263): OpenCodeRuntimeError {
294 });264 return OpenCodeRuntimeError.is(cause)
295 const address = server.address() as AddressInfo;265 ? cause
296 const port = address.port;266 : new OpenCodeRuntimeError({ operation, detail, cause });
297 await new Promise<void>((resolve, reject) => {
298 server.close((error) => (error ? reject(error) : resolve()));
299 });
300 return port;
301}267}
302268
303export function resolveOpenCodeBinaryPath(binaryPath: string): string {269const makeOpenCodeRuntime = Effect.gen(function* () {
304 if (Path.isAbsolute(binaryPath)) {270 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
305 return binaryPath;271 const netService = yield* NetService;
306 }
307 return execFileSync("which", [binaryPath], {
308 encoding: "utf8",
309 timeout: 3_000,
310 }).trim();
311}
312272
313export function detectMacosSigkillHint(binaryPath: string): string | null {273 const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) =>
314 try {274 Effect.gen(function* () {
315 // Check for quarantine xattr first.275 const child = yield* spawner.spawn(
316 const resolvedPath = resolveOpenCodeBinaryPath(binaryPath);276 ChildProcess.make(input.binaryPath, [...input.args], {
317 const xattr = execFileSync("xattr", ["-l", resolvedPath], {277 shell: process.platform === "win32",
318 encoding: "utf8",278 env: process.env,
319 timeout: 3_000,279 }),
320 });
321 if (xattr.includes("com.apple.quarantine")) {
322 return (
323 `macOS quarantine is blocking the OpenCode binary. ` +
324 `Run: xattr -d com.apple.quarantine ${resolvedPath}`
325 );280 );
326 }281 const [stdout, stderr, code] = yield* Effect.all(
327282 [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode],
328 // Look for a recent crash report with the termination reason.283 { concurrency: "unbounded" },
329 const crashDir = Path.join(OS.homedir(), "Library/Logs/DiagnosticReports");284 );
330 const binaryName = Path.basename(resolvedPath);285 const exitCode = Number(code);
331 const recentReports = FS.readdirSync(crashDir)286 if (isWindowsCommandNotFound(exitCode, stderr)) {
332 .filter((f) => f.startsWith(binaryName) && f.endsWith(".ips"))287 return yield* new OpenCodeRuntimeError({
333 .toSorted()288 operation: "runOpenCodeCommand",
334 .toReversed()289 detail: `spawn ${input.binaryPath} ENOENT`,
335 .slice(0, 1);290 });
336
337 for (const report of recentReports) {
338 const content = FS.readFileSync(Path.join(crashDir, report), "utf8");
339 if (content.includes('"namespace":"CODESIGNING"')) {
340 return (
341 "macOS killed the process due to an invalid code signature. " +
342 "The binary may be corrupted — try reinstalling OpenCode."
343 );
344 }291 }
345 }292 return {
346 } catch {293 stdout,
347 // Best-effort detection — don't fail the original error path.294 stderr,
348 }295 code: exitCode,
349 return null;296 } satisfies OpenCodeCommandResult;
350}297 }).pipe(
351298 Effect.scoped,
352export async function startOpenCodeServerProcess(input: {299 Effect.mapError((cause) =>
353 readonly binaryPath: string;300 ensureRuntimeError(
354 readonly port?: number;301 "runOpenCodeCommand",
355 readonly hostname?: string;302 `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`,
356 readonly timeoutMs?: number;303 cause,
357}): Promise<OpenCodeServerProcess> {304 ),
358 const hostname = input.hostname ?? DEFAULT_HOSTNAME;305 ),
359 const port = input.port ?? (await findAvailablePort());306 );
360 const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;307
361 const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];308 const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) =>
362 const child = spawn(input.binaryPath, args, {309 Effect.gen(function* () {
363 stdio: ["ignore", "pipe", "pipe"],310 // Bind this server's lifetime to the caller's scope. When the caller's
364 env: {311 // scope closes, the spawned child is killed and all associated fibers
365 ...process.env,312 // are interrupted automatically — no `close()` method needed.
366 OPENCODE_CONFIG_CONTENT: JSON.stringify({}),313 const runtimeScope = yield* Scope.Scope;
367 },314
368 });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");352 const stdoutRef = yield* Ref.make("");
371 child.stderr.setEncoding("utf8");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 = "";366 const stdoutFiber = yield* child.stdout.pipe(
374 let stderr = "";367 Stream.decodeText(),
375 let closed = false;368 Stream.runForEach(setReadyFromStdoutChunk),
376 const close = () => {369 Effect.ignore,
377 if (closed) {370 Effect.forkIn(runtimeScope),
378 return;371 );
379 }372 const stderrFiber = yield* child.stderr.pipe(
380 closed = true;373 Stream.decodeText(),
381 child.kill();374 Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)),
382 };375 Effect.ignore,
376 Effect.forkIn(runtimeScope),
377 );
383378
384 const url = await new Promise<string>((resolve, reject) => {379 const exitFiber = yield* child.exitCode.pipe(
385 const timeout = setTimeout(() => {380 Effect.flatMap((code) =>
386 close();381 Effect.gen(function* () {
387 reject(new Error(`Timed out waiting for OpenCode server start after ${timeoutMs}ms.`));382 const stdout = yield* Ref.get(stdoutRef);
388 }, timeoutMs);383 const stderr = yield* Ref.get(stderrRef);
389384 const exitCode = Number(code);
390 const cleanup = () => {385 yield* Deferred.fail(
391 clearTimeout(timeout);386 readyDeferred,
392 child.stdout.off("data", onStdout);387 new OpenCodeRuntimeError({
393 child.stderr.off("data", onStderr);388 operation: "startOpenCodeServerProcess",
394 child.off("error", onError);389 detail: [
395 child.off("close", onClose);390 `OpenCode server exited before startup completed (code: ${String(exitCode)}).`,
396 };391 stdout.trim() ? `stdout:\n${stdout.trim()}` : null,
397392 stderr.trim() ? `stderr:\n${stderr.trim()}` : null,
398 const onStdout = (chunk: string) => {393 ]
399 stdout += chunk;394 .filter(Boolean)
400 const parsed = parseServerUrlFromOutput(stdout);395 .join("\n\n"),
401 if (!parsed) {396 cause: { exitCode, stdout, stderr },
402 return;397 }),
403 }398 ).pipe(Effect.ignore);
404 cleanup();399 }),
405 resolve(parsed);
406 };
407
408 const onStderr = (chunk: string) => {
409 stderr += chunk;
410 };
411
412 const onError = (error: Error) => {
413 cleanup();
414 reject(error);
415 };
416
417 const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
418 cleanup();
419 const exitReason = signal ? `signal: ${signal}` : `code: ${code ?? "unknown"}`;
420 const hint =
421 signal === "SIGKILL" && process.platform === "darwin"
422 ? detectMacosSigkillHint(input.binaryPath)
423 : null;
424 reject(
425 new Error(
426 [
427 `OpenCode server exited before startup completed (${exitReason}).`,
428 hint,
429 stdout.trim() ? `stdout:\n${stdout.trim()}` : null,
430 stderr.trim() ? `stderr:\n${stderr.trim()}` : null,
431 ]
432 .filter(Boolean)
433 .join("\n\n"),
434 ),400 ),
401 Effect.ignore,
402 Effect.forkIn(runtimeScope),
435 );403 );
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({405 const readyExit = yield* Effect.exit(
469 binaryPath: input.binaryPath,406 Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)),
470 ...(input.port !== undefined ? { port: input.port } : {}),407 );
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> = [];
498408
499 child.stdout?.on("data", (chunk: string) => stdoutChunks.push(chunk));409 // Startup-time fibers are no longer needed once ready has resolved (either
500 child.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk));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) => {425 const readyOption = readyExit.value;
503 child.once("error", reject);426 if (Option.isNone(readyOption)) {
504 child.once("exit", (exitCode) => resolve(exitCode ?? 0));427 yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);
505 });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 {434 return {
508 stdout: stdoutChunks.join(""),435 url: readyOption.value,
509 stderr: stderrChunks.join(""),436 exitCode: child.exitCode.pipe(
510 code,437 Effect.map(Number),
511 };438 Effect.orElseSucceed(() => 0),
512}439 ),
440 } satisfies OpenCodeServerProcess;
441 });
513442
514export function createOpenCodeSdkClient(input: {443 const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => {
515 readonly baseUrl: string;444 const serverUrl = input.serverUrl?.trim();
516 readonly directory: string;445 if (serverUrl) {
517 readonly serverPassword?: string;446 // We don't own externally-configured servers — no scope interaction.
518}): OpencodeClient {447 return Effect.succeed({
519 return createOpencodeClient({448 url: serverUrl,
520 baseUrl: input.baseUrl,449 exitCode: null,
521 directory: input.directory,450 external: true,
522 ...(input.serverPassword451 });
523 ? {452 }
524 headers: {
525 Authorization: buildOpenCodeBasicAuthorizationHeader(input.serverPassword),
526 },
527 }
528 : {}),
529 throwOnError: true,
530 });
531}
532453
533export async function loadOpenCodeInventory(client: OpencodeClient): Promise<OpenCodeInventory> {454 return startOpenCodeServerProcess({
534 const [providerListResult, agentsResult] = await Promise.all([455 binaryPath: input.binaryPath,
535 client.provider.list(),456 ...(input.port !== undefined ? { port: input.port } : {}),
536 client.app.agents(),457 ...(input.hostname !== undefined ? { hostname: input.hostname } : {}),
537 ]);458 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
538 if (!providerListResult.data) {459 }).pipe(
539 throw new Error("OpenCode provider inventory was empty.");460 Effect.map((server) => ({
540 }461 url: server.url,
541 return {462 exitCode: server.exitCode,
542 providerList: providerListResult.data,463 external: false,
543 agents: agentsResult.data ?? [],464 })),
465 );
544 };466 };
545}
546467
547export function flattenOpenCodeModels(468 const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) =>
548 input: OpenCodeInventory,469 createOpencodeClient({
549): ReadonlyArray<ServerProviderModel> {470 baseUrl: input.baseUrl,
550 const connected = new Set(input.providerList.connected);471 directory: input.directory,
551 const models: Array<ServerProviderModel> = [];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) {482 const loadProviders = (client: OpencodeClient) =>
554 if (!connected.has(provider.id)) {483 runOpenCodeSdk("provider.list", () => client.provider.list()).pipe(
555 continue;484 Effect.filterMapOrFail(
556 }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)) {508 return {
559 models.push({509 startOpenCodeServerProcess,
560 slug: toOpenCodeModelSlug(provider.id, model.id),510 connectToOpenCodeServer,
561 name: model.name,511 runOpenCodeCommand,
562 subProvider: provider.name,512 createOpenCodeSdkClient,
563 isCustom: false,513 loadOpenCodeInventory,
564 capabilities: openCodeCapabilitiesForModel({514 } satisfies OpenCodeRuntimeShape;
565 providerID: provider.id,515});
566 model,516
567 agents: input.agents,517export class OpenCodeRuntime extends Context.Service<OpenCodeRuntime, OpenCodeRuntimeShape>()(
568 }),518 "t3/provider/OpenCodeRuntime",
569 });519) {}
570 }520
571 }521export const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe(
572522 Layer.provide(NetService.layer),
573 return models.toSorted((left, right) => left.name.localeCompare(right.name));523);
574}
apps/server/src/server.test.ts+8-1
...@@ -1830,7 +1830,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1830,7 +1830,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1830 );1830 );
18311831
1832 assertTrue(result._tag === "Failure");1832 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 );
1834 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1841 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1835 );1842 );
18361843
apps/server/src/server.ts+2
...@@ -76,6 +76,7 @@ import {...@@ -76,6 +76,7 @@ import {
76 orchestrationDispatchRouteLayer,76 orchestrationDispatchRouteLayer,
77 orchestrationSnapshotRouteLayer,77 orchestrationSnapshotRouteLayer,
78} from "./orchestration/http.ts";78} from "./orchestration/http.ts";
79import { NetService } from "@t3tools/shared/Net";
7980
80const PtyAdapterLive = Layer.unwrap(81const PtyAdapterLive = Layer.unwrap(
81 Effect.gen(function* () {82 Effect.gen(function* () {
...@@ -243,6 +244,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe(...@@ -243,6 +244,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe(
243 Layer.provideMerge(AnalyticsServiceLayerLive),244 Layer.provideMerge(AnalyticsServiceLayerLive),
244 Layer.provideMerge(OpenLive),245 Layer.provideMerge(OpenLive),
245 Layer.provideMerge(ServerLifecycleEventsLive),246 Layer.provideMerge(ServerLifecycleEventsLive),
247 Layer.provide(NetService.layer),
246);248);
247249
248const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(250const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
apps/web/src/components/chat/TraitsPicker.browser.tsx+41-2
...@@ -630,7 +630,11 @@ describe("TraitsPicker (Codex)", () => {...@@ -630,7 +630,11 @@ describe("TraitsPicker (Codex)", () => {
630630
631// ── OpenCode TraitsPicker tests ───────────────────────────────────────631// ── 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}) {
634 const threadId = ThreadId.make("thread-opencode-traits");638 const threadId = ThreadId.make("thread-opencode-traits");
635 const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);639 const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId);
636 const threadKey = scopedThreadKey(threadRef);640 const threadKey = scopedThreadKey(threadRef);
...@@ -665,7 +669,7 @@ async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeMo...@@ -665,7 +669,7 @@ async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeMo
665 const screen = await render(669 const screen = await render(
666 <TraitsPicker670 <TraitsPicker
667 provider="opencode"671 provider="opencode"
668 models={findTestProvider("opencode").models}672 models={props.models ?? findTestProvider("opencode").models}
669 threadRef={threadRef}673 threadRef={threadRef}
670 model={model}674 model={model}
671 prompt=""675 prompt=""
...@@ -712,6 +716,41 @@ describe("TraitsPicker (OpenCode)", () => {...@@ -712,6 +716,41 @@ describe("TraitsPicker (OpenCode)", () => {
712 expect(text).not.toContain("Medium · plan");716 expect(text).not.toContain("Medium · plan");
713 });717 });
714 });718 });
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 });
715});754});
716755
717describe("TraitsPicker (Cursor)", () => {756describe("TraitsPicker (Cursor)", () => {
apps/web/src/components/chat/TraitsPicker.tsx+16-8
...@@ -91,7 +91,7 @@ function resolveNamedOption(...@@ -91,7 +91,7 @@ function resolveNamedOption(
91 return matchingOption;91 return matchingOption;
92 }92 }
93 }93 }
94 return options.find((option) => option.isDefault) ?? null;94 return options.find((option) => option.isDefault) ?? options[0] ?? null;
95}95}
9696
97function getRawContextWindow(97function getRawContextWindow(
...@@ -301,6 +301,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -301,6 +301,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
301 allowPromptInjectedEffort,301 allowPromptInjectedEffort,
302 });302 });
303 const defaultEffort = getDefaultEffort(caps);303 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
305 const handleEffortChange = useCallback(311 const handleEffortChange = useCallback(
306 (value: string) => {312 (value: string) => {
...@@ -348,7 +354,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -348,7 +354,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
348354
349 return (355 return (
350 <>356 <>
351 {showEffort ? (357 {showsEffortSection ? (
352 <>358 <>
353 <MenuGroup>359 <MenuGroup>
354 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">360 <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({...@@ -378,7 +384,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
378 </MenuRadioGroup>384 </MenuRadioGroup>
379 </MenuGroup>385 </MenuGroup>
380 </>386 </>
381 ) : showThinking ? (387 ) : showsThinkingSection ? (
382 <MenuGroup>388 <MenuGroup>
383 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>389 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
384 <MenuRadioGroup390 <MenuRadioGroup
...@@ -394,9 +400,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -394,9 +400,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
394 </MenuRadioGroup>400 </MenuRadioGroup>
395 </MenuGroup>401 </MenuGroup>
396 ) : null}402 ) : null}
397 {showFastMode ? (403 {showsFastModeSection ? (
398 <>404 <>
399 {showEffort || showThinking ? <MenuDivider /> : null}405 {showsEffortSection || showsThinkingSection ? <MenuDivider /> : null}
400 <MenuGroup>406 <MenuGroup>
401 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>407 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
402 <MenuRadioGroup408 <MenuRadioGroup
...@@ -413,9 +419,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -413,9 +419,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
413 </MenuGroup>419 </MenuGroup>
414 </>420 </>
415 ) : null}421 ) : null}
416 {showContextWindow ? (422 {showsContextWindowSection ? (
417 <>423 <>
418 {showEffort || showThinking || showFastMode ? <MenuDivider /> : null}424 {showsEffortSection || showsThinkingSection || showsFastModeSection ? (
425 <MenuDivider />
426 ) : null}
419 <MenuGroup>427 <MenuGroup>
420 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">428 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">
421 Context Window429 Context Window
...@@ -442,7 +450,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({...@@ -442,7 +450,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
442 ) : null}450 ) : null}
443 {agentOptions.length > 0 ? (451 {agentOptions.length > 0 ? (
444 <>452 <>
445 <MenuDivider />453 {hasSectionsBeforeAgent ? <MenuDivider /> : null}
446 <MenuGroup>454 <MenuGroup>
447 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Agent</div>455 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Agent</div>
448 <MenuRadioGroup456 <MenuRadioGroup
packages/shared/src/model.ts+1-1
...@@ -134,7 +134,7 @@ function resolveLabeledOption(...@@ -134,7 +134,7 @@ function resolveLabeledOption(
134 if (raw && options.some((option) => option.value === raw)) {134 if (raw && options.some((option) => option.value === raw)) {
135 return raw;135 return raw;
136 }136 }
137 return options.find((option) => option.isDefault)?.value;137 return options.find((option) => option.isDefault)?.value ?? options[0]?.value;
138}138}
139139
140export function normalizeOpenCodeModelOptionsWithCapabilities(140export function normalizeOpenCodeModelOptionsWithCapabilities(