| author | |
| committer | |
| log | 306ec4bb842fac22099192e964334fbf5368ad84 |
| tree | 6913984707ba0fbe83ce93f6a749b3d8a19a1fb4 |
| parent | 3b98fe3548831c536cf8c22d4b8a63e03e0a7d58 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import type { ChildProcess } from "node:child_process"; | ||
| 2 | |||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it } from "@effect/vitest"; | 2 | import { it } from "@effect/vitest"; |
| 5 | import { Duration, Effect, Layer } from "effect"; | 3 | import { Duration, Effect, Layer } from "effect"; |
| 6 | import { TestClock } from "effect/testing"; | 4 | import { TestClock } from "effect/testing"; |
| 7 | import { beforeEach, expect, vi } from "vitest"; | 5 | import { NetService } from "@t3tools/shared/Net"; |
| 6 | import { beforeEach, expect } from "vitest"; | ||
| 8 | 7 | ||
| 9 | import { ServerConfig } from "../../config.ts"; | 8 | import { ServerConfig } from "../../config.ts"; |
| 10 | import { ServerSettingsService } from "../../serverSettings.ts"; | 9 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 10 | import { | ||
| 11 | OpenCodeRuntime, | ||
| 12 | OpenCodeRuntimeError, | ||
| 13 | type OpenCodeRuntimeShape, | ||
| 14 | } from "../../provider/opencodeRuntime.ts"; | ||
| 11 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 15 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 12 | import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; | 16 | import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; |
| 13 | 17 | ||
| 14 | const runtimeMock = vi.hoisted(() => { | 18 | const 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 as | 24 | 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 | }, |
| 24 | 28 | 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 | |||
| 37 | vi.mock("../../provider/opencodeRuntime.ts", async () => { | ||
| 38 | const actual = await vi.importActual<typeof import("../../provider/opencodeRuntime.ts")>( | ||
| 39 | "../../provider/opencodeRuntime.ts", | ||
| 40 | ); | ||
| 41 | 36 | ||
| 42 | return { | 37 | const 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 | }); | ||
| 86 | 98 | ||
| 87 | const DEFAULT_TEST_MODEL_SELECTION = { | 99 | const 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 = { |
| 92 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | 104 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; |
| 93 | 105 | ||
| 94 | const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( | 106 | const 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 | ); |
| 111 | 125 | ||
| 112 | const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe( | 126 | const 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 | ); |
| 131 | 147 |
apps/server/src/git/Layers/OpenCodeTextGeneration.ts+77-36| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; | 1 | import { Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; |
| 2 | import * as Semaphore from "effect/Semaphore"; | 2 | import * as Semaphore from "effect/Semaphore"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| ... | @@ -25,15 +25,15 @@ import { | ... | @@ -25,15 +25,15 @@ import { |
| 25 | sanitizeThreadTitle, | 25 | sanitizeThreadTitle, |
| 26 | } from "../Utils.ts"; | 26 | } from "../Utils.ts"; |
| 27 | import { | 27 | import { |
| 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"; |
| 35 | 35 | ||
| 36 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | 36 | const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; |
| 37 | 37 | ||
| 38 | function getOpenCodePromptErrorMessage(error: unknown): string | null { | 38 | function 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 |
| 80 | 80 | ||
| 81 | interface SharedOpenCodeTextGenerationServerState { | 81 | interface 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 { |
| 88 | const makeOpenCodeTextGeneration = Effect.gen(function* () { | 95 | const 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 | }; |
| 101 | 110 | ||
| 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 | }; | ||
| 109 | 120 | ||
| 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 === 0 | 166 | 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 | } |
| 172 | 183 | ||
| 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 | } | ||
| 182 | 222 | ||
| 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 | ); |
| 189 | 233 | ||
| ... | @@ -200,17 +244,15 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { | ... | @@ -200,17 +244,15 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { |
| 200 | }), | 244 | }), |
| 201 | ); | 245 | ); |
| 202 | 246 | ||
| 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.serverPassword | 312 | ...(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"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it } from "@effect/vitest"; | 4 | import { it } from "@effect/vitest"; |
| 5 | import { Effect, Layer, Option } from "effect"; | 5 | import { Effect, Layer, Option } from "effect"; |
| 6 | import { beforeEach, vi } from "vitest"; | 6 | import { beforeEach } from "vitest"; |
| 7 | 7 | ||
| 8 | import { ThreadId } from "@t3tools/contracts"; | 8 | import { ThreadId } from "@t3tools/contracts"; |
| 9 | import { ServerConfig } from "../../config.ts"; | 9 | import { ServerConfig } from "../../config.ts"; |
| 10 | import { ServerSettingsService } from "../../serverSettings.ts"; | 10 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 11 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | 11 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; |
| 12 | import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; | 12 | import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; |
| 13 | import { | ||
| 14 | OpenCodeRuntime, | ||
| 15 | OpenCodeRuntimeError, | ||
| 16 | type OpenCodeRuntimeShape, | ||
| 17 | } from "../opencodeRuntime.ts"; | ||
| 13 | import { | 18 | import { |
| 14 | appendOpenCodeAssistantTextDelta, | 19 | appendOpenCodeAssistantTextDelta, |
| 15 | makeOpenCodeAdapterLive, | 20 | makeOpenCodeAdapterLive, |
| ... | @@ -18,16 +23,16 @@ import { | ... | @@ -18,16 +23,16 @@ import { |
| 18 | 23 | ||
| 19 | const asThreadId = (value: string): ThreadId => ThreadId.make(value); | 24 | const asThreadId = (value: string): ThreadId => ThreadId.make(value); |
| 20 | 25 | ||
| 21 | const runtimeMock = vi.hoisted(() => { | 26 | type 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 | }; | ||
| 29 | 33 | ||
| 30 | const state = { | 34 | const 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 | }, |
| 42 | 47 | 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 | }, | 61 | const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { |
| 57 | }; | 62 | startOpenCodeServerProcess: ({ binaryPath }) => |
| 58 | }); | 63 | Effect.gen(function* () { |
| 59 | |||
| 60 | vi.mock("../opencodeRuntime.ts", async () => { | ||
| 61 | const actual = | ||
| 62 | await vi.importActual<typeof import("../opencodeRuntime.ts")>("../opencodeRuntime.ts"); | ||
| 63 | |||
| 64 | return { | ||
| 65 | ...actual, | ||
| 66 | startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { | ||
| 67 | runtimeMock.state.startCalls.push(binaryPath); | 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 | }); | ||
| 140 | 158 | ||
| 141 | const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { | 159 | const 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 | }); |
| 149 | 167 | ||
| 150 | const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe( | 168 | const 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 | ); |
| 213 | 232 | ||
| 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 | }); |
| 227 | 246 | ||
| 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(); |
| 231 | 255 | ||
| 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 | }; |
| 376 | 398 | ||
| 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 | }; |
| 452 | 477 | ||
| 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 | ); |
| 469 | 497 | ||
| 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)); |
| 480 | 517 | ||
| 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"; |
| 14 | import { Cause, Effect, Layer, Queue, Stream } from "effect"; | 14 | import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; |
| 15 | import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; | 15 | import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; |
| 16 | 16 | ||
| 17 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 17 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| ... | @@ -28,10 +28,12 @@ import { | ... | @@ -28,10 +28,12 @@ import { |
| 28 | import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; | 28 | import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; |
| 29 | import { | 29 | import { |
| 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 | } |
| 47 | 49 | ||
| 50 | type OpenCodeSubscribedEvent = | ||
| 51 | Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>> extends { | ||
| 52 | readonly stream: AsyncIterable<infer TEvent>; | ||
| 53 | } | ||
| 54 | ? TEvent | ||
| 55 | : never; | ||
| 56 | |||
| 48 | interface OpenCodeSessionContext { | 57 | interface 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 | } |
| 67 | 89 | ||
| 68 | export interface OpenCodeAdapterLiveOptions { | 90 | export 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 | } |
| 76 | 98 | ||
| 77 | function 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 | ); | 105 | const 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 | */ | ||
| 119 | const 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 | }); | ||
| 85 | 126 | ||
| 86 | function buildEventBase(input: { | 127 | function 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 | } |
| 371 | 415 | ||
| 372 | async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise<void> { | 416 | const 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.session | 420 | 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 })); | ||
| 382 | 430 | ||
| 383 | export 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 | |||
| 437 | export 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 !== undefined | 446 | (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>(); |
| 399 | 457 | ||
| 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)); |
| 421 | 502 | ||
| 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 | }); | ||
| 448 | 545 | ||
| 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 | }); | ||
| 522 | 612 | ||
| 523 | for await (const event of subscription.stream) { | 613 | const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( |
| 524 | const payloadSessionId = | 614 | context: OpenCodeSessionContext, |
| 525 | "properties" in event | 615 | event: OpenCodeSubscribedEvent, |
| 526 | ? (event.properties as { sessionID?: unknown }).sessionID | 616 | ) { |
| 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 | } | ||
| 531 | 624 | ||
| 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 | }); |
| 544 | 637 | ||
| 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 | } | ||
| 558 | 651 | ||
| 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 | } |
| 563 | 656 | ||
| 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 | } | ||
| 611 | 701 | ||
| 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 | } | ||
| 661 | 706 | ||
| 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 | } | ||
| 683 | 710 | ||
| 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 | } | ||
| 701 | 751 | ||
| 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 | } | ||
| 718 | 773 | ||
| 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 | } | ||
| 740 | 791 | ||
| 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 | } | ||
| 755 | 808 | ||
| 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( |
| 760 | 813 | (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 | }), |
| 772 | 825 | 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 | } | ||
| 790 | 830 | ||
| 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 | } | ||
| 828 | 845 | ||
| 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 | })(); | ||
| 843 | 914 | ||
| 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 | }); |
| 854 | 988 | ||
| 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 | } |
| 886 | 1011 | ||
| 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 | }); |
| 913 | 1049 | ||
| 914 | // Guard against a concurrent startSession call that may have raced | 1050 | // 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 created | 1054 | // 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.session | 1058 | ).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 | } |
| 930 | 1062 | ||
| ... | @@ -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); |
| 964 | 1096 | ||
| 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 | }); |
| 1045 | 1177 | ||
| 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 | ? failure | 1190 | // 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 | } | ||
| 1099 | 1216 | ||
| 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 | } |
| 1142 | 1252 | ||
| 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 | }); |
| 1161 | 1260 | ||
| 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 | } |
| 1174 | 1273 | ||
| 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 | }); |
| 1190 | 1281 | ||
| 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 | }); | ||
| 1236 | 1311 | ||
| 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 | }); | ||
| 1265 | 1332 | ||
| 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 | }); | ||
| 1285 | 1344 | ||
| 1286 | return yield* readThread(threadId); | 1345 | return yield* readThread(threadId); |
| 1287 | }, | 1346 | }, |
| 1288 | ); | 1347 | ); |
| 1289 | 1348 | ||
| 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 = results | 1357 | 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 | }); |
| 1319 | 1363 | ||
| 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"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it } from "@effect/vitest"; | 4 | import { it } from "@effect/vitest"; |
| 5 | import { Effect, Layer } from "effect"; | 5 | import { Effect, Layer } from "effect"; |
| 6 | import { beforeEach, vi } from "vitest"; | 6 | import { beforeEach } from "vitest"; |
| 7 | 7 | ||
| 8 | import { ServerConfig } from "../../config.ts"; | 8 | import { ServerConfig } from "../../config.ts"; |
| 9 | import { ServerSettingsService } from "../../serverSettings.ts"; | 9 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 10 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | 10 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; |
| 11 | import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts"; | 11 | import { |
| 12 | 12 | OpenCodeRuntime, | |
| 13 | const runtimeMock = vi.hoisted(() => { | 13 | OpenCodeRuntimeError, |
| 14 | const state = { | 14 | type OpenCodeRuntimeShape, |
| 15 | } from "../opencodeRuntime.ts"; | ||
| 16 | import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; | ||
| 17 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; | ||
| 18 | |||
| 19 | const 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: { |
| 18 | 24 | 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: {} }, |
| 27 | 33 | agents: [] as unknown[], | |
| 28 | vi.mock("../opencodeRuntime.ts", async () => { | 34 | }; |
| 29 | const actual = | 35 | }, |
| 30 | await vi.importActual<typeof import("../opencodeRuntime.ts")>("../opencodeRuntime.ts"); | 36 | }; |
| 31 | 37 | ||
| 32 | return { | 38 | const 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 | }; | ||
| 59 | 73 | ||
| 60 | beforeEach(() => { | 74 | beforeEach(() => { |
| 61 | runtimeMock.reset(); | 75 | runtimeMock.reset(); |
| 62 | }); | 76 | }); |
| 63 | 77 | ||
| 64 | const makeTestLayer = (settingsOverrides?: Parameters<typeof ServerSettingsService.layerTest>[0]) => | 78 | const 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 | }); |
| 96 | 159 | ||
| 97 | it.layer( | 160 | it.layer( |
apps/server/src/provider/Layers/OpenCodeProvider.ts+286-188| ... | @@ -1,57 +1,58 @@ | ... | @@ -1,57 +1,58 @@ |
| 1 | import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts"; | 1 | import type { |
| 2 | import { Cause, Effect, Equal, Layer, Stream } from "effect"; | 2 | ModelCapabilities, |
| 3 | OpenCodeSettings, | ||
| 4 | ServerProvider, | ||
| 5 | ServerProviderModel, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { Cause, Data, Effect, Equal, Layer, Stream } from "effect"; | ||
| 3 | 8 | ||
| 4 | import { ServerConfig } from "../../config.ts"; | 9 | import { ServerConfig } from "../../config.ts"; |
| 5 | import { ServerSettingsService } from "../../serverSettings.ts"; | 10 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 6 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 11 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 7 | import { | 12 | import { |
| 8 | buildServerProvider, | 13 | buildServerProvider, |
| 9 | isCommandMissingCause, | ||
| 10 | parseGenericCliVersion, | 14 | parseGenericCliVersion, |
| 11 | providerModelsFromSettings, | 15 | providerModelsFromSettings, |
| 12 | } from "../providerSnapshot.ts"; | 16 | } from "../providerSnapshot.ts"; |
| 13 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | 17 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; |
| 14 | import { | 18 | import { |
| 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"; |
| 23 | import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; | ||
| 22 | 24 | ||
| 23 | const PROVIDER = "opencode" as const; | 25 | const PROVIDER = "opencode" as const; |
| 24 | 26 | ||
| 25 | class OpenCodeProbePromiseError extends Error { | 27 | class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ |
| 26 | override readonly cause: unknown; | 28 | readonly cause: unknown; |
| 29 | readonly detail: string; | ||
| 30 | }> {} | ||
| 27 | 31 | ||
| 28 | constructor(cause: unknown) { | 32 | function 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 ( |
| 34 | 38 | trimmed === "An error occurred in Effect.tryPromise" || | |
| 35 | function 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 | } |
| 38 | 45 | ||
| 39 | function normalizedErrorMessage(cause: unknown): string | undefined { | 46 | function 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 | } |
| 43 | 50 | ||
| 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 | } |
| 56 | 57 | ||
| 57 | function formatOpenCodeProbeError(input: { | 58 | function 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() ?? ""; | ||
| 64 | 65 | ||
| 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 | } |
| 98 | 99 | ||
| 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 | } |
| 129 | 130 | ||
| 131 | function 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 | |||
| 139 | function 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 | |||
| 155 | function inferDefaultAgent(agents: ReadonlyArray<Agent>): string | undefined { | ||
| 156 | return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; | ||
| 157 | } | ||
| 158 | |||
| 159 | const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { | ||
| 160 | reasoningEffortLevels: [], | ||
| 161 | supportsFastMode: false, | ||
| 162 | supportsThinkingToggle: false, | ||
| 163 | contextWindowOptions: [], | ||
| 164 | promptInjectedEffortLevels: [], | ||
| 165 | }; | ||
| 166 | |||
| 167 | function 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 | |||
| 197 | function 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 | |||
| 130 | const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => { | 224 | const 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 | }; |
| 172 | 266 | ||
| 173 | export function checkOpenCodeProviderStatus(input: { | 267 | export 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 | |
| 180 | 274 | 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: isExternalServer | 406 | 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 | }); |
| 230 | 418 | ||
| 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 | } | ||
| 248 | 422 | ||
| 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 | |||
| 311 | export function makeOpenCodeProviderLive() { | ||
| 312 | return Layer.effect( | ||
| 313 | OpenCodeProvider, | ||
| 314 | Effect.gen(function* () { | ||
| 315 | const serverSettings = yield* ServerSettingsService; | ||
| 316 | const serverConfig = yield* ServerConfig; | ||
| 317 | |||
| 318 | const getProviderSettings = serverSettings.getSettings.pipe( | ||
| 319 | Effect.map((settings) => settings.providers.opencode), | ||
| 320 | ); | ||
| 321 | |||
| 322 | return yield* makeManagedServerProvider<OpenCodeSettings>({ | ||
| 323 | getSettings: getProviderSettings.pipe(Effect.orDie), | ||
| 324 | streamSettings: serverSettings.streamChanges.pipe( | ||
| 325 | Stream.map((settings) => settings.providers.opencode), | ||
| 326 | ), | ||
| 327 | haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), | ||
| 328 | initialSnapshot: makePendingOpenCodeProvider, | ||
| 329 | checkProvider: getProviderSettings.pipe( | ||
| 330 | Effect.flatMap((settings) => | ||
| 331 | checkOpenCodeProviderStatus({ | ||
| 332 | settings, | ||
| 333 | cwd: serverConfig.cwd, | ||
| 334 | }), | ||
| 335 | ), | ||
| 336 | ), | ||
| 337 | }); | ||
| 338 | }), | ||
| 339 | ); | ||
| 340 | } | ||
| 341 | |||
| 342 | export const OpenCodeProviderLive = makeOpenCodeProviderLive(); |
apps/server/src/provider/Layers/ProviderRegistry.ts+2| ... | @@ -16,6 +16,7 @@ import { CodexProvider } from "../Services/CodexProvider.ts"; | ... | @@ -16,6 +16,7 @@ import { CodexProvider } from "../Services/CodexProvider.ts"; |
| 16 | import { CursorProvider } from "../Services/CursorProvider.ts"; | 16 | import { CursorProvider } from "../Services/CursorProvider.ts"; |
| 17 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | 17 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; |
| 18 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; | 18 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; |
| 19 | import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; | ||
| 19 | import { | 20 | import { |
| 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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { DEFAULT_OPENCODE_MODEL_CAPABILITIES, flattenOpenCodeModels } from "./opencodeRuntime.ts"; | ||
| 4 | |||
| 5 | describe("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 @@ |
| 1 | import { execFileSync, spawn, type ChildProcess } from "node:child_process"; | ||
| 2 | import * as FS from "node:fs"; | ||
| 3 | import { createServer, type AddressInfo } from "node:net"; | ||
| 4 | import * as OS from "node:os"; | ||
| 5 | import * as Path from "node:path"; | ||
| 6 | import { pathToFileURL } from "node:url"; | 1 | import { pathToFileURL } from "node:url"; |
| 7 | 2 | ||
| 8 | import type { | 3 | import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; |
| 9 | ChatAttachment, | ||
| 10 | ModelCapabilities, | ||
| 11 | ProviderApprovalDecision, | ||
| 12 | RuntimeMode, | ||
| 13 | ServerProviderModel, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { | 4 | import { |
| 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"; |
| 14 | import { | ||
| 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"; | ||
| 30 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 31 | |||
| 32 | import { isWindowsCommandNotFound } from "../processRunner.ts"; | ||
| 33 | import { collectStreamAsString } from "./providerSnapshot.ts"; | ||
| 34 | import { NetService } from "@t3tools/shared/Net"; | ||
| 25 | 35 | ||
| 26 | const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; | 36 | const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; |
| 27 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; | 37 | const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; |
| 28 | const DEFAULT_HOSTNAME = "127.0.0.1"; | 38 | const DEFAULT_HOSTNAME = "127.0.0.1"; |
| 29 | 39 | ||
| 30 | const OPENAI_VARIANTS = ["none", "minimal", "low", "medium", "high", "xhigh"]; | ||
| 31 | const ANTHROPIC_VARIANTS = ["high", "max"]; | ||
| 32 | const GOOGLE_VARIANTS = ["low", "high"]; | ||
| 33 | |||
| 34 | export const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { | ||
| 35 | reasoningEffortLevels: [], | ||
| 36 | supportsFastMode: false, | ||
| 37 | supportsThinkingToggle: false, | ||
| 38 | contextWindowOptions: [], | ||
| 39 | promptInjectedEffortLevels: [], | ||
| 40 | }; | ||
| 41 | |||
| 42 | export interface OpenCodeServerProcess { | 40 | export 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 | } |
| 47 | 44 | ||
| 48 | export interface OpenCodeServerConnection { | 45 | export 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 | } |
| 54 | 50 | ||
| 55 | function buildOpenCodeBasicAuthorizationHeader(password: string): string { | 51 | const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; |
| 56 | return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; | 52 | export 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 | |||
| 61 | export 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 | } |
| 58 | 77 | ||
| 78 | export 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 | |||
| 59 | export interface OpenCodeCommandResult { | 88 | export 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 | } |
| 74 | 103 | ||
| 75 | function titleCaseSlug(value: string): string { | 104 | export interface OpenCodeRuntimeShape { |
| 76 | return value | 105 | /** |
| 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 | } |
| 82 | 142 | ||
| 83 | function parseServerUrlFromOutput(output: string): string | null { | 143 | function 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 | } |
| 93 | 153 | ||
| 94 | function isPrimaryAgent(agent: Agent): boolean { | ||
| 95 | return !agent.hidden && (agent.mode === "primary" || agent.mode === "all"); | ||
| 96 | } | ||
| 97 | |||
| 98 | function inferVariantValues(providerID: string): ReadonlyArray<string> { | ||
| 99 | if (providerID === "anthropic") { | ||
| 100 | return ANTHROPIC_VARIANTS; | ||
| 101 | } | ||
| 102 | if (providerID === "openai" || providerID === "opencode") { | ||
| 103 | return OPENAI_VARIANTS; | ||
| 104 | } | ||
| 105 | if (providerID.startsWith("google")) { | ||
| 106 | return GOOGLE_VARIANTS; | ||
| 107 | } | ||
| 108 | return []; | ||
| 109 | } | ||
| 110 | |||
| 111 | function inferDefaultVariant( | ||
| 112 | providerID: string, | ||
| 113 | variants: ReadonlyArray<string>, | ||
| 114 | ): string | undefined { | ||
| 115 | if (variants.length === 1) { | ||
| 116 | return variants[0]; | ||
| 117 | } | ||
| 118 | if (providerID === "anthropic" || providerID.startsWith("google")) { | ||
| 119 | return variants.includes("high") ? "high" : undefined; | ||
| 120 | } | ||
| 121 | if (providerID === "openai" || providerID === "opencode") { | ||
| 122 | return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; | ||
| 123 | } | ||
| 124 | return undefined; | ||
| 125 | } | ||
| 126 | |||
| 127 | function buildVariantOptions( | ||
| 128 | providerID: string, | ||
| 129 | model: ProviderListResponse["all"][number]["models"][string], | ||
| 130 | ) { | ||
| 131 | const variantValues = Object.keys(model.variants ?? {}); | ||
| 132 | const resolvedValues = | ||
| 133 | variantValues.length > 0 ? variantValues : [...inferVariantValues(providerID)]; | ||
| 134 | const defaultVariant = inferDefaultVariant(providerID, resolvedValues); | ||
| 135 | |||
| 136 | return resolvedValues.map((value) => { | ||
| 137 | const option: { value: string; label: string; isDefault?: boolean } = { | ||
| 138 | value, | ||
| 139 | label: titleCaseSlug(value), | ||
| 140 | }; | ||
| 141 | if (defaultVariant === value) { | ||
| 142 | option.isDefault = true; | ||
| 143 | } | ||
| 144 | return option; | ||
| 145 | }); | ||
| 146 | } | ||
| 147 | |||
| 148 | function buildAgentOptions(agents: ReadonlyArray<Agent>) { | ||
| 149 | const primaryAgents = agents.filter(isPrimaryAgent); | ||
| 150 | const defaultAgent = | ||
| 151 | primaryAgents.find((agent) => agent.name === "build")?.name ?? | ||
| 152 | primaryAgents[0]?.name ?? | ||
| 153 | undefined; | ||
| 154 | return primaryAgents.map((agent) => { | ||
| 155 | const option: { value: string; label: string; isDefault?: boolean } = { | ||
| 156 | value: agent.name, | ||
| 157 | label: titleCaseSlug(agent.name), | ||
| 158 | }; | ||
| 159 | if (defaultAgent === agent.name) { | ||
| 160 | option.isDefault = true; | ||
| 161 | } | ||
| 162 | return option; | ||
| 163 | }); | ||
| 164 | } | ||
| 165 | |||
| 166 | function openCodeCapabilitiesForModel(input: { | ||
| 167 | readonly providerID: string; | ||
| 168 | readonly model: ProviderListResponse["all"][number]["models"][string]; | ||
| 169 | readonly agents: ReadonlyArray<Agent>; | ||
| 170 | }): ModelCapabilities { | ||
| 171 | const variantOptions = buildVariantOptions(input.providerID, input.model); | ||
| 172 | const agentOptions = buildAgentOptions(input.agents); | ||
| 173 | return { | ||
| 174 | ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, | ||
| 175 | ...(variantOptions.length > 0 ? { variantOptions } : {}), | ||
| 176 | ...(agentOptions.length > 0 ? { agentOptions } : {}), | ||
| 177 | }; | ||
| 178 | } | ||
| 179 | |||
| 180 | export function parseOpenCodeModelSlug( | 154 | export 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 | } |
| 198 | 172 | ||
| 199 | export function toOpenCodeModelSlug(providerID: string, modelID: string): string { | ||
| 200 | return `${providerID}/${modelID}`; | ||
| 201 | } | ||
| 202 | |||
| 203 | export function openCodeQuestionId( | 173 | export 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 | } |
| 288 | 258 | ||
| 289 | export async function findAvailablePort(): Promise<number> { | 259 | function 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 | } |
| 302 | 268 | ||
| 303 | export function resolveOpenCodeBinaryPath(binaryPath: string): string { | 269 | const 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 | } | ||
| 312 | 272 | ||
| 313 | export 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( |
| 327 | 282 | [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( |
| 351 | 298 | Effect.scoped, | |
| 352 | export 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 | ); | ||
| 369 | 351 | ||
| 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 | ); | ||
| 372 | 365 | ||
| 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 | ); | ||
| 383 | 378 | ||
| 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); |
| 389 | 384 | 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, |
| 397 | 392 | 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 | |||
| 451 | export async function connectToOpenCodeServer(input: { | ||
| 452 | readonly binaryPath: string; | ||
| 453 | readonly serverUrl?: string | null; | ||
| 454 | readonly port?: number; | ||
| 455 | readonly hostname?: string; | ||
| 456 | readonly timeoutMs?: number; | ||
| 457 | }): Promise<OpenCodeServerConnection> { | ||
| 458 | const serverUrl = input.serverUrl?.trim(); | ||
| 459 | if (serverUrl) { | ||
| 460 | return { | ||
| 461 | url: serverUrl, | ||
| 462 | process: null, | ||
| 463 | external: true, | ||
| 464 | close() {}, | ||
| 465 | }; | ||
| 466 | } | ||
| 467 | 404 | ||
| 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 | |||
| 483 | export async function runOpenCodeCommand(input: { | ||
| 484 | readonly binaryPath: string; | ||
| 485 | readonly args: ReadonlyArray<string>; | ||
| 486 | }): Promise<OpenCodeCommandResult> { | ||
| 487 | const child = spawn(input.binaryPath, [...input.args], { | ||
| 488 | stdio: ["ignore", "pipe", "pipe"], | ||
| 489 | shell: process.platform === "win32", | ||
| 490 | env: process.env, | ||
| 491 | }); | ||
| 492 | |||
| 493 | child.stdout?.setEncoding("utf8"); | ||
| 494 | child.stderr?.setEncoding("utf8"); | ||
| 495 | |||
| 496 | const stdoutChunks: Array<string> = []; | ||
| 497 | const stderrChunks: Array<string> = []; | ||
| 498 | 408 | ||
| 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 | } | ||
| 501 | 424 | ||
| 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 | } | ||
| 506 | 433 | ||
| 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 | }); | ||
| 513 | 442 | ||
| 514 | export 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.serverPassword | 451 | }); |
| 523 | ? { | 452 | } |
| 524 | headers: { | ||
| 525 | Authorization: buildOpenCodeBasicAuthorizationHeader(input.serverPassword), | ||
| 526 | }, | ||
| 527 | } | ||
| 528 | : {}), | ||
| 529 | throwOnError: true, | ||
| 530 | }); | ||
| 531 | } | ||
| 532 | 453 | ||
| 533 | export 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 | } | ||
| 546 | 467 | ||
| 547 | export 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 | }); | ||
| 552 | 481 | ||
| 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 | ); | ||
| 557 | 507 | ||
| 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, | 517 | export class OpenCodeRuntime extends Context.Service<OpenCodeRuntime, OpenCodeRuntimeShape>()( |
| 568 | }), | 518 | "t3/provider/OpenCodeRuntime", |
| 569 | }); | 519 | ) {} |
| 570 | } | 520 | |
| 571 | } | 521 | export const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( |
| 572 | 522 | 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 | ); |
| 1831 | 1831 | ||
| 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 | ); |
| 1836 | 1843 |
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"; |
| 79 | import { NetService } from "@t3tools/shared/Net"; | ||
| 79 | 80 | ||
| 80 | const PtyAdapterLive = Layer.unwrap( | 81 | const 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 | ); |
| 247 | 249 | ||
| 248 | const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( | 250 | const 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)", () => { |
| 630 | 630 | ||
| 631 | // ── OpenCode TraitsPicker tests ─────────────────────────────────────── | 631 | // ── OpenCode TraitsPicker tests ─────────────────────────────────────── |
| 632 | 632 | ||
| 633 | async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeModelOptions }) { | 633 | async 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 | <TraitsPicker | 670 | <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 | }); |
| 716 | 755 | ||
| 717 | describe("TraitsPicker (Cursor)", () => { | 756 | describe("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 | } |
| 96 | 96 | ||
| 97 | function getRawContextWindow( | 97 | function 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; | ||
| 304 | 310 | ||
| 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({ |
| 348 | 354 | ||
| 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 | <MenuRadioGroup | 390 | <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 | <MenuRadioGroup | 408 | <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 Window | 429 | 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 | <MenuRadioGroup | 456 | <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 | } |
| 139 | 139 | ||
| 140 | export function normalizeOpenCodeModelOptionsWithCapabilities( | 140 | export function normalizeOpenCodeModelOptionsWithCapabilities( |