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