Unified · Split
| @@ -29,6 +29,7 @@ · 28 unmodified lines | ||
| 29 | 29 | "@effect/platform-bun": "catalog:", |
| 30 | 30 | "@effect/platform-node": "catalog:", |
| 31 | 31 | "@effect/sql-sqlite-bun": "catalog:", |
| 32 | + "@opencode-ai/sdk": "^1.3.15", | |
| 32 | 33 | "@pierre/diffs": "^1.1.0-beta.16", |
| 33 | 34 | "effect": "catalog:", |
| 34 | 35 | "node-pty": "^1.1.0", |
| @@ -0,0 +1,259 @@ | ||
| 1 | +import type { ChildProcess } from "node:child_process"; | |
| 2 | + | |
| 3 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | +import { it } from "@effect/vitest"; | |
| 5 | +import { Duration, Effect, Layer } from "effect"; | |
| 6 | +import { TestClock } from "effect/testing"; | |
| 7 | +import { beforeEach, expect, vi } from "vitest"; | |
| 8 | + | |
| 9 | +import { ServerConfig } from "../../config.ts"; | |
| 10 | +import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 11 | +import { TextGeneration } from "../Services/TextGeneration.ts"; | |
| 12 | +import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; | |
| 13 | + | |
| 14 | +const runtimeMock = vi.hoisted(() => { | |
| 15 | + const state = { | |
| 16 | + startCalls: [] as string[], | |
| 17 | + promptUrls: [] as string[], | |
| 18 | + authHeaders: [] as Array<string | null>, | |
| 19 | + closeCalls: [] as string[], | |
| 20 | + promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined, | |
| 21 | + }; | |
| 22 | + | |
| 23 | + return { | |
| 24 | + state, | |
| 25 | + reset() { | |
| 26 | + state.startCalls.length = 0; | |
| 27 | + state.promptUrls.length = 0; | |
| 28 | + state.authHeaders.length = 0; | |
| 29 | + state.closeCalls.length = 0; | |
| 30 | + state.promptResult = undefined; | |
| 31 | + }, | |
| 32 | + }; | |
| 33 | +}); | |
| 34 | + | |
| 35 | +vi.mock("../../provider/opencodeRuntime.ts", async () => { | |
| 36 | + const actual = await vi.importActual<typeof import("../../provider/opencodeRuntime.ts")>( | |
| 37 | + "../../provider/opencodeRuntime.ts", | |
| 38 | + ); | |
| 39 | + | |
| 40 | + return { | |
| 41 | + ...actual, | |
| 42 | + startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { | |
| 43 | + const index = runtimeMock.state.startCalls.length + 1; | |
| 44 | + const url = `http://127.0.0.1:${4_300 + index}`; | |
| 45 | + runtimeMock.state.startCalls.push(binaryPath); | |
| 46 | + return { | |
| 47 | + url, | |
| 48 | + process: {} as ChildProcess, | |
| 49 | + close: () => { | |
| 50 | + runtimeMock.state.closeCalls.push(url); | |
| 51 | + }, | |
| 52 | + }; | |
| 53 | + }), | |
| 54 | + createOpenCodeSdkClient: vi.fn( | |
| 55 | + ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ | |
| 56 | + session: { | |
| 57 | + create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })), | |
| 58 | + prompt: vi.fn(async () => { | |
| 59 | + runtimeMock.state.promptUrls.push(baseUrl); | |
| 60 | + runtimeMock.state.authHeaders.push( | |
| 61 | + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, | |
| 62 | + ); | |
| 63 | + return ( | |
| 64 | + runtimeMock.state.promptResult ?? { | |
| 65 | + data: { | |
| 66 | + info: { | |
| 67 | + structured: { | |
| 68 | + subject: "Improve OpenCode reuse", | |
| 69 | + body: "Reuse one server for the full action.", | |
| 70 | + }, | |
| 71 | + }, | |
| 72 | + }, | |
| 73 | + } | |
| 74 | + ); | |
| 75 | + }), | |
| 76 | + }, | |
| 77 | + }), | |
| 78 | + ), | |
| 79 | + }; | |
| 80 | +}); | |
| 81 | + | |
| 82 | +const DEFAULT_TEST_MODEL_SELECTION = { | |
| 83 | + provider: "opencode" as const, | |
| 84 | + model: "openai/gpt-5", | |
| 85 | +}; | |
| 86 | + | |
| 87 | +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | |
| 88 | + | |
| 89 | +const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( | |
| 90 | + Layer.provideMerge( | |
| 91 | + ServerSettingsService.layerTest({ | |
| 92 | + providers: { | |
| 93 | + opencode: { | |
| 94 | + binaryPath: "fake-opencode", | |
| 95 | + }, | |
| 96 | + }, | |
| 97 | + }), | |
| 98 | + ), | |
| 99 | + Layer.provideMerge( | |
| 100 | + ServerConfig.layerTest(process.cwd(), { | |
| 101 | + prefix: "t3code-opencode-text-generation-test-", | |
| 102 | + }), | |
| 103 | + ), | |
| 104 | + Layer.provideMerge(NodeServices.layer), | |
| 105 | +); | |
| 106 | + | |
| 107 | +const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe( | |
| 108 | + Layer.provideMerge( | |
| 109 | + ServerSettingsService.layerTest({ | |
| 110 | + providers: { | |
| 111 | + opencode: { | |
| 112 | + binaryPath: "fake-opencode", | |
| 113 | + serverUrl: "http://127.0.0.1:9999", | |
| 114 | + serverPassword: "secret-password", | |
| 115 | + }, | |
| 116 | + }, | |
| 117 | + }), | |
| 118 | + ), | |
| 119 | + Layer.provideMerge( | |
| 120 | + ServerConfig.layerTest(process.cwd(), { | |
| 121 | + prefix: "t3code-opencode-text-generation-existing-server-test-", | |
| 122 | + }), | |
| 123 | + ), | |
| 124 | + Layer.provideMerge(NodeServices.layer), | |
| 125 | +); | |
| 126 | + | |
| 127 | +beforeEach(() => { | |
| 128 | + runtimeMock.reset(); | |
| 129 | +}); | |
| 130 | + | |
| 131 | +const advanceIdleClock = Effect.gen(function* () { | |
| 132 | + yield* Effect.yieldNow; | |
| 133 | + yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); | |
| 134 | + yield* Effect.yieldNow; | |
| 135 | +}); | |
| 136 | + | |
| 137 | +it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { | |
| 138 | + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => | |
| 139 | + Effect.gen(function* () { | |
| 140 | + const textGeneration = yield* TextGeneration; | |
| 141 | + | |
| 142 | + yield* textGeneration.generateCommitMessage({ | |
| 143 | + cwd: process.cwd(), | |
| 144 | + branch: "feature/opencode-reuse", | |
| 145 | + stagedSummary: "M README.md", | |
| 146 | + stagedPatch: "diff --git a/README.md b/README.md", | |
| 147 | + modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 148 | + }); | |
| 149 | + yield* textGeneration.generateCommitMessage({ | |
| 150 | + cwd: process.cwd(), | |
| 151 | + branch: "feature/opencode-reuse", | |
| 152 | + stagedSummary: "M README.md", | |
| 153 | + stagedPatch: "diff --git a/README.md b/README.md", | |
| 154 | + modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 155 | + }); | |
| 156 | + | |
| 157 | + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); | |
| 158 | + expect(runtimeMock.state.promptUrls).toEqual([ | |
| 159 | + "http://127.0.0.1:4301", | |
| 160 | + "http://127.0.0.1:4301", | |
| 161 | + ]); | |
| 162 | + expect(runtimeMock.state.closeCalls).toEqual([]); | |
| 163 | + | |
| 164 | + yield* advanceIdleClock; | |
| 165 | + | |
| 166 | + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | |
| 167 | + }).pipe(Effect.provide(TestClock.layer())), | |
| 168 | + ); | |
| 169 | + | |
| 170 | + it.effect("starts a new server after the warm server idles out", () => | |
| 171 | + Effect.gen(function* () { | |
| 172 | + const textGeneration = yield* TextGeneration; | |
| 173 | + | |
| 174 | + yield* textGeneration.generateCommitMessage({ | |
| 175 | + cwd: process.cwd(), | |
| 176 | + branch: "feature/opencode-reuse", | |
| 177 | + stagedSummary: "M README.md", | |
| 178 | + stagedPatch: "diff --git a/README.md b/README.md", | |
| 179 | + modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 180 | + }); | |
| 181 | + | |
| 182 | + yield* advanceIdleClock; | |
| 183 | + | |
| 184 | + yield* textGeneration.generateCommitMessage({ | |
| 185 | + cwd: process.cwd(), | |
| 186 | + branch: "feature/opencode-reuse", | |
| 187 | + stagedSummary: "M README.md", | |
| 188 | + stagedPatch: "diff --git a/README.md b/README.md", | |
| 189 | + modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 190 | + }); | |
| 191 | + | |
| 192 | + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); | |
| 193 | + expect(runtimeMock.state.promptUrls).toEqual([ | |
| 194 | + "http://127.0.0.1:4301", | |
| 195 | + "http://127.0.0.1:4302", | |
| 196 | + ]); | |
| 197 | + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | |
| 198 | + }).pipe(Effect.provide(TestClock.layer())), | |
| 199 | + ); | |
| 200 | + | |
| 201 | + it.effect("returns a typed missing-output error when OpenCode omits info.structured", () => | |
| 202 | + Effect.gen(function* () { | |
| 203 | + runtimeMock.state.promptResult = { data: {} }; | |
| 204 | + const textGeneration = yield* TextGeneration; | |
| 205 | + | |
| 206 | + const error = yield* textGeneration | |
| 207 | + .generateCommitMessage({ | |
| 208 | + cwd: process.cwd(), | |
| 209 | + branch: "feature/opencode-reuse", | |
| 210 | + stagedSummary: "M README.md", | |
| 211 | + stagedPatch: "diff --git a/README.md b/README.md", | |
| 212 | + modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 213 | + }) | |
| 214 | + .pipe(Effect.flip); | |
| 215 | + | |
| 216 | + expect(error.message).toContain("OpenCode returned no structured output."); | |
| 217 | + }), | |
| 218 | + ); | |
| 219 | +}); | |
| 220 | + | |
| 221 | +it.layer(OpenCodeTextGenerationExistingServerTestLayer)( | |
| 222 | + "OpenCodeTextGenerationLive with configured server URL", | |
| 223 | + (it) => { | |
| 224 | + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => | |
| 225 | + Effect.gen(function* () { | |
| 226 | + const textGeneration = yield* TextGeneration; | |
| 227 | + | |
| 228 | + yield* textGeneration.generateCommitMessage({ | |
| 229 | + cwd: process.cwd(), | |
| 230 | + branch: "feature/opencode-reuse", | |
| 231 | + stagedSummary: "M README.md", | |
| 232 | + stagedPatch: "diff --git a/README.md b/README.md", | |
| 233 | + modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 234 | + }); | |
| 235 | + yield* textGeneration.generateCommitMessage({ | |
| 236 | + cwd: process.cwd(), | |
| 237 | + branch: "feature/opencode-reuse", | |
| 238 | + stagedSummary: "M README.md", | |
| 239 | + stagedPatch: "diff --git a/README.md b/README.md", | |
| 240 | + modelSelection: DEFAULT_TEST_MODEL_SELECTION, | |
| 241 | + }); | |
| 242 | + | |
| 243 | + expect(runtimeMock.state.startCalls).toEqual([]); | |
| 244 | + expect(runtimeMock.state.promptUrls).toEqual([ | |
| 245 | + "http://127.0.0.1:9999", | |
| 246 | + "http://127.0.0.1:9999", | |
| 247 | + ]); | |
| 248 | + expect(runtimeMock.state.authHeaders).toEqual([ | |
| 249 | + `Basic ${btoa("opencode:secret-password")}`, | |
| 250 | + `Basic ${btoa("opencode:secret-password")}`, | |
| 251 | + ]); | |
| 252 | + | |
| 253 | + yield* advanceIdleClock; | |
| 254 | + | |
| 255 | + expect(runtimeMock.state.closeCalls).toEqual([]); | |
| 256 | + }).pipe(Effect.provide(TestClock.layer())), | |
| 257 | + ); | |
| 258 | + }, | |
| 259 | +); | |
| @@ -0,0 +1,422 @@ | ||
| 1 | +import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; | |
| 2 | +import * as Semaphore from "effect/Semaphore"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + TextGenerationError, | |
| 6 | + type ChatAttachment, | |
| 7 | + type OpenCodeModelSelection, | |
| 8 | +} from "@t3tools/contracts"; | |
| 9 | +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | |
| 10 | + | |
| 11 | +import { ServerConfig } from "../../config.ts"; | |
| 12 | +import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 13 | +import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 14 | +import { | |
| 15 | + buildBranchNamePrompt, | |
| 16 | + buildCommitMessagePrompt, | |
| 17 | + buildPrContentPrompt, | |
| 18 | + buildThreadTitlePrompt, | |
| 19 | +} from "../Prompts.ts"; | |
| 20 | +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; | |
| 21 | +import { | |
| 22 | + sanitizeCommitSubject, | |
| 23 | + sanitizePrTitle, | |
| 24 | + sanitizeThreadTitle, | |
| 25 | + toJsonSchemaObject, | |
| 26 | +} from "../Utils.ts"; | |
| 27 | +import { | |
| 28 | + createOpenCodeSdkClient, | |
| 29 | + type OpenCodeServerConnection, | |
| 30 | + type OpenCodeServerProcess, | |
| 31 | + parseOpenCodeModelSlug, | |
| 32 | + startOpenCodeServerProcess, | |
| 33 | + toOpenCodeFileParts, | |
| 34 | +} from "../../provider/opencodeRuntime.ts"; | |
| 35 | + | |
| 36 | +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | |
| 37 | + | |
| 38 | +interface SharedOpenCodeTextGenerationServerState { | |
| 39 | + server: OpenCodeServerProcess | null; | |
| 40 | + binaryPath: string | null; | |
| 41 | + activeRequests: number; | |
| 42 | + idleCloseFiber: Fiber.Fiber<void, never> | null; | |
| 43 | +} | |
| 44 | + | |
| 45 | +const makeOpenCodeTextGeneration = Effect.gen(function* () { | |
| 46 | + const serverConfig = yield* ServerConfig; | |
| 47 | + const serverSettingsService = yield* ServerSettingsService; | |
| 48 | + const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | |
| 49 | + Scope.close(scope, Exit.void), | |
| 50 | + ); | |
| 51 | + const sharedServerMutex = yield* Semaphore.make(1); | |
| 52 | + const sharedServerState: SharedOpenCodeTextGenerationServerState = { | |
| 53 | + server: null, | |
| 54 | + binaryPath: null, | |
| 55 | + activeRequests: 0, | |
| 56 | + idleCloseFiber: null, | |
| 57 | + }; | |
| 58 | + | |
| 59 | + const closeSharedServer = (server: OpenCodeServerProcess) => { | |
| 60 | + if (sharedServerState.server === server) { | |
| 61 | + sharedServerState.server = null; | |
| 62 | + sharedServerState.binaryPath = null; | |
| 63 | + } | |
| 64 | + server.close(); | |
| 65 | + }; | |
| 66 | + | |
| 67 | + const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { | |
| 68 | + const idleCloseFiber = sharedServerState.idleCloseFiber; | |
| 69 | + sharedServerState.idleCloseFiber = null; | |
| 70 | + if (idleCloseFiber !== null) { | |
| 71 | + yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); | |
| 72 | + } | |
| 73 | + }); | |
| 74 | + | |
| 75 | + const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( | |
| 76 | + server: OpenCodeServerProcess, | |
| 77 | + ) { | |
| 78 | + yield* cancelIdleCloseFiber(); | |
| 79 | + const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe( | |
| 80 | + Effect.andThen( | |
| 81 | + sharedServerMutex.withPermit( | |
| 82 | + Effect.sync(() => { | |
| 83 | + if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { | |
| 84 | + return; | |
| 85 | + } | |
| 86 | + sharedServerState.idleCloseFiber = null; | |
| 87 | + closeSharedServer(server); | |
| 88 | + }), | |
| 89 | + ), | |
| 90 | + ), | |
| 91 | + Effect.forkIn(idleFiberScope), | |
| 92 | + ); | |
| 93 | + sharedServerState.idleCloseFiber = fiber; | |
| 94 | + }); | |
| 95 | + | |
| 96 | + const acquireSharedServer = (input: { | |
| 97 | + readonly binaryPath: string; | |
| 98 | + readonly operation: | |
| 99 | + | "generateCommitMessage" | |
| 100 | + | "generatePrContent" | |
| 101 | + | "generateBranchName" | |
| 102 | + | "generateThreadTitle"; | |
| 103 | + }) => | |
| 104 | + sharedServerMutex.withPermit( | |
| 105 | + Effect.gen(function* () { | |
| 106 | + yield* cancelIdleCloseFiber(); | |
| 107 | + | |
| 108 | + const existingServer = sharedServerState.server; | |
| 109 | + if (existingServer !== null) { | |
| 110 | + if ( | |
| 111 | + sharedServerState.binaryPath !== input.binaryPath && | |
| 112 | + sharedServerState.activeRequests === 0 | |
| 113 | + ) { | |
| 114 | + closeSharedServer(existingServer); | |
| 115 | + } else { | |
| 116 | + if (sharedServerState.binaryPath !== input.binaryPath) { | |
| 117 | + yield* Effect.logWarning( | |
| 118 | + "OpenCode shared server binary path mismatch: requested " + | |
| 119 | + input.binaryPath + | |
| 120 | + " but active server uses " + | |
| 121 | + sharedServerState.binaryPath + | |
| 122 | + "; reusing existing server because there are active requests", | |
| 123 | + ); | |
| 124 | + } | |
| 125 | + sharedServerState.activeRequests += 1; | |
| 126 | + return existingServer; | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + const server = yield* Effect.tryPromise({ | |
| 131 | + try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }), | |
| 132 | + catch: (cause) => | |
| 133 | + new TextGenerationError({ | |
| 134 | + operation: input.operation, | |
| 135 | + detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.", | |
| 136 | + cause, | |
| 137 | + }), | |
| 138 | + }); | |
| 139 | + | |
| 140 | + sharedServerState.server = server; | |
| 141 | + sharedServerState.binaryPath = input.binaryPath; | |
| 142 | + sharedServerState.activeRequests = 1; | |
| 143 | + return server; | |
| 144 | + }), | |
| 145 | + ); | |
| 146 | + | |
| 147 | + const releaseSharedServer = (server: OpenCodeServerProcess) => | |
| 148 | + sharedServerMutex.withPermit( | |
| 149 | + Effect.gen(function* () { | |
| 150 | + if (sharedServerState.server !== server) { | |
| 151 | + return; | |
| 152 | + } | |
| 153 | + sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); | |
| 154 | + if (sharedServerState.activeRequests === 0) { | |
| 155 | + yield* scheduleIdleClose(server); | |
| 156 | + } | |
| 157 | + }), | |
| 158 | + ); | |
| 159 | + | |
| 160 | + yield* Effect.addFinalizer(() => | |
| 161 | + sharedServerMutex.withPermit( | |
| 162 | + Effect.gen(function* () { | |
| 163 | + yield* cancelIdleCloseFiber(); | |
| 164 | + const server = sharedServerState.server; | |
| 165 | + sharedServerState.server = null; | |
| 166 | + sharedServerState.binaryPath = null; | |
| 167 | + sharedServerState.activeRequests = 0; | |
| 168 | + if (server !== null) { | |
| 169 | + server.close(); | |
| 170 | + } | |
| 171 | + }), | |
| 172 | + ), | |
| 173 | + ); | |
| 174 | + | |
| 175 | + const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: { | |
| 176 | + readonly operation: | |
| 177 | + | "generateCommitMessage" | |
| 178 | + | "generatePrContent" | |
| 179 | + | "generateBranchName" | |
| 180 | + | "generateThreadTitle"; | |
| 181 | + readonly cwd: string; | |
| 182 | + readonly prompt: string; | |
| 183 | + readonly outputSchemaJson: S; | |
| 184 | + readonly modelSelection: OpenCodeModelSelection; | |
| 185 | + readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | |
| 186 | + }) { | |
| 187 | + const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); | |
| 188 | + if (!parsedModel) { | |
| 189 | + return yield* new TextGenerationError({ | |
| 190 | + operation: input.operation, | |
| 191 | + detail: "OpenCode model selection must use the 'provider/model' format.", | |
| 192 | + }); | |
| 193 | + } | |
| 194 | + | |
| 195 | + const settings = yield* serverSettingsService.getSettings.pipe( | |
| 196 | + Effect.map( | |
| 197 | + (value) => | |
| 198 | + value.providers?.opencode ?? { | |
| 199 | + enabled: true, | |
| 200 | + binaryPath: "opencode", | |
| 201 | + serverUrl: "", | |
| 202 | + serverPassword: "", | |
| 203 | + customModels: [], | |
| 204 | + }, | |
| 205 | + ), | |
| 206 | + Effect.orElseSucceed(() => ({ | |
| 207 | + enabled: true, | |
| 208 | + binaryPath: "opencode", | |
| 209 | + serverUrl: "", | |
| 210 | + serverPassword: "", | |
| 211 | + customModels: [], | |
| 212 | + })), | |
| 213 | + ); | |
| 214 | + | |
| 215 | + const fileParts = toOpenCodeFileParts({ | |
| 216 | + attachments: input.attachments, | |
| 217 | + resolveAttachmentPath: (attachment) => | |
| 218 | + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | |
| 219 | + }); | |
| 220 | + | |
| 221 | + const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) => | |
| 222 | + Effect.tryPromise({ | |
| 223 | + try: async () => { | |
| 224 | + const client = createOpenCodeSdkClient({ | |
| 225 | + baseUrl: server.url, | |
| 226 | + directory: input.cwd, | |
| 227 | + ...(settings.serverUrl.length > 0 && settings.serverPassword | |
| 228 | + ? { serverPassword: settings.serverPassword } | |
| 229 | + : {}), | |
| 230 | + }); | |
| 231 | + const session = await client.session.create({ | |
| 232 | + title: `T3 Code ${input.operation}`, | |
| 233 | + permission: [{ permission: "*", pattern: "*", action: "deny" }], | |
| 234 | + }); | |
| 235 | + if (!session.data) { | |
| 236 | + throw new Error("OpenCode session.create returned no session payload."); | |
| 237 | + } | |
| 238 | + | |
| 239 | + const result = await client.session.prompt({ | |
| 240 | + sessionID: session.data.id, | |
| 241 | + model: parsedModel, | |
| 242 | + ...(input.modelSelection.options?.agent | |
| 243 | + ? { agent: input.modelSelection.options.agent } | |
| 244 | + : {}), | |
| 245 | + ...(input.modelSelection.options?.variant | |
| 246 | + ? { variant: input.modelSelection.options.variant } | |
| 247 | + : {}), | |
| 248 | + format: { | |
| 249 | + type: "json_schema", | |
| 250 | + schema: toJsonSchemaObject(input.outputSchemaJson) as Record<string, unknown>, | |
| 251 | + }, | |
| 252 | + parts: [{ type: "text", text: input.prompt }, ...fileParts], | |
| 253 | + }); | |
| 254 | + const structured = result.data?.info?.structured; | |
| 255 | + if (structured === undefined) { | |
| 256 | + throw new Error("OpenCode returned no structured output."); | |
| 257 | + } | |
| 258 | + return structured; | |
| 259 | + }, | |
| 260 | + catch: (cause) => | |
| 261 | + new TextGenerationError({ | |
| 262 | + operation: input.operation, | |
| 263 | + detail: | |
| 264 | + cause instanceof Error ? cause.message : "OpenCode text generation request failed.", | |
| 265 | + cause, | |
| 266 | + }), | |
| 267 | + }); | |
| 268 | + | |
| 269 | + const structuredOutput = | |
| 270 | + settings.serverUrl.length > 0 | |
| 271 | + ? yield* runAgainstServer({ url: settings.serverUrl }) | |
| 272 | + : yield* Effect.acquireUseRelease( | |
| 273 | + acquireSharedServer({ | |
| 274 | + binaryPath: settings.binaryPath, | |
| 275 | + operation: input.operation, | |
| 276 | + }), | |
| 277 | + runAgainstServer, | |
| 278 | + releaseSharedServer, | |
| 279 | + ); | |
| 280 | + | |
| 281 | + return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( | |
| 282 | + Effect.catchTag("SchemaError", (cause) => | |
| 283 | + Effect.fail( | |
| 284 | + new TextGenerationError({ | |
| 285 | + operation: input.operation, | |
| 286 | + detail: "OpenCode returned invalid structured output.", | |
| 287 | + cause, | |
| 288 | + }), | |
| 289 | + ), | |
| 290 | + ), | |
| 291 | + ); | |
| 292 | + }); | |
| 293 | + | |
| 294 | + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | |
| 295 | + "OpenCodeTextGeneration.generateCommitMessage", | |
| 296 | + )(function* (input) { | |
| 297 | + if (input.modelSelection.provider !== "opencode") { | |
| 298 | + return yield* new TextGenerationError({ | |
| 299 | + operation: "generateCommitMessage", | |
| 300 | + detail: "Invalid model selection.", | |
| 301 | + }); | |
| 302 | + } | |
| 303 | + | |
| 304 | + const { prompt, outputSchema } = buildCommitMessagePrompt({ | |
| 305 | + branch: input.branch, | |
| 306 | + stagedSummary: input.stagedSummary, | |
| 307 | + stagedPatch: input.stagedPatch, | |
| 308 | + includeBranch: input.includeBranch === true, | |
| 309 | + }); | |
| 310 | + const generated = yield* runOpenCodeJson({ | |
| 311 | + operation: "generateCommitMessage", | |
| 312 | + cwd: input.cwd, | |
| 313 | + prompt, | |
| 314 | + outputSchemaJson: outputSchema, | |
| 315 | + modelSelection: input.modelSelection, | |
| 316 | + }); | |
| 317 | + | |
| 318 | + return { | |
| 319 | + subject: sanitizeCommitSubject(generated.subject), | |
| 320 | + body: generated.body.trim(), | |
| 321 | + ...("branch" in generated && typeof generated.branch === "string" | |
| 322 | + ? { branch: sanitizeFeatureBranchName(generated.branch) } | |
| 323 | + : {}), | |
| 324 | + }; | |
| 325 | + }); | |
| 326 | + | |
| 327 | + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | |
| 328 | + "OpenCodeTextGeneration.generatePrContent", | |
| 329 | + )(function* (input) { | |
| 330 | + if (input.modelSelection.provider !== "opencode") { | |
| 331 | + return yield* new TextGenerationError({ | |
| 332 | + operation: "generatePrContent", | |
| 333 | + detail: "Invalid model selection.", | |
| 334 | + }); | |
| 335 | + } | |
| 336 | + | |
| 337 | + const { prompt, outputSchema } = buildPrContentPrompt({ | |
| 338 | + baseBranch: input.baseBranch, | |
| 339 | + headBranch: input.headBranch, | |
| 340 | + commitSummary: input.commitSummary, | |
| 341 | + diffSummary: input.diffSummary, | |
| 342 | + diffPatch: input.diffPatch, | |
| 343 | + }); | |
| 344 | + const generated = yield* runOpenCodeJson({ | |
| 345 | + operation: "generatePrContent", | |
| 346 | + cwd: input.cwd, | |
| 347 | + prompt, | |
| 348 | + outputSchemaJson: outputSchema, | |
| 349 | + modelSelection: input.modelSelection, | |
| 350 | + }); | |
| 351 | + | |
| 352 | + return { | |
| 353 | + title: sanitizePrTitle(generated.title), | |
| 354 | + body: generated.body.trim(), | |
| 355 | + }; | |
| 356 | + }); | |
| 357 | + | |
| 358 | + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | |
| 359 | + "OpenCodeTextGeneration.generateBranchName", | |
| 360 | + )(function* (input) { | |
| 361 | + if (input.modelSelection.provider !== "opencode") { | |
| 362 | + return yield* new TextGenerationError({ | |
| 363 | + operation: "generateBranchName", | |
| 364 | + detail: "Invalid model selection.", | |
| 365 | + }); | |
| 366 | + } | |
| 367 | + | |
| 368 | + const { prompt, outputSchema } = buildBranchNamePrompt({ | |
| 369 | + message: input.message, | |
| 370 | + attachments: input.attachments, | |
| 371 | + }); | |
| 372 | + const generated = yield* runOpenCodeJson({ | |
| 373 | + operation: "generateBranchName", | |
| 374 | + cwd: input.cwd, | |
| 375 | + prompt, | |
| 376 | + outputSchemaJson: outputSchema, | |
| 377 | + modelSelection: input.modelSelection, | |
| 378 | + attachments: input.attachments, | |
| 379 | + }); | |
| 380 | + | |
| 381 | + return { | |
| 382 | + branch: sanitizeBranchFragment(generated.branch), | |
| 383 | + }; | |
| 384 | + }); | |
| 385 | + | |
| 386 | + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | |
| 387 | + "OpenCodeTextGeneration.generateThreadTitle", | |
| 388 | + )(function* (input) { | |
| 389 | + if (input.modelSelection.provider !== "opencode") { | |
| 390 | + return yield* new TextGenerationError({ | |
| 391 | + operation: "generateThreadTitle", | |
| 392 | + detail: "Invalid model selection.", | |
| 393 | + }); | |
| 394 | + } | |
| 395 | + | |
| 396 | + const { prompt, outputSchema } = buildThreadTitlePrompt({ | |
| 397 | + message: input.message, | |
| 398 | + attachments: input.attachments, | |
| 399 | + }); | |
| 400 | + const generated = yield* runOpenCodeJson({ | |
| 401 | + operation: "generateThreadTitle", | |
| 402 | + cwd: input.cwd, | |
| 403 | + prompt, | |
| 404 | + outputSchemaJson: outputSchema, | |
| 405 | + modelSelection: input.modelSelection, | |
| 406 | + attachments: input.attachments, | |
| 407 | + }); | |
| 408 | + | |
| 409 | + return { | |
| 410 | + title: sanitizeThreadTitle(generated.title), | |
| 411 | + }; | |
| 412 | + }); | |
| 413 | + | |
| 414 | + return { | |
| 415 | + generateCommitMessage, | |
| 416 | + generatePrContent, | |
| 417 | + generateBranchName, | |
| 418 | + generateThreadTitle, | |
| 419 | + } satisfies TextGenerationShape; | |
| 420 | +}); | |
| 421 | + | |
| 422 | +export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration); | |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | } from "../Services/TextGeneration.ts"; |
| 19 | 19 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; |
| 20 | 20 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; |
| 21 | +import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; | |
| 21 | 22 | |
| 22 | 23 | // --------------------------------------------------------------------------- |
| 23 | 24 | // Internal service tags so both concrete layers can coexist. |
| @@ -31,6 +32,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( · 7 unmodified lines | ||
| 31 | 32 | "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", |
| 32 | 33 | ) {} |
| 33 | 34 | |
| 35 | +class OpenCodeTextGen extends Context.Service<OpenCodeTextGen, TextGenerationShape>()( | |
| 36 | + "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", | |
| 37 | +) {} | |
| 38 | + | |
| 34 | 39 | // --------------------------------------------------------------------------- |
| 35 | 40 | // Routing implementation |
| 36 | 41 | // --------------------------------------------------------------------------- |
| @@ -38,9 +43,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( · 1 unmodified line | ||
| 38 | 43 | const makeRoutingTextGeneration = Effect.gen(function* () { |
| 39 | 44 | const codex = yield* CodexTextGen; |
| 40 | 45 | const claude = yield* ClaudeTextGen; |
| 46 | + const openCode = yield* OpenCodeTextGen; | |
| 41 | 47 | |
| 42 | 48 | const route = (provider?: TextGenerationProvider): TextGenerationShape => |
| 43 | − provider === "claudeAgent" ? claude : codex; | |
| 49 | + provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex; | |
| 44 | 50 | |
| 45 | 51 | return { |
| 46 | 52 | generateCommitMessage: (input) => |
| @@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect( · 20 unmodified lines | ||
| 67 | 73 | }), |
| 68 | 74 | ).pipe(Layer.provide(ClaudeTextGenerationLive)); |
| 69 | 75 | |
| 76 | +const InternalOpenCodeLayer = Layer.effect( | |
| 77 | + OpenCodeTextGen, | |
| 78 | + Effect.gen(function* () { | |
| 79 | + const svc = yield* TextGeneration; | |
| 80 | + return svc; | |
| 81 | + }), | |
| 82 | +).pipe(Layer.provide(OpenCodeTextGenerationLive)); | |
| 83 | + | |
| 70 | 84 | export const RoutingTextGenerationLive = Layer.effect( |
| 71 | 85 | TextGeneration, |
| 72 | 86 | makeRoutingTextGeneration, |
| 73 | −).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); | |
| 87 | +).pipe( | |
| 88 | + Layer.provide(InternalCodexLayer), | |
| 89 | + Layer.provide(InternalClaudeLayer), | |
| 90 | + Layer.provide(InternalOpenCodeLayer), | |
| 91 | +); | |
| @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; · 12 unmodified lines | ||
| 13 | 13 | import type { TextGenerationError } from "@t3tools/contracts"; |
| 14 | 14 | |
| 15 | 15 | /** Providers that support git text generation (commit messages, PR content, branch names). */ |
| 16 | −export type TextGenerationProvider = "codex" | "claudeAgent"; | |
| 16 | +export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode"; | |
| 17 | 17 | |
| 18 | 18 | export interface CommitMessageGenerationInput { |
| 19 | 19 | cwd: string; |
| @@ -0,0 +1,486 @@ | ||
| 1 | +import assert from "node:assert/strict"; | |
| 2 | + | |
| 3 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | +import { it } from "@effect/vitest"; | |
| 5 | +import { Effect, Layer, Option } from "effect"; | |
| 6 | +import { beforeEach, vi } from "vitest"; | |
| 7 | + | |
| 8 | +import { ThreadId } from "@t3tools/contracts"; | |
| 9 | +import { ServerConfig } from "../../config.ts"; | |
| 10 | +import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 11 | +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | |
| 12 | +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; | |
| 13 | +import { | |
| 14 | + appendOpenCodeAssistantTextDelta, | |
| 15 | + makeOpenCodeAdapterLive, | |
| 16 | + mergeOpenCodeAssistantText, | |
| 17 | +} from "./OpenCodeAdapter.ts"; | |
| 18 | + | |
| 19 | +const asThreadId = (value: string): ThreadId => ThreadId.make(value); | |
| 20 | + | |
| 21 | +const runtimeMock = vi.hoisted(() => { | |
| 22 | + type MessageEntry = { | |
| 23 | + info: { | |
| 24 | + id: string; | |
| 25 | + role: "user" | "assistant"; | |
| 26 | + }; | |
| 27 | + parts: Array<unknown>; | |
| 28 | + }; | |
| 29 | + | |
| 30 | + const state = { | |
| 31 | + startCalls: [] as string[], | |
| 32 | + sessionCreateUrls: [] as string[], | |
| 33 | + authHeaders: [] as Array<string | null>, | |
| 34 | + abortCalls: [] as string[], | |
| 35 | + closeCalls: [] as string[], | |
| 36 | + revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, | |
| 37 | + promptAsyncError: null as Error | null, | |
| 38 | + closeError: null as Error | null, | |
| 39 | + messages: [] as MessageEntry[], | |
| 40 | + subscribedEvents: [] as unknown[], | |
| 41 | + }; | |
| 42 | + | |
| 43 | + return { | |
| 44 | + state, | |
| 45 | + reset() { | |
| 46 | + state.startCalls.length = 0; | |
| 47 | + state.sessionCreateUrls.length = 0; | |
| 48 | + state.authHeaders.length = 0; | |
| 49 | + state.abortCalls.length = 0; | |
| 50 | + state.closeCalls.length = 0; | |
| 51 | + state.revertCalls.length = 0; | |
| 52 | + state.promptAsyncError = null; | |
| 53 | + state.closeError = null; | |
| 54 | + state.messages = []; | |
| 55 | + state.subscribedEvents = []; | |
| 56 | + }, | |
| 57 | + }; | |
| 58 | +}); | |
| 59 | + | |
| 60 | +vi.mock("../opencodeRuntime.ts", async () => { | |
| 61 | + const actual = | |
| 62 | + await vi.importActual<typeof import("../opencodeRuntime.ts")>("../opencodeRuntime.ts"); | |
| 63 | + | |
| 64 | + return { | |
| 65 | + ...actual, | |
| 66 | + startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { | |
| 67 | + runtimeMock.state.startCalls.push(binaryPath); | |
| 68 | + return { | |
| 69 | + url: "http://127.0.0.1:4301", | |
| 70 | + process: { | |
| 71 | + once() {}, | |
| 72 | + }, | |
| 73 | + close() {}, | |
| 74 | + }; | |
| 75 | + }), | |
| 76 | + connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ | |
| 77 | + url: serverUrl ?? "http://127.0.0.1:4301", | |
| 78 | + process: null, | |
| 79 | + external: Boolean(serverUrl), | |
| 80 | + close() { | |
| 81 | + runtimeMock.state.closeCalls.push(serverUrl ?? "http://127.0.0.1:4301"); | |
| 82 | + if (runtimeMock.state.closeError) { | |
| 83 | + throw runtimeMock.state.closeError; | |
| 84 | + } | |
| 85 | + }, | |
| 86 | + })), | |
| 87 | + createOpenCodeSdkClient: vi.fn( | |
| 88 | + ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ | |
| 89 | + session: { | |
| 90 | + create: vi.fn(async () => { | |
| 91 | + runtimeMock.state.sessionCreateUrls.push(baseUrl); | |
| 92 | + runtimeMock.state.authHeaders.push( | |
| 93 | + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, | |
| 94 | + ); | |
| 95 | + return { data: { id: `${baseUrl}/session` } }; | |
| 96 | + }), | |
| 97 | + abort: vi.fn(async ({ sessionID }: { sessionID: string }) => { | |
| 98 | + runtimeMock.state.abortCalls.push(sessionID); | |
| 99 | + }), | |
| 100 | + promptAsync: vi.fn(async () => { | |
| 101 | + if (runtimeMock.state.promptAsyncError) { | |
| 102 | + throw runtimeMock.state.promptAsyncError; | |
| 103 | + } | |
| 104 | + }), | |
| 105 | + messages: vi.fn(async () => ({ data: runtimeMock.state.messages })), | |
| 106 | + revert: vi.fn( | |
| 107 | + async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { | |
| 108 | + runtimeMock.state.revertCalls.push({ | |
| 109 | + sessionID, | |
| 110 | + ...(messageID ? { messageID } : {}), | |
| 111 | + }); | |
| 112 | + if (!messageID) { | |
| 113 | + runtimeMock.state.messages = []; | |
| 114 | + return; | |
| 115 | + } | |
| 116 | + | |
| 117 | + const targetIndex = runtimeMock.state.messages.findIndex( | |
| 118 | + (entry) => entry.info.id === messageID, | |
| 119 | + ); | |
| 120 | + runtimeMock.state.messages = | |
| 121 | + targetIndex >= 0 | |
| 122 | + ? runtimeMock.state.messages.slice(0, targetIndex + 1) | |
| 123 | + : runtimeMock.state.messages; | |
| 124 | + }, | |
| 125 | + ), | |
| 126 | + }, | |
| 127 | + event: { | |
| 128 | + subscribe: vi.fn(async () => ({ | |
| 129 | + stream: (async function* () { | |
| 130 | + for (const event of runtimeMock.state.subscribedEvents) { | |
| 131 | + yield event; | |
| 132 | + } | |
| 133 | + })(), | |
| 134 | + })), | |
| 135 | + }, | |
| 136 | + }), | |
| 137 | + ), | |
| 138 | + }; | |
| 139 | +}); | |
| 140 | + | |
| 141 | +const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { | |
| 142 | + upsert: () => Effect.void, | |
| 143 | + getProvider: () => | |
| 144 | + Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), | |
| 145 | + getBinding: () => Effect.succeed(Option.none()), | |
| 146 | + listThreadIds: () => Effect.succeed([]), | |
| 147 | + listBindings: () => Effect.succeed([]), | |
| 148 | +}); | |
| 149 | + | |
| 150 | +const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe( | |
| 151 | + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | |
| 152 | + Layer.provideMerge( | |
| 153 | + ServerSettingsService.layerTest({ | |
| 154 | + providers: { | |
| 155 | + opencode: { | |
| 156 | + binaryPath: "fake-opencode", | |
| 157 | + serverUrl: "http://127.0.0.1:9999", | |
| 158 | + serverPassword: "secret-password", | |
| 159 | + }, | |
| 160 | + }, | |
| 161 | + }), | |
| 162 | + ), | |
| 163 | + Layer.provideMerge(providerSessionDirectoryTestLayer), | |
| 164 | + Layer.provideMerge(NodeServices.layer), | |
| 165 | +); | |
| 166 | + | |
| 167 | +beforeEach(() => { | |
| 168 | + runtimeMock.reset(); | |
| 169 | +}); | |
| 170 | + | |
| 171 | +const sleep = (ms: number) => | |
| 172 | + Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); | |
| 173 | + | |
| 174 | +it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { | |
| 175 | + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => | |
| 176 | + Effect.gen(function* () { | |
| 177 | + const adapter = yield* OpenCodeAdapter; | |
| 178 | + | |
| 179 | + const session = yield* adapter.startSession({ | |
| 180 | + provider: "opencode", | |
| 181 | + threadId: asThreadId("thread-opencode"), | |
| 182 | + runtimeMode: "full-access", | |
| 183 | + }); | |
| 184 | + | |
| 185 | + assert.equal(session.provider, "opencode"); | |
| 186 | + assert.equal(session.threadId, "thread-opencode"); | |
| 187 | + assert.deepEqual(runtimeMock.state.startCalls, []); | |
| 188 | + assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); | |
| 189 | + assert.deepEqual(runtimeMock.state.authHeaders, [ | |
| 190 | + `Basic ${btoa("opencode:secret-password")}`, | |
| 191 | + ]); | |
| 192 | + }), | |
| 193 | + ); | |
| 194 | + | |
| 195 | + it.effect("stops a configured-server session without trying to own server lifecycle", () => | |
| 196 | + Effect.gen(function* () { | |
| 197 | + const adapter = yield* OpenCodeAdapter; | |
| 198 | + yield* adapter.startSession({ | |
| 199 | + provider: "opencode", | |
| 200 | + threadId: asThreadId("thread-opencode"), | |
| 201 | + runtimeMode: "full-access", | |
| 202 | + }); | |
| 203 | + | |
| 204 | + yield* adapter.stopSession(asThreadId("thread-opencode")); | |
| 205 | + | |
| 206 | + assert.deepEqual(runtimeMock.state.startCalls, []); | |
| 207 | + assert.deepEqual( | |
| 208 | + runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), | |
| 209 | + true, | |
| 210 | + ); | |
| 211 | + }), | |
| 212 | + ); | |
| 213 | + | |
| 214 | + it.effect("clears session state when stopAll cleanup fails", () => | |
| 215 | + Effect.gen(function* () { | |
| 216 | + const adapter = yield* OpenCodeAdapter; | |
| 217 | + yield* adapter.startSession({ | |
| 218 | + provider: "opencode", | |
| 219 | + threadId: asThreadId("thread-stop-all-a"), | |
| 220 | + runtimeMode: "full-access", | |
| 221 | + }); | |
| 222 | + yield* adapter.startSession({ | |
| 223 | + provider: "opencode", | |
| 224 | + threadId: asThreadId("thread-stop-all-b"), | |
| 225 | + runtimeMode: "full-access", | |
| 226 | + }); | |
| 227 | + | |
| 228 | + runtimeMock.state.closeError = new Error("close failed"); | |
| 229 | + const error = yield* adapter.stopAll().pipe(Effect.flip); | |
| 230 | + const sessions = yield* adapter.listSessions(); | |
| 231 | + | |
| 232 | + assert.equal(error._tag, "ProviderAdapterProcessError"); | |
| 233 | + assert.equal(error.detail, "Failed to stop 2 OpenCode sessions."); | |
| 234 | + assert.deepEqual(runtimeMock.state.closeCalls, [ | |
| 235 | + "http://127.0.0.1:9999", | |
| 236 | + "http://127.0.0.1:9999", | |
| 237 | + ]); | |
| 238 | + assert.deepEqual(sessions, []); | |
| 239 | + }), | |
| 240 | + ); | |
| 241 | + | |
| 242 | + it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => | |
| 243 | + Effect.gen(function* () { | |
| 244 | + const adapter = yield* OpenCodeAdapter; | |
| 245 | + yield* adapter.startSession({ | |
| 246 | + provider: "opencode", | |
| 247 | + threadId: asThreadId("thread-send-turn-failure"), | |
| 248 | + runtimeMode: "full-access", | |
| 249 | + }); | |
| 250 | + | |
| 251 | + runtimeMock.state.promptAsyncError = new Error("prompt failed"); | |
| 252 | + const error = yield* adapter | |
| 253 | + .sendTurn({ | |
| 254 | + threadId: asThreadId("thread-send-turn-failure"), | |
| 255 | + input: "Fix it", | |
| 256 | + modelSelection: { | |
| 257 | + provider: "opencode", | |
| 258 | + model: "openai/gpt-5", | |
| 259 | + }, | |
| 260 | + }) | |
| 261 | + .pipe(Effect.flip); | |
| 262 | + const sessions = yield* adapter.listSessions(); | |
| 263 | + | |
| 264 | + assert.equal(error._tag, "ProviderAdapterRequestError"); | |
| 265 | + if (error._tag !== "ProviderAdapterRequestError") { | |
| 266 | + throw new Error("Unexpected error type"); | |
| 267 | + } | |
| 268 | + assert.equal(error.detail, "prompt failed"); | |
| 269 | + assert.equal( | |
| 270 | + error.message, | |
| 271 | + "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", | |
| 272 | + ); | |
| 273 | + assert.equal(sessions.length, 1); | |
| 274 | + assert.equal(sessions[0]?.status, "ready"); | |
| 275 | + assert.equal(sessions[0]?.activeTurnId, undefined); | |
| 276 | + assert.equal(sessions[0]?.lastError, "prompt failed"); | |
| 277 | + }), | |
| 278 | + ); | |
| 279 | + | |
| 280 | + it.effect("reverts the full thread when rollback removes every assistant turn", () => | |
| 281 | + Effect.gen(function* () { | |
| 282 | + const adapter = yield* OpenCodeAdapter; | |
| 283 | + const threadId = asThreadId("thread-rollback-all"); | |
| 284 | + yield* adapter.startSession({ | |
| 285 | + provider: "opencode", | |
| 286 | + threadId, | |
| 287 | + runtimeMode: "full-access", | |
| 288 | + }); | |
| 289 | + | |
| 290 | + runtimeMock.state.messages = [ | |
| 291 | + { | |
| 292 | + info: { id: "assistant-1", role: "assistant" }, | |
| 293 | + parts: [], | |
| 294 | + }, | |
| 295 | + { | |
| 296 | + info: { id: "assistant-2", role: "assistant" }, | |
| 297 | + parts: [], | |
| 298 | + }, | |
| 299 | + ]; | |
| 300 | + | |
| 301 | + const snapshot = yield* adapter.rollbackThread(threadId, 2); | |
| 302 | + | |
| 303 | + assert.deepEqual(runtimeMock.state.revertCalls, [ | |
| 304 | + { sessionID: "http://127.0.0.1:9999/session" }, | |
| 305 | + ]); | |
| 306 | + assert.deepEqual(snapshot.turns, []); | |
| 307 | + }), | |
| 308 | + ); | |
| 309 | + | |
| 310 | + it.effect("deduplicates overlapping assistant text deltas after part updates", () => | |
| 311 | + Effect.sync(() => { | |
| 312 | + const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); | |
| 313 | + const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); | |
| 314 | + const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hello world!"); | |
| 315 | + | |
| 316 | + assert.deepEqual( | |
| 317 | + [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], | |
| 318 | + ["Hello", " world", "!"], | |
| 319 | + ); | |
| 320 | + assert.equal(secondUpdate.latestText, "Hello world!"); | |
| 321 | + }), | |
| 322 | + ); | |
| 323 | + | |
| 324 | + it.effect("writes provider-native observability records using the session thread id", () => | |
| 325 | + Effect.gen(function* () { | |
| 326 | + const nativeEvents: Array<{ | |
| 327 | + readonly event?: { | |
| 328 | + readonly provider?: string; | |
| 329 | + readonly threadId?: string; | |
| 330 | + readonly providerThreadId?: string; | |
| 331 | + readonly type?: string; | |
| 332 | + }; | |
| 333 | + }> = []; | |
| 334 | + const nativeThreadIds: Array<string | null> = []; | |
| 335 | + runtimeMock.state.subscribedEvents = [ | |
| 336 | + { | |
| 337 | + type: "message.updated", | |
| 338 | + properties: { | |
| 339 | + info: { | |
| 340 | + id: "msg-missing-session", | |
| 341 | + role: "assistant", | |
| 342 | + }, | |
| 343 | + }, | |
| 344 | + }, | |
| 345 | + { | |
| 346 | + type: "message.updated", | |
| 347 | + properties: { | |
| 348 | + sessionID: "http://127.0.0.1:9999/other-session", | |
| 349 | + info: { | |
| 350 | + id: "msg-other-session", | |
| 351 | + role: "assistant", | |
| 352 | + }, | |
| 353 | + }, | |
| 354 | + }, | |
| 355 | + { | |
| 356 | + type: "message.updated", | |
| 357 | + properties: { | |
| 358 | + sessionID: "http://127.0.0.1:9999/session", | |
| 359 | + info: { | |
| 360 | + id: "msg-native-log", | |
| 361 | + role: "assistant", | |
| 362 | + }, | |
| 363 | + }, | |
| 364 | + }, | |
| 365 | + ]; | |
| 366 | + | |
| 367 | + const nativeEventLogger = { | |
| 368 | + filePath: "memory://opencode-native-events", | |
| 369 | + write: (event: unknown, threadId: ThreadId | null) => { | |
| 370 | + nativeEvents.push(event as (typeof nativeEvents)[number]); | |
| 371 | + nativeThreadIds.push(threadId ?? null); | |
| 372 | + return Effect.void; | |
| 373 | + }, | |
| 374 | + close: () => Effect.void, | |
| 375 | + }; | |
| 376 | + | |
| 377 | + const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( | |
| 378 | + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | |
| 379 | + Layer.provideMerge( | |
| 380 | + ServerSettingsService.layerTest({ | |
| 381 | + providers: { | |
| 382 | + opencode: { | |
| 383 | + binaryPath: "fake-opencode", | |
| 384 | + serverUrl: "http://127.0.0.1:9999", | |
| 385 | + serverPassword: "secret-password", | |
| 386 | + }, | |
| 387 | + }, | |
| 388 | + }), | |
| 389 | + ), | |
| 390 | + Layer.provideMerge(providerSessionDirectoryTestLayer), | |
| 391 | + Layer.provideMerge(NodeServices.layer), | |
| 392 | + ); | |
| 393 | + | |
| 394 | + const session = yield* Effect.gen(function* () { | |
| 395 | + const adapter = yield* OpenCodeAdapter; | |
| 396 | + const started = yield* adapter.startSession({ | |
| 397 | + provider: "opencode", | |
| 398 | + threadId: asThreadId("thread-native-log"), | |
| 399 | + runtimeMode: "full-access", | |
| 400 | + }); | |
| 401 | + yield* sleep(10); | |
| 402 | + return started; | |
| 403 | + }).pipe(Effect.provide(adapterLayer)); | |
| 404 | + | |
| 405 | + assert.equal(session.threadId, "thread-native-log"); | |
| 406 | + assert.equal(nativeEvents.length, 1); | |
| 407 | + assert.equal( | |
| 408 | + nativeEvents.some((record) => record.event?.provider === "opencode"), | |
| 409 | + true, | |
| 410 | + ); | |
| 411 | + assert.equal( | |
| 412 | + nativeEvents.some( | |
| 413 | + (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session", | |
| 414 | + ), | |
| 415 | + true, | |
| 416 | + ); | |
| 417 | + assert.equal( | |
| 418 | + nativeEvents.some((record) => record.event?.threadId === "thread-native-log"), | |
| 419 | + true, | |
| 420 | + ); | |
| 421 | + assert.equal( | |
| 422 | + nativeEvents.some((record) => record.event?.type === "message.updated"), | |
| 423 | + true, | |
| 424 | + ); | |
| 425 | + assert.equal( | |
| 426 | + nativeThreadIds.every((threadId) => threadId === "thread-native-log"), | |
| 427 | + true, | |
| 428 | + ); | |
| 429 | + }), | |
| 430 | + ); | |
| 431 | + | |
| 432 | + it.effect("keeps the event pump alive when native event logging fails", () => | |
| 433 | + Effect.gen(function* () { | |
| 434 | + runtimeMock.state.subscribedEvents = [ | |
| 435 | + { | |
| 436 | + type: "message.updated", | |
| 437 | + properties: { | |
| 438 | + sessionID: "http://127.0.0.1:9999/session", | |
| 439 | + info: { | |
| 440 | + id: "msg-native-log-failure", | |
| 441 | + role: "assistant", | |
| 442 | + }, | |
| 443 | + }, | |
| 444 | + }, | |
| 445 | + ]; | |
| 446 | + | |
| 447 | + const nativeEventLogger = { | |
| 448 | + filePath: "memory://opencode-native-events", | |
| 449 | + write: () => Effect.die(new Error("native log write failed")), | |
| 450 | + close: () => Effect.void, | |
| 451 | + }; | |
| 452 | + | |
| 453 | + const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( | |
| 454 | + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | |
| 455 | + Layer.provideMerge( | |
| 456 | + ServerSettingsService.layerTest({ | |
| 457 | + providers: { | |
| 458 | + opencode: { | |
| 459 | + binaryPath: "fake-opencode", | |
| 460 | + serverUrl: "http://127.0.0.1:9999", | |
| 461 | + serverPassword: "secret-password", | |
| 462 | + }, | |
| 463 | + }, | |
| 464 | + }), | |
| 465 | + ), | |
| 466 | + Layer.provideMerge(providerSessionDirectoryTestLayer), | |
| 467 | + Layer.provideMerge(NodeServices.layer), | |
| 468 | + ); | |
| 469 | + | |
| 470 | + const sessions = yield* Effect.gen(function* () { | |
| 471 | + const adapter = yield* OpenCodeAdapter; | |
| 472 | + yield* adapter.startSession({ | |
| 473 | + provider: "opencode", | |
| 474 | + threadId: asThreadId("thread-native-log-failure"), | |
| 475 | + runtimeMode: "full-access", | |
| 476 | + }); | |
| 477 | + yield* sleep(10); | |
| 478 | + return yield* adapter.listSessions(); | |
| 479 | + }).pipe(Effect.provide(adapterLayer)); | |
| 480 | + | |
| 481 | + assert.equal(sessions.length, 1); | |
| 482 | + assert.equal(sessions[0]?.threadId, "thread-native-log-failure"); | |
| 483 | + assert.deepEqual(runtimeMock.state.closeCalls, []); | |
| 484 | + }), | |
| 485 | + ); | |
| 486 | +}); | |
| @@ -0,0 +1,1344 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + EventId, | |
| 5 | + type ProviderRuntimeEvent, | |
| 6 | + type ProviderSession, | |
| 7 | + RuntimeItemId, | |
| 8 | + RuntimeRequestId, | |
| 9 | + ThreadId, | |
| 10 | + type ToolLifecycleItemType, | |
| 11 | + TurnId, | |
| 12 | + type UserInputQuestion, | |
| 13 | +} from "@t3tools/contracts"; | |
| 14 | +import { Cause, Effect, Layer, Queue, Stream } from "effect"; | |
| 15 | +import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; | |
| 16 | + | |
| 17 | +import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 18 | +import { ServerConfig } from "../../config.ts"; | |
| 19 | +import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 20 | +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | |
| 21 | +import { | |
| 22 | + ProviderAdapterProcessError, | |
| 23 | + ProviderAdapterRequestError, | |
| 24 | + ProviderAdapterSessionClosedError, | |
| 25 | + ProviderAdapterSessionNotFoundError, | |
| 26 | + ProviderAdapterValidationError, | |
| 27 | +} from "../Errors.ts"; | |
| 28 | +import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; | |
| 29 | +import { | |
| 30 | + buildOpenCodePermissionRules, | |
| 31 | + connectToOpenCodeServer, | |
| 32 | + createOpenCodeSdkClient, | |
| 33 | + openCodeQuestionId, | |
| 34 | + parseOpenCodeModelSlug, | |
| 35 | + toOpenCodeFileParts, | |
| 36 | + toOpenCodePermissionReply, | |
| 37 | + toOpenCodeQuestionAnswers, | |
| 38 | + type OpenCodeServerConnection, | |
| 39 | +} from "../opencodeRuntime.ts"; | |
| 40 | + | |
| 41 | +const PROVIDER = "opencode" as const; | |
| 42 | + | |
| 43 | +interface OpenCodeTurnSnapshot { | |
| 44 | + readonly id: TurnId; | |
| 45 | + readonly items: Array<unknown>; | |
| 46 | +} | |
| 47 | + | |
| 48 | +interface OpenCodeSessionContext { | |
| 49 | + session: ProviderSession; | |
| 50 | + readonly client: OpencodeClient; | |
| 51 | + readonly server: OpenCodeServerConnection; | |
| 52 | + readonly directory: string; | |
| 53 | + readonly openCodeSessionId: string; | |
| 54 | + readonly pendingPermissions: Map<string, PermissionRequest>; | |
| 55 | + readonly pendingQuestions: Map<string, QuestionRequest>; | |
| 56 | + readonly messageRoleById: Map<string, "user" | "assistant">; | |
| 57 | + readonly partById: Map<string, Part>; | |
| 58 | + readonly emittedTextByPartId: Map<string, string>; | |
| 59 | + readonly completedAssistantPartIds: Set<string>; | |
| 60 | + readonly turns: Array<OpenCodeTurnSnapshot>; | |
| 61 | + activeTurnId: TurnId | undefined; | |
| 62 | + activeAgent: string | undefined; | |
| 63 | + activeVariant: string | undefined; | |
| 64 | + stopped: boolean; | |
| 65 | + readonly eventsAbortController: AbortController; | |
| 66 | +} | |
| 67 | + | |
| 68 | +export interface OpenCodeAdapterLiveOptions { | |
| 69 | + readonly nativeEventLogPath?: string; | |
| 70 | + readonly nativeEventLogger?: EventNdjsonLogger; | |
| 71 | +} | |
| 72 | + | |
| 73 | +function nowIso(): string { | |
| 74 | + return new Date().toISOString(); | |
| 75 | +} | |
| 76 | + | |
| 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 | +} | |
| 85 | + | |
| 86 | +function buildEventBase(input: { | |
| 87 | + readonly threadId: ThreadId; | |
| 88 | + readonly turnId?: TurnId | undefined; | |
| 89 | + readonly itemId?: string | undefined; | |
| 90 | + readonly requestId?: string | undefined; | |
| 91 | + readonly createdAt?: string | undefined; | |
| 92 | + readonly raw?: unknown; | |
| 93 | +}): Pick< | |
| 94 | + ProviderRuntimeEvent, | |
| 95 | + "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" | |
| 96 | +> { | |
| 97 | + return { | |
| 98 | + eventId: EventId.make(randomUUID()), | |
| 99 | + provider: PROVIDER, | |
| 100 | + threadId: input.threadId, | |
| 101 | + createdAt: input.createdAt ?? nowIso(), | |
| 102 | + ...(input.turnId ? { turnId: input.turnId } : {}), | |
| 103 | + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), | |
| 104 | + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), | |
| 105 | + ...(input.raw !== undefined | |
| 106 | + ? { | |
| 107 | + raw: { | |
| 108 | + source: "opencode.sdk.event", | |
| 109 | + payload: input.raw, | |
| 110 | + }, | |
| 111 | + } | |
| 112 | + : {}), | |
| 113 | + }; | |
| 114 | +} | |
| 115 | + | |
| 116 | +function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { | |
| 117 | + const normalized = toolName.toLowerCase(); | |
| 118 | + if (normalized.includes("bash") || normalized.includes("command")) { | |
| 119 | + return "command_execution"; | |
| 120 | + } | |
| 121 | + if ( | |
| 122 | + normalized.includes("edit") || | |
| 123 | + normalized.includes("write") || | |
| 124 | + normalized.includes("patch") || | |
| 125 | + normalized.includes("multiedit") | |
| 126 | + ) { | |
| 127 | + return "file_change"; | |
| 128 | + } | |
| 129 | + if (normalized.includes("web")) { | |
| 130 | + return "web_search"; | |
| 131 | + } | |
| 132 | + if (normalized.includes("mcp")) { | |
| 133 | + return "mcp_tool_call"; | |
| 134 | + } | |
| 135 | + if (normalized.includes("image")) { | |
| 136 | + return "image_view"; | |
| 137 | + } | |
| 138 | + if ( | |
| 139 | + normalized.includes("task") || | |
| 140 | + normalized.includes("agent") || | |
| 141 | + normalized.includes("subtask") | |
| 142 | + ) { | |
| 143 | + return "collab_agent_tool_call"; | |
| 144 | + } | |
| 145 | + return "dynamic_tool_call"; | |
| 146 | +} | |
| 147 | + | |
| 148 | +function mapPermissionToRequestType( | |
| 149 | + permission: string, | |
| 150 | +): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { | |
| 151 | + switch (permission) { | |
| 152 | + case "bash": | |
| 153 | + return "command_execution_approval"; | |
| 154 | + case "read": | |
| 155 | + return "file_read_approval"; | |
| 156 | + case "edit": | |
| 157 | + return "file_change_approval"; | |
| 158 | + default: | |
| 159 | + return "unknown"; | |
| 160 | + } | |
| 161 | +} | |
| 162 | + | |
| 163 | +function mapPermissionDecision(reply: "once" | "always" | "reject"): string { | |
| 164 | + switch (reply) { | |
| 165 | + case "once": | |
| 166 | + return "accept"; | |
| 167 | + case "always": | |
| 168 | + return "acceptForSession"; | |
| 169 | + case "reject": | |
| 170 | + default: | |
| 171 | + return "decline"; | |
| 172 | + } | |
| 173 | +} | |
| 174 | + | |
| 175 | +function resolveTurnSnapshot( | |
| 176 | + context: OpenCodeSessionContext, | |
| 177 | + turnId: TurnId, | |
| 178 | +): OpenCodeTurnSnapshot { | |
| 179 | + const existing = context.turns.find((turn) => turn.id === turnId); | |
| 180 | + if (existing) { | |
| 181 | + return existing; | |
| 182 | + } | |
| 183 | + | |
| 184 | + const created: OpenCodeTurnSnapshot = { id: turnId, items: [] }; | |
| 185 | + context.turns.push(created); | |
| 186 | + return created; | |
| 187 | +} | |
| 188 | + | |
| 189 | +function appendTurnItem( | |
| 190 | + context: OpenCodeSessionContext, | |
| 191 | + turnId: TurnId | undefined, | |
| 192 | + item: unknown, | |
| 193 | +): void { | |
| 194 | + if (!turnId) { | |
| 195 | + return; | |
| 196 | + } | |
| 197 | + resolveTurnSnapshot(context, turnId).items.push(item); | |
| 198 | +} | |
| 199 | + | |
| 200 | +function ensureSessionContext( | |
| 201 | + sessions: ReadonlyMap<ThreadId, OpenCodeSessionContext>, | |
| 202 | + threadId: ThreadId, | |
| 203 | +): OpenCodeSessionContext { | |
| 204 | + const session = sessions.get(threadId); | |
| 205 | + if (!session) { | |
| 206 | + throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); | |
| 207 | + } | |
| 208 | + if (session.stopped) { | |
| 209 | + throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); | |
| 210 | + } | |
| 211 | + return session; | |
| 212 | +} | |
| 213 | + | |
| 214 | +function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray<UserInputQuestion> { | |
| 215 | + return request.questions.map((question, index) => ({ | |
| 216 | + id: openCodeQuestionId(index, question), | |
| 217 | + header: question.header, | |
| 218 | + question: question.question, | |
| 219 | + options: question.options.map((option) => ({ | |
| 220 | + label: option.label, | |
| 221 | + description: option.description, | |
| 222 | + })), | |
| 223 | + ...(question.multiple ? { multiSelect: true } : {}), | |
| 224 | + })); | |
| 225 | +} | |
| 226 | + | |
| 227 | +function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" { | |
| 228 | + return part?.type === "reasoning" ? "reasoning_text" : "assistant_text"; | |
| 229 | +} | |
| 230 | + | |
| 231 | +function textFromPart(part: Part): string | undefined { | |
| 232 | + switch (part.type) { | |
| 233 | + case "text": | |
| 234 | + case "reasoning": | |
| 235 | + return part.text; | |
| 236 | + default: | |
| 237 | + return undefined; | |
| 238 | + } | |
| 239 | +} | |
| 240 | + | |
| 241 | +function commonPrefixLength(left: string, right: string): number { | |
| 242 | + let index = 0; | |
| 243 | + while (index < left.length && index < right.length && left[index] === right[index]) { | |
| 244 | + index += 1; | |
| 245 | + } | |
| 246 | + return index; | |
| 247 | +} | |
| 248 | + | |
| 249 | +function suffixPrefixOverlap(text: string, delta: string): number { | |
| 250 | + const maxLength = Math.min(text.length, delta.length); | |
| 251 | + for (let length = maxLength; length > 0; length -= 1) { | |
| 252 | + if (text.endsWith(delta.slice(0, length))) { | |
| 253 | + return length; | |
| 254 | + } | |
| 255 | + } | |
| 256 | + return 0; | |
| 257 | +} | |
| 258 | + | |
| 259 | +function resolveLatestAssistantText(previousText: string | undefined, nextText: string): string { | |
| 260 | + if (previousText && previousText.length > nextText.length && previousText.startsWith(nextText)) { | |
| 261 | + return previousText; | |
| 262 | + } | |
| 263 | + return nextText; | |
| 264 | +} | |
| 265 | + | |
| 266 | +export function mergeOpenCodeAssistantText( | |
| 267 | + previousText: string | undefined, | |
| 268 | + nextText: string, | |
| 269 | +): { | |
| 270 | + readonly latestText: string; | |
| 271 | + readonly deltaToEmit: string; | |
| 272 | +} { | |
| 273 | + const latestText = resolveLatestAssistantText(previousText, nextText); | |
| 274 | + return { | |
| 275 | + latestText, | |
| 276 | + deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), | |
| 277 | + }; | |
| 278 | +} | |
| 279 | + | |
| 280 | +export function appendOpenCodeAssistantTextDelta( | |
| 281 | + previousText: string, | |
| 282 | + delta: string, | |
| 283 | +): { | |
| 284 | + readonly nextText: string; | |
| 285 | + readonly deltaToEmit: string; | |
| 286 | +} { | |
| 287 | + const deltaToEmit = delta.slice(suffixPrefixOverlap(previousText, delta)); | |
| 288 | + return { | |
| 289 | + nextText: previousText + deltaToEmit, | |
| 290 | + deltaToEmit, | |
| 291 | + }; | |
| 292 | +} | |
| 293 | + | |
| 294 | +function isoFromEpochMs(value: number | undefined): string | undefined { | |
| 295 | + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { | |
| 296 | + return undefined; | |
| 297 | + } | |
| 298 | + return new Date(value).toISOString(); | |
| 299 | +} | |
| 300 | + | |
| 301 | +function messageRoleForPart( | |
| 302 | + context: OpenCodeSessionContext, | |
| 303 | + part: Pick<Part, "messageID" | "type">, | |
| 304 | +): "assistant" | "user" | undefined { | |
| 305 | + const known = context.messageRoleById.get(part.messageID); | |
| 306 | + if (known) { | |
| 307 | + return known; | |
| 308 | + } | |
| 309 | + return part.type === "tool" ? "assistant" : undefined; | |
| 310 | +} | |
| 311 | + | |
| 312 | +function detailFromToolPart(part: Extract<Part, { type: "tool" }>): string | undefined { | |
| 313 | + switch (part.state.status) { | |
| 314 | + case "completed": | |
| 315 | + return part.state.output; | |
| 316 | + case "error": | |
| 317 | + return part.state.error; | |
| 318 | + case "running": | |
| 319 | + return part.state.title; | |
| 320 | + default: | |
| 321 | + return undefined; | |
| 322 | + } | |
| 323 | +} | |
| 324 | + | |
| 325 | +function toolStateCreatedAt(part: Extract<Part, { type: "tool" }>): string | undefined { | |
| 326 | + switch (part.state.status) { | |
| 327 | + case "running": | |
| 328 | + return isoFromEpochMs(part.state.time.start); | |
| 329 | + case "completed": | |
| 330 | + case "error": | |
| 331 | + return isoFromEpochMs(part.state.time.end); | |
| 332 | + default: | |
| 333 | + return undefined; | |
| 334 | + } | |
| 335 | +} | |
| 336 | + | |
| 337 | +function sessionErrorMessage(error: unknown): string { | |
| 338 | + if (!error || typeof error !== "object") { | |
| 339 | + return "OpenCode session failed."; | |
| 340 | + } | |
| 341 | + const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null; | |
| 342 | + const message = data && "message" in data ? data.message : null; | |
| 343 | + return typeof message === "string" && message.trim().length > 0 | |
| 344 | + ? message | |
| 345 | + : "OpenCode session failed."; | |
| 346 | +} | |
| 347 | + | |
| 348 | +function updateProviderSession( | |
| 349 | + context: OpenCodeSessionContext, | |
| 350 | + patch: Partial<ProviderSession>, | |
| 351 | + options?: { | |
| 352 | + readonly clearActiveTurnId?: boolean; | |
| 353 | + readonly clearLastError?: boolean; | |
| 354 | + }, | |
| 355 | +): ProviderSession { | |
| 356 | + const nextSession = { | |
| 357 | + ...context.session, | |
| 358 | + ...patch, | |
| 359 | + updatedAt: nowIso(), | |
| 360 | + } as ProviderSession & Record<string, unknown>; | |
| 361 | + const mutableSession = nextSession as Record<string, unknown>; | |
| 362 | + if (options?.clearActiveTurnId) { | |
| 363 | + delete mutableSession.activeTurnId; | |
| 364 | + } | |
| 365 | + if (options?.clearLastError) { | |
| 366 | + delete mutableSession.lastError; | |
| 367 | + } | |
| 368 | + context.session = nextSession; | |
| 369 | + return nextSession; | |
| 370 | +} | |
| 371 | + | |
| 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 | +} | |
| 382 | + | |
| 383 | +export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { | |
| 384 | + return Layer.effect( | |
| 385 | + OpenCodeAdapter, | |
| 386 | + Effect.gen(function* () { | |
| 387 | + const serverConfig = yield* ServerConfig; | |
| 388 | + const serverSettings = yield* ServerSettingsService; | |
| 389 | + const services = yield* Effect.context<never>(); | |
| 390 | + const nativeEventLogger = | |
| 391 | + _options?.nativeEventLogger ?? | |
| 392 | + (_options?.nativeEventLogPath !== undefined | |
| 393 | + ? yield* makeEventNdjsonLogger(_options.nativeEventLogPath, { | |
| 394 | + stream: "native", | |
| 395 | + }) | |
| 396 | + : undefined); | |
| 397 | + const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); | |
| 398 | + const sessions = new Map<ThreadId, OpenCodeSessionContext>(); | |
| 399 | + | |
| 400 | + const emit = (event: ProviderRuntimeEvent) => | |
| 401 | + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); | |
| 402 | + const emitPromise = (event: ProviderRuntimeEvent) => | |
| 403 | + emit(event).pipe(Effect.runPromiseWith(services)); | |
| 404 | + const writeNativeEventPromise = ( | |
| 405 | + threadId: ThreadId, | |
| 406 | + event: { | |
| 407 | + readonly observedAt: string; | |
| 408 | + readonly event: Record<string, unknown>; | |
| 409 | + }, | |
| 410 | + ) => | |
| 411 | + (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void).pipe( | |
| 412 | + Effect.runPromiseWith(services), | |
| 413 | + ); | |
| 414 | + const writeNativeEventBestEffort = ( | |
| 415 | + threadId: ThreadId, | |
| 416 | + event: { | |
| 417 | + readonly observedAt: string; | |
| 418 | + readonly event: Record<string, unknown>; | |
| 419 | + }, | |
| 420 | + ) => writeNativeEventPromise(threadId, event).catch(() => undefined); | |
| 421 | + | |
| 422 | + const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => { | |
| 423 | + if (context.stopped) { | |
| 424 | + return; | |
| 425 | + } | |
| 426 | + context.stopped = true; | |
| 427 | + sessions.delete(context.session.threadId); | |
| 428 | + context.server.close(); | |
| 429 | + const turnId = context.activeTurnId; | |
| 430 | + void emitPromise({ | |
| 431 | + ...buildEventBase({ threadId: context.session.threadId, turnId }), | |
| 432 | + type: "runtime.error", | |
| 433 | + payload: { | |
| 434 | + message, | |
| 435 | + class: "transport_error", | |
| 436 | + }, | |
| 437 | + }).catch(() => undefined); | |
| 438 | + void emitPromise({ | |
| 439 | + ...buildEventBase({ threadId: context.session.threadId, turnId }), | |
| 440 | + type: "session.exited", | |
| 441 | + payload: { | |
| 442 | + reason: message, | |
| 443 | + recoverable: false, | |
| 444 | + exitKind: "error", | |
| 445 | + }, | |
| 446 | + }).catch(() => undefined); | |
| 447 | + }; | |
| 448 | + | |
| 449 | + /** Emit content.delta and item.completed events for an assistant text part. */ | |
| 450 | + const emitAssistantTextDelta = async ( | |
| 451 | + context: OpenCodeSessionContext, | |
| 452 | + part: Part, | |
| 453 | + turnId: TurnId | undefined, | |
| 454 | + raw: unknown, | |
| 455 | + ): Promise<void> => { | |
| 456 | + const text = textFromPart(part); | |
| 457 | + if (text === undefined) { | |
| 458 | + return; | |
| 459 | + } | |
| 460 | + const previousText = context.emittedTextByPartId.get(part.id); | |
| 461 | + const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); | |
| 462 | + context.emittedTextByPartId.set(part.id, latestText); | |
| 463 | + if (latestText !== text) { | |
| 464 | + context.partById.set( | |
| 465 | + part.id, | |
| 466 | + (part.type === "text" || part.type === "reasoning" | |
| 467 | + ? { ...part, text: latestText } | |
| 468 | + : part) satisfies Part, | |
| 469 | + ); | |
| 470 | + } | |
| 471 | + if (deltaToEmit.length > 0) { | |
| 472 | + await emitPromise({ | |
| 473 | + ...buildEventBase({ | |
| 474 | + threadId: context.session.threadId, | |
| 475 | + turnId, | |
| 476 | + itemId: part.id, | |
| 477 | + createdAt: | |
| 478 | + part.type === "text" || part.type === "reasoning" | |
| 479 | + ? isoFromEpochMs(part.time?.start) | |
| 480 | + : undefined, | |
| 481 | + raw, | |
| 482 | + }), | |
| 483 | + type: "content.delta", | |
| 484 | + payload: { | |
| 485 | + streamKind: resolveTextStreamKind(part), | |
| 486 | + delta: deltaToEmit, | |
| 487 | + }, | |
| 488 | + }); | |
| 489 | + } | |
| 490 | + | |
| 491 | + if ( | |
| 492 | + part.type === "text" && | |
| 493 | + part.time?.end !== undefined && | |
| 494 | + !context.completedAssistantPartIds.has(part.id) | |
| 495 | + ) { | |
| 496 | + context.completedAssistantPartIds.add(part.id); | |
| 497 | + await emitPromise({ | |
| 498 | + ...buildEventBase({ | |
| 499 | + threadId: context.session.threadId, | |
| 500 | + turnId, | |
| 501 | + itemId: part.id, | |
| 502 | + createdAt: isoFromEpochMs(part.time.end), | |
| 503 | + raw, | |
| 504 | + }), | |
| 505 | + type: "item.completed", | |
| 506 | + payload: { | |
| 507 | + itemType: "assistant_message", | |
| 508 | + status: "completed", | |
| 509 | + title: "Assistant message", | |
| 510 | + ...(latestText.length > 0 ? { detail: latestText } : {}), | |
| 511 | + }, | |
| 512 | + }); | |
| 513 | + } | |
| 514 | + }; | |
| 515 | + | |
| 516 | + const startEventPump = (context: OpenCodeSessionContext) => { | |
| 517 | + void (async () => { | |
| 518 | + try { | |
| 519 | + const subscription = await context.client.event.subscribe(undefined, { | |
| 520 | + signal: context.eventsAbortController.signal, | |
| 521 | + }); | |
| 522 | + | |
| 523 | + for await (const event of subscription.stream) { | |
| 524 | + const payloadSessionId = | |
| 525 | + "properties" in event | |
| 526 | + ? (event.properties as { sessionID?: unknown }).sessionID | |
| 527 | + : undefined; | |
| 528 | + if (payloadSessionId !== context.openCodeSessionId) { | |
| 529 | + continue; | |
| 530 | + } | |
| 531 | + | |
| 532 | + const turnId = context.activeTurnId; | |
| 533 | + await writeNativeEventBestEffort(context.session.threadId, { | |
| 534 | + observedAt: nowIso(), | |
| 535 | + event: { | |
| 536 | + provider: PROVIDER, | |
| 537 | + threadId: context.session.threadId, | |
| 538 | + providerThreadId: context.openCodeSessionId, | |
| 539 | + type: event.type, | |
| 540 | + ...(turnId ? { turnId } : {}), | |
| 541 | + payload: event, | |
| 542 | + }, | |
| 543 | + }); | |
| 544 | + | |
| 545 | + switch (event.type) { | |
| 546 | + case "message.updated": { | |
| 547 | + context.messageRoleById.set(event.properties.info.id, event.properties.info.role); | |
| 548 | + if (event.properties.info.role === "assistant") { | |
| 549 | + for (const part of context.partById.values()) { | |
| 550 | + if (part.messageID !== event.properties.info.id) { | |
| 551 | + continue; | |
| 552 | + } | |
| 553 | + await emitAssistantTextDelta(context, part, turnId, event); | |
| 554 | + } | |
| 555 | + } | |
| 556 | + break; | |
| 557 | + } | |
| 558 | + | |
| 559 | + case "message.removed": { | |
| 560 | + context.messageRoleById.delete(event.properties.messageID); | |
| 561 | + break; | |
| 562 | + } | |
| 563 | + | |
| 564 | + case "message.part.delta": { | |
| 565 | + const existingPart = context.partById.get(event.properties.partID); | |
| 566 | + if (!existingPart) { | |
| 567 | + break; | |
| 568 | + } | |
| 569 | + const role = messageRoleForPart(context, existingPart); | |
| 570 | + if (role !== "assistant") { | |
| 571 | + break; | |
| 572 | + } | |
| 573 | + const streamKind = resolveTextStreamKind(existingPart); | |
| 574 | + const delta = event.properties.delta; | |
| 575 | + if (delta.length === 0) { | |
| 576 | + break; | |
| 577 | + } | |
| 578 | + const previousText = | |
| 579 | + context.emittedTextByPartId.get(event.properties.partID) ?? | |
| 580 | + textFromPart(existingPart) ?? | |
| 581 | + ""; | |
| 582 | + const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta( | |
| 583 | + previousText, | |
| 584 | + delta, | |
| 585 | + ); | |
| 586 | + if (deltaToEmit.length === 0) { | |
| 587 | + break; | |
| 588 | + } | |
| 589 | + context.emittedTextByPartId.set(event.properties.partID, nextText); | |
| 590 | + if (existingPart.type === "text" || existingPart.type === "reasoning") { | |
| 591 | + context.partById.set(event.properties.partID, { | |
| 592 | + ...existingPart, | |
| 593 | + text: nextText, | |
| 594 | + }); | |
| 595 | + } | |
| 596 | + await emitPromise({ | |
| 597 | + ...buildEventBase({ | |
| 598 | + threadId: context.session.threadId, | |
| 599 | + turnId, | |
| 600 | + itemId: event.properties.partID, | |
| 601 | + raw: event, | |
| 602 | + }), | |
| 603 | + type: "content.delta", | |
| 604 | + payload: { | |
| 605 | + streamKind, | |
| 606 | + delta: deltaToEmit, | |
| 607 | + }, | |
| 608 | + }); | |
| 609 | + break; | |
| 610 | + } | |
| 611 | + | |
| 612 | + case "message.part.updated": { | |
| 613 | + const part = event.properties.part; | |
| 614 | + context.partById.set(part.id, part); | |
| 615 | + const messageRole = messageRoleForPart(context, part); | |
| 616 | + | |
| 617 | + if (messageRole === "assistant") { | |
| 618 | + await emitAssistantTextDelta(context, part, turnId, event); | |
| 619 | + } | |
| 620 | + | |
| 621 | + if (part.type === "tool") { | |
| 622 | + const itemType = toToolLifecycleItemType(part.tool); | |
| 623 | + const title = | |
| 624 | + part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; | |
| 625 | + const detail = detailFromToolPart(part); | |
| 626 | + const payload = { | |
| 627 | + itemType, | |
| 628 | + ...(part.state.status === "error" | |
| 629 | + ? { status: "failed" as const } | |
| 630 | + : part.state.status === "completed" | |
| 631 | + ? { status: "completed" as const } | |
| 632 | + : { status: "inProgress" as const }), | |
| 633 | + ...(title ? { title } : {}), | |
| 634 | + ...(detail ? { detail } : {}), | |
| 635 | + data: { | |
| 636 | + tool: part.tool, | |
| 637 | + state: part.state, | |
| 638 | + }, | |
| 639 | + }; | |
| 640 | + const runtimeEvent: ProviderRuntimeEvent = { | |
| 641 | + ...buildEventBase({ | |
| 642 | + threadId: context.session.threadId, | |
| 643 | + turnId, | |
| 644 | + itemId: part.callID, | |
| 645 | + createdAt: toolStateCreatedAt(part), | |
| 646 | + raw: event, | |
| 647 | + }), | |
| 648 | + type: | |
| 649 | + part.state.status === "pending" | |
| 650 | + ? "item.started" | |
| 651 | + : part.state.status === "completed" || part.state.status === "error" | |
| 652 | + ? "item.completed" | |
| 653 | + : "item.updated", | |
| 654 | + payload, | |
| 655 | + }; | |
| 656 | + appendTurnItem(context, turnId, part); | |
| 657 | + await emitPromise(runtimeEvent); | |
| 658 | + } | |
| 659 | + break; | |
| 660 | + } | |
| 661 | + | |
| 662 | + case "permission.asked": { | |
| 663 | + context.pendingPermissions.set(event.properties.id, event.properties); | |
| 664 | + await emitPromise({ | |
| 665 | + ...buildEventBase({ | |
| 666 | + threadId: context.session.threadId, | |
| 667 | + turnId, | |
| 668 | + requestId: event.properties.id, | |
| 669 | + raw: event, | |
| 670 | + }), | |
| 671 | + type: "request.opened", | |
| 672 | + payload: { | |
| 673 | + requestType: mapPermissionToRequestType(event.properties.permission), | |
| 674 | + detail: | |
| 675 | + event.properties.patterns.length > 0 | |
| 676 | + ? event.properties.patterns.join("\n") | |
| 677 | + : event.properties.permission, | |
| 678 | + args: event.properties.metadata, | |
| 679 | + }, | |
| 680 | + }); | |
| 681 | + break; | |
| 682 | + } | |
| 683 | + | |
| 684 | + case "permission.replied": { | |
| 685 | + context.pendingPermissions.delete(event.properties.requestID); | |
| 686 | + await emitPromise({ | |
| 687 | + ...buildEventBase({ | |
| 688 | + threadId: context.session.threadId, | |
| 689 | + turnId, | |
| 690 | + requestId: event.properties.requestID, | |
| 691 | + raw: event, | |
| 692 | + }), | |
| 693 | + type: "request.resolved", | |
| 694 | + payload: { | |
| 695 | + requestType: "unknown", | |
| 696 | + decision: mapPermissionDecision(event.properties.reply), | |
| 697 | + }, | |
| 698 | + }); | |
| 699 | + break; | |
| 700 | + } | |
| 701 | + | |
| 702 | + case "question.asked": { | |
| 703 | + context.pendingQuestions.set(event.properties.id, event.properties); | |
| 704 | + await emitPromise({ | |
| 705 | + ...buildEventBase({ | |
| 706 | + threadId: context.session.threadId, | |
| 707 | + turnId, | |
| 708 | + requestId: event.properties.id, | |
| 709 | + raw: event, | |
| 710 | + }), | |
| 711 | + type: "user-input.requested", | |
| 712 | + payload: { | |
| 713 | + questions: normalizeQuestionRequest(event.properties), | |
| 714 | + }, | |
| 715 | + }); | |
| 716 | + break; | |
| 717 | + } | |
| 718 | + | |
| 719 | + case "question.replied": { | |
| 720 | + const request = context.pendingQuestions.get(event.properties.requestID); | |
| 721 | + context.pendingQuestions.delete(event.properties.requestID); | |
| 722 | + const answers = Object.fromEntries( | |
| 723 | + (request?.questions ?? []).map((question, index) => [ | |
| 724 | + openCodeQuestionId(index, question), | |
| 725 | + event.properties.answers[index]?.join(", ") ?? "", | |
| 726 | + ]), | |
| 727 | + ); | |
| 728 | + await emitPromise({ | |
| 729 | + ...buildEventBase({ | |
| 730 | + threadId: context.session.threadId, | |
| 731 | + turnId, | |
| 732 | + requestId: event.properties.requestID, | |
| 733 | + raw: event, | |
| 734 | + }), | |
| 735 | + type: "user-input.resolved", | |
| 736 | + payload: { answers }, | |
| 737 | + }); | |
| 738 | + break; | |
| 739 | + } | |
| 740 | + | |
| 741 | + case "question.rejected": { | |
| 742 | + context.pendingQuestions.delete(event.properties.requestID); | |
| 743 | + await emitPromise({ | |
| 744 | + ...buildEventBase({ | |
| 745 | + threadId: context.session.threadId, | |
| 746 | + turnId, | |
| 747 | + requestId: event.properties.requestID, | |
| 748 | + raw: event, | |
| 749 | + }), | |
| 750 | + type: "user-input.resolved", | |
| 751 | + payload: { answers: {} }, | |
| 752 | + }); | |
| 753 | + break; | |
| 754 | + } | |
| 755 | + | |
| 756 | + case "session.status": { | |
| 757 | + if (event.properties.status.type === "busy") { | |
| 758 | + updateProviderSession(context, { status: "running", activeTurnId: turnId }); | |
| 759 | + } | |
| 760 | + | |
| 761 | + if (event.properties.status.type === "retry") { | |
| 762 | + await emitPromise({ | |
| 763 | + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), | |
| 764 | + type: "runtime.warning", | |
| 765 | + payload: { | |
| 766 | + message: event.properties.status.message, | |
| 767 | + detail: event.properties.status, | |
| 768 | + }, | |
| 769 | + }); | |
| 770 | + break; | |
| 771 | + } | |
| 772 | + | |
| 773 | + if (event.properties.status.type === "idle" && turnId) { | |
| 774 | + context.activeTurnId = undefined; | |
| 775 | + updateProviderSession( | |
| 776 | + context, | |
| 777 | + { status: "ready" }, | |
| 778 | + { clearActiveTurnId: true }, | |
| 779 | + ); | |
| 780 | + await emitPromise({ | |
| 781 | + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), | |
| 782 | + type: "turn.completed", | |
| 783 | + payload: { | |
| 784 | + state: "completed", | |
| 785 | + }, | |
| 786 | + }); | |
| 787 | + } | |
| 788 | + break; | |
| 789 | + } | |
| 790 | + | |
| 791 | + case "session.error": { | |
| 792 | + const message = sessionErrorMessage(event.properties.error); | |
| 793 | + const activeTurnId = context.activeTurnId; | |
| 794 | + context.activeTurnId = undefined; | |
| 795 | + updateProviderSession( | |
| 796 | + context, | |
| 797 | + { | |
| 798 | + status: "error", | |
| 799 | + lastError: message, | |
| 800 | + }, | |
| 801 | + { clearActiveTurnId: true }, | |
| 802 | + ); | |
| 803 | + if (activeTurnId) { | |
| 804 | + await emitPromise({ | |
| 805 | + ...buildEventBase({ | |
| 806 | + threadId: context.session.threadId, | |
| 807 | + turnId: activeTurnId, | |
| 808 | + raw: event, | |
| 809 | + }), | |
| 810 | + type: "turn.completed", | |
| 811 | + payload: { | |
| 812 | + state: "failed", | |
| 813 | + errorMessage: message, | |
| 814 | + }, | |
| 815 | + }); | |
| 816 | + } | |
| 817 | + await emitPromise({ | |
| 818 | + ...buildEventBase({ threadId: context.session.threadId, raw: event }), | |
| 819 | + type: "runtime.error", | |
| 820 | + payload: { | |
| 821 | + message, | |
| 822 | + class: "provider_error", | |
| 823 | + detail: event.properties.error, | |
| 824 | + }, | |
| 825 | + }); | |
| 826 | + break; | |
| 827 | + } | |
| 828 | + | |
| 829 | + default: | |
| 830 | + break; | |
| 831 | + } | |
| 832 | + } | |
| 833 | + } catch (error) { | |
| 834 | + if (context.eventsAbortController.signal.aborted || context.stopped) { | |
| 835 | + return; | |
| 836 | + } | |
| 837 | + emitUnexpectedExit( | |
| 838 | + context, | |
| 839 | + error instanceof Error ? error.message : "OpenCode event stream failed.", | |
| 840 | + ); | |
| 841 | + } | |
| 842 | + })(); | |
| 843 | + | |
| 844 | + context.server.process?.once("exit", (code, signal) => { | |
| 845 | + if (context.stopped) { | |
| 846 | + return; | |
| 847 | + } | |
| 848 | + emitUnexpectedExit( | |
| 849 | + context, | |
| 850 | + `OpenCode server exited unexpectedly (${signal ?? code ?? "unknown"}).`, | |
| 851 | + ); | |
| 852 | + }); | |
| 853 | + }; | |
| 854 | + | |
| 855 | + const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( | |
| 856 | + function* (input) { | |
| 857 | + const settings = yield* serverSettings.getSettings.pipe( | |
| 858 | + Effect.mapError( | |
| 859 | + (cause) => | |
| 860 | + new ProviderAdapterProcessError({ | |
| 861 | + provider: PROVIDER, | |
| 862 | + threadId: input.threadId, | |
| 863 | + detail: "Failed to read OpenCode settings.", | |
| 864 | + cause, | |
| 865 | + }), | |
| 866 | + ), | |
| 867 | + ); | |
| 868 | + const binaryPath = settings.providers.opencode.binaryPath; | |
| 869 | + const serverUrl = settings.providers.opencode.serverUrl; | |
| 870 | + const serverPassword = settings.providers.opencode.serverPassword; | |
| 871 | + const directory = input.cwd ?? serverConfig.cwd; | |
| 872 | + const existing = sessions.get(input.threadId); | |
| 873 | + if (existing) { | |
| 874 | + yield* Effect.tryPromise({ | |
| 875 | + try: () => stopOpenCodeContext(existing), | |
| 876 | + catch: (cause) => | |
| 877 | + new ProviderAdapterProcessError({ | |
| 878 | + provider: PROVIDER, | |
| 879 | + threadId: input.threadId, | |
| 880 | + detail: "Failed to stop existing OpenCode session.", | |
| 881 | + cause, | |
| 882 | + }), | |
| 883 | + }); | |
| 884 | + sessions.delete(input.threadId); | |
| 885 | + } | |
| 886 | + | |
| 887 | + const started = yield* Effect.tryPromise({ | |
| 888 | + try: async () => { | |
| 889 | + const server = await connectToOpenCodeServer({ binaryPath, serverUrl }); | |
| 890 | + const client = createOpenCodeSdkClient({ | |
| 891 | + baseUrl: server.url, | |
| 892 | + directory, | |
| 893 | + ...(server.external && serverPassword ? { serverPassword } : {}), | |
| 894 | + }); | |
| 895 | + const openCodeSession = await client.session.create({ | |
| 896 | + title: `T3 Code ${input.threadId}`, | |
| 897 | + permission: buildOpenCodePermissionRules(input.runtimeMode), | |
| 898 | + }); | |
| 899 | + if (!openCodeSession.data) { | |
| 900 | + throw new Error("OpenCode session.create returned no session payload."); | |
| 901 | + } | |
| 902 | + return { server, client, openCodeSession: openCodeSession.data }; | |
| 903 | + }, | |
| 904 | + catch: (cause) => | |
| 905 | + new ProviderAdapterProcessError({ | |
| 906 | + provider: PROVIDER, | |
| 907 | + threadId: input.threadId, | |
| 908 | + detail: | |
| 909 | + cause instanceof Error ? cause.message : "Failed to start OpenCode session.", | |
| 910 | + cause, | |
| 911 | + }), | |
| 912 | + }); | |
| 913 | + | |
| 914 | + // Guard against a concurrent startSession call that may have raced | |
| 915 | + // and already inserted a session while we were awaiting async work. | |
| 916 | + const raceWinner = sessions.get(input.threadId); | |
| 917 | + if (raceWinner) { | |
| 918 | + // Another call won the race – clean up the session we just created | |
| 919 | + // (including the remote SDK session) and return the existing one. | |
| 920 | + yield* Effect.tryPromise({ | |
| 921 | + try: () => | |
| 922 | + started.client.session | |
| 923 | + .abort({ sessionID: started.openCodeSession.id }) | |
| 924 | + .catch(() => undefined), | |
| 925 | + catch: () => undefined, | |
| 926 | + }).pipe(Effect.ignore); | |
| 927 | + started.server.close(); | |
| 928 | + return raceWinner.session; | |
| 929 | + } | |
| 930 | + | |
| 931 | + const createdAt = nowIso(); | |
| 932 | + const session: ProviderSession = { | |
| 933 | + provider: PROVIDER, | |
| 934 | + status: "ready", | |
| 935 | + runtimeMode: input.runtimeMode, | |
| 936 | + cwd: directory, | |
| 937 | + ...(input.modelSelection ? { model: input.modelSelection.model } : {}), | |
| 938 | + threadId: input.threadId, | |
| 939 | + createdAt, | |
| 940 | + updatedAt: createdAt, | |
| 941 | + }; | |
| 942 | + | |
| 943 | + const context: OpenCodeSessionContext = { | |
| 944 | + session, | |
| 945 | + client: started.client, | |
| 946 | + server: started.server, | |
| 947 | + directory, | |
| 948 | + openCodeSessionId: started.openCodeSession.id, | |
| 949 | + pendingPermissions: new Map(), | |
| 950 | + pendingQuestions: new Map(), | |
| 951 | + partById: new Map(), | |
| 952 | + emittedTextByPartId: new Map(), | |
| 953 | + messageRoleById: new Map(), | |
| 954 | + completedAssistantPartIds: new Set(), | |
| 955 | + turns: [], | |
| 956 | + activeTurnId: undefined, | |
| 957 | + activeAgent: undefined, | |
| 958 | + activeVariant: undefined, | |
| 959 | + stopped: false, | |
| 960 | + eventsAbortController: new AbortController(), | |
| 961 | + }; | |
| 962 | + sessions.set(input.threadId, context); | |
| 963 | + startEventPump(context); | |
| 964 | + | |
| 965 | + yield* emit({ | |
| 966 | + ...buildEventBase({ threadId: input.threadId }), | |
| 967 | + type: "session.started", | |
| 968 | + payload: { | |
| 969 | + message: "OpenCode session started", | |
| 970 | + }, | |
| 971 | + }); | |
| 972 | + yield* emit({ | |
| 973 | + ...buildEventBase({ threadId: input.threadId }), | |
| 974 | + type: "thread.started", | |
| 975 | + payload: { | |
| 976 | + providerThreadId: started.openCodeSession.id, | |
| 977 | + }, | |
| 978 | + }); | |
| 979 | + | |
| 980 | + return session; | |
| 981 | + }, | |
| 982 | + ); | |
| 983 | + | |
| 984 | + const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { | |
| 985 | + const context = ensureSessionContext(sessions, input.threadId); | |
| 986 | + const turnId = TurnId.make(`opencode-turn-${randomUUID()}`); | |
| 987 | + const modelSelection = | |
| 988 | + input.modelSelection ?? | |
| 989 | + (context.session.model | |
| 990 | + ? { provider: PROVIDER, model: context.session.model } | |
| 991 | + : undefined); | |
| 992 | + const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); | |
| 993 | + if (!parsedModel) { | |
| 994 | + return yield* new ProviderAdapterValidationError({ | |
| 995 | + provider: PROVIDER, | |
| 996 | + operation: "sendTurn", | |
| 997 | + issue: "OpenCode model selection must use the 'provider/model' format.", | |
| 998 | + }); | |
| 999 | + } | |
| 1000 | + | |
| 1001 | + const text = input.input?.trim(); | |
| 1002 | + const fileParts = toOpenCodeFileParts({ | |
| 1003 | + attachments: input.attachments, | |
| 1004 | + resolveAttachmentPath: (attachment) => | |
| 1005 | + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | |
| 1006 | + }); | |
| 1007 | + if ((!text || text.length === 0) && fileParts.length === 0) { | |
| 1008 | + return yield* new ProviderAdapterValidationError({ | |
| 1009 | + provider: PROVIDER, | |
| 1010 | + operation: "sendTurn", | |
| 1011 | + issue: "OpenCode turns require text input or at least one attachment.", | |
| 1012 | + }); | |
| 1013 | + } | |
| 1014 | + | |
| 1015 | + const agent = | |
| 1016 | + input.modelSelection?.provider === PROVIDER | |
| 1017 | + ? input.modelSelection.options?.agent | |
| 1018 | + : undefined; | |
| 1019 | + const variant = | |
| 1020 | + input.modelSelection?.provider === PROVIDER | |
| 1021 | + ? input.modelSelection.options?.variant | |
| 1022 | + : undefined; | |
| 1023 | + | |
| 1024 | + context.activeTurnId = turnId; | |
| 1025 | + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); | |
| 1026 | + context.activeVariant = variant; | |
| 1027 | + updateProviderSession( | |
| 1028 | + context, | |
| 1029 | + { | |
| 1030 | + status: "running", | |
| 1031 | + activeTurnId: turnId, | |
| 1032 | + model: modelSelection?.model ?? context.session.model, | |
| 1033 | + }, | |
| 1034 | + { clearLastError: true }, | |
| 1035 | + ); | |
| 1036 | + | |
| 1037 | + yield* emit({ | |
| 1038 | + ...buildEventBase({ threadId: input.threadId, turnId }), | |
| 1039 | + type: "turn.started", | |
| 1040 | + payload: { | |
| 1041 | + model: modelSelection?.model ?? context.session.model, | |
| 1042 | + ...(variant ? { effort: variant } : {}), | |
| 1043 | + }, | |
| 1044 | + }); | |
| 1045 | + | |
| 1046 | + const promptExit = yield* Effect.exit( | |
| 1047 | + Effect.tryPromise({ | |
| 1048 | + try: async () => { | |
| 1049 | + await context.client.session.promptAsync({ | |
| 1050 | + sessionID: context.openCodeSessionId, | |
| 1051 | + model: parsedModel, | |
| 1052 | + ...(context.activeAgent ? { agent: context.activeAgent } : {}), | |
| 1053 | + ...(context.activeVariant ? { variant: context.activeVariant } : {}), | |
| 1054 | + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], | |
| 1055 | + }); | |
| 1056 | + }, | |
| 1057 | + catch: (cause) => | |
| 1058 | + new ProviderAdapterRequestError({ | |
| 1059 | + provider: PROVIDER, | |
| 1060 | + method: "session.promptAsync", | |
| 1061 | + detail: cause instanceof Error ? cause.message : "Failed to send OpenCode turn.", | |
| 1062 | + cause, | |
| 1063 | + }), | |
| 1064 | + }), | |
| 1065 | + ); | |
| 1066 | + if (promptExit._tag === "Failure") { | |
| 1067 | + const failure = Cause.squash(promptExit.cause); | |
| 1068 | + const requestError = isProviderAdapterRequestError(failure) | |
| 1069 | + ? failure | |
| 1070 | + : new ProviderAdapterRequestError({ | |
| 1071 | + provider: PROVIDER, | |
| 1072 | + method: "session.promptAsync", | |
| 1073 | + detail: | |
| 1074 | + failure instanceof Error ? failure.message : "Failed to send OpenCode turn.", | |
| 1075 | + cause: failure, | |
| 1076 | + }); | |
| 1077 | + const failureMessage = requestError.detail; | |
| 1078 | + context.activeTurnId = undefined; | |
| 1079 | + context.activeAgent = undefined; | |
| 1080 | + context.activeVariant = undefined; | |
| 1081 | + updateProviderSession( | |
| 1082 | + context, | |
| 1083 | + { | |
| 1084 | + status: "ready", | |
| 1085 | + model: modelSelection?.model ?? context.session.model, | |
| 1086 | + lastError: failureMessage, | |
| 1087 | + }, | |
| 1088 | + { clearActiveTurnId: true }, | |
| 1089 | + ); | |
| 1090 | + yield* emit({ | |
| 1091 | + ...buildEventBase({ threadId: input.threadId, turnId }), | |
| 1092 | + type: "turn.aborted", | |
| 1093 | + payload: { | |
| 1094 | + reason: failureMessage, | |
| 1095 | + }, | |
| 1096 | + }); | |
| 1097 | + return yield* requestError; | |
| 1098 | + } | |
| 1099 | + | |
| 1100 | + return { | |
| 1101 | + threadId: input.threadId, | |
| 1102 | + turnId, | |
| 1103 | + }; | |
| 1104 | + }); | |
| 1105 | + | |
| 1106 | + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( | |
| 1107 | + function* (threadId, turnId) { | |
| 1108 | + const context = ensureSessionContext(sessions, threadId); | |
| 1109 | + yield* Effect.tryPromise({ | |
| 1110 | + try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }), | |
| 1111 | + catch: (cause) => | |
| 1112 | + new ProviderAdapterRequestError({ | |
| 1113 | + provider: PROVIDER, | |
| 1114 | + method: "session.abort", | |
| 1115 | + detail: cause instanceof Error ? cause.message : "Failed to abort OpenCode turn.", | |
| 1116 | + cause, | |
| 1117 | + }), | |
| 1118 | + }); | |
| 1119 | + if (turnId ?? context.activeTurnId) { | |
| 1120 | + yield* emit({ | |
| 1121 | + ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }), | |
| 1122 | + type: "turn.aborted", | |
| 1123 | + payload: { | |
| 1124 | + reason: "Interrupted by user.", | |
| 1125 | + }, | |
| 1126 | + }); | |
| 1127 | + } | |
| 1128 | + }, | |
| 1129 | + ); | |
| 1130 | + | |
| 1131 | + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( | |
| 1132 | + "respondToRequest", | |
| 1133 | + )(function* (threadId, requestId, decision) { | |
| 1134 | + const context = ensureSessionContext(sessions, threadId); | |
| 1135 | + if (!context.pendingPermissions.has(requestId)) { | |
| 1136 | + return yield* new ProviderAdapterRequestError({ | |
| 1137 | + provider: PROVIDER, | |
| 1138 | + method: "permission.reply", | |
| 1139 | + detail: `Unknown pending permission request: ${requestId}`, | |
| 1140 | + }); | |
| 1141 | + } | |
| 1142 | + | |
| 1143 | + yield* Effect.tryPromise({ | |
| 1144 | + try: () => | |
| 1145 | + context.client.permission.reply({ | |
| 1146 | + requestID: requestId, | |
| 1147 | + reply: toOpenCodePermissionReply(decision), | |
| 1148 | + }), | |
| 1149 | + catch: (cause) => | |
| 1150 | + new ProviderAdapterRequestError({ | |
| 1151 | + provider: PROVIDER, | |
| 1152 | + method: "permission.reply", | |
| 1153 | + detail: | |
| 1154 | + cause instanceof Error | |
| 1155 | + ? cause.message | |
| 1156 | + : "Failed to submit OpenCode permission reply.", | |
| 1157 | + cause, | |
| 1158 | + }), | |
| 1159 | + }); | |
| 1160 | + }); | |
| 1161 | + | |
| 1162 | + const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( | |
| 1163 | + "respondToUserInput", | |
| 1164 | + )(function* (threadId, requestId, answers) { | |
| 1165 | + const context = ensureSessionContext(sessions, threadId); | |
| 1166 | + const request = context.pendingQuestions.get(requestId); | |
| 1167 | + if (!request) { | |
| 1168 | + return yield* new ProviderAdapterRequestError({ | |
| 1169 | + provider: PROVIDER, | |
| 1170 | + method: "question.reply", | |
| 1171 | + detail: `Unknown pending user-input request: ${requestId}`, | |
| 1172 | + }); | |
| 1173 | + } | |
| 1174 | + | |
| 1175 | + yield* Effect.tryPromise({ | |
| 1176 | + try: () => | |
| 1177 | + context.client.question.reply({ | |
| 1178 | + requestID: requestId, | |
| 1179 | + answers: toOpenCodeQuestionAnswers(request, answers), | |
| 1180 | + }), | |
| 1181 | + catch: (cause) => | |
| 1182 | + new ProviderAdapterRequestError({ | |
| 1183 | + provider: PROVIDER, | |
| 1184 | + method: "question.reply", | |
| 1185 | + detail: cause instanceof Error ? cause.message : "Failed to submit OpenCode answers.", | |
| 1186 | + cause, | |
| 1187 | + }), | |
| 1188 | + }); | |
| 1189 | + }); | |
| 1190 | + | |
| 1191 | + const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( | |
| 1192 | + function* (threadId) { | |
| 1193 | + const context = ensureSessionContext(sessions, threadId); | |
| 1194 | + yield* Effect.tryPromise({ | |
| 1195 | + try: () => stopOpenCodeContext(context), | |
| 1196 | + catch: (cause) => | |
| 1197 | + new ProviderAdapterProcessError({ | |
| 1198 | + provider: PROVIDER, | |
| 1199 | + threadId, | |
| 1200 | + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode session.", | |
| 1201 | + cause, | |
| 1202 | + }), | |
| 1203 | + }); | |
| 1204 | + sessions.delete(threadId); | |
| 1205 | + yield* emit({ | |
| 1206 | + ...buildEventBase({ threadId }), | |
| 1207 | + type: "session.exited", | |
| 1208 | + payload: { | |
| 1209 | + reason: "Session stopped.", | |
| 1210 | + recoverable: false, | |
| 1211 | + exitKind: "graceful", | |
| 1212 | + }, | |
| 1213 | + }); | |
| 1214 | + }, | |
| 1215 | + ); | |
| 1216 | + | |
| 1217 | + const listSessions: OpenCodeAdapterShape["listSessions"] = () => | |
| 1218 | + Effect.sync(() => [...sessions.values()].map((context) => context.session)); | |
| 1219 | + | |
| 1220 | + const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => | |
| 1221 | + Effect.sync(() => sessions.has(threadId)); | |
| 1222 | + | |
| 1223 | + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( | |
| 1224 | + function* (threadId) { | |
| 1225 | + const context = ensureSessionContext(sessions, threadId); | |
| 1226 | + const messages = yield* Effect.tryPromise({ | |
| 1227 | + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), | |
| 1228 | + catch: (cause) => | |
| 1229 | + new ProviderAdapterRequestError({ | |
| 1230 | + provider: PROVIDER, | |
| 1231 | + method: "session.messages", | |
| 1232 | + detail: cause instanceof Error ? cause.message : "Failed to read OpenCode thread.", | |
| 1233 | + cause, | |
| 1234 | + }), | |
| 1235 | + }); | |
| 1236 | + | |
| 1237 | + const turns = (messages.data ?? []) | |
| 1238 | + .filter((entry) => entry.info.role === "assistant") | |
| 1239 | + .map((entry) => ({ | |
| 1240 | + id: TurnId.make(entry.info.id), | |
| 1241 | + items: [entry.info, ...entry.parts], | |
| 1242 | + })); | |
| 1243 | + | |
| 1244 | + return { | |
| 1245 | + threadId, | |
| 1246 | + turns, | |
| 1247 | + }; | |
| 1248 | + }, | |
| 1249 | + ); | |
| 1250 | + | |
| 1251 | + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( | |
| 1252 | + function* (threadId, numTurns) { | |
| 1253 | + const context = ensureSessionContext(sessions, threadId); | |
| 1254 | + const messages = yield* Effect.tryPromise({ | |
| 1255 | + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), | |
| 1256 | + catch: (cause) => | |
| 1257 | + new ProviderAdapterRequestError({ | |
| 1258 | + provider: PROVIDER, | |
| 1259 | + method: "session.messages", | |
| 1260 | + detail: | |
| 1261 | + cause instanceof Error ? cause.message : "Failed to inspect OpenCode thread.", | |
| 1262 | + cause, | |
| 1263 | + }), | |
| 1264 | + }); | |
| 1265 | + | |
| 1266 | + const assistantMessages = (messages.data ?? []).filter( | |
| 1267 | + (entry) => entry.info.role === "assistant", | |
| 1268 | + ); | |
| 1269 | + const targetIndex = assistantMessages.length - numTurns - 1; | |
| 1270 | + const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; | |
| 1271 | + yield* Effect.tryPromise({ | |
| 1272 | + try: () => | |
| 1273 | + context.client.session.revert({ | |
| 1274 | + sessionID: context.openCodeSessionId, | |
| 1275 | + ...(target ? { messageID: target.info.id } : {}), | |
| 1276 | + }), | |
| 1277 | + catch: (cause) => | |
| 1278 | + new ProviderAdapterRequestError({ | |
| 1279 | + provider: PROVIDER, | |
| 1280 | + method: "session.revert", | |
| 1281 | + detail: cause instanceof Error ? cause.message : "Failed to revert OpenCode turn.", | |
| 1282 | + cause, | |
| 1283 | + }), | |
| 1284 | + }); | |
| 1285 | + | |
| 1286 | + return yield* readThread(threadId); | |
| 1287 | + }, | |
| 1288 | + ); | |
| 1289 | + | |
| 1290 | + const stopAll: OpenCodeAdapterShape["stopAll"] = () => | |
| 1291 | + Effect.tryPromise({ | |
| 1292 | + try: async () => { | |
| 1293 | + const contexts = [...sessions.values()]; | |
| 1294 | + sessions.clear(); | |
| 1295 | + const results = await Promise.allSettled( | |
| 1296 | + contexts.map((context) => stopOpenCodeContext(context)), | |
| 1297 | + ); | |
| 1298 | + const errors = results | |
| 1299 | + .filter((result): result is PromiseRejectedResult => result.status === "rejected") | |
| 1300 | + .map((result) => result.reason); | |
| 1301 | + if (errors.length === 1) { | |
| 1302 | + throw errors[0]; | |
| 1303 | + } | |
| 1304 | + if (errors.length > 1) { | |
| 1305 | + throw new AggregateError( | |
| 1306 | + errors, | |
| 1307 | + `Failed to stop ${errors.length} OpenCode sessions.`, | |
| 1308 | + ); | |
| 1309 | + } | |
| 1310 | + }, | |
| 1311 | + catch: (cause) => | |
| 1312 | + new ProviderAdapterProcessError({ | |
| 1313 | + provider: PROVIDER, | |
| 1314 | + threadId: "*", | |
| 1315 | + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.", | |
| 1316 | + cause, | |
| 1317 | + }), | |
| 1318 | + }); | |
| 1319 | + | |
| 1320 | + return { | |
| 1321 | + provider: PROVIDER, | |
| 1322 | + capabilities: { | |
| 1323 | + sessionModelSwitch: "in-session", | |
| 1324 | + }, | |
| 1325 | + startSession, | |
| 1326 | + sendTurn, | |
| 1327 | + interruptTurn, | |
| 1328 | + respondToRequest, | |
| 1329 | + respondToUserInput, | |
| 1330 | + stopSession, | |
| 1331 | + listSessions, | |
| 1332 | + hasSession, | |
| 1333 | + readThread, | |
| 1334 | + rollbackThread, | |
| 1335 | + stopAll, | |
| 1336 | + get streamEvents() { | |
| 1337 | + return Stream.fromQueue(runtimeEvents); | |
| 1338 | + }, | |
| 1339 | + } satisfies OpenCodeAdapterShape; | |
| 1340 | + }), | |
| 1341 | + ); | |
| 1342 | +} | |
| 1343 | + | |
| 1344 | +export const OpenCodeAdapterLive = makeOpenCodeAdapterLive(); | |
| @@ -0,0 +1,138 @@ | ||
| 1 | +import assert from "node:assert/strict"; | |
| 2 | + | |
| 3 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | +import { it } from "@effect/vitest"; | |
| 5 | +import { Effect, Layer } from "effect"; | |
| 6 | +import { beforeEach, vi } from "vitest"; | |
| 7 | + | |
| 8 | +import { ServerConfig } from "../../config.ts"; | |
| 9 | +import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 10 | +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | |
| 11 | +import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts"; | |
| 12 | + | |
| 13 | +const runtimeMock = vi.hoisted(() => { | |
| 14 | + const state = { | |
| 15 | + runVersionError: null as Error | null, | |
| 16 | + inventoryError: null as Error | null, | |
| 17 | + }; | |
| 18 | + | |
| 19 | + return { | |
| 20 | + state, | |
| 21 | + reset() { | |
| 22 | + state.runVersionError = null; | |
| 23 | + state.inventoryError = null; | |
| 24 | + }, | |
| 25 | + }; | |
| 26 | +}); | |
| 27 | + | |
| 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 }; | |
| 39 | + }), | |
| 40 | + connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ | |
| 41 | + url: serverUrl ?? "http://127.0.0.1:4301", | |
| 42 | + process: null, | |
| 43 | + external: Boolean(serverUrl), | |
| 44 | + close() {}, | |
| 45 | + })), | |
| 46 | + createOpenCodeSdkClient: vi.fn(() => ({})), | |
| 47 | + loadOpenCodeInventory: vi.fn(async () => { | |
| 48 | + if (runtimeMock.state.inventoryError) { | |
| 49 | + throw runtimeMock.state.inventoryError; | |
| 50 | + } | |
| 51 | + return { | |
| 52 | + providerList: { connected: [], all: [] }, | |
| 53 | + agents: [], | |
| 54 | + }; | |
| 55 | + }), | |
| 56 | + flattenOpenCodeModels: vi.fn(() => []), | |
| 57 | + }; | |
| 58 | +}); | |
| 59 | + | |
| 60 | +beforeEach(() => { | |
| 61 | + runtimeMock.reset(); | |
| 62 | +}); | |
| 63 | + | |
| 64 | +const makeTestLayer = (settingsOverrides?: Parameters<typeof ServerSettingsService.layerTest>[0]) => | |
| 65 | + makeOpenCodeProviderLive().pipe( | |
| 66 | + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | |
| 67 | + Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)), | |
| 68 | + Layer.provideMerge(NodeServices.layer), | |
| 69 | + ); | |
| 70 | + | |
| 71 | +it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { | |
| 72 | + it.effect("shows a codex-style missing binary message", () => | |
| 73 | + Effect.gen(function* () { | |
| 74 | + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); | |
| 75 | + const provider = yield* OpenCodeProvider; | |
| 76 | + const snapshot = yield* provider.refresh; | |
| 77 | + | |
| 78 | + assert.equal(snapshot.status, "error"); | |
| 79 | + assert.equal(snapshot.installed, false); | |
| 80 | + assert.equal(snapshot.message, "OpenCode CLI (`opencode`) is not installed or not on PATH."); | |
| 81 | + }), | |
| 82 | + ); | |
| 83 | + | |
| 84 | + it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => | |
| 85 | + Effect.gen(function* () { | |
| 86 | + runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); | |
| 87 | + const provider = yield* OpenCodeProvider; | |
| 88 | + const snapshot = yield* provider.refresh; | |
| 89 | + | |
| 90 | + assert.equal(snapshot.status, "error"); | |
| 91 | + assert.equal(snapshot.installed, true); | |
| 92 | + assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); | |
| 93 | + }), | |
| 94 | + ); | |
| 95 | +}); | |
| 96 | + | |
| 97 | +it.layer( | |
| 98 | + makeTestLayer({ | |
| 99 | + providers: { | |
| 100 | + opencode: { | |
| 101 | + serverUrl: "http://127.0.0.1:9999", | |
| 102 | + serverPassword: "secret-password", | |
| 103 | + }, | |
| 104 | + }, | |
| 105 | + }), | |
| 106 | +)("OpenCodeProviderLive with configured server URL", (it) => { | |
| 107 | + it.effect("surfaces a friendly auth error for configured servers", () => | |
| 108 | + Effect.gen(function* () { | |
| 109 | + runtimeMock.state.inventoryError = new Error("401 Unauthorized"); | |
| 110 | + const provider = yield* OpenCodeProvider; | |
| 111 | + const snapshot = yield* provider.refresh; | |
| 112 | + | |
| 113 | + assert.equal(snapshot.status, "error"); | |
| 114 | + assert.equal(snapshot.installed, true); | |
| 115 | + assert.equal( | |
| 116 | + snapshot.message, | |
| 117 | + "OpenCode server rejected authentication. Check the server URL and password.", | |
| 118 | + ); | |
| 119 | + }), | |
| 120 | + ); | |
| 121 | + | |
| 122 | + it.effect("surfaces a friendly connection error for configured servers", () => | |
| 123 | + Effect.gen(function* () { | |
| 124 | + runtimeMock.state.inventoryError = new Error( | |
| 125 | + "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", | |
| 126 | + ); | |
| 127 | + const provider = yield* OpenCodeProvider; | |
| 128 | + const snapshot = yield* provider.refresh; | |
| 129 | + | |
| 130 | + assert.equal(snapshot.status, "error"); | |
| 131 | + assert.equal(snapshot.installed, true); | |
| 132 | + assert.equal( | |
| 133 | + snapshot.message, | |
| 134 | + "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", | |
| 135 | + ); | |
| 136 | + }), | |
| 137 | + ); | |
| 138 | +}); | |
| @@ -0,0 +1,342 @@ | ||
| 1 | +import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts"; | |
| 2 | +import { Cause, Effect, Equal, Layer, Stream } from "effect"; | |
| 3 | + | |
| 4 | +import { ServerConfig } from "../../config.ts"; | |
| 5 | +import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 6 | +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | |
| 7 | +import { | |
| 8 | + buildServerProvider, | |
| 9 | + isCommandMissingCause, | |
| 10 | + parseGenericCliVersion, | |
| 11 | + providerModelsFromSettings, | |
| 12 | +} from "../providerSnapshot.ts"; | |
| 13 | +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | |
| 14 | +import { | |
| 15 | + connectToOpenCodeServer, | |
| 16 | + DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 17 | + createOpenCodeSdkClient, | |
| 18 | + flattenOpenCodeModels, | |
| 19 | + loadOpenCodeInventory, | |
| 20 | + runOpenCodeCommand, | |
| 21 | +} from "../opencodeRuntime.ts"; | |
| 22 | + | |
| 23 | +const PROVIDER = "opencode" as const; | |
| 24 | + | |
| 25 | +class OpenCodeProbePromiseError extends Error { | |
| 26 | + override readonly cause: unknown; | |
| 27 | + | |
| 28 | + constructor(cause: unknown) { | |
| 29 | + super(cause instanceof Error ? cause.message : String(cause)); | |
| 30 | + this.cause = cause; | |
| 31 | + this.name = "OpenCodeProbePromiseError"; | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +function toOpenCodeProbeError(cause: unknown): OpenCodeProbePromiseError { | |
| 36 | + return new OpenCodeProbePromiseError(cause); | |
| 37 | +} | |
| 38 | + | |
| 39 | +function normalizedErrorMessage(cause: unknown): string | undefined { | |
| 40 | + if (!(cause instanceof Error)) { | |
| 41 | + return undefined; | |
| 42 | + } | |
| 43 | + | |
| 44 | + const message = cause.message.trim(); | |
| 45 | + if (message.length === 0) { | |
| 46 | + return undefined; | |
| 47 | + } | |
| 48 | + if ( | |
| 49 | + message === "An error occurred in Effect.tryPromise" || | |
| 50 | + message === "An error occurred in Effect.try" | |
| 51 | + ) { | |
| 52 | + return undefined; | |
| 53 | + } | |
| 54 | + return message; | |
| 55 | +} | |
| 56 | + | |
| 57 | +function formatOpenCodeProbeError(input: { | |
| 58 | + readonly cause: unknown; | |
| 59 | + readonly isExternalServer: boolean; | |
| 60 | + readonly serverUrl: string; | |
| 61 | +}): { readonly installed: boolean; readonly message: string } { | |
| 62 | + const lower = input.cause instanceof Error ? input.cause.message.toLowerCase() : ""; | |
| 63 | + const detail = normalizedErrorMessage(input.cause); | |
| 64 | + | |
| 65 | + if (input.isExternalServer) { | |
| 66 | + if ( | |
| 67 | + lower.includes("401") || | |
| 68 | + lower.includes("403") || | |
| 69 | + lower.includes("unauthorized") || | |
| 70 | + lower.includes("forbidden") | |
| 71 | + ) { | |
| 72 | + return { | |
| 73 | + installed: true, | |
| 74 | + message: "OpenCode server rejected authentication. Check the server URL and password.", | |
| 75 | + }; | |
| 76 | + } | |
| 77 | + | |
| 78 | + if ( | |
| 79 | + lower.includes("econnrefused") || | |
| 80 | + lower.includes("enotfound") || | |
| 81 | + lower.includes("fetch failed") || | |
| 82 | + lower.includes("networkerror") || | |
| 83 | + lower.includes("timed out") || | |
| 84 | + lower.includes("timeout") || | |
| 85 | + lower.includes("socket hang up") | |
| 86 | + ) { | |
| 87 | + return { | |
| 88 | + installed: true, | |
| 89 | + message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, | |
| 90 | + }; | |
| 91 | + } | |
| 92 | + | |
| 93 | + return { | |
| 94 | + installed: true, | |
| 95 | + message: detail ?? "Failed to connect to the configured OpenCode server.", | |
| 96 | + }; | |
| 97 | + } | |
| 98 | + | |
| 99 | + if (input.cause instanceof Error && isCommandMissingCause(input.cause)) { | |
| 100 | + return { | |
| 101 | + installed: false, | |
| 102 | + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", | |
| 103 | + }; | |
| 104 | + } | |
| 105 | + | |
| 106 | + if (lower.includes("quarantine")) { | |
| 107 | + return { | |
| 108 | + installed: true, | |
| 109 | + message: | |
| 110 | + "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", | |
| 111 | + }; | |
| 112 | + } | |
| 113 | + | |
| 114 | + if (lower.includes("invalid code signature") || lower.includes("corrupted")) { | |
| 115 | + return { | |
| 116 | + installed: true, | |
| 117 | + message: | |
| 118 | + "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", | |
| 119 | + }; | |
| 120 | + } | |
| 121 | + | |
| 122 | + return { | |
| 123 | + installed: true, | |
| 124 | + message: detail | |
| 125 | + ? `Failed to execute OpenCode CLI health check: ${detail}` | |
| 126 | + : "Failed to execute OpenCode CLI health check.", | |
| 127 | + }; | |
| 128 | +} | |
| 129 | + | |
| 130 | +const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => { | |
| 131 | + const checkedAt = new Date().toISOString(); | |
| 132 | + const models = providerModelsFromSettings( | |
| 133 | + [], | |
| 134 | + PROVIDER, | |
| 135 | + openCodeSettings.customModels, | |
| 136 | + DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 137 | + ); | |
| 138 | + | |
| 139 | + if (!openCodeSettings.enabled) { | |
| 140 | + return buildServerProvider({ | |
| 141 | + provider: PROVIDER, | |
| 142 | + enabled: false, | |
| 143 | + checkedAt, | |
| 144 | + models, | |
| 145 | + probe: { | |
| 146 | + installed: false, | |
| 147 | + version: null, | |
| 148 | + status: "warning", | |
| 149 | + auth: { status: "unknown" }, | |
| 150 | + message: | |
| 151 | + openCodeSettings.serverUrl.trim().length > 0 | |
| 152 | + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | |
| 153 | + : "OpenCode is disabled in T3 Code settings.", | |
| 154 | + }, | |
| 155 | + }); | |
| 156 | + } | |
| 157 | + | |
| 158 | + return buildServerProvider({ | |
| 159 | + provider: PROVIDER, | |
| 160 | + enabled: true, | |
| 161 | + checkedAt, | |
| 162 | + models, | |
| 163 | + probe: { | |
| 164 | + installed: false, | |
| 165 | + version: null, | |
| 166 | + status: "warning", | |
| 167 | + auth: { status: "unknown" }, | |
| 168 | + message: "OpenCode provider status has not been checked in this session yet.", | |
| 169 | + }, | |
| 170 | + }); | |
| 171 | +}; | |
| 172 | + | |
| 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 | + [], | |
| 193 | + PROVIDER, | |
| 194 | + customModels, | |
| 195 | + DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 196 | + ), | |
| 197 | + probe: { | |
| 198 | + installed: failure.installed, | |
| 199 | + version, | |
| 200 | + status: "error", | |
| 201 | + auth: { status: "unknown" }, | |
| 202 | + message: failure.message, | |
| 203 | + }, | |
| 204 | + }); | |
| 205 | + }; | |
| 206 | + | |
| 207 | + return Effect.gen(function* () { | |
| 208 | + if (!input.settings.enabled) { | |
| 209 | + return buildServerProvider({ | |
| 210 | + provider: PROVIDER, | |
| 211 | + enabled: false, | |
| 212 | + checkedAt, | |
| 213 | + models: providerModelsFromSettings( | |
| 214 | + [], | |
| 215 | + PROVIDER, | |
| 216 | + customModels, | |
| 217 | + DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 218 | + ), | |
| 219 | + probe: { | |
| 220 | + installed: false, | |
| 221 | + version: null, | |
| 222 | + status: "warning", | |
| 223 | + auth: { status: "unknown" }, | |
| 224 | + message: isExternalServer | |
| 225 | + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." | |
| 226 | + : "OpenCode is disabled in T3 Code settings.", | |
| 227 | + }, | |
| 228 | + }); | |
| 229 | + } | |
| 230 | + | |
| 231 | + let version: string | null = null; | |
| 232 | + if (!isExternalServer) { | |
| 233 | + const versionExit = yield* Effect.exit( | |
| 234 | + Effect.tryPromise({ | |
| 235 | + try: () => | |
| 236 | + runOpenCodeCommand({ | |
| 237 | + binaryPath: input.settings.binaryPath, | |
| 238 | + args: ["--version"], | |
| 239 | + }), | |
| 240 | + catch: toOpenCodeProbeError, | |
| 241 | + }), | |
| 242 | + ); | |
| 243 | + if (versionExit._tag === "Failure") { | |
| 244 | + return fallback(Cause.squash(versionExit.cause)); | |
| 245 | + } | |
| 246 | + version = parseGenericCliVersion(versionExit.value.stdout) ?? null; | |
| 247 | + } | |
| 248 | + | |
| 249 | + const inventoryExit = yield* Effect.exit( | |
| 250 | + Effect.acquireUseRelease( | |
| 251 | + Effect.tryPromise({ | |
| 252 | + try: () => | |
| 253 | + connectToOpenCodeServer({ | |
| 254 | + binaryPath: input.settings.binaryPath, | |
| 255 | + serverUrl: input.settings.serverUrl, | |
| 256 | + }), | |
| 257 | + catch: toOpenCodeProbeError, | |
| 258 | + }), | |
| 259 | + (server) => | |
| 260 | + Effect.tryPromise({ | |
| 261 | + try: async () => { | |
| 262 | + const client = createOpenCodeSdkClient({ | |
| 263 | + baseUrl: server.url, | |
| 264 | + directory: input.cwd, | |
| 265 | + ...(isExternalServer && input.settings.serverPassword | |
| 266 | + ? { serverPassword: input.settings.serverPassword } | |
| 267 | + : {}), | |
| 268 | + }); | |
| 269 | + return await loadOpenCodeInventory(client); | |
| 270 | + }, | |
| 271 | + catch: toOpenCodeProbeError, | |
| 272 | + }), | |
| 273 | + (server) => Effect.sync(() => server.close()), | |
| 274 | + ), | |
| 275 | + ); | |
| 276 | + if (inventoryExit._tag === "Failure") { | |
| 277 | + return fallback(Cause.squash(inventoryExit.cause), version); | |
| 278 | + } | |
| 279 | + | |
| 280 | + const models = providerModelsFromSettings( | |
| 281 | + flattenOpenCodeModels(inventoryExit.value), | |
| 282 | + PROVIDER, | |
| 283 | + customModels, | |
| 284 | + DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 285 | + ); | |
| 286 | + const connectedCount = inventoryExit.value.providerList.connected.length; | |
| 287 | + return buildServerProvider({ | |
| 288 | + provider: PROVIDER, | |
| 289 | + enabled: true, | |
| 290 | + checkedAt, | |
| 291 | + models, | |
| 292 | + probe: { | |
| 293 | + installed: true, | |
| 294 | + version, | |
| 295 | + status: connectedCount > 0 ? "ready" : "warning", | |
| 296 | + auth: { | |
| 297 | + status: connectedCount > 0 ? "authenticated" : "unknown", | |
| 298 | + type: "opencode", | |
| 299 | + }, | |
| 300 | + message: | |
| 301 | + connectedCount > 0 | |
| 302 | + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` | |
| 303 | + : isExternalServer | |
| 304 | + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." | |
| 305 | + : "OpenCode is available, but it did not report any connected upstream providers.", | |
| 306 | + }, | |
| 307 | + }); | |
| 308 | + }); | |
| 309 | +} | |
| 310 | + | |
| 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(); | |
| @@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; · 7 unmodified lines | ||
| 8 | 8 | import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; |
| 9 | 9 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 10 | 10 | import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 11 | +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; | |
| 12 | +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; | |
| 11 | 13 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 12 | 14 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; |
| 13 | 15 | import { ProviderUnsupportedError } from "../Errors.ts"; |
| @@ -47,6 +49,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { · 33 unmodified lines | ||
| 47 | 49 | streamEvents: Stream.empty, |
| 48 | 50 | }; |
| 49 | 51 | |
| 52 | +const fakeOpenCodeAdapter: OpenCodeAdapterShape = { | |
| 53 | + provider: "opencode", | |
| 54 | + capabilities: { sessionModelSwitch: "in-session" }, | |
| 55 | + startSession: vi.fn(), | |
| 56 | + sendTurn: vi.fn(), | |
| 57 | + interruptTurn: vi.fn(), | |
| 58 | + respondToRequest: vi.fn(), | |
| 59 | + respondToUserInput: vi.fn(), | |
| 60 | + stopSession: vi.fn(), | |
| 61 | + listSessions: vi.fn(), | |
| 62 | + hasSession: vi.fn(), | |
| 63 | + readThread: vi.fn(), | |
| 64 | + rollbackThread: vi.fn(), | |
| 65 | + stopAll: vi.fn(), | |
| 66 | + streamEvents: Stream.empty, | |
| 67 | +}; | |
| 68 | + | |
| 50 | 69 | const layer = it.layer( |
| 51 | 70 | Layer.mergeAll( |
| 52 | 71 | Layer.provide( |
| @@ -54,6 +73,7 @@ const layer = it.layer( · 1 unmodified line | ||
| 54 | 73 | Layer.mergeAll( |
| 55 | 74 | Layer.succeed(CodexAdapter, fakeCodexAdapter), |
| 56 | 75 | Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), |
| 76 | + Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), | |
| 57 | 77 | ), |
| 58 | 78 | ), |
| 59 | 79 | NodeServices.layer, |
| @@ -66,11 +86,13 @@ layer("ProviderAdapterRegistryLive", (it) => { · 6 unmodified lines | ||
| 66 | 86 | const registry = yield* ProviderAdapterRegistry; |
| 67 | 87 | const codex = yield* registry.getByProvider("codex"); |
| 68 | 88 | const claude = yield* registry.getByProvider("claudeAgent"); |
| 89 | + const openCode = yield* registry.getByProvider("opencode"); | |
| 69 | 90 | assert.equal(codex, fakeCodexAdapter); |
| 70 | 91 | assert.equal(claude, fakeClaudeAdapter); |
| 92 | + assert.equal(openCode, fakeOpenCodeAdapter); | |
| 71 | 93 | |
| 72 | 94 | const providers = yield* registry.listProviders(); |
| 73 | − assert.deepEqual(providers, ["codex", "claudeAgent"]); | |
| 95 | + assert.deepEqual(providers, ["codex", "claudeAgent", "opencode"]); | |
| 74 | 96 | }), |
| 75 | 97 | ); |
| 76 | 98 | |
| @@ -17,6 +17,7 @@ import { · 16 unmodified lines | ||
| 17 | 17 | } from "../Services/ProviderAdapterRegistry.ts"; |
| 18 | 18 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; |
| 19 | 19 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 20 | +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; | |
| 20 | 21 | |
| 21 | 22 | export interface ProviderAdapterRegistryLiveOptions { |
| 22 | 23 | readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>; |
| @@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun · 5 unmodified lines | ||
| 28 | 29 | const adapters = |
| 29 | 30 | options?.adapters !== undefined |
| 30 | 31 | ? options.adapters |
| 31 | − : [yield* CodexAdapter, yield* ClaudeAdapter]; | |
| 32 | + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* OpenCodeAdapter]; | |
| 32 | 33 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); |
| 33 | 34 | |
| 34 | 35 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { |
| @@ -31,6 +31,7 @@ import { · 30 unmodified lines | ||
| 31 | 31 | } from "./CodexProvider.ts"; |
| 32 | 32 | import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; |
| 33 | 33 | import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; |
| 34 | +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | |
| 34 | 35 | import { ServerConfig } from "../../config.ts"; |
| 35 | 36 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; |
| 36 | 37 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; |
| @@ -38,6 +39,19 @@ import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; · 1 unmodified line | ||
| 38 | 39 | // ── Test helpers ──────────────────────────────────────────────────── |
| 39 | 40 | |
| 40 | 41 | const encoder = new TextEncoder(); |
| 42 | +const fakeOpenCodeSnapshot: ServerProvider = { | |
| 43 | + provider: "opencode", | |
| 44 | + status: "warning", | |
| 45 | + enabled: true, | |
| 46 | + installed: false, | |
| 47 | + auth: { status: "unknown" }, | |
| 48 | + checkedAt: "2026-03-25T00:00:00.000Z", | |
| 49 | + version: null, | |
| 50 | + models: [], | |
| 51 | + slashCommands: [], | |
| 52 | + skills: [], | |
| 53 | + message: "OpenCode test stub", | |
| 54 | +}; | |
| 41 | 55 | |
| 42 | 56 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { |
| 43 | 57 | return ChildProcessSpawner.makeHandle({ |
| @@ -596,12 +610,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 552 unmodified lines | ||
| 596 | 610 | }), |
| 597 | 611 | ), |
| 598 | 612 | ); |
| 599 | − const runtimeServices = yield* Layer.build( | |
| 600 | − Layer.mergeAll( | |
| 601 | − Layer.succeed(ServerSettingsService, serverSettings), | |
| 602 | − providerRegistryLayer, | |
| 603 | − ), | |
| 604 | − ).pipe(Scope.provide(scope)); | |
| 613 | + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( | |
| 614 | + Scope.provide(scope), | |
| 615 | + ); | |
| 605 | 616 | |
| 606 | 617 | yield* Effect.gen(function* () { |
| 607 | 618 | const registry = yield* ProviderRegistry; |
| @@ -630,6 +641,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 22 unmodified lines | ||
| 630 | 641 | prefix: "t3-provider-registry-", |
| 631 | 642 | }), |
| 632 | 643 | ), |
| 644 | + Layer.provideMerge( | |
| 645 | + Layer.succeed(OpenCodeProvider, { | |
| 646 | + getSnapshot: Effect.succeed(fakeOpenCodeSnapshot), | |
| 647 | + refresh: Effect.succeed(fakeOpenCodeSnapshot), | |
| 648 | + streamChanges: Stream.empty, | |
| 649 | + }), | |
| 650 | + ), | |
| 633 | 651 | Layer.provideMerge( |
| 634 | 652 | mockCommandSpawnerLayer((command, args) => { |
| 635 | 653 | const joined = args.join(" "); |
| @@ -646,12 +664,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( · 10 unmodified lines | ||
| 646 | 664 | }), |
| 647 | 665 | ), |
| 648 | 666 | ); |
| 649 | − const runtimeServices = yield* Layer.build( | |
| 650 | − Layer.mergeAll( | |
| 651 | − Layer.succeed(ServerSettingsService, serverSettings), | |
| 652 | − providerRegistryLayer, | |
| 653 | − ), | |
| 654 | − ).pipe(Scope.provide(scope)); | |
| 667 | + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( | |
| 668 | + Scope.provide(scope), | |
| 669 | + ); | |
| 655 | 670 | |
| 656 | 671 | yield* Effect.gen(function* () { |
| 657 | 672 | const registry = yield* ProviderRegistry; |
| @@ -9,10 +9,13 @@ import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "eff · 8 unmodified lines | ||
| 9 | 9 | import { ServerConfig } from "../../config.ts"; |
| 10 | 10 | import { ClaudeProviderLive } from "./ClaudeProvider.ts"; |
| 11 | 11 | import { CodexProviderLive } from "./CodexProvider.ts"; |
| 12 | +import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; | |
| 12 | 13 | import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts"; |
| 13 | 14 | import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; |
| 14 | 15 | import type { CodexProviderShape } from "../Services/CodexProvider.ts"; |
| 15 | 16 | import { CodexProvider } from "../Services/CodexProvider.ts"; |
| 17 | +import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider.ts"; | |
| 18 | +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | |
| 16 | 19 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; |
| 17 | 20 | import { |
| 18 | 21 | hydrateCachedProvider, |
| @@ -26,10 +29,14 @@ import { · 7 unmodified lines | ||
| 26 | 29 | const loadProviders = ( |
| 27 | 30 | codexProvider: CodexProviderShape, |
| 28 | 31 | claudeProvider: ClaudeProviderShape, |
| 29 | −): Effect.Effect<readonly [ServerProvider, ServerProvider]> => | |
| 30 | − Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { | |
| 31 | − concurrency: "unbounded", | |
| 32 | − }); | |
| 32 | + openCodeProvider: OpenCodeProviderShape, | |
| 33 | +): Effect.Effect<readonly [ServerProvider, ServerProvider, ServerProvider]> => | |
| 34 | + Effect.all( | |
| 35 | + [codexProvider.getSnapshot, claudeProvider.getSnapshot, openCodeProvider.getSnapshot], | |
| 36 | + { | |
| 37 | + concurrency: "unbounded", | |
| 38 | + }, | |
| 39 | + ); | |
| 33 | 40 | |
| 34 | 41 | export const haveProvidersChanged = ( |
| 35 | 42 | previousProviders: ReadonlyArray<ServerProvider>, |
| @@ -41,6 +48,7 @@ export const ProviderRegistryLive = Layer.effect( · 5 unmodified lines | ||
| 41 | 48 | Effect.gen(function* () { |
| 42 | 49 | const codexProvider = yield* CodexProvider; |
| 43 | 50 | const claudeProvider = yield* ClaudeProvider; |
| 51 | + const openCodeProvider = yield* OpenCodeProvider; | |
| 44 | 52 | const config = yield* ServerConfig; |
| 45 | 53 | const fileSystem = yield* FileSystem.FileSystem; |
| 46 | 54 | const path = yield* Path.Path; |
| @@ -48,7 +56,7 @@ export const ProviderRegistryLive = Layer.effect( · 1 unmodified line | ||
| 48 | 56 | PubSub.unbounded<ReadonlyArray<ServerProvider>>(), |
| 49 | 57 | PubSub.shutdown, |
| 50 | 58 | ); |
| 51 | − const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider); | |
| 59 | + const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider, openCodeProvider); | |
| 52 | 60 | const cachePathByProvider = new Map( |
| 53 | 61 | PROVIDER_CACHE_IDS.map( |
| 54 | 62 | (provider) => |
| @@ -156,6 +164,10 @@ export const ProviderRegistryLive = Layer.effect( · 101 unmodified lines | ||
| 156 | 164 | return yield* claudeProvider.refresh.pipe( |
| 157 | 165 | Effect.flatMap((nextProvider) => syncProvider(nextProvider)), |
| 158 | 166 | ); |
| 167 | + case "opencode": | |
| 168 | + return yield* openCodeProvider.refresh.pipe( | |
| 169 | + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), | |
| 170 | + ); | |
| 159 | 171 | default: |
| 160 | 172 | return yield* Effect.all( |
| 161 | 173 | [ |
| @@ -165,6 +177,9 @@ export const ProviderRegistryLive = Layer.effect( · 3 unmodified lines | ||
| 165 | 177 | claudeProvider.refresh.pipe( |
| 166 | 178 | Effect.flatMap((nextProvider) => syncProvider(nextProvider)), |
| 167 | 179 | ), |
| 180 | + openCodeProvider.refresh.pipe( | |
| 181 | + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), | |
| 182 | + ), | |
| 168 | 183 | ], |
| 169 | 184 | { |
| 170 | 185 | concurrency: "unbounded", |
| @@ -180,6 +195,9 @@ export const ProviderRegistryLive = Layer.effect( · 9 unmodified lines | ||
| 180 | 195 | yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => |
| 181 | 196 | syncProvider(provider), |
| 182 | 197 | ).pipe(Effect.forkScoped); |
| 198 | + yield* Stream.runForEach(openCodeProvider.streamChanges, (provider) => | |
| 199 | + syncProvider(provider), | |
| 200 | + ).pipe(Effect.forkScoped); | |
| 183 | 201 | |
| 184 | 202 | return { |
| 185 | 203 | getProviders: Ref.get(providersRef), |
| @@ -193,4 +211,8 @@ export const ProviderRegistryLive = Layer.effect( · 7 unmodified lines | ||
| 193 | 211 | }, |
| 194 | 212 | } satisfies ProviderRegistryShape; |
| 195 | 213 | }), |
| 196 | −).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); | |
| 214 | +).pipe( | |
| 215 | + Layer.provideMerge(CodexProviderLive), | |
| 216 | + Layer.provideMerge(ClaudeProviderLive), | |
| 217 | + Layer.provideMerge(OpenCodeProviderLive), | |
| 218 | +); | |
| @@ -333,6 +333,62 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () · 332 unmodified lines | ||
| 333 | 333 | ); |
| 334 | 334 | |
| 335 | 335 | const routing = makeProviderServiceLayer(); |
| 336 | + | |
| 337 | +it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => | |
| 338 | + Effect.gen(function* () { | |
| 339 | + const codex = makeFakeCodexAdapter(); | |
| 340 | + const canonicalEvents: ProviderRuntimeEvent[] = []; | |
| 341 | + const canonicalThreadIds: Array<string | null> = []; | |
| 342 | + const registry: typeof ProviderAdapterRegistry.Service = { | |
| 343 | + getByProvider: (provider) => | |
| 344 | + provider === "codex" | |
| 345 | + ? Effect.succeed(codex.adapter) | |
| 346 | + : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 347 | + listProviders: () => Effect.succeed(["codex"]), | |
| 348 | + }; | |
| 349 | + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( | |
| 350 | + Layer.provide(SqlitePersistenceMemory), | |
| 351 | + ); | |
| 352 | + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); | |
| 353 | + const providerLayer = makeProviderServiceLive({ | |
| 354 | + canonicalEventLogger: { | |
| 355 | + filePath: "memory://provider-canonical-events", | |
| 356 | + write: (event, threadId) => { | |
| 357 | + canonicalEvents.push(event as ProviderRuntimeEvent); | |
| 358 | + canonicalThreadIds.push(threadId ?? null); | |
| 359 | + return Effect.void; | |
| 360 | + }, | |
| 361 | + close: () => Effect.void, | |
| 362 | + }, | |
| 363 | + }).pipe( | |
| 364 | + Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), | |
| 365 | + Layer.provide(directoryLayer), | |
| 366 | + Layer.provide(defaultServerSettingsLayer), | |
| 367 | + Layer.provide(AnalyticsService.layerTest), | |
| 368 | + ); | |
| 369 | + | |
| 370 | + yield* Effect.gen(function* () { | |
| 371 | + yield* ProviderService; | |
| 372 | + yield* sleep(10); | |
| 373 | + codex.emit({ | |
| 374 | + eventId: asEventId("evt-canonical-thread-segment"), | |
| 375 | + provider: "codex", | |
| 376 | + threadId: asThreadId("thread-canonical-thread-segment"), | |
| 377 | + createdAt: new Date().toISOString(), | |
| 378 | + type: "turn.completed", | |
| 379 | + payload: { | |
| 380 | + state: "completed", | |
| 381 | + }, | |
| 382 | + }); | |
| 383 | + yield* sleep(20); | |
| 384 | + }).pipe(Effect.provide(providerLayer)); | |
| 385 | + | |
| 386 | + assert.equal(canonicalEvents.length, 1); | |
| 387 | + assert.equal(canonicalEvents[0]?.threadId, "thread-canonical-thread-segment"); | |
| 388 | + assert.deepEqual(canonicalThreadIds, ["thread-canonical-thread-segment"]); | |
| 389 | + }).pipe(Effect.provide(NodeServices.layer)), | |
| 390 | +); | |
| 391 | + | |
| 336 | 392 | it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => |
| 337 | 393 | Effect.gen(function* () { |
| 338 | 394 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); |
| @@ -161,7 +161,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( · 160 unmodified lines | ||
| 161 | 161 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 162 | 162 | Effect.succeed(event).pipe( |
| 163 | 163 | Effect.tap((canonicalEvent) => |
| 164 | − canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void, | |
| 164 | + canonicalEventLogger | |
| 165 | + ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) | |
| 166 | + : Effect.void, | |
| 165 | 167 | ), |
| 166 | 168 | Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), |
| 167 | 169 | Effect.asVoid, |
| @@ -24,7 +24,7 @@ function decodeProviderKind( · 23 unmodified lines | ||
| 24 | 24 | providerName: string, |
| 25 | 25 | operation: string, |
| 26 | 26 | ): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> { |
| 27 | − if (providerName === "codex" || providerName === "claudeAgent") { | |
| 27 | + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") { | |
| 28 | 28 | return Effect.succeed(providerName); |
| 29 | 29 | } |
| 30 | 30 | return Effect.fail( |
| @@ -0,0 +1,12 @@ | ||
| 1 | +import { Context } from "effect"; | |
| 2 | + | |
| 3 | +import type { ProviderAdapterError } from "../Errors.ts"; | |
| 4 | +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; | |
| 5 | + | |
| 6 | +export interface OpenCodeAdapterShape extends ProviderAdapterShape<ProviderAdapterError> { | |
| 7 | + readonly provider: "opencode"; | |
| 8 | +} | |
| 9 | + | |
| 10 | +export class OpenCodeAdapter extends Context.Service<OpenCodeAdapter, OpenCodeAdapterShape>()( | |
| 11 | + "t3/provider/Services/OpenCodeAdapter", | |
| 12 | +) {} | |
| @@ -0,0 +1,9 @@ | ||
| 1 | +import { Context } from "effect"; | |
| 2 | + | |
| 3 | +import type { ServerProviderShape } from "./ServerProvider.ts"; | |
| 4 | + | |
| 5 | +export interface OpenCodeProviderShape extends ServerProviderShape {} | |
| 6 | + | |
| 7 | +export class OpenCodeProvider extends Context.Service<OpenCodeProvider, OpenCodeProviderShape>()( | |
| 8 | + "t3/provider/Services/OpenCodeProvider", | |
| 9 | +) {} | |
| @@ -0,0 +1,38 @@ | ||
| 1 | +import assert from "node:assert/strict"; | |
| 2 | + | |
| 3 | +import { describe, it, vi } from "vitest"; | |
| 4 | + | |
| 5 | +const childProcessMock = vi.hoisted(() => ({ | |
| 6 | + execFileSync: vi.fn((command: string, args: ReadonlyArray<string>) => { | |
| 7 | + if (command === "which" && args[0] === "opencode") { | |
| 8 | + return "/opt/homebrew/bin/opencode\n"; | |
| 9 | + } | |
| 10 | + return ""; | |
| 11 | + }), | |
| 12 | + spawn: vi.fn(), | |
| 13 | +})); | |
| 14 | + | |
| 15 | +vi.mock("node:child_process", () => childProcessMock); | |
| 16 | + | |
| 17 | +describe("resolveOpenCodeBinaryPath", () => { | |
| 18 | + it("returns absolute binary paths without PATH lookup", async () => { | |
| 19 | + const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); | |
| 20 | + | |
| 21 | + assert.equal(resolveOpenCodeBinaryPath("/usr/local/bin/opencode"), "/usr/local/bin/opencode"); | |
| 22 | + assert.equal(childProcessMock.execFileSync.mock.calls.length, 0); | |
| 23 | + }); | |
| 24 | + | |
| 25 | + it("resolves command names through PATH", async () => { | |
| 26 | + const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); | |
| 27 | + | |
| 28 | + assert.equal(resolveOpenCodeBinaryPath("opencode"), "/opt/homebrew/bin/opencode"); | |
| 29 | + assert.deepEqual(childProcessMock.execFileSync.mock.calls[0], [ | |
| 30 | + "which", | |
| 31 | + ["opencode"], | |
| 32 | + { | |
| 33 | + encoding: "utf8", | |
| 34 | + timeout: 3_000, | |
| 35 | + }, | |
| 36 | + ]); | |
| 37 | + }); | |
| 38 | +}); | |
| @@ -0,0 +1,573 @@ | ||
| 1 | +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; | |
| 2 | +import * as FS from "node:fs"; | |
| 3 | +import { createServer, type AddressInfo } from "node:net"; | |
| 4 | +import * as OS from "node:os"; | |
| 5 | +import * as Path from "node:path"; | |
| 6 | +import { pathToFileURL } from "node:url"; | |
| 7 | + | |
| 8 | +import type { | |
| 9 | + ChatAttachment, | |
| 10 | + ModelCapabilities, | |
| 11 | + ProviderApprovalDecision, | |
| 12 | + RuntimeMode, | |
| 13 | + ServerProviderModel, | |
| 14 | +} from "@t3tools/contracts"; | |
| 15 | +import { | |
| 16 | + createOpencodeClient, | |
| 17 | + type Agent, | |
| 18 | + type FilePartInput, | |
| 19 | + type OpencodeClient, | |
| 20 | + type PermissionRuleset, | |
| 21 | + type ProviderListResponse, | |
| 22 | + type QuestionAnswer, | |
| 23 | + type QuestionRequest, | |
| 24 | +} from "@opencode-ai/sdk/v2"; | |
| 25 | + | |
| 26 | +const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; | |
| 27 | +const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; | |
| 28 | +const DEFAULT_HOSTNAME = "127.0.0.1"; | |
| 29 | + | |
| 30 | +const OPENAI_VARIANTS = ["none", "minimal", "low", "medium", "high", "xhigh"]; | |
| 31 | +const ANTHROPIC_VARIANTS = ["high", "max"]; | |
| 32 | +const GOOGLE_VARIANTS = ["low", "high"]; | |
| 33 | + | |
| 34 | +export const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { | |
| 35 | + reasoningEffortLevels: [], | |
| 36 | + supportsFastMode: false, | |
| 37 | + supportsThinkingToggle: false, | |
| 38 | + contextWindowOptions: [], | |
| 39 | + promptInjectedEffortLevels: [], | |
| 40 | +}; | |
| 41 | + | |
| 42 | +export interface OpenCodeServerProcess { | |
| 43 | + readonly url: string; | |
| 44 | + readonly process: ChildProcess; | |
| 45 | + close(): void; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export interface OpenCodeServerConnection { | |
| 49 | + readonly url: string; | |
| 50 | + readonly process: ChildProcess | null; | |
| 51 | + readonly external: boolean; | |
| 52 | + close(): void; | |
| 53 | +} | |
| 54 | + | |
| 55 | +function buildOpenCodeBasicAuthorizationHeader(password: string): string { | |
| 56 | + return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export interface OpenCodeCommandResult { | |
| 60 | + readonly stdout: string; | |
| 61 | + readonly stderr: string; | |
| 62 | + readonly code: number; | |
| 63 | +} | |
| 64 | + | |
| 65 | +export interface OpenCodeInventory { | |
| 66 | + readonly providerList: ProviderListResponse; | |
| 67 | + readonly agents: ReadonlyArray<Agent>; | |
| 68 | +} | |
| 69 | + | |
| 70 | +export interface ParsedOpenCodeModelSlug { | |
| 71 | + readonly providerID: string; | |
| 72 | + readonly modelID: string; | |
| 73 | +} | |
| 74 | + | |
| 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(" "); | |
| 81 | +} | |
| 82 | + | |
| 83 | +function parseServerUrlFromOutput(output: string): string | null { | |
| 84 | + for (const line of output.split("\n")) { | |
| 85 | + if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { | |
| 86 | + continue; | |
| 87 | + } | |
| 88 | + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); | |
| 89 | + return match?.[1] ?? null; | |
| 90 | + } | |
| 91 | + return null; | |
| 92 | +} | |
| 93 | + | |
| 94 | +function isPrimaryAgent(agent: Agent): boolean { | |
| 95 | + return !agent.hidden && (agent.mode === "primary" || agent.mode === "all"); | |
| 96 | +} | |
| 97 | + | |
| 98 | +function inferVariantValues(providerID: string): ReadonlyArray<string> { | |
| 99 | + if (providerID === "anthropic") { | |
| 100 | + return ANTHROPIC_VARIANTS; | |
| 101 | + } | |
| 102 | + if (providerID === "openai" || providerID === "opencode") { | |
| 103 | + return OPENAI_VARIANTS; | |
| 104 | + } | |
| 105 | + if (providerID.startsWith("google")) { | |
| 106 | + return GOOGLE_VARIANTS; | |
| 107 | + } | |
| 108 | + return []; | |
| 109 | +} | |
| 110 | + | |
| 111 | +function inferDefaultVariant( | |
| 112 | + providerID: string, | |
| 113 | + variants: ReadonlyArray<string>, | |
| 114 | +): string | undefined { | |
| 115 | + if (variants.length === 1) { | |
| 116 | + return variants[0]; | |
| 117 | + } | |
| 118 | + if (providerID === "anthropic" || providerID.startsWith("google")) { | |
| 119 | + return variants.includes("high") ? "high" : undefined; | |
| 120 | + } | |
| 121 | + if (providerID === "openai" || providerID === "opencode") { | |
| 122 | + return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; | |
| 123 | + } | |
| 124 | + return undefined; | |
| 125 | +} | |
| 126 | + | |
| 127 | +function buildVariantOptions( | |
| 128 | + providerID: string, | |
| 129 | + model: ProviderListResponse["all"][number]["models"][string], | |
| 130 | +) { | |
| 131 | + const variantValues = Object.keys(model.variants ?? {}); | |
| 132 | + const resolvedValues = | |
| 133 | + variantValues.length > 0 ? variantValues : [...inferVariantValues(providerID)]; | |
| 134 | + const defaultVariant = inferDefaultVariant(providerID, resolvedValues); | |
| 135 | + | |
| 136 | + return resolvedValues.map((value) => { | |
| 137 | + const option: { value: string; label: string; isDefault?: boolean } = { | |
| 138 | + value, | |
| 139 | + label: titleCaseSlug(value), | |
| 140 | + }; | |
| 141 | + if (defaultVariant === value) { | |
| 142 | + option.isDefault = true; | |
| 143 | + } | |
| 144 | + return option; | |
| 145 | + }); | |
| 146 | +} | |
| 147 | + | |
| 148 | +function buildAgentOptions(agents: ReadonlyArray<Agent>) { | |
| 149 | + const primaryAgents = agents.filter(isPrimaryAgent); | |
| 150 | + const defaultAgent = | |
| 151 | + primaryAgents.find((agent) => agent.name === "build")?.name ?? | |
| 152 | + primaryAgents[0]?.name ?? | |
| 153 | + undefined; | |
| 154 | + return primaryAgents.map((agent) => { | |
| 155 | + const option: { value: string; label: string; isDefault?: boolean } = { | |
| 156 | + value: agent.name, | |
| 157 | + label: titleCaseSlug(agent.name), | |
| 158 | + }; | |
| 159 | + if (defaultAgent === agent.name) { | |
| 160 | + option.isDefault = true; | |
| 161 | + } | |
| 162 | + return option; | |
| 163 | + }); | |
| 164 | +} | |
| 165 | + | |
| 166 | +function openCodeCapabilitiesForModel(input: { | |
| 167 | + readonly providerID: string; | |
| 168 | + readonly model: ProviderListResponse["all"][number]["models"][string]; | |
| 169 | + readonly agents: ReadonlyArray<Agent>; | |
| 170 | +}): ModelCapabilities { | |
| 171 | + const variantOptions = buildVariantOptions(input.providerID, input.model); | |
| 172 | + const agentOptions = buildAgentOptions(input.agents); | |
| 173 | + return { | |
| 174 | + ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, | |
| 175 | + ...(variantOptions.length > 0 ? { variantOptions } : {}), | |
| 176 | + ...(agentOptions.length > 0 ? { agentOptions } : {}), | |
| 177 | + }; | |
| 178 | +} | |
| 179 | + | |
| 180 | +export function parseOpenCodeModelSlug( | |
| 181 | + slug: string | null | undefined, | |
| 182 | +): ParsedOpenCodeModelSlug | null { | |
| 183 | + if (typeof slug !== "string") { | |
| 184 | + return null; | |
| 185 | + } | |
| 186 | + | |
| 187 | + const trimmed = slug.trim(); | |
| 188 | + const separator = trimmed.indexOf("/"); | |
| 189 | + if (separator <= 0 || separator === trimmed.length - 1) { | |
| 190 | + return null; | |
| 191 | + } | |
| 192 | + | |
| 193 | + return { | |
| 194 | + providerID: trimmed.slice(0, separator), | |
| 195 | + modelID: trimmed.slice(separator + 1), | |
| 196 | + }; | |
| 197 | +} | |
| 198 | + | |
| 199 | +export function toOpenCodeModelSlug(providerID: string, modelID: string): string { | |
| 200 | + return `${providerID}/${modelID}`; | |
| 201 | +} | |
| 202 | + | |
| 203 | +export function openCodeQuestionId( | |
| 204 | + index: number, | |
| 205 | + question: QuestionRequest["questions"][number], | |
| 206 | +): string { | |
| 207 | + const header = question.header | |
| 208 | + .trim() | |
| 209 | + .toLowerCase() | |
| 210 | + .replace(/[^a-z0-9_-]+/g, "-"); | |
| 211 | + return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; | |
| 212 | +} | |
| 213 | + | |
| 214 | +export function toOpenCodeFileParts(input: { | |
| 215 | + readonly attachments: ReadonlyArray<ChatAttachment> | undefined; | |
| 216 | + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; | |
| 217 | +}): Array<FilePartInput> { | |
| 218 | + const parts: Array<FilePartInput> = []; | |
| 219 | + | |
| 220 | + for (const attachment of input.attachments ?? []) { | |
| 221 | + const attachmentPath = input.resolveAttachmentPath(attachment); | |
| 222 | + if (!attachmentPath) { | |
| 223 | + continue; | |
| 224 | + } | |
| 225 | + | |
| 226 | + parts.push({ | |
| 227 | + type: "file", | |
| 228 | + mime: attachment.mimeType, | |
| 229 | + filename: attachment.name, | |
| 230 | + url: pathToFileURL(attachmentPath).href, | |
| 231 | + }); | |
| 232 | + } | |
| 233 | + | |
| 234 | + return parts; | |
| 235 | +} | |
| 236 | + | |
| 237 | +export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { | |
| 238 | + if (runtimeMode === "full-access") { | |
| 239 | + return [{ permission: "*", pattern: "*", action: "allow" }]; | |
| 240 | + } | |
| 241 | + | |
| 242 | + return [ | |
| 243 | + { permission: "*", pattern: "*", action: "ask" }, | |
| 244 | + { permission: "bash", pattern: "*", action: "ask" }, | |
| 245 | + { permission: "edit", pattern: "*", action: "ask" }, | |
| 246 | + { permission: "webfetch", pattern: "*", action: "ask" }, | |
| 247 | + { permission: "websearch", pattern: "*", action: "ask" }, | |
| 248 | + { permission: "codesearch", pattern: "*", action: "ask" }, | |
| 249 | + { permission: "external_directory", pattern: "*", action: "ask" }, | |
| 250 | + { permission: "doom_loop", pattern: "*", action: "ask" }, | |
| 251 | + { permission: "question", pattern: "*", action: "allow" }, | |
| 252 | + ]; | |
| 253 | +} | |
| 254 | + | |
| 255 | +export function toOpenCodePermissionReply( | |
| 256 | + decision: ProviderApprovalDecision, | |
| 257 | +): "once" | "always" | "reject" { | |
| 258 | + switch (decision) { | |
| 259 | + case "accept": | |
| 260 | + return "once"; | |
| 261 | + case "acceptForSession": | |
| 262 | + return "always"; | |
| 263 | + case "decline": | |
| 264 | + case "cancel": | |
| 265 | + default: | |
| 266 | + return "reject"; | |
| 267 | + } | |
| 268 | +} | |
| 269 | + | |
| 270 | +export function toOpenCodeQuestionAnswers( | |
| 271 | + request: QuestionRequest, | |
| 272 | + answers: Record<string, unknown>, | |
| 273 | +): Array<QuestionAnswer> { | |
| 274 | + return request.questions.map((question, index) => { | |
| 275 | + const raw = | |
| 276 | + answers[openCodeQuestionId(index, question)] ?? | |
| 277 | + answers[question.header] ?? | |
| 278 | + answers[question.question]; | |
| 279 | + if (Array.isArray(raw)) { | |
| 280 | + return raw.filter((value): value is string => typeof value === "string"); | |
| 281 | + } | |
| 282 | + if (typeof raw === "string") { | |
| 283 | + return raw.trim().length > 0 ? [raw] : []; | |
| 284 | + } | |
| 285 | + return []; | |
| 286 | + }); | |
| 287 | +} | |
| 288 | + | |
| 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; | |
| 301 | +} | |
| 302 | + | |
| 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 | +} | |
| 312 | + | |
| 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}` | |
| 325 | + ); | |
| 326 | + } | |
| 327 | + | |
| 328 | + // Look for a recent crash report with the termination reason. | |
| 329 | + const crashDir = Path.join(OS.homedir(), "Library/Logs/DiagnosticReports"); | |
| 330 | + const binaryName = Path.basename(resolvedPath); | |
| 331 | + const recentReports = FS.readdirSync(crashDir) | |
| 332 | + .filter((f) => f.startsWith(binaryName) && f.endsWith(".ips")) | |
| 333 | + .toSorted() | |
| 334 | + .toReversed() | |
| 335 | + .slice(0, 1); | |
| 336 | + | |
| 337 | + for (const report of recentReports) { | |
| 338 | + const content = FS.readFileSync(Path.join(crashDir, report), "utf8"); | |
| 339 | + if (content.includes('"namespace":"CODESIGNING"')) { | |
| 340 | + return ( | |
| 341 | + "macOS killed the process due to an invalid code signature. " + | |
| 342 | + "The binary may be corrupted — try reinstalling OpenCode." | |
| 343 | + ); | |
| 344 | + } | |
| 345 | + } | |
| 346 | + } catch { | |
| 347 | + // Best-effort detection — don't fail the original error path. | |
| 348 | + } | |
| 349 | + return null; | |
| 350 | +} | |
| 351 | + | |
| 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 | + }); | |
| 369 | + | |
| 370 | + child.stdout.setEncoding("utf8"); | |
| 371 | + child.stderr.setEncoding("utf8"); | |
| 372 | + | |
| 373 | + let stdout = ""; | |
| 374 | + let stderr = ""; | |
| 375 | + let closed = false; | |
| 376 | + const close = () => { | |
| 377 | + if (closed) { | |
| 378 | + return; | |
| 379 | + } | |
| 380 | + closed = true; | |
| 381 | + child.kill(); | |
| 382 | + }; | |
| 383 | + | |
| 384 | + const url = await new Promise<string>((resolve, reject) => { | |
| 385 | + const timeout = setTimeout(() => { | |
| 386 | + close(); | |
| 387 | + reject(new Error(`Timed out waiting for OpenCode server start after ${timeoutMs}ms.`)); | |
| 388 | + }, timeoutMs); | |
| 389 | + | |
| 390 | + const cleanup = () => { | |
| 391 | + clearTimeout(timeout); | |
| 392 | + child.stdout.off("data", onStdout); | |
| 393 | + child.stderr.off("data", onStderr); | |
| 394 | + child.off("error", onError); | |
| 395 | + child.off("close", onClose); | |
| 396 | + }; | |
| 397 | + | |
| 398 | + const onStdout = (chunk: string) => { | |
| 399 | + stdout += chunk; | |
| 400 | + const parsed = parseServerUrlFromOutput(stdout); | |
| 401 | + if (!parsed) { | |
| 402 | + return; | |
| 403 | + } | |
| 404 | + cleanup(); | |
| 405 | + resolve(parsed); | |
| 406 | + }; | |
| 407 | + | |
| 408 | + const onStderr = (chunk: string) => { | |
| 409 | + stderr += chunk; | |
| 410 | + }; | |
| 411 | + | |
| 412 | + const onError = (error: Error) => { | |
| 413 | + cleanup(); | |
| 414 | + reject(error); | |
| 415 | + }; | |
| 416 | + | |
| 417 | + const onClose = (code: number | null, signal: NodeJS.Signals | null) => { | |
| 418 | + cleanup(); | |
| 419 | + const exitReason = signal ? `signal: ${signal}` : `code: ${code ?? "unknown"}`; | |
| 420 | + const hint = | |
| 421 | + signal === "SIGKILL" && process.platform === "darwin" | |
| 422 | + ? detectMacosSigkillHint(input.binaryPath) | |
| 423 | + : null; | |
| 424 | + reject( | |
| 425 | + new Error( | |
| 426 | + [ | |
| 427 | + `OpenCode server exited before startup completed (${exitReason}).`, | |
| 428 | + hint, | |
| 429 | + stdout.trim() ? `stdout:\n${stdout.trim()}` : null, | |
| 430 | + stderr.trim() ? `stderr:\n${stderr.trim()}` : null, | |
| 431 | + ] | |
| 432 | + .filter(Boolean) | |
| 433 | + .join("\n\n"), | |
| 434 | + ), | |
| 435 | + ); | |
| 436 | + }; | |
| 437 | + | |
| 438 | + child.stdout.on("data", onStdout); | |
| 439 | + child.stderr.on("data", onStderr); | |
| 440 | + child.once("error", onError); | |
| 441 | + child.once("close", onClose); | |
| 442 | + }); | |
| 443 | + | |
| 444 | + return { | |
| 445 | + url, | |
| 446 | + process: child, | |
| 447 | + close, | |
| 448 | + }; | |
| 449 | +} | |
| 450 | + | |
| 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 | + | |
| 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> = []; | |
| 498 | + | |
| 499 | + child.stdout?.on("data", (chunk: string) => stdoutChunks.push(chunk)); | |
| 500 | + child.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk)); | |
| 501 | + | |
| 502 | + const code = await new Promise<number>((resolve, reject) => { | |
| 503 | + child.once("error", reject); | |
| 504 | + child.once("exit", (exitCode) => resolve(exitCode ?? 0)); | |
| 505 | + }); | |
| 506 | + | |
| 507 | + return { | |
| 508 | + stdout: stdoutChunks.join(""), | |
| 509 | + stderr: stderrChunks.join(""), | |
| 510 | + code, | |
| 511 | + }; | |
| 512 | +} | |
| 513 | + | |
| 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 | +} | |
| 532 | + | |
| 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 ?? [], | |
| 544 | + }; | |
| 545 | +} | |
| 546 | + | |
| 547 | +export function flattenOpenCodeModels( | |
| 548 | + input: OpenCodeInventory, | |
| 549 | +): ReadonlyArray<ServerProviderModel> { | |
| 550 | + const connected = new Set(input.providerList.connected); | |
| 551 | + const models: Array<ServerProviderModel> = []; | |
| 552 | + | |
| 553 | + for (const provider of input.providerList.all) { | |
| 554 | + if (!connected.has(provider.id)) { | |
| 555 | + continue; | |
| 556 | + } | |
| 557 | + | |
| 558 | + for (const model of Object.values(provider.models)) { | |
| 559 | + models.push({ | |
| 560 | + slug: toOpenCodeModelSlug(provider.id, model.id), | |
| 561 | + name: `${provider.name} · ${model.name}`, | |
| 562 | + isCustom: false, | |
| 563 | + capabilities: openCodeCapabilitiesForModel({ | |
| 564 | + providerID: provider.id, | |
| 565 | + model, | |
| 566 | + agents: input.agents, | |
| 567 | + }), | |
| 568 | + }); | |
| 569 | + } | |
| 570 | + } | |
| 571 | + | |
| 572 | + return models.toSorted((left, right) => left.name.localeCompare(right.name)); | |
| 573 | +} | |
| @@ -0,0 +1,34 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import type { ModelCapabilities } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +import { providerModelsFromSettings } from "./providerSnapshot.ts"; | |
| 5 | + | |
| 6 | +const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = { | |
| 7 | + reasoningEffortLevels: [], | |
| 8 | + supportsFastMode: false, | |
| 9 | + supportsThinkingToggle: false, | |
| 10 | + contextWindowOptions: [], | |
| 11 | + promptInjectedEffortLevels: [], | |
| 12 | + variantOptions: [{ value: "medium", label: "Medium", isDefault: true }], | |
| 13 | + agentOptions: [{ value: "build", label: "Build", isDefault: true }], | |
| 14 | +}; | |
| 15 | + | |
| 16 | +describe("providerModelsFromSettings", () => { | |
| 17 | + it("applies the provided capabilities to custom models", () => { | |
| 18 | + const models = providerModelsFromSettings( | |
| 19 | + [], | |
| 20 | + "opencode", | |
| 21 | + ["openai/gpt-5"], | |
| 22 | + OPENCODE_CUSTOM_MODEL_CAPABILITIES, | |
| 23 | + ); | |
| 24 | + | |
| 25 | + expect(models).toEqual([ | |
| 26 | + { | |
| 27 | + slug: "openai/gpt-5", | |
| 28 | + name: "openai/gpt-5", | |
| 29 | + isCustom: true, | |
| 30 | + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, | |
| 31 | + }, | |
| 32 | + ]); | |
| 33 | + }); | |
| 34 | +}); | |
| @@ -37,6 +37,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 36 unmodified lines | ||
| 37 | 37 | status: "warning", |
| 38 | 38 | auth: { status: "unknown" }, |
| 39 | 39 | }); |
| 40 | + const openCodeProvider = makeProvider("opencode", { | |
| 41 | + status: "warning", | |
| 42 | + auth: { status: "unknown", type: "opencode" }, | |
| 43 | + }); | |
| 40 | 44 | const codexPath = resolveProviderStatusCachePath({ |
| 41 | 45 | cacheDir: tempDir, |
| 42 | 46 | provider: "codex", |
| @@ -45,6 +49,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 2 unmodified lines | ||
| 45 | 49 | cacheDir: tempDir, |
| 46 | 50 | provider: "claudeAgent", |
| 47 | 51 | }); |
| 52 | + const openCodePath = resolveProviderStatusCachePath({ | |
| 53 | + cacheDir: tempDir, | |
| 54 | + provider: "opencode", | |
| 55 | + }); | |
| 48 | 56 | |
| 49 | 57 | yield* writeProviderStatusCache({ |
| 50 | 58 | filePath: codexPath, |
| @@ -54,9 +62,14 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { · 3 unmodified lines | ||
| 54 | 62 | filePath: claudePath, |
| 55 | 63 | provider: claudeProvider, |
| 56 | 64 | }); |
| 65 | + yield* writeProviderStatusCache({ | |
| 66 | + filePath: openCodePath, | |
| 67 | + provider: openCodeProvider, | |
| 68 | + }); | |
| 57 | 69 | |
| 58 | 70 | assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); |
| 59 | 71 | assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); |
| 72 | + assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider); | |
| 60 | 73 | }), |
| 61 | 74 | ); |
| 62 | 75 | |
| @@ -2,9 +2,11 @@ import * as nodePath from "node:path"; · 1 unmodified line | ||
| 2 | 2 | import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; |
| 3 | 3 | import { Cause, Effect, FileSystem, Path, Schema } from "effect"; |
| 4 | 4 | |
| 5 | −export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray< | |
| 6 | − ServerProvider["provider"] | |
| 7 | −>; | |
| 5 | +export const PROVIDER_CACHE_IDS = [ | |
| 6 | + "codex", | |
| 7 | + "claudeAgent", | |
| 8 | + "opencode", | |
| 9 | +] as const satisfies ReadonlyArray<ServerProvider["provider"]>; | |
| 8 | 10 | |
| 9 | 11 | const decodeProviderStatusCache = Schema.decodeUnknownEffect( |
| 10 | 12 | Schema.fromJsonString(ServerProviderSchema), |
| @@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD · 20 unmodified lines | ||
| 21 | 21 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; |
| 22 | 22 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; |
| 23 | 23 | import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; |
| 24 | +import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts"; | |
| 24 | 25 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; |
| 25 | 26 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 26 | 27 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; |
| @@ -154,9 +155,13 @@ const ProviderLayerLive = Layer.unwrap( · 127 unmodified lines | ||
| 154 | 155 | const claudeAdapterLayer = makeClaudeAdapterLive( |
| 155 | 156 | nativeEventLogger ? { nativeEventLogger } : undefined, |
| 156 | 157 | ); |
| 158 | + const openCodeAdapterLayer = makeOpenCodeAdapterLive( | |
| 159 | + nativeEventLogger ? { nativeEventLogger } : undefined, | |
| 160 | + ); | |
| 157 | 161 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( |
| 158 | 162 | Layer.provide(codexAdapterLayer), |
| 159 | 163 | Layer.provide(claudeAdapterLayer), |
| 164 | + Layer.provide(openCodeAdapterLayer), | |
| 160 | 165 | Layer.provideMerge(ProviderSessionDirectoryLayerLive), |
| 161 | 166 | ); |
| 162 | 167 | return makeProviderServiceLive( |
| @@ -184,6 +184,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 183 unmodified lines | ||
| 184 | 184 | claudeAgent: { |
| 185 | 185 | binaryPath: " /opt/homebrew/bin/claude ", |
| 186 | 186 | }, |
| 187 | + opencode: { | |
| 188 | + binaryPath: " /opt/homebrew/bin/opencode ", | |
| 189 | + serverUrl: " http://127.0.0.1:4096 ", | |
| 190 | + serverPassword: " secret-password ", | |
| 191 | + }, | |
| 187 | 192 | }, |
| 188 | 193 | }); |
| 189 | 194 | |
| @@ -199,6 +204,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 9 unmodified lines | ||
| 199 | 204 | customModels: [], |
| 200 | 205 | launchArgs: "", |
| 201 | 206 | }); |
| 207 | + assert.deepEqual(next.providers.opencode, { | |
| 208 | + enabled: true, | |
| 209 | + binaryPath: "/opt/homebrew/bin/opencode", | |
| 210 | + serverUrl: "http://127.0.0.1:4096", | |
| 211 | + serverPassword: "secret-password", | |
| 212 | + customModels: [], | |
| 213 | + }); | |
| 202 | 214 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 203 | 215 | ); |
| 204 | 216 | |
| @@ -257,6 +269,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 52 unmodified lines | ||
| 257 | 269 | codex: { |
| 258 | 270 | binaryPath: "/opt/homebrew/bin/codex", |
| 259 | 271 | }, |
| 272 | + opencode: { | |
| 273 | + serverUrl: "http://127.0.0.1:4096", | |
| 274 | + serverPassword: "secret-password", | |
| 275 | + }, | |
| 260 | 276 | }, |
| 261 | 277 | }); |
| 262 | 278 | |
| @@ -273,6 +289,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 10 unmodified lines | ||
| 273 | 289 | codex: { |
| 274 | 290 | binaryPath: "/opt/homebrew/bin/codex", |
| 275 | 291 | }, |
| 292 | + opencode: { | |
| 293 | + serverUrl: "http://127.0.0.1:4096", | |
| 294 | + serverPassword: "secret-password", | |
| 295 | + }, | |
| 276 | 296 | }, |
| 277 | 297 | }); |
| 278 | 298 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| @@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< · 104 unmodified lines | ||
| 105 | 105 | |
| 106 | 106 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| 107 | 107 | |
| 108 | −const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; | |
| 108 | +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode"]; | |
| 109 | 109 | |
| 110 | 110 | /** |
| 111 | 111 | * Ensure the `textGenerationModelSelection` points to an enabled provider. |
| @@ -26,7 +26,7 @@ import { · 25 unmodified lines | ||
| 26 | 26 | scopeProjectRef, |
| 27 | 27 | scopeThreadRef, |
| 28 | 28 | } from "@t3tools/client-runtime"; |
| 29 | −import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; | |
| 29 | +import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; | |
| 30 | 30 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 31 | 31 | import { truncate } from "@t3tools/shared/String"; |
| 32 | 32 | import { Debouncer } from "@tanstack/react-pacer"; |
| @@ -2534,16 +2534,13 @@ export default function ChatView(props: ChatViewProps) { · 2501 unmodified lines | ||
| 2534 | 2534 | } |
| 2535 | 2535 | } |
| 2536 | 2536 | const title = truncate(titleSeed); |
| 2537 | − const threadCreateModelSelection: ModelSelection = { | |
| 2538 | − provider: ctxSelectedProvider, | |
| 2539 | − model: | |
| 2540 | − ctxSelectedModel || | |
| 2537 | + const threadCreateModelSelection = createModelSelection( | |
| 2538 | + ctxSelectedProvider, | |
| 2539 | + ctxSelectedModel || | |
| 2541 | 2540 | activeProject.defaultModelSelection?.model || |
| 2542 | 2541 | DEFAULT_MODEL_BY_PROVIDER.codex, |
| 2543 | − ...(ctxSelectedModelSelection.options | |
| 2544 | − ? { options: ctxSelectedModelSelection.options } | |
| 2545 | − : {}), | |
| 2546 | − }; | |
| 2542 | + ctxSelectedModelSelection.options, | |
| 2543 | + ); | |
| 2547 | 2544 | |
| 2548 | 2545 | // Auto-title from first message |
| 2549 | 2546 | if (isFirstMessage && isServerThread) { |
| @@ -553,8 +553,10 @@ export const IntelliJIdeaIcon: Icon = (props) => { · 552 unmodified lines | ||
| 553 | 553 | export const OpenCodeIcon: Icon = (props) => ( |
| 554 | 554 | <svg {...props} viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 555 | 555 | <g clipPath="url(#opencode__clip0_1311_94969)"> |
| 556 | − <path d="M24 32H8V16H24V32Z" fill="#BCBBBB" /> | |
| 557 | − <path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E" /> | |
| 556 | + <path className="dark:hidden" d="M24 32H8V16H24V32Z" fill="#CFCECD" /> | |
| 557 | + <path className="dark:hidden" d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E" /> | |
| 558 | + <path className="hidden dark:block" d="M24 32H8V16H24V32Z" fill="#4B4646" /> | |
| 559 | + <path className="hidden dark:block" d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC" /> | |
| 558 | 560 | </g> |
| 559 | 561 | <defs> |
| 560 | 562 | <clipPath id="opencode__clip0_1311_94969"> |
| @@ -99,6 +99,13 @@ function createBaseServerConfig(): ServerConfig { · 98 unmodified lines | ||
| 99 | 99 | providers: { |
| 100 | 100 | codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, |
| 101 | 101 | claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, |
| 102 | + opencode: { | |
| 103 | + enabled: true, | |
| 104 | + binaryPath: "", | |
| 105 | + serverUrl: "", | |
| 106 | + serverPassword: "", | |
| 107 | + customModels: [], | |
| 108 | + }, | |
| 102 | 109 | }, |
| 103 | 110 | }, |
| 104 | 111 | }; |
| @@ -16,7 +16,7 @@ import { · 15 unmodified lines | ||
| 16 | 16 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 17 | 17 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | −import { normalizeModelSlug } from "@t3tools/shared/model"; | |
| 19 | +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | |
| 20 | 20 | import { |
| 21 | 21 | forwardRef, |
| 22 | 22 | memo, |
| @@ -70,6 +70,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; · 47 unmodified lines | ||
| 70 | 70 | import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; |
| 71 | 71 | import { searchSlashCommandItems } from "./composerSlashCommandSearch"; |
| 72 | 72 | import { |
| 73 | + getComposerProviderControls, | |
| 73 | 74 | getComposerProviderState, |
| 74 | 75 | renderProviderTraitsMenuContent, |
| 75 | 76 | renderProviderTraitsPicker, |
| @@ -159,6 +160,7 @@ const terminalContextIdListsEqual = ( · 83 unmodified lines | ||
| 159 | 160 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); |
| 160 | 161 | |
| 161 | 162 | const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { |
| 163 | + showInteractionModeToggle: boolean; | |
| 162 | 164 | interactionMode: ProviderInteractionMode; |
| 163 | 165 | runtimeMode: RuntimeMode; |
| 164 | 166 | showPlanToggle: boolean; |
| @@ -175,25 +177,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop · 10 unmodified lines | ||
| 175 | 177 | <> |
| 176 | 178 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| 177 | 179 | |
| 178 | − <Button | |
| 179 | − variant="ghost" | |
| 180 | − className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 181 | − size="sm" | |
| 182 | − type="button" | |
| 183 | − onClick={props.onToggleInteractionMode} | |
| 184 | − title={ | |
| 185 | − props.interactionMode === "plan" | |
| 186 | − ? "Plan mode — click to return to normal build mode" | |
| 187 | − : "Default mode — click to enter plan mode" | |
| 188 | − } | |
| 189 | − > | |
| 190 | − <BotIcon /> | |
| 191 | − <span className="sr-only sm:not-sr-only"> | |
| 192 | − {props.interactionMode === "plan" ? "Plan" : "Build"} | |
| 193 | − </span> | |
| 194 | − </Button> | |
| 180 | + {props.showInteractionModeToggle ? ( | |
| 181 | + <> | |
| 182 | + <Button | |
| 183 | + variant="ghost" | |
| 184 | + className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | |
| 185 | + size="sm" | |
| 186 | + type="button" | |
| 187 | + onClick={props.onToggleInteractionMode} | |
| 188 | + title={ | |
| 189 | + props.interactionMode === "plan" | |
| 190 | + ? "Plan mode — click to return to normal build mode" | |
| 191 | + : "Default mode — click to enter plan mode" | |
| 192 | + } | |
| 193 | + > | |
| 194 | + <BotIcon /> | |
| 195 | + <span className="sr-only sm:not-sr-only"> | |
| 196 | + {props.interactionMode === "plan" ? "Plan" : "Build"} | |
| 197 | + </span> | |
| 198 | + </Button> | |
| 195 | 199 | |
| 196 | − <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 200 | + <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | |
| 201 | + </> | |
| 202 | + ) : null} | |
| 197 | 203 | |
| 198 | 204 | <Select |
| 199 | 205 | value={props.runtimeMode} |
| @@ -588,12 +594,12 @@ export const ChatComposer = memo( · 388 unmodified lines | ||
| 588 | 594 | |
| 589 | 595 | const selectedPromptEffort = composerProviderState.promptEffort; |
| 590 | 596 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; |
| 597 | + const composerProviderControls = useMemo( | |
| 598 | + () => getComposerProviderControls(selectedProvider), | |
| 599 | + [selectedProvider], | |
| 600 | + ); | |
| 591 | 601 | const selectedModelSelection = useMemo<ModelSelection>( |
| 592 | − () => ({ | |
| 593 | − provider: selectedProvider, | |
| 594 | − model: selectedModel, | |
| 595 | − ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), | |
| 596 | − }), | |
| 602 | + () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), | |
| 597 | 603 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], |
| 598 | 604 | ); |
| 599 | 605 | const selectedModelForPicker = selectedModel; |
| @@ -604,6 +610,8 @@ export const ChatComposer = memo( · 4 unmodified lines | ||
| 604 | 610 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], |
| 605 | 611 | claudeAgent: |
| 606 | 612 | providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], |
| 613 | + opencode: | |
| 614 | + providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [], | |
| 607 | 615 | }), |
| 608 | 616 | [providerStatuses], |
| 609 | 617 | ); |
| @@ -1933,6 +1941,7 @@ export const ChatComposer = memo( · 1323 unmodified lines | ||
| 1933 | 1941 | planSidebarLabel={planSidebarLabel} |
| 1934 | 1942 | planSidebarOpen={planSidebarOpen} |
| 1935 | 1943 | runtimeMode={runtimeMode} |
| 1944 | + showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} | |
| 1936 | 1945 | traitsMenuContent={providerTraitsMenuContent} |
| 1937 | 1946 | onToggleInteractionMode={toggleInteractionMode} |
| 1938 | 1947 | onTogglePlanSidebar={togglePlanSidebar} |
| @@ -1950,6 +1959,9 @@ export const ChatComposer = memo( · 11 unmodified lines | ||
| 1950 | 1959 | </> |
| 1951 | 1960 | ) : null} |
| 1952 | 1961 | <ComposerFooterModeControls |
| 1962 | + showInteractionModeToggle={ | |
| 1963 | + composerProviderControls.showInteractionModeToggle | |
| 1964 | + } | |
| 1953 | 1965 | interactionMode={interactionMode} |
| 1954 | 1966 | runtimeMode={runtimeMode} |
| 1955 | 1967 | showPlanToggle={showPlanSidebarToggle} |
| @@ -126,6 +126,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str · 125 unmodified lines | ||
| 126 | 126 | planSidebarLabel="Plan" |
| 127 | 127 | planSidebarOpen={false} |
| 128 | 128 | runtimeMode="approval-required" |
| 129 | + showInteractionModeToggle | |
| 129 | 130 | traitsMenuContent={ |
| 130 | 131 | <TraitsMenuContent |
| 131 | 132 | provider={provider} |
| @@ -267,4 +268,38 @@ describe("CompactComposerControlsMenu", () => { · 135 unmodified lines | ||
| 267 | 268 | ); |
| 268 | 269 | }); |
| 269 | 270 | }); |
| 271 | + | |
| 272 | + it("can hide the interaction mode section", async () => { | |
| 273 | + const host = document.createElement("div"); | |
| 274 | + document.body.append(host); | |
| 275 | + const screen = await render( | |
| 276 | + <CompactComposerControlsMenu | |
| 277 | + activePlan={false} | |
| 278 | + interactionMode="default" | |
| 279 | + planSidebarLabel="Plan" | |
| 280 | + planSidebarOpen={false} | |
| 281 | + runtimeMode="approval-required" | |
| 282 | + showInteractionModeToggle={false} | |
| 283 | + onToggleInteractionMode={vi.fn()} | |
| 284 | + onTogglePlanSidebar={vi.fn()} | |
| 285 | + onRuntimeModeChange={vi.fn()} | |
| 286 | + />, | |
| 287 | + { container: host }, | |
| 288 | + ); | |
| 289 | + | |
| 290 | + await page.getByLabelText("More composer controls").click(); | |
| 291 | + | |
| 292 | + await vi.waitFor(() => { | |
| 293 | + const text = document.body.textContent ?? ""; | |
| 294 | + expect(text).not.toContain("Mode"); | |
| 295 | + expect(text).not.toContain("Chat"); | |
| 296 | + expect(text).not.toContain("Plan"); | |
| 297 | + expect(text).toContain("Access"); | |
| 298 | + expect(text).toContain("Supervised"); | |
| 299 | + expect(text).toContain("Full access"); | |
| 300 | + }); | |
| 301 | + | |
| 302 | + await screen.unmount(); | |
| 303 | + host.remove(); | |
| 304 | + }); | |
| 270 | 305 | }); |
| @@ -18,6 +18,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls · 17 unmodified lines | ||
| 18 | 18 | planSidebarLabel: string; |
| 19 | 19 | planSidebarOpen: boolean; |
| 20 | 20 | runtimeMode: RuntimeMode; |
| 21 | + showInteractionModeToggle: boolean; | |
| 21 | 22 | traitsMenuContent?: ReactNode; |
| 22 | 23 | onToggleInteractionMode: () => void; |
| 23 | 24 | onTogglePlanSidebar: () => void; |
| @@ -44,18 +45,22 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls · 20 unmodified lines | ||
| 44 | 45 | <MenuDivider /> |
| 45 | 46 | </> |
| 46 | 47 | ) : null} |
| 47 | − <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div> | |
| 48 | − <MenuRadioGroup | |
| 49 | − value={props.interactionMode} | |
| 50 | − onValueChange={(value) => { | |
| 51 | − if (!value || value === props.interactionMode) return; | |
| 52 | − props.onToggleInteractionMode(); | |
| 53 | − }} | |
| 54 | − > | |
| 55 | − <MenuRadioItem value="default">Chat</MenuRadioItem> | |
| 56 | − <MenuRadioItem value="plan">Plan</MenuRadioItem> | |
| 57 | − </MenuRadioGroup> | |
| 58 | − <MenuDivider /> | |
| 48 | + {props.showInteractionModeToggle ? ( | |
| 49 | + <> | |
| 50 | + <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div> | |
| 51 | + <MenuRadioGroup | |
| 52 | + value={props.interactionMode} | |
| 53 | + onValueChange={(value) => { | |
| 54 | + if (!value || value === props.interactionMode) return; | |
| 55 | + props.onToggleInteractionMode(); | |
| 56 | + }} | |
| 57 | + > | |
| 58 | + <MenuRadioItem value="default">Chat</MenuRadioItem> | |
| 59 | + <MenuRadioItem value="plan">Plan</MenuRadioItem> | |
| 60 | + </MenuRadioGroup> | |
| 61 | + <MenuDivider /> | |
| 62 | + </> | |
| 63 | + ) : null} | |
| 59 | 64 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div> |
| 60 | 65 | <MenuRadioGroup |
| 61 | 66 | value={props.runtimeMode} |
| @@ -33,15 +33,13 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o · 32 unmodified lines | ||
| 33 | 33 | const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = { |
| 34 | 34 | codex: OpenAI, |
| 35 | 35 | claudeAgent: ClaudeAI, |
| 36 | + opencode: OpenCodeIcon, | |
| 36 | 37 | cursor: CursorIcon, |
| 37 | 38 | }; |
| 38 | 39 | |
| 39 | 40 | export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); |
| 40 | 41 | const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); |
| 41 | −const COMING_SOON_PROVIDER_OPTIONS = [ | |
| 42 | − { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, | |
| 43 | − { id: "gemini", label: "Gemini", icon: Gemini }, | |
| 44 | −] as const; | |
| 42 | +const COMING_SOON_PROVIDER_OPTIONS = [{ id: "gemini", label: "Gemini", icon: Gemini }] as const; | |
| 45 | 43 | |
| 46 | 44 | function providerIconClassName( |
| 47 | 45 | provider: ProviderKind | ProviderPickerKind, |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | CodexModelOptions, |
| 7 | 7 | DEFAULT_MODEL_BY_PROVIDER, |
| 8 | 8 | DEFAULT_SERVER_SETTINGS, |
| 9 | + OpenCodeModelOptions, | |
| 9 | 10 | EnvironmentId, |
| 10 | 11 | type ServerProvider, |
| 11 | 12 | ThreadId, |
| @@ -64,6 +65,39 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ · 52 unmodified lines | ||
| 64 | 65 | }, |
| 65 | 66 | ], |
| 66 | 67 | }, |
| 68 | + { | |
| 69 | + provider: "opencode", | |
| 70 | + enabled: true, | |
| 71 | + installed: true, | |
| 72 | + version: "0.1.0", | |
| 73 | + status: "ready", | |
| 74 | + auth: { status: "authenticated" }, | |
| 75 | + checkedAt: "2026-01-01T00:00:00.000Z", | |
| 76 | + slashCommands: [], | |
| 77 | + skills: [], | |
| 78 | + models: [ | |
| 79 | + { | |
| 80 | + slug: "openai/gpt-5", | |
| 81 | + name: "GPT-5", | |
| 82 | + isCustom: false, | |
| 83 | + capabilities: { | |
| 84 | + reasoningEffortLevels: [], | |
| 85 | + supportsFastMode: false, | |
| 86 | + supportsThinkingToggle: false, | |
| 87 | + contextWindowOptions: [], | |
| 88 | + promptInjectedEffortLevels: [], | |
| 89 | + variantOptions: [ | |
| 90 | + { value: "low", label: "Low" }, | |
| 91 | + { value: "medium", label: "Medium", isDefault: true }, | |
| 92 | + ], | |
| 93 | + agentOptions: [ | |
| 94 | + { value: "build", label: "Build", isDefault: true }, | |
| 95 | + { value: "plan", label: "Plan" }, | |
| 96 | + ], | |
| 97 | + }, | |
| 98 | + }, | |
| 99 | + ], | |
| 100 | + }, | |
| 67 | 101 | { |
| 68 | 102 | provider: "claudeAgent", |
| 69 | 103 | enabled: true, |
| @@ -125,6 +159,13 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ · 55 unmodified lines | ||
| 125 | 159 | ], |
| 126 | 160 | }, |
| 127 | 161 | ]; |
| 162 | +const findTestProvider = (provider: ServerProvider["provider"]) => { | |
| 163 | + const testProvider = TEST_PROVIDERS.find((candidate) => candidate.provider === provider); | |
| 164 | + if (!testProvider) { | |
| 165 | + throw new Error(`Missing test provider fixture: ${provider}`); | |
| 166 | + } | |
| 167 | + return testProvider; | |
| 168 | +}; | |
| 128 | 169 | |
| 129 | 170 | function ClaudeTraitsPickerHarness(props: { |
| 130 | 171 | model: string; |
| @@ -154,7 +195,7 @@ function ClaudeTraitsPickerHarness(props: { · 23 unmodified lines | ||
| 154 | 195 | return ( |
| 155 | 196 | <TraitsPicker |
| 156 | 197 | provider="claudeAgent" |
| 157 | − models={TEST_PROVIDERS[1]!.models} | |
| 198 | + models={TEST_PROVIDERS[2]!.models} | |
| 158 | 199 | threadRef={CLAUDE_THREAD_REF} |
| 159 | 200 | model={selectedModel ?? props.model} |
| 160 | 201 | prompt={prompt} |
| @@ -501,3 +542,89 @@ describe("TraitsPicker (Codex)", () => { · 340 unmodified lines | ||
| 501 | 542 | }); |
| 502 | 543 | }); |
| 503 | 544 | }); |
| 545 | + | |
| 546 | +// ── OpenCode TraitsPicker tests ─────────────────────────────────────── | |
| 547 | + | |
| 548 | +async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeModelOptions }) { | |
| 549 | + const threadId = ThreadId.make("thread-opencode-traits"); | |
| 550 | + const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); | |
| 551 | + const threadKey = scopedThreadKey(threadRef); | |
| 552 | + const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.opencode; | |
| 553 | + const draftsByThreadKey: Record<string, ComposerThreadDraftState> = { | |
| 554 | + [threadKey]: { | |
| 555 | + prompt: "", | |
| 556 | + images: [], | |
| 557 | + nonPersistedImageIds: [], | |
| 558 | + persistedAttachments: [], | |
| 559 | + terminalContexts: [], | |
| 560 | + modelSelectionByProvider: { | |
| 561 | + opencode: { | |
| 562 | + provider: "opencode", | |
| 563 | + model, | |
| 564 | + ...(props.options ? { options: props.options } : {}), | |
| 565 | + }, | |
| 566 | + }, | |
| 567 | + activeProvider: "opencode", | |
| 568 | + runtimeMode: null, | |
| 569 | + interactionMode: null, | |
| 570 | + }, | |
| 571 | + }; | |
| 572 | + | |
| 573 | + useComposerDraftStore.setState({ | |
| 574 | + draftsByThreadKey, | |
| 575 | + draftThreadsByThreadKey: {}, | |
| 576 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 577 | + }); | |
| 578 | + const host = document.createElement("div"); | |
| 579 | + document.body.append(host); | |
| 580 | + const screen = await render( | |
| 581 | + <TraitsPicker | |
| 582 | + provider="opencode" | |
| 583 | + models={findTestProvider("opencode").models} | |
| 584 | + threadRef={threadRef} | |
| 585 | + model={model} | |
| 586 | + prompt="" | |
| 587 | + modelOptions={props.options} | |
| 588 | + onPromptChange={() => {}} | |
| 589 | + />, | |
| 590 | + { container: host }, | |
| 591 | + ); | |
| 592 | + | |
| 593 | + const cleanup = async () => { | |
| 594 | + await screen.unmount(); | |
| 595 | + host.remove(); | |
| 596 | + }; | |
| 597 | + | |
| 598 | + return { | |
| 599 | + [Symbol.asyncDispose]: cleanup, | |
| 600 | + cleanup, | |
| 601 | + }; | |
| 602 | +} | |
| 603 | + | |
| 604 | +describe("TraitsPicker (OpenCode)", () => { | |
| 605 | + afterEach(() => { | |
| 606 | + document.body.innerHTML = ""; | |
| 607 | + localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); | |
| 608 | + useComposerDraftStore.setState({ | |
| 609 | + draftsByThreadKey: {}, | |
| 610 | + draftThreadsByThreadKey: {}, | |
| 611 | + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, | |
| 612 | + stickyModelSelectionByProvider: {}, | |
| 613 | + }); | |
| 614 | + }); | |
| 615 | + | |
| 616 | + it("shows the selected agent label with capitalization in the trigger", async () => { | |
| 617 | + await using _ = await mountOpenCodePicker({ | |
| 618 | + options: { | |
| 619 | + variant: "medium", | |
| 620 | + agent: "plan", | |
| 621 | + }, | |
| 622 | + }); | |
| 623 | + | |
| 624 | + await vi.waitFor(() => { | |
| 625 | + const text = document.body.textContent ?? ""; | |
| 626 | + expect(text).toContain("Medium · Plan"); | |
| 627 | + expect(text).not.toContain("Medium · plan"); | |
| 628 | + }); | |
| 629 | + }); | |
| 630 | +}); | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | type ClaudeModelOptions, |
| 3 | 3 | type CodexModelOptions, |
| 4 | + type OpenCodeModelOptions, | |
| 4 | 5 | type ProviderKind, |
| 5 | 6 | type ProviderModelOptions, |
| 6 | 7 | type ScopedThreadRef, |
| @@ -33,6 +34,12 @@ import { getProviderModelCapabilities } from "../../providerModels"; · 26 unmodified lines | ||
| 33 | 34 | import { cn } from "~/lib/utils"; |
| 34 | 35 | |
| 35 | 36 | type ProviderOptions = ProviderModelOptions[ProviderKind]; |
| 37 | +type NamedOption = { | |
| 38 | + value: string; | |
| 39 | + label: string; | |
| 40 | + isDefault?: boolean | undefined; | |
| 41 | +}; | |
| 42 | + | |
| 36 | 43 | type TraitsPersistence = |
| 37 | 44 | | { |
| 38 | 45 | threadRef?: ScopedThreadRef; |
| @@ -53,9 +60,29 @@ function getRawEffort( · 14 unmodified lines | ||
| 53 | 60 | if (provider === "codex") { |
| 54 | 61 | return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); |
| 55 | 62 | } |
| 63 | + if (provider === "opencode") { | |
| 64 | + return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant); | |
| 65 | + } | |
| 56 | 66 | return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); |
| 57 | 67 | } |
| 58 | 68 | |
| 69 | +function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null { | |
| 70 | + return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent); | |
| 71 | +} | |
| 72 | + | |
| 73 | +function resolveNamedOption( | |
| 74 | + options: ReadonlyArray<NamedOption>, | |
| 75 | + raw: string | null, | |
| 76 | +): NamedOption | null { | |
| 77 | + if (raw) { | |
| 78 | + const matchingOption = options.find((option) => option.value === raw); | |
| 79 | + if (matchingOption) { | |
| 80 | + return matchingOption; | |
| 81 | + } | |
| 82 | + } | |
| 83 | + return options.find((option) => option.isDefault) ?? null; | |
| 84 | +} | |
| 85 | + | |
| 59 | 86 | function getRawContextWindow( |
| 60 | 87 | provider: ProviderKind, |
| 61 | 88 | modelOptions: ProviderOptions | null | undefined, |
| @@ -74,6 +101,12 @@ function buildNextOptions( · 12 unmodified lines | ||
| 74 | 101 | if (provider === "codex") { |
| 75 | 102 | return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; |
| 76 | 103 | } |
| 104 | + if (provider === "opencode") { | |
| 105 | + return { | |
| 106 | + ...(modelOptions as OpenCodeModelOptions | undefined), | |
| 107 | + ...patch, | |
| 108 | + } as OpenCodeModelOptions; | |
| 109 | + } | |
| 77 | 110 | return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; |
| 78 | 111 | } |
| 79 | 112 | |
| @@ -86,15 +119,21 @@ function getSelectedTraits( · 6 unmodified lines | ||
| 86 | 119 | allowPromptInjectedEffort: boolean, |
| 87 | 120 | ) { |
| 88 | 121 | const caps = getProviderModelCapabilities(models, model, provider); |
| 89 | − const effortLevels = allowPromptInjectedEffort | |
| 90 | − ? caps.reasoningEffortLevels | |
| 91 | − : caps.reasoningEffortLevels.filter( | |
| 92 | − (option) => !caps.promptInjectedEffortLevels.includes(option.value), | |
| 93 | − ); | |
| 122 | + const effortLevels = | |
| 123 | + provider === "opencode" | |
| 124 | + ? (caps.variantOptions ?? []) | |
| 125 | + : allowPromptInjectedEffort | |
| 126 | + ? caps.reasoningEffortLevels | |
| 127 | + : caps.reasoningEffortLevels.filter( | |
| 128 | + (option) => !caps.promptInjectedEffortLevels.includes(option.value), | |
| 129 | + ); | |
| 94 | 130 | |
| 95 | 131 | // Resolve effort from options (provider-specific key) |
| 96 | 132 | const rawEffort = getRawEffort(provider, modelOptions); |
| 97 | − const effort = resolveEffort(caps, rawEffort) ?? null; | |
| 133 | + const effort = | |
| 134 | + provider === "opencode" | |
| 135 | + ? (resolveNamedOption(effortLevels, rawEffort)?.value ?? null) | |
| 136 | + : (resolveEffort(caps, rawEffort) ?? null); | |
| 98 | 137 | |
| 99 | 138 | // Thinking toggle (only for models that support it) |
| 100 | 139 | const thinkingEnabled = caps.supportsThinkingToggle |
| @@ -125,6 +164,10 @@ function getSelectedTraits( · 24 unmodified lines | ||
| 125 | 164 | const ultrathinkInBodyText = |
| 126 | 165 | ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, "")); |
| 127 | 166 | |
| 167 | + const agentOptions = caps.agentOptions ?? []; | |
| 168 | + const selectedAgentOption = | |
| 169 | + provider === "opencode" ? resolveNamedOption(agentOptions, getRawAgent(modelOptions)) : null; | |
| 170 | + | |
| 128 | 171 | return { |
| 129 | 172 | caps, |
| 130 | 173 | effort, |
| @@ -136,6 +179,9 @@ function getSelectedTraits( · 5 unmodified lines | ||
| 136 | 179 | defaultContextWindow, |
| 137 | 180 | ultrathinkPromptControlled, |
| 138 | 181 | ultrathinkInBodyText, |
| 182 | + agentOptions, | |
| 183 | + selectedAgent: selectedAgentOption?.value ?? null, | |
| 184 | + selectedAgentLabel: selectedAgentOption?.label ?? null, | |
| 139 | 185 | }; |
| 140 | 186 | } |
| 141 | 187 | |
| @@ -189,6 +235,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 47 unmodified lines | ||
| 189 | 235 | defaultContextWindow, |
| 190 | 236 | ultrathinkPromptControlled, |
| 191 | 237 | ultrathinkInBodyText, |
| 238 | + agentOptions, | |
| 239 | + selectedAgent, | |
| 192 | 240 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); |
| 193 | 241 | const defaultEffort = getDefaultEffort(caps); |
| 194 | 242 | |
| @@ -197,6 +245,10 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 2 unmodified lines | ||
| 197 | 245 | if (!value) return; |
| 198 | 246 | const nextOption = effortLevels.find((option) => option.value === value); |
| 199 | 247 | if (!nextOption) return; |
| 248 | + if (provider === "opencode") { | |
| 249 | + updateModelOptions(buildNextOptions(provider, modelOptions, { variant: nextOption.value })); | |
| 250 | + return; | |
| 251 | + } | |
| 200 | 252 | if (caps.promptInjectedEffortLevels.includes(nextOption.value)) { |
| 201 | 253 | const nextPrompt = |
| 202 | 254 | prompt.trim().length === 0 |
| @@ -228,7 +280,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 25 unmodified lines | ||
| 228 | 280 | ], |
| 229 | 281 | ); |
| 230 | 282 | |
| 231 | − if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) { | |
| 283 | + if ( | |
| 284 | + effort === null && | |
| 285 | + thinkingEnabled === null && | |
| 286 | + contextWindowOptions.length <= 1 && | |
| 287 | + agentOptions.length === 0 | |
| 288 | + ) { | |
| 232 | 289 | return null; |
| 233 | 290 | } |
| 234 | 291 | |
| @@ -237,7 +294,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 2 unmodified lines | ||
| 237 | 294 | {effort ? ( |
| 238 | 295 | <> |
| 239 | 296 | <MenuGroup> |
| 240 | − <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">Effort</div> | |
| 297 | + <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs"> | |
| 298 | + {provider === "opencode" ? "Variant" : "Effort"} | |
| 299 | + </div> | |
| 241 | 300 | {ultrathinkInBodyText ? ( |
| 242 | 301 | <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs"> |
| 243 | 302 | Your prompt contains "ultrathink" in the text. Remove it to change effort. |
| @@ -254,7 +313,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 10 unmodified lines | ||
| 254 | 313 | disabled={ultrathinkInBodyText} |
| 255 | 314 | > |
| 256 | 315 | {option.label} |
| 257 | − {option.value === defaultEffort ? " (default)" : ""} | |
| 316 | + {(provider === "opencode" ? option.isDefault : option.value === defaultEffort) | |
| 317 | + ? " (default)" | |
| 318 | + : ""} | |
| 258 | 319 | </MenuRadioItem> |
| 259 | 320 | ))} |
| 260 | 321 | </MenuRadioGroup> |
| @@ -322,6 +383,27 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ · 61 unmodified lines | ||
| 322 | 383 | </MenuGroup> |
| 323 | 384 | </> |
| 324 | 385 | ) : null} |
| 386 | + {agentOptions.length > 0 ? ( | |
| 387 | + <> | |
| 388 | + <MenuDivider /> | |
| 389 | + <MenuGroup> | |
| 390 | + <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Agent</div> | |
| 391 | + <MenuRadioGroup | |
| 392 | + value={selectedAgent ?? ""} | |
| 393 | + onValueChange={(value) => { | |
| 394 | + updateModelOptions(buildNextOptions(provider, modelOptions, { agent: value })); | |
| 395 | + }} | |
| 396 | + > | |
| 397 | + {agentOptions.map((option) => ( | |
| 398 | + <MenuRadioItem key={option.value} value={option.value}> | |
| 399 | + {option.label} | |
| 400 | + {option.isDefault ? " (default)" : ""} | |
| 401 | + </MenuRadioItem> | |
| 402 | + ))} | |
| 403 | + </MenuRadioGroup> | |
| 404 | + </MenuGroup> | |
| 405 | + </> | |
| 406 | + ) : null} | |
| 325 | 407 | </> |
| 326 | 408 | ); |
| 327 | 409 | }); |
| @@ -349,6 +431,7 @@ export const TraitsPicker = memo(function TraitsPicker({ · 21 unmodified lines | ||
| 349 | 431 | contextWindow, |
| 350 | 432 | defaultContextWindow, |
| 351 | 433 | ultrathinkPromptControlled, |
| 434 | + selectedAgentLabel, | |
| 352 | 435 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); |
| 353 | 436 | |
| 354 | 437 | const effortLabel = effort |
| @@ -368,6 +451,7 @@ export const TraitsPicker = memo(function TraitsPicker({ · 13 unmodified lines | ||
| 368 | 451 | : `Thinking ${thinkingEnabled ? "On" : "Off"}`, |
| 369 | 452 | ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), |
| 370 | 453 | ...(contextWindowLabel ? [contextWindowLabel] : []), |
| 454 | + ...(selectedAgentLabel ? [selectedAgentLabel] : []), | |
| 371 | 455 | ] |
| 372 | 456 | .filter(Boolean) |
| 373 | 457 | .join(" · "); |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | import type { ServerProviderModel } from "@t3tools/contracts"; |
| 3 | 3 | import { |
| 4 | + getComposerProviderControls, | |
| 4 | 5 | getComposerProviderState, |
| 5 | 6 | renderProviderTraitsMenuContent, |
| 6 | 7 | renderProviderTraitsPicker, |
| @@ -110,6 +111,29 @@ const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray<ServerProviderModel> = [ · 103 unmodified lines | ||
| 110 | 111 | }, |
| 111 | 112 | ]; |
| 112 | 113 | |
| 114 | +const OPENCODE_MODELS: ReadonlyArray<ServerProviderModel> = [ | |
| 115 | + { | |
| 116 | + slug: "openai/gpt-5", | |
| 117 | + name: "GPT-5", | |
| 118 | + isCustom: false, | |
| 119 | + capabilities: { | |
| 120 | + reasoningEffortLevels: [], | |
| 121 | + supportsFastMode: false, | |
| 122 | + supportsThinkingToggle: false, | |
| 123 | + contextWindowOptions: [], | |
| 124 | + promptInjectedEffortLevels: [], | |
| 125 | + variantOptions: [ | |
| 126 | + { value: "low", label: "Low" }, | |
| 127 | + { value: "medium", label: "Medium", isDefault: true }, | |
| 128 | + ], | |
| 129 | + agentOptions: [ | |
| 130 | + { value: "build", label: "Build", isDefault: true }, | |
| 131 | + { value: "plan", label: "Plan" }, | |
| 132 | + ], | |
| 133 | + }, | |
| 134 | + }, | |
| 135 | +]; | |
| 136 | + | |
| 113 | 137 | describe("getComposerProviderState", () => { |
| 114 | 138 | it("returns codex defaults when no codex draft options exist", () => { |
| 115 | 139 | const state = getComposerProviderState({ |
| @@ -420,6 +444,47 @@ describe("getComposerProviderState", () => { · 304 unmodified lines | ||
| 420 | 444 | |
| 421 | 445 | expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); |
| 422 | 446 | }); |
| 447 | + | |
| 448 | + it("preserves OpenCode variant and agent options for dispatch", () => { | |
| 449 | + const state = getComposerProviderState({ | |
| 450 | + provider: "opencode", | |
| 451 | + model: "openai/gpt-5", | |
| 452 | + models: OPENCODE_MODELS, | |
| 453 | + prompt: "", | |
| 454 | + modelOptions: { | |
| 455 | + opencode: { | |
| 456 | + variant: "medium", | |
| 457 | + agent: "plan", | |
| 458 | + }, | |
| 459 | + }, | |
| 460 | + }); | |
| 461 | + | |
| 462 | + expect(state).toEqual({ | |
| 463 | + provider: "opencode", | |
| 464 | + promptEffort: "medium", | |
| 465 | + modelOptionsForDispatch: { | |
| 466 | + variant: "medium", | |
| 467 | + agent: "plan", | |
| 468 | + }, | |
| 469 | + }); | |
| 470 | + }); | |
| 471 | +}); | |
| 472 | + | |
| 473 | +describe("getComposerProviderControls", () => { | |
| 474 | + it("hides the interaction mode toggle for OpenCode", () => { | |
| 475 | + expect(getComposerProviderControls("opencode")).toEqual({ | |
| 476 | + showInteractionModeToggle: false, | |
| 477 | + }); | |
| 478 | + }); | |
| 479 | + | |
| 480 | + it("keeps the interaction mode toggle for Codex and Claude", () => { | |
| 481 | + expect(getComposerProviderControls("codex")).toEqual({ | |
| 482 | + showInteractionModeToggle: true, | |
| 483 | + }); | |
| 484 | + expect(getComposerProviderControls("claudeAgent")).toEqual({ | |
| 485 | + showInteractionModeToggle: true, | |
| 486 | + }); | |
| 487 | + }); | |
| 423 | 488 | }); |
| 424 | 489 | |
| 425 | 490 | describe("provider traits render guards", () => { |
| @@ -4,15 +4,17 @@ import { · 3 unmodified lines | ||
| 4 | 4 | type ScopedThreadRef, |
| 5 | 5 | type ServerProviderModel, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | −import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; | |
| 7 | +import { | |
| 8 | + isClaudeUltrathinkPrompt, | |
| 9 | + normalizeProviderModelOptionsWithCapabilities, | |
| 10 | + resolveEffort, | |
| 11 | + trimOrNull, | |
| 12 | +} from "@t3tools/shared/model"; | |
| 8 | 13 | import type { ReactNode } from "react"; |
| 14 | + | |
| 9 | 15 | import type { DraftId } from "../../composerDraftStore"; |
| 10 | 16 | import { getProviderModelCapabilities } from "../../providerModels"; |
| 11 | 17 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; |
| 12 | −import { | |
| 13 | − normalizeClaudeModelOptionsWithCapabilities, | |
| 14 | − normalizeCodexModelOptionsWithCapabilities, | |
| 15 | −} from "@t3tools/shared/model"; | |
| 16 | 18 | |
| 17 | 19 | export type ComposerProviderStateInput = { |
| 18 | 20 | provider: ProviderKind; |
| @@ -31,7 +33,12 @@ export type ComposerProviderState = { · 12 unmodified lines | ||
| 31 | 33 | modelPickerIconClassName?: string; |
| 32 | 34 | }; |
| 33 | 35 | |
| 36 | +export type ComposerProviderControls = { | |
| 37 | + showInteractionModeToggle: boolean; | |
| 38 | +}; | |
| 39 | + | |
| 34 | 40 | type ProviderRegistryEntry = { |
| 41 | + controls: ComposerProviderControls; | |
| 35 | 42 | getState: (input: ComposerProviderStateInput) => ComposerProviderState; |
| 36 | 43 | renderTraitsMenuContent: (input: { |
| 37 | 44 | threadRef?: ScopedThreadRef; |
| @@ -66,25 +73,26 @@ function getProviderStateFromCapabilities( · 28 unmodified lines | ||
| 66 | 73 | const { provider, model, models, prompt, modelOptions } = input; |
| 67 | 74 | const caps = getProviderModelCapabilities(models, model, provider); |
| 68 | 75 | const providerOptions = modelOptions?.[provider]; |
| 69 | − | |
| 70 | − // Resolve effort | |
| 71 | 76 | const rawEffort = providerOptions |
| 72 | 77 | ? "effort" in providerOptions |
| 73 | 78 | ? providerOptions.effort |
| 74 | 79 | : "reasoningEffort" in providerOptions |
| 75 | 80 | ? providerOptions.reasoningEffort |
| 76 | − : null | |
| 81 | + : "variant" in providerOptions | |
| 82 | + ? providerOptions.variant | |
| 83 | + : null | |
| 77 | 84 | : null; |
| 78 | − | |
| 79 | − const promptEffort = resolveEffort(caps, rawEffort) ?? null; | |
| 80 | − | |
| 81 | − // Normalize options for dispatch | |
| 82 | − const normalizedOptions = | |
| 83 | − provider === "codex" | |
| 84 | − ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) | |
| 85 | − : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); | |
| 86 | − | |
| 87 | − // Ultrathink styling (driven by capabilities data, not provider identity) | |
| 85 | + const normalizedOptions = normalizeProviderModelOptionsWithCapabilities( | |
| 86 | + provider, | |
| 87 | + caps, | |
| 88 | + providerOptions, | |
| 89 | + ); | |
| 90 | + const promptEffort = | |
| 91 | + provider === "opencode" | |
| 92 | + ? (trimOrNull( | |
| 93 | + normalizedOptions && "variant" in normalizedOptions ? normalizedOptions.variant : null, | |
| 94 | + ) ?? null) | |
| 95 | + : (resolveEffort(caps, rawEffort) ?? null); | |
| 88 | 96 | const ultrathinkActive = |
| 89 | 97 | caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); |
| 90 | 98 | |
| @@ -100,53 +108,19 @@ function getProviderStateFromCapabilities( · 9 unmodified lines | ||
| 100 | 108 | }; |
| 101 | 109 | } |
| 102 | 110 | |
| 103 | −const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | |
| 104 | − codex: { | |
| 105 | − getState: (input) => getProviderStateFromCapabilities(input), | |
| 106 | − renderTraitsMenuContent: ({ | |
| 107 | − threadRef, | |
| 108 | − draftId, | |
| 109 | − model, | |
| 110 | − models, | |
| 111 | − modelOptions, | |
| 112 | − prompt, | |
| 113 | − onPromptChange, | |
| 114 | − }) => | |
| 115 | − !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( | |
| 116 | − <TraitsMenuContent | |
| 117 | − provider="codex" | |
| 118 | − models={models} | |
| 119 | − {...(threadRef ? { threadRef } : {})} | |
| 120 | − {...(draftId ? { draftId } : {})} | |
| 121 | − model={model} | |
| 122 | − modelOptions={modelOptions} | |
| 123 | − prompt={prompt} | |
| 124 | − onPromptChange={onPromptChange} | |
| 125 | − /> | |
| 126 | − ), | |
| 127 | − renderTraitsPicker: ({ | |
| 128 | − threadRef, | |
| 129 | − draftId, | |
| 130 | − model, | |
| 131 | − models, | |
| 132 | − modelOptions, | |
| 133 | − prompt, | |
| 134 | − onPromptChange, | |
| 135 | − }) => | |
| 136 | − !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( | |
| 137 | − <TraitsPicker | |
| 138 | − provider="codex" | |
| 139 | − models={models} | |
| 140 | − {...(threadRef ? { threadRef } : {})} | |
| 141 | − {...(draftId ? { draftId } : {})} | |
| 142 | − model={model} | |
| 143 | − modelOptions={modelOptions} | |
| 144 | − prompt={prompt} | |
| 145 | − onPromptChange={onPromptChange} | |
| 146 | − /> | |
| 147 | − ), | |
| 148 | − }, | |
| 149 | − claudeAgent: { | |
| 111 | +const DEFAULT_PROVIDER_CONTROLS: ComposerProviderControls = { | |
| 112 | + showInteractionModeToggle: true, | |
| 113 | +}; | |
| 114 | + | |
| 115 | +function createProviderRegistryEntry( | |
| 116 | + provider: ProviderKind, | |
| 117 | + controls?: Partial<ComposerProviderControls>, | |
| 118 | +): ProviderRegistryEntry { | |
| 119 | + return { | |
| 120 | + controls: { | |
| 121 | + ...DEFAULT_PROVIDER_CONTROLS, | |
| 122 | + ...controls, | |
| 123 | + }, | |
| 150 | 124 | getState: (input) => getProviderStateFromCapabilities(input), |
| 151 | 125 | renderTraitsMenuContent: ({ |
| 152 | 126 | threadRef, |
| @@ -159,7 +133,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 6 unmodified lines | ||
| 159 | 133 | }) => |
| 160 | 134 | !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( |
| 161 | 135 | <TraitsMenuContent |
| 162 | − provider="claudeAgent" | |
| 136 | + provider={provider} | |
| 163 | 137 | models={models} |
| 164 | 138 | {...(threadRef ? { threadRef } : {})} |
| 165 | 139 | {...(draftId ? { draftId } : {})} |
| @@ -180,7 +154,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 14 unmodified lines | ||
| 180 | 154 | }) => |
| 181 | 155 | !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( |
| 182 | 156 | <TraitsPicker |
| 183 | − provider="claudeAgent" | |
| 157 | + provider={provider} | |
| 184 | 158 | models={models} |
| 185 | 159 | {...(threadRef ? { threadRef } : {})} |
| 186 | 160 | {...(draftId ? { draftId } : {})} |
| @@ -190,13 +164,25 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 3 unmodified lines | ||
| 190 | 164 | onPromptChange={onPromptChange} |
| 191 | 165 | /> |
| 192 | 166 | ), |
| 193 | − }, | |
| 167 | + }; | |
| 168 | +} | |
| 169 | + | |
| 170 | +const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | |
| 171 | + codex: createProviderRegistryEntry("codex"), | |
| 172 | + claudeAgent: createProviderRegistryEntry("claudeAgent"), | |
| 173 | + opencode: createProviderRegistryEntry("opencode", { | |
| 174 | + showInteractionModeToggle: false, | |
| 175 | + }), | |
| 194 | 176 | }; |
| 195 | 177 | |
| 196 | 178 | export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { |
| 197 | 179 | return composerProviderRegistry[input.provider].getState(input); |
| 198 | 180 | } |
| 199 | 181 | |
| 182 | +export function getComposerProviderControls(provider: ProviderKind): ComposerProviderControls { | |
| 183 | + return composerProviderRegistry[provider].controls; | |
| 184 | +} | |
| 185 | + | |
| 200 | 186 | export function renderProviderTraitsMenuContent(input: { |
| 201 | 187 | provider: ProviderKind; |
| 202 | 188 | threadRef?: ScopedThreadRef; |
| @@ -701,4 +701,21 @@ describe("GeneralSettingsPanel observability", () => { · 700 unmodified lines | ||
| 701 | 701 | |
| 702 | 702 | expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor"); |
| 703 | 703 | }); |
| 704 | + | |
| 705 | + it("shows an OpenCode server URL field in provider settings", async () => { | |
| 706 | + setServerConfigSnapshot(createBaseServerConfig()); | |
| 707 | + | |
| 708 | + mounted = await render( | |
| 709 | + <AppAtomRegistryProvider> | |
| 710 | + <GeneralSettingsPanel /> | |
| 711 | + </AppAtomRegistryProvider>, | |
| 712 | + ); | |
| 713 | + | |
| 714 | + await page.getByLabelText("Toggle OpenCode details").click(); | |
| 715 | + | |
| 716 | + await expect.element(page.getByText("OpenCode server URL")).toBeInTheDocument(); | |
| 717 | + await expect.element(page.getByPlaceholder("http://127.0.0.1:4096")).toBeInTheDocument(); | |
| 718 | + await expect.element(page.getByText("OpenCode server password")).toBeInTheDocument(); | |
| 719 | + await expect.element(page.getByPlaceholder("Server password")).toBeInTheDocument(); | |
| 720 | + }); | |
| 704 | 721 | }); |
| @@ -21,6 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 22 | 22 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 23 | 23 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 24 | +import { createModelSelection } from "@t3tools/shared/model"; | |
| 24 | 25 | import { Equal } from "effect"; |
| 25 | 26 | import { APP_VERSION } from "../../branding"; |
| 26 | 27 | import { |
| @@ -104,6 +105,10 @@ type InstallProviderSettings = { · 77 unmodified lines | ||
| 104 | 105 | title: string; |
| 105 | 106 | binaryPlaceholder: string; |
| 106 | 107 | binaryDescription: ReactNode; |
| 108 | + serverUrlPlaceholder?: string; | |
| 109 | + serverUrlDescription?: ReactNode; | |
| 110 | + serverPasswordPlaceholder?: string; | |
| 111 | + serverPasswordDescription?: ReactNode; | |
| 107 | 112 | homePathKey?: "codexHomePath"; |
| 108 | 113 | homePlaceholder?: string; |
| 109 | 114 | homeDescription?: ReactNode; |
| @@ -125,6 +130,17 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ · 15 unmodified lines | ||
| 125 | 130 | binaryPlaceholder: "Claude binary path", |
| 126 | 131 | binaryDescription: "Path to the Claude binary", |
| 127 | 132 | }, |
| 133 | + { | |
| 134 | + provider: "opencode", | |
| 135 | + title: "OpenCode", | |
| 136 | + binaryPlaceholder: "OpenCode binary path", | |
| 137 | + binaryDescription: "Path to the OpenCode binary", | |
| 138 | + serverUrlPlaceholder: "http://127.0.0.1:4096", | |
| 139 | + serverUrlDescription: "Leave blank to let T3 Code spawn the server when needed", | |
| 140 | + serverPasswordPlaceholder: "Server password (optional)", | |
| 141 | + serverPasswordDescription: | |
| 142 | + "If your OpenCode server requires authentication, enter the password here. NOTE: Stored in plain text on disk", | |
| 143 | + }, | |
| 128 | 144 | ] as const; |
| 129 | 145 | |
| 130 | 146 | const PROVIDER_STATUS_STYLES = { |
| @@ -514,12 +530,22 @@ export function GeneralSettingsPanel() { · 383 unmodified lines | ||
| 514 | 530 | settings.providers.claudeAgent.customModels.length > 0 || |
| 515 | 531 | settings.providers.claudeAgent.launchArgs !== "", |
| 516 | 532 | ), |
| 533 | + opencode: Boolean( | |
| 534 | + settings.providers.opencode.binaryPath !== | |
| 535 | + DEFAULT_UNIFIED_SETTINGS.providers.opencode.binaryPath || | |
| 536 | + settings.providers.opencode.serverUrl !== | |
| 537 | + DEFAULT_UNIFIED_SETTINGS.providers.opencode.serverUrl || | |
| 538 | + settings.providers.opencode.serverPassword !== | |
| 539 | + DEFAULT_UNIFIED_SETTINGS.providers.opencode.serverPassword || | |
| 540 | + settings.providers.opencode.customModels.length > 0, | |
| 541 | + ), | |
| 517 | 542 | }); |
| 518 | 543 | const [customModelInputByProvider, setCustomModelInputByProvider] = useState< |
| 519 | 544 | Record<ProviderKind, string> |
| 520 | 545 | >({ |
| 521 | 546 | codex: "", |
| 522 | 547 | claudeAgent: "", |
| 548 | + opencode: "", | |
| 523 | 549 | }); |
| 524 | 550 | const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< |
| 525 | 551 | Partial<Record<ProviderKind, string | null>> |
| @@ -732,10 +758,16 @@ export function GeneralSettingsPanel() { · 206 unmodified lines | ||
| 732 | 758 | title: providerSettings.title, |
| 733 | 759 | binaryPlaceholder: providerSettings.binaryPlaceholder, |
| 734 | 760 | binaryDescription: providerSettings.binaryDescription, |
| 761 | + serverUrlPlaceholder: providerSettings.serverUrlPlaceholder, | |
| 762 | + serverUrlDescription: providerSettings.serverUrlDescription, | |
| 763 | + serverPasswordPlaceholder: providerSettings.serverPasswordPlaceholder, | |
| 764 | + serverPasswordDescription: providerSettings.serverPasswordDescription, | |
| 735 | 765 | homePathKey: providerSettings.homePathKey, |
| 736 | 766 | homePlaceholder: providerSettings.homePlaceholder, |
| 737 | 767 | homeDescription: providerSettings.homeDescription, |
| 738 | 768 | binaryPathValue: providerConfig.binaryPath, |
| 769 | + serverUrlValue: "serverUrl" in providerConfig ? providerConfig.serverUrl : "", | |
| 770 | + serverPasswordValue: "serverPassword" in providerConfig ? providerConfig.serverPassword : "", | |
| 739 | 771 | isDirty: !Equal.equals(providerConfig, defaultProviderConfig), |
| 740 | 772 | liveProvider, |
| 741 | 773 | models, |
| @@ -1035,7 +1067,7 @@ export function GeneralSettingsPanel() { · 293 unmodified lines | ||
| 1035 | 1067 | textGenerationModelSelection: resolveAppModelSelectionState( |
| 1036 | 1068 | { |
| 1037 | 1069 | ...settings, |
| 1038 | − textGenerationModelSelection: { provider, model }, | |
| 1070 | + textGenerationModelSelection: createModelSelection(provider, model), | |
| 1039 | 1071 | }, |
| 1040 | 1072 | serverProviders, |
| 1041 | 1073 | ), |
| @@ -1060,11 +1092,11 @@ export function GeneralSettingsPanel() { · 18 unmodified lines | ||
| 1060 | 1092 | textGenerationModelSelection: resolveAppModelSelectionState( |
| 1061 | 1093 | { |
| 1062 | 1094 | ...settings, |
| 1063 | − textGenerationModelSelection: { | |
| 1064 | − provider: textGenProvider, | |
| 1065 | − model: textGenModel, | |
| 1066 | − ...(nextOptions ? { options: nextOptions } : {}), | |
| 1067 | − }, | |
| 1095 | + textGenerationModelSelection: createModelSelection( | |
| 1096 | + textGenProvider, | |
| 1097 | + textGenModel, | |
| 1098 | + nextOptions, | |
| 1099 | + ), | |
| 1068 | 1100 | }, |
| 1069 | 1101 | serverProviders, |
| 1070 | 1102 | ), |
| @@ -1243,6 +1275,84 @@ export function GeneralSettingsPanel() { · 172 unmodified lines | ||
| 1243 | 1275 | </label> |
| 1244 | 1276 | </div> |
| 1245 | 1277 | |
| 1278 | + {providerCard.serverUrlPlaceholder ? ( | |
| 1279 | + <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | |
| 1280 | + <label | |
| 1281 | + htmlFor={`provider-install-${providerCard.provider}-server-url`} | |
| 1282 | + className="block" | |
| 1283 | + > | |
| 1284 | + <span className="text-xs font-medium text-foreground"> | |
| 1285 | + {providerDisplayName} server URL | |
| 1286 | + </span> | |
| 1287 | + <Input | |
| 1288 | + id={`provider-install-${providerCard.provider}-server-url`} | |
| 1289 | + className="mt-1.5" | |
| 1290 | + value={providerCard.serverUrlValue} | |
| 1291 | + onChange={(event) => | |
| 1292 | + updateSettings({ | |
| 1293 | + providers: { | |
| 1294 | + ...settings.providers, | |
| 1295 | + [providerCard.provider]: { | |
| 1296 | + ...settings.providers[providerCard.provider], | |
| 1297 | + ...(providerCard.provider === "opencode" | |
| 1298 | + ? { serverUrl: event.target.value } | |
| 1299 | + : {}), | |
| 1300 | + }, | |
| 1301 | + }, | |
| 1302 | + }) | |
| 1303 | + } | |
| 1304 | + placeholder={providerCard.serverUrlPlaceholder} | |
| 1305 | + spellCheck={false} | |
| 1306 | + /> | |
| 1307 | + {providerCard.serverUrlDescription ? ( | |
| 1308 | + <span className="mt-1 block text-xs text-muted-foreground"> | |
| 1309 | + {providerCard.serverUrlDescription} | |
| 1310 | + </span> | |
| 1311 | + ) : null} | |
| 1312 | + </label> | |
| 1313 | + </div> | |
| 1314 | + ) : null} | |
| 1315 | + | |
| 1316 | + {providerCard.serverPasswordPlaceholder ? ( | |
| 1317 | + <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | |
| 1318 | + <label | |
| 1319 | + htmlFor={`provider-install-${providerCard.provider}-server-password`} | |
| 1320 | + className="block" | |
| 1321 | + > | |
| 1322 | + <span className="text-xs font-medium text-foreground"> | |
| 1323 | + {providerDisplayName} server password | |
| 1324 | + </span> | |
| 1325 | + <Input | |
| 1326 | + id={`provider-install-${providerCard.provider}-server-password`} | |
| 1327 | + className="mt-1.5" | |
| 1328 | + type="password" | |
| 1329 | + autoComplete="off" | |
| 1330 | + value={providerCard.serverPasswordValue} | |
| 1331 | + onChange={(event) => | |
| 1332 | + updateSettings({ | |
| 1333 | + providers: { | |
| 1334 | + ...settings.providers, | |
| 1335 | + [providerCard.provider]: { | |
| 1336 | + ...settings.providers[providerCard.provider], | |
| 1337 | + ...(providerCard.provider === "opencode" | |
| 1338 | + ? { serverPassword: event.target.value } | |
| 1339 | + : {}), | |
| 1340 | + }, | |
| 1341 | + }, | |
| 1342 | + }) | |
| 1343 | + } | |
| 1344 | + placeholder={providerCard.serverPasswordPlaceholder} | |
| 1345 | + spellCheck={false} | |
| 1346 | + /> | |
| 1347 | + {providerCard.serverPasswordDescription ? ( | |
| 1348 | + <span className="mt-1 block text-xs text-muted-foreground"> | |
| 1349 | + {providerCard.serverPasswordDescription} | |
| 1350 | + </span> | |
| 1351 | + ) : null} | |
| 1352 | + </label> | |
| 1353 | + </div> | |
| 1354 | + ) : null} | |
| 1355 | + | |
| 1246 | 1356 | {providerCard.homePathKey ? ( |
| 1247 | 1357 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> |
| 1248 | 1358 | <label |
| @@ -1422,7 +1532,9 @@ export function GeneralSettingsPanel() { · 173 unmodified lines | ||
| 1422 | 1532 | placeholder={ |
| 1423 | 1533 | providerCard.provider === "codex" |
| 1424 | 1534 | ? "gpt-6.7-codex-ultra-preview" |
| 1425 | − : "claude-sonnet-5-0" | |
| 1535 | + : providerCard.provider === "opencode" | |
| 1536 | + ? "openai/gpt-5" | |
| 1537 | + : "claude-sonnet-5-0" | |
| 1426 | 1538 | } |
| 1427 | 1539 | spellCheck={false} |
| 1428 | 1540 | /> |
| @@ -25,7 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | import * as Schema from "effect/Schema"; |
| 26 | 26 | import * as Equal from "effect/Equal"; |
| 27 | 27 | import { DeepMutable } from "effect/Types"; |
| 28 | −import { normalizeModelSlug } from "@t3tools/shared/model"; | |
| 28 | +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; | |
| 29 | 29 | import { useMemo } from "react"; |
| 30 | 30 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; |
| 31 | 31 | import { resolveAppModelSelection } from "./modelSelection"; |
| @@ -527,7 +527,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { · 495 unmodified lines | ||
| 527 | 527 | } |
| 528 | 528 | |
| 529 | 529 | function normalizeProviderKind(value: unknown): ProviderKind | null { |
| 530 | − return value === "codex" || value === "claudeAgent" ? value : null; | |
| 530 | + return value === "codex" || value === "claudeAgent" || value === "opencode" ? value : null; | |
| 531 | 531 | } |
| 532 | 532 | |
| 533 | 533 | function normalizeProviderModelOptions( |
| @@ -544,6 +544,10 @@ function normalizeProviderModelOptions( · 10 unmodified lines | ||
| 544 | 544 | candidate?.claudeAgent && typeof candidate.claudeAgent === "object" |
| 545 | 545 | ? (candidate.claudeAgent as Record<string, unknown>) |
| 546 | 546 | : null; |
| 547 | + const openCodeCandidate = | |
| 548 | + candidate?.opencode && typeof candidate.opencode === "object" | |
| 549 | + ? (candidate.opencode as Record<string, unknown>) | |
| 550 | + : null; | |
| 547 | 551 | |
| 548 | 552 | const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort) |
| 549 | 553 | ? codexCandidate.reasoningEffort |
| @@ -600,13 +604,29 @@ function normalizeProviderModelOptions( · 50 unmodified lines | ||
| 600 | 604 | ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}), |
| 601 | 605 | } |
| 602 | 606 | : undefined; |
| 607 | + const openCodeVariant = | |
| 608 | + typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0 | |
| 609 | + ? openCodeCandidate.variant | |
| 610 | + : undefined; | |
| 611 | + const openCodeAgent = | |
| 612 | + typeof openCodeCandidate?.agent === "string" && openCodeCandidate.agent.length > 0 | |
| 613 | + ? openCodeCandidate.agent | |
| 614 | + : undefined; | |
| 615 | + const opencode = | |
| 616 | + openCodeVariant !== undefined || openCodeAgent !== undefined | |
| 617 | + ? { | |
| 618 | + ...(openCodeVariant !== undefined ? { variant: openCodeVariant } : {}), | |
| 619 | + ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}), | |
| 620 | + } | |
| 621 | + : undefined; | |
| 603 | 622 | |
| 604 | − if (!codex && !claude) { | |
| 623 | + if (!codex && !claude && !opencode) { | |
| 605 | 624 | return null; |
| 606 | 625 | } |
| 607 | 626 | return { |
| 608 | 627 | ...(codex ? { codex } : {}), |
| 609 | 628 | ...(claude ? { claudeAgent: claude } : {}), |
| 629 | + ...(opencode ? { opencode } : {}), | |
| 610 | 630 | }; |
| 611 | 631 | } |
| 612 | 632 | |
| @@ -637,12 +657,8 @@ function normalizeModelSelection( · 24 unmodified lines | ||
| 637 | 657 | provider, |
| 638 | 658 | provider === "codex" ? legacy?.legacyCodex : undefined, |
| 639 | 659 | ); |
| 640 | − const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; | |
| 641 | − return { | |
| 642 | − provider, | |
| 643 | − model, | |
| 644 | − ...(options ? { options } : {}), | |
| 645 | − }; | |
| 660 | + const options = modelOptions?.[provider]; | |
| 661 | + return createModelSelection(provider, model, options); | |
| 646 | 662 | } |
| 647 | 663 | |
| 648 | 664 | // ── Legacy sync helpers (used only during migration from v2 storage) ── |
| @@ -655,11 +671,7 @@ function legacySyncModelSelectionOptions( · 6 unmodified lines | ||
| 655 | 671 | return null; |
| 656 | 672 | } |
| 657 | 673 | const options = modelOptions?.[modelSelection.provider]; |
| 658 | − return { | |
| 659 | − provider: modelSelection.provider, | |
| 660 | − model: modelSelection.model, | |
| 661 | − ...(options ? { options } : {}), | |
| 662 | − }; | |
| 674 | + return createModelSelection(modelSelection.provider, modelSelection.model, options); | |
| 663 | 675 | } |
| 664 | 676 | |
| 665 | 677 | function legacyMergeModelSelectionIntoProviderModelOptions( |
| @@ -703,17 +715,16 @@ function legacyToModelSelectionByProvider( · 37 unmodified lines | ||
| 703 | 715 | const result: Partial<Record<ProviderKind, ModelSelection>> = {}; |
| 704 | 716 | // Add entries from the options bag (for non-active providers) |
| 705 | 717 | if (modelOptions) { |
| 706 | − for (const provider of ["codex", "claudeAgent"] as const) { | |
| 718 | + for (const provider of ["codex", "claudeAgent", "opencode"] as const) { | |
| 707 | 719 | const options = modelOptions[provider]; |
| 708 | 720 | if (options && Object.keys(options).length > 0) { |
| 709 | − result[provider] = { | |
| 721 | + result[provider] = createModelSelection( | |
| 710 | 722 | provider, |
| 711 | − model: | |
| 712 | − modelSelection?.provider === provider | |
| 713 | − ? modelSelection.model | |
| 714 | − : DEFAULT_MODEL_BY_PROVIDER[provider], | |
| 723 | + modelSelection?.provider === provider | |
| 724 | + ? modelSelection.model | |
| 725 | + : DEFAULT_MODEL_BY_PROVIDER[provider], | |
| 715 | 726 | options, |
| 716 | − }; | |
| 727 | + ); | |
| 717 | 728 | } |
| 718 | 729 | } |
| 719 | 730 | } |
| @@ -2205,11 +2216,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 1485 unmodified lines | ||
| 2205 | 2216 | nextMap[normalized.provider] = normalized; |
| 2206 | 2217 | } else { |
| 2207 | 2218 | // No options in selection → preserve existing options, update provider+model |
| 2208 | − nextMap[normalized.provider] = { | |
| 2209 | − provider: normalized.provider, | |
| 2210 | − model: normalized.model, | |
| 2211 | − ...(current?.options ? { options: current.options } : {}), | |
| 2212 | − }; | |
| 2219 | + nextMap[normalized.provider] = createModelSelection( | |
| 2220 | + normalized.provider, | |
| 2221 | + normalized.model, | |
| 2222 | + current?.options, | |
| 2223 | + ); | |
| 2213 | 2224 | } |
| 2214 | 2225 | } |
| 2215 | 2226 | const nextActiveProvider = normalized?.provider ?? base.activeProvider; |
| @@ -2246,17 +2257,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 30 unmodified lines | ||
| 2246 | 2257 | } |
| 2247 | 2258 | const base = existing ?? createEmptyThreadDraft(); |
| 2248 | 2259 | const nextMap = { ...base.modelSelectionByProvider }; |
| 2249 | − for (const provider of ["codex", "claudeAgent"] as const) { | |
| 2260 | + for (const provider of ["codex", "claudeAgent", "opencode"] as const) { | |
| 2250 | 2261 | // Only touch providers explicitly present in the input |
| 2251 | 2262 | if (!normalizedOpts || !(provider in normalizedOpts)) continue; |
| 2252 | 2263 | const opts = normalizedOpts[provider]; |
| 2253 | 2264 | const current = nextMap[provider]; |
| 2254 | 2265 | if (opts) { |
| 2255 | − nextMap[provider] = { | |
| 2266 | + nextMap[provider] = createModelSelection( | |
| 2256 | 2267 | provider, |
| 2257 | − model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], | |
| 2258 | − options: opts, | |
| 2259 | − }; | |
| 2268 | + current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], | |
| 2269 | + opts, | |
| 2270 | + ); | |
| 2260 | 2271 | } else if (current?.options) { |
| 2261 | 2272 | // Remove options but keep the selection |
| 2262 | 2273 | const { options: _, ...rest } = current; |
| @@ -2303,11 +2314,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 40 unmodified lines | ||
| 2303 | 2314 | const nextMap = { ...base.modelSelectionByProvider }; |
| 2304 | 2315 | const currentForProvider = nextMap[normalizedProvider]; |
| 2305 | 2316 | if (providerOpts) { |
| 2306 | − nextMap[normalizedProvider] = { | |
| 2307 | − provider: normalizedProvider, | |
| 2308 | − model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | |
| 2309 | − options: providerOpts, | |
| 2310 | − }; | |
| 2317 | + nextMap[normalizedProvider] = createModelSelection( | |
| 2318 | + normalizedProvider, | |
| 2319 | + currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | |
| 2320 | + providerOpts, | |
| 2321 | + ); | |
| 2311 | 2322 | } else if (currentForProvider?.options) { |
| 2312 | 2323 | const { options: _, ...rest } = currentForProvider; |
| 2313 | 2324 | nextMap[normalizedProvider] = rest as ModelSelection; |
| @@ -2321,16 +2332,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 7 unmodified lines | ||
| 2321 | 2332 | const stickyBase = |
| 2322 | 2333 | nextStickyMap[normalizedProvider] ?? |
| 2323 | 2334 | base.modelSelectionByProvider[normalizedProvider] ?? |
| 2324 | − ({ | |
| 2325 | − provider: normalizedProvider, | |
| 2326 | − model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | |
| 2327 | − } as ModelSelection); | |
| 2335 | + createModelSelection( | |
| 2336 | + normalizedProvider, | |
| 2337 | + DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | |
| 2338 | + ); | |
| 2328 | 2339 | if (providerOpts) { |
| 2329 | − nextStickyMap[normalizedProvider] = { | |
| 2330 | − ...stickyBase, | |
| 2331 | − provider: normalizedProvider, | |
| 2332 | − options: providerOpts, | |
| 2333 | − }; | |
| 2340 | + nextStickyMap[normalizedProvider] = createModelSelection( | |
| 2341 | + normalizedProvider, | |
| 2342 | + stickyBase.model, | |
| 2343 | + providerOpts, | |
| 2344 | + ); | |
| 2334 | 2345 | } else if (stickyBase.options) { |
| 2335 | 2346 | const { options: _, ...rest } = stickyBase; |
| 2336 | 2347 | nextStickyMap[normalizedProvider] = rest as ModelSelection; |
| @@ -4,7 +4,11 @@ import { · 3 unmodified lines | ||
| 4 | 4 | type ProviderKind, |
| 5 | 5 | type ServerProvider, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | −import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; | |
| 7 | +import { | |
| 8 | + createModelSelection, | |
| 9 | + normalizeModelSlug, | |
| 10 | + resolveSelectableModel, | |
| 11 | +} from "@t3tools/shared/model"; | |
| 8 | 12 | import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; |
| 9 | 13 | import { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 10 | 14 | import { |
| @@ -45,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf · 34 unmodified lines | ||
| 45 | 49 | placeholder: "your-claude-model-slug", |
| 46 | 50 | example: "claude-sonnet-5-0", |
| 47 | 51 | }, |
| 52 | + opencode: { | |
| 53 | + provider: "opencode", | |
| 54 | + title: "OpenCode", | |
| 55 | + description: "Save additional OpenCode model slugs in `provider/model` format.", | |
| 56 | + placeholder: "openai/gpt-5", | |
| 57 | + example: "anthropic/claude-sonnet-4-5-20250929", | |
| 58 | + }, | |
| 48 | 59 | }; |
| 49 | 60 | |
| 50 | 61 | export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); |
| @@ -165,6 +176,12 @@ export function getCustomModelOptionsByProvider( · 114 unmodified lines | ||
| 165 | 176 | "claudeAgent", |
| 166 | 177 | selectedProvider === "claudeAgent" ? selectedModel : undefined, |
| 167 | 178 | ), |
| 179 | + opencode: getAppModelOptions( | |
| 180 | + settings, | |
| 181 | + providers, | |
| 182 | + "opencode", | |
| 183 | + selectedProvider === "opencode" ? selectedModel : undefined, | |
| 184 | + ), | |
| 168 | 185 | }; |
| 169 | 186 | } |
| 170 | 187 | |
| @@ -192,9 +209,5 @@ export function resolveAppModelSelectionState( · 21 unmodified lines | ||
| 192 | 209 | }, |
| 193 | 210 | }); |
| 194 | 211 | |
| 195 | − return { | |
| 196 | − provider, | |
| 197 | − model, | |
| 198 | − ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}), | |
| 199 | − }; | |
| 212 | + return createModelSelection(provider, model, modelOptionsForDispatch); | |
| 200 | 213 | } |
| @@ -1326,12 +1326,14 @@ describe("deriveActiveWorkStartedAt", () => { · 1325 unmodified lines | ||
| 1326 | 1326 | }); |
| 1327 | 1327 | |
| 1328 | 1328 | describe("PROVIDER_OPTIONS", () => { |
| 1329 | − it("advertises Claude as available while keeping Cursor as a placeholder", () => { | |
| 1329 | + it("advertises available providers while keeping Cursor as a placeholder", () => { | |
| 1330 | 1330 | const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); |
| 1331 | + const opencode = PROVIDER_OPTIONS.find((option) => option.value === "opencode"); | |
| 1331 | 1332 | const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); |
| 1332 | 1333 | expect(PROVIDER_OPTIONS).toEqual([ |
| 1333 | 1334 | { value: "codex", label: "Codex", available: true }, |
| 1334 | 1335 | { value: "claudeAgent", label: "Claude", available: true }, |
| 1336 | + { value: "opencode", label: "OpenCode", available: true }, | |
| 1335 | 1337 | { value: "cursor", label: "Cursor", available: false }, |
| 1336 | 1338 | ]); |
| 1337 | 1339 | expect(claude).toEqual({ |
| @@ -1339,6 +1341,11 @@ describe("PROVIDER_OPTIONS", () => { · 1 unmodified line | ||
| 1339 | 1341 | label: "Claude", |
| 1340 | 1342 | available: true, |
| 1341 | 1343 | }); |
| 1344 | + expect(opencode).toEqual({ | |
| 1345 | + value: "opencode", | |
| 1346 | + label: "OpenCode", | |
| 1347 | + available: true, | |
| 1348 | + }); | |
| 1342 | 1349 | expect(cursor).toEqual({ |
| 1343 | 1350 | value: "cursor", |
| 1344 | 1351 | label: "Cursor", |
| @@ -29,6 +29,7 @@ export const PROVIDER_OPTIONS: Array<{ · 28 unmodified lines | ||
| 29 | 29 | }> = [ |
| 30 | 30 | { value: "codex", label: "Codex", available: true }, |
| 31 | 31 | { value: "claudeAgent", label: "Claude", available: true }, |
| 32 | + { value: "opencode", label: "OpenCode", available: true }, | |
| 32 | 33 | { value: "cursor", label: "Cursor", available: false }, |
| 33 | 34 | ]; |
| 34 | 35 | |
| @@ -129,7 +129,7 @@ function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { · 128 unmodified lines | ||
| 129 | 129 | return left.length === right.length && left.every((value, index) => value === right[index]); |
| 130 | 130 | } |
| 131 | 131 | |
| 132 | −function normalizeModelSelection<T extends { provider: "codex" | "claudeAgent"; model: string }>( | |
| 132 | +function normalizeModelSelection<T extends { provider: ProviderKind; model: string }>( | |
| 133 | 133 | selection: T, |
| 134 | 134 | ): T { |
| 135 | 135 | return { |
| @@ -1001,7 +1001,7 @@ function toLegacySessionStatus( · 865 unmodified lines | ||
| 1001 | 1001 | } |
| 1002 | 1002 | |
| 1003 | 1003 | function toLegacyProvider(providerName: string | null): ProviderKind { |
| 1004 | − if (providerName === "codex" || providerName === "claudeAgent") { | |
| 1004 | + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") { | |
| 1005 | 1005 | return providerName; |
| 1006 | 1006 | } |
| 1007 | 1007 | return "codex"; |
| @@ -51,6 +51,7 @@ · 50 unmodified lines | ||
| 51 | 51 | "@effect/platform-bun": "catalog:", |
| 52 | 52 | "@effect/platform-node": "catalog:", |
| 53 | 53 | "@effect/sql-sqlite-bun": "catalog:", |
| 54 | + "@opencode-ai/sdk": "^1.3.15", | |
| 54 | 55 | "@pierre/diffs": "^1.1.0-beta.16", |
| 55 | 56 | "effect": "catalog:", |
| 56 | 57 | "node-pty": "^1.1.0", |
| @@ -536,6 +537,8 @@ · 479 unmodified lines | ||
| 536 | 537 | |
| 537 | 538 | "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], |
| 538 | 539 | |
| 540 | + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.15", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Uk59C7wsK20wpdr277yx7Xz7TqG5jGqlZUpSW3wDH/7a2K2iBg0lXc2wskHuCXLRXMhXpPZtb4a3SOpPENkkbg=="], | |
| 541 | + | |
| 539 | 542 | "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], |
| 540 | 543 | |
| 541 | 544 | "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], |
| @@ -29,9 +29,16 @@ export const ClaudeModelOptions = Schema.Struct({ · 28 unmodified lines | ||
| 29 | 29 | }); |
| 30 | 30 | export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; |
| 31 | 31 | |
| 32 | +export const OpenCodeModelOptions = Schema.Struct({ | |
| 33 | + variant: Schema.optional(TrimmedNonEmptyString), | |
| 34 | + agent: Schema.optional(TrimmedNonEmptyString), | |
| 35 | +}); | |
| 36 | +export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; | |
| 37 | + | |
| 32 | 38 | export const ProviderModelOptions = Schema.Struct({ |
| 33 | 39 | codex: Schema.optional(CodexModelOptions), |
| 34 | 40 | claudeAgent: Schema.optional(ClaudeModelOptions), |
| 41 | + opencode: Schema.optional(OpenCodeModelOptions), | |
| 35 | 42 | }); |
| 36 | 43 | export type ProviderModelOptions = typeof ProviderModelOptions.Type; |
| 37 | 44 | |
| @@ -55,12 +62,15 @@ export const ModelCapabilities = Schema.Struct({ · 17 unmodified lines | ||
| 55 | 62 | supportsThinkingToggle: Schema.Boolean, |
| 56 | 63 | contextWindowOptions: Schema.Array(ContextWindowOption), |
| 57 | 64 | promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), |
| 65 | + variantOptions: Schema.optional(Schema.Array(EffortOption)), | |
| 66 | + agentOptions: Schema.optional(Schema.Array(EffortOption)), | |
| 58 | 67 | }); |
| 59 | 68 | export type ModelCapabilities = typeof ModelCapabilities.Type; |
| 60 | 69 | |
| 61 | 70 | export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = { |
| 62 | 71 | codex: "gpt-5.4", |
| 63 | 72 | claudeAgent: "claude-sonnet-4-6", |
| 73 | + opencode: "openai/gpt-5", | |
| 64 | 74 | }; |
| 65 | 75 | |
| 66 | 76 | export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; |
| @@ -69,6 +79,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; · 2 unmodified lines | ||
| 69 | 79 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = { |
| 70 | 80 | codex: "gpt-5.4-mini", |
| 71 | 81 | claudeAgent: "claude-haiku-4-5", |
| 82 | + opencode: "openai/gpt-5", | |
| 72 | 83 | }; |
| 73 | 84 | |
| 74 | 85 | export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = { |
| @@ -96,6 +107,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, · 21 unmodified lines | ||
| 96 | 107 | "claude-haiku-4.5": "claude-haiku-4-5", |
| 97 | 108 | "claude-haiku-4-5-20251001": "claude-haiku-4-5", |
| 98 | 109 | }, |
| 110 | + opencode: {}, | |
| 99 | 111 | }; |
| 100 | 112 | |
| 101 | 113 | // ── Provider display names ──────────────────────────────────────────── |
| @@ -103,4 +115,5 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, · 1 unmodified line | ||
| 103 | 115 | export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = { |
| 104 | 116 | codex: "Codex", |
| 105 | 117 | claudeAgent: "Claude", |
| 118 | + opencode: "OpenCode", | |
| 106 | 119 | }; |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; |
| 2 | −import { ClaudeModelOptions, CodexModelOptions } from "./model.ts"; | |
| 2 | +import { ClaudeModelOptions, CodexModelOptions, OpenCodeModelOptions } from "./model.ts"; | |
| 3 | 3 | import { RepositoryIdentity } from "./environment.ts"; |
| 4 | 4 | import { |
| 5 | 5 | ApprovalRequestId, |
| @@ -25,7 +25,7 @@ export const ORCHESTRATION_WS_METHODS = { · 19 unmodified lines | ||
| 25 | 25 | subscribeThread: "orchestration.subscribeThread", |
| 26 | 26 | } as const; |
| 27 | 27 | |
| 28 | −export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); | |
| 28 | +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "opencode"]); | |
| 29 | 29 | export type ProviderKind = typeof ProviderKind.Type; |
| 30 | 30 | export const ProviderApprovalPolicy = Schema.Literals([ |
| 31 | 31 | "untrusted", |
| @@ -57,7 +57,18 @@ export const ClaudeModelSelection = Schema.Struct({ · 25 unmodified lines | ||
| 57 | 57 | }); |
| 58 | 58 | export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; |
| 59 | 59 | |
| 60 | −export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); | |
| 60 | +export const OpenCodeModelSelection = Schema.Struct({ | |
| 61 | + provider: Schema.Literal("opencode"), | |
| 62 | + model: TrimmedNonEmptyString, | |
| 63 | + options: Schema.optionalKey(OpenCodeModelOptions), | |
| 64 | +}); | |
| 65 | +export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; | |
| 66 | + | |
| 67 | +export const ModelSelection = Schema.Union([ | |
| 68 | + CodexModelSelection, | |
| 69 | + ClaudeModelSelection, | |
| 70 | + OpenCodeModelSelection, | |
| 71 | +]); | |
| 61 | 72 | export type ModelSelection = typeof ModelSelection.Type; |
| 62 | 73 | |
| 63 | 74 | export const RuntimeMode = Schema.Literals([ |
| @@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Literals([ · 23 unmodified lines | ||
| 24 | 24 | "claude.sdk.message", |
| 25 | 25 | "claude.sdk.permission", |
| 26 | 26 | "codex.sdk.thread-event", |
| 27 | + "opencode.sdk.event", | |
| 27 | 28 | ]); |
| 28 | 29 | export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; |
| 29 | 30 | |
| @@ -6,6 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | ClaudeModelOptions, |
| 7 | 7 | CodexModelOptions, |
| 8 | 8 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 9 | + OpenCodeModelOptions, | |
| 9 | 10 | } from "./model.ts"; |
| 10 | 11 | import { ModelSelection } from "./orchestration.ts"; |
| 11 | 12 | |
| @@ -89,6 +90,15 @@ export const ClaudeSettings = Schema.Struct({ · 77 unmodified lines | ||
| 89 | 90 | }); |
| 90 | 91 | export type ClaudeSettings = typeof ClaudeSettings.Type; |
| 91 | 92 | |
| 93 | +export const OpenCodeSettings = Schema.Struct({ | |
| 94 | + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 95 | + binaryPath: makeBinaryPathSetting("opencode"), | |
| 96 | + serverUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 97 | + serverPassword: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 98 | + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | |
| 99 | +}); | |
| 100 | +export type OpenCodeSettings = typeof OpenCodeSettings.Type; | |
| 101 | + | |
| 92 | 102 | export const ObservabilitySettings = Schema.Struct({ |
| 93 | 103 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 94 | 104 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| @@ -114,6 +124,7 @@ export const ServerSettings = Schema.Struct({ · 19 unmodified lines | ||
| 114 | 124 | providers: Schema.Struct({ |
| 115 | 125 | codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 116 | 126 | claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 127 | + opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 117 | 128 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 118 | 129 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 119 | 130 | }); |
| @@ -156,6 +167,11 @@ const ClaudeModelOptionsPatch = Schema.Struct({ · 36 unmodified lines | ||
| 156 | 167 | contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow), |
| 157 | 168 | }); |
| 158 | 169 | |
| 170 | +const OpenCodeModelOptionsPatch = Schema.Struct({ | |
| 171 | + variant: Schema.optionalKey(OpenCodeModelOptions.fields.variant), | |
| 172 | + agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent), | |
| 173 | +}); | |
| 174 | + | |
| 159 | 175 | const ModelSelectionPatch = Schema.Union([ |
| 160 | 176 | Schema.Struct({ |
| 161 | 177 | provider: Schema.optionalKey(Schema.Literal("codex")), |
| @@ -167,6 +183,11 @@ const ModelSelectionPatch = Schema.Union([ · 5 unmodified lines | ||
| 167 | 183 | model: Schema.optionalKey(TrimmedNonEmptyString), |
| 168 | 184 | options: Schema.optionalKey(ClaudeModelOptionsPatch), |
| 169 | 185 | }), |
| 186 | + Schema.Struct({ | |
| 187 | + provider: Schema.optionalKey(Schema.Literal("opencode")), | |
| 188 | + model: Schema.optionalKey(TrimmedNonEmptyString), | |
| 189 | + options: Schema.optionalKey(OpenCodeModelOptionsPatch), | |
| 190 | + }), | |
| 170 | 191 | ]); |
| 171 | 192 | |
| 172 | 193 | const CodexSettingsPatch = Schema.Struct({ |
| @@ -183,6 +204,14 @@ const ClaudeSettingsPatch = Schema.Struct({ · 10 unmodified lines | ||
| 183 | 204 | launchArgs: Schema.optionalKey(Schema.String), |
| 184 | 205 | }); |
| 185 | 206 | |
| 207 | +const OpenCodeSettingsPatch = Schema.Struct({ | |
| 208 | + enabled: Schema.optionalKey(Schema.Boolean), | |
| 209 | + binaryPath: Schema.optionalKey(Schema.String), | |
| 210 | + serverUrl: Schema.optionalKey(Schema.String), | |
| 211 | + serverPassword: Schema.optionalKey(Schema.String), | |
| 212 | + customModels: Schema.optionalKey(Schema.Array(Schema.String)), | |
| 213 | +}); | |
| 214 | + | |
| 186 | 215 | export const ServerSettingsPatch = Schema.Struct({ |
| 187 | 216 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), |
| 188 | 217 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| @@ -198,6 +227,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 9 unmodified lines | ||
| 198 | 227 | Schema.Struct({ |
| 199 | 228 | codex: Schema.optionalKey(CodexSettingsPatch), |
| 200 | 229 | claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), |
| 230 | + opencode: Schema.optionalKey(OpenCodeSettingsPatch), | |
| 201 | 231 | }), |
| 202 | 232 | ), |
| 203 | 233 | }); |
| @@ -6,7 +6,9 @@ import { · 5 unmodified lines | ||
| 6 | 6 | type CodexModelOptions, |
| 7 | 7 | type ModelCapabilities, |
| 8 | 8 | type ModelSelection, |
| 9 | + type OpenCodeModelOptions, | |
| 9 | 10 | type ProviderKind, |
| 11 | + type ProviderModelOptions, | |
| 10 | 12 | } from "@t3tools/contracts"; |
| 11 | 13 | |
| 12 | 14 | export interface SelectableModelOption { |
| @@ -117,6 +119,50 @@ export function normalizeClaudeModelOptionsWithCapabilities( · 104 unmodified lines | ||
| 117 | 119 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; |
| 118 | 120 | } |
| 119 | 121 | |
| 122 | +function resolveLabeledOption( | |
| 123 | + options: ReadonlyArray<{ value: string; isDefault?: boolean | undefined }> | undefined, | |
| 124 | + raw: string | null | undefined, | |
| 125 | +): string | undefined { | |
| 126 | + if (!options || options.length === 0) { | |
| 127 | + return raw ?? undefined; | |
| 128 | + } | |
| 129 | + if (raw && options.some((option) => option.value === raw)) { | |
| 130 | + return raw; | |
| 131 | + } | |
| 132 | + return options.find((option) => option.isDefault)?.value; | |
| 133 | +} | |
| 134 | + | |
| 135 | +export function normalizeOpenCodeModelOptionsWithCapabilities( | |
| 136 | + caps: ModelCapabilities, | |
| 137 | + modelOptions: OpenCodeModelOptions | null | undefined, | |
| 138 | +): OpenCodeModelOptions | undefined { | |
| 139 | + const variant = resolveLabeledOption(caps.variantOptions, trimOrNull(modelOptions?.variant)); | |
| 140 | + const agent = resolveLabeledOption(caps.agentOptions, trimOrNull(modelOptions?.agent)); | |
| 141 | + const nextOptions: OpenCodeModelOptions = { | |
| 142 | + ...(variant ? { variant } : {}), | |
| 143 | + ...(agent ? { agent } : {}), | |
| 144 | + }; | |
| 145 | + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | |
| 146 | +} | |
| 147 | + | |
| 148 | +export function normalizeProviderModelOptionsWithCapabilities( | |
| 149 | + provider: ProviderKind, | |
| 150 | + caps: ModelCapabilities, | |
| 151 | + modelOptions: ProviderModelOptions[ProviderKind] | null | undefined, | |
| 152 | +): ProviderModelOptions[ProviderKind] | undefined { | |
| 153 | + switch (provider) { | |
| 154 | + case "codex": | |
| 155 | + return normalizeCodexModelOptionsWithCapabilities(caps, modelOptions as CodexModelOptions); | |
| 156 | + case "claudeAgent": | |
| 157 | + return normalizeClaudeModelOptionsWithCapabilities(caps, modelOptions as ClaudeModelOptions); | |
| 158 | + case "opencode": | |
| 159 | + return normalizeOpenCodeModelOptionsWithCapabilities( | |
| 160 | + caps, | |
| 161 | + modelOptions as OpenCodeModelOptions, | |
| 162 | + ); | |
| 163 | + } | |
| 164 | +} | |
| 165 | + | |
| 120 | 166 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { |
| 121 | 167 | return typeof text === "string" && /\bultrathink\b/i.test(text); |
| 122 | 168 | } |
| @@ -196,6 +242,33 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n · 73 unmodified lines | ||
| 196 | 242 | return trimmed || null; |
| 197 | 243 | } |
| 198 | 244 | |
| 245 | +export function createModelSelection( | |
| 246 | + provider: ProviderKind, | |
| 247 | + model: string, | |
| 248 | + options?: ProviderModelOptions[ProviderKind] | undefined, | |
| 249 | +): ModelSelection { | |
| 250 | + switch (provider) { | |
| 251 | + case "codex": | |
| 252 | + return { | |
| 253 | + provider, | |
| 254 | + model, | |
| 255 | + ...(options ? { options: options as CodexModelOptions } : {}), | |
| 256 | + }; | |
| 257 | + case "claudeAgent": | |
| 258 | + return { | |
| 259 | + provider, | |
| 260 | + model, | |
| 261 | + ...(options ? { options: options as ClaudeModelOptions } : {}), | |
| 262 | + }; | |
| 263 | + case "opencode": | |
| 264 | + return { | |
| 265 | + provider, | |
| 266 | + model, | |
| 267 | + ...(options ? { options: options as OpenCodeModelOptions } : {}), | |
| 268 | + }; | |
| 269 | + } | |
| 270 | +} | |
| 271 | + | |
| 199 | 272 | /** |
| 200 | 273 | * Resolve the actual API model identifier from a model selection. |
| 201 | 274 | * |
| @@ -109,4 +109,30 @@ describe("serverSettings helpers", () => { · 108 unmodified lines | ||
| 109 | 109 | }, |
| 110 | 110 | }); |
| 111 | 111 | }); |
| 112 | + | |
| 113 | + it("replaces text generation selection across providers without leaking stale options", () => { | |
| 114 | + const current = { | |
| 115 | + ...DEFAULT_SERVER_SETTINGS, | |
| 116 | + textGenerationModelSelection: { | |
| 117 | + provider: "codex" as const, | |
| 118 | + model: "gpt-5.4-mini", | |
| 119 | + options: { | |
| 120 | + reasoningEffort: "high" as const, | |
| 121 | + fastMode: true, | |
| 122 | + }, | |
| 123 | + }, | |
| 124 | + }; | |
| 125 | + | |
| 126 | + expect( | |
| 127 | + applyServerSettingsPatch(current, { | |
| 128 | + textGenerationModelSelection: { | |
| 129 | + provider: "opencode", | |
| 130 | + model: "openai/gpt-5", | |
| 131 | + }, | |
| 132 | + }).textGenerationModelSelection, | |
| 133 | + ).toEqual({ | |
| 134 | + provider: "opencode", | |
| 135 | + model: "openai/gpt-5", | |
| 136 | + }); | |
| 137 | + }); | |
| 112 | 138 | }); |
| @@ -1,4 +1,10 @@ | ||
| 1 | −import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; | |
| 1 | +import { | |
| 2 | + ServerSettings, | |
| 3 | + type ClaudeModelOptions, | |
| 4 | + type CodexModelOptions, | |
| 5 | + type OpenCodeModelOptions, | |
| 6 | + type ServerSettingsPatch, | |
| 7 | +} from "@t3tools/contracts"; | |
| 2 | 8 | import { Schema } from "effect"; |
| 3 | 9 | import { deepMerge } from "./Struct.ts"; |
| 4 | 10 | import { fromLenientJson } from "./schemaJson.ts"; |
| @@ -46,6 +52,9 @@ function shouldReplaceTextGenerationModelSelection( · 41 unmodified lines | ||
| 46 | 52 | return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); |
| 47 | 53 | } |
| 48 | 54 | |
| 55 | +const withModelSelectionOptions = <Options>(options: Options | undefined) => | |
| 56 | + options ? { options } : {}; | |
| 57 | + | |
| 49 | 58 | /** |
| 50 | 59 | * Applies a server settings patch while treating textGenerationModelSelection as |
| 51 | 60 | * replace-on-provider/model updates. This prevents stale nested options from |
| @@ -61,12 +70,32 @@ export function applyServerSettingsPatch( · 9 unmodified lines | ||
| 61 | 70 | return next; |
| 62 | 71 | } |
| 63 | 72 | |
| 73 | + const provider = selectionPatch.provider ?? current.textGenerationModelSelection.provider; | |
| 74 | + const model = selectionPatch.model ?? current.textGenerationModelSelection.model; | |
| 75 | + | |
| 64 | 76 | return { |
| 65 | 77 | ...next, |
| 66 | − textGenerationModelSelection: { | |
| 67 | − provider: selectionPatch.provider ?? current.textGenerationModelSelection.provider, | |
| 68 | − model: selectionPatch.model ?? current.textGenerationModelSelection.model, | |
| 69 | − ...(selectionPatch.options ? { options: selectionPatch.options } : {}), | |
| 70 | − }, | |
| 78 | + textGenerationModelSelection: | |
| 79 | + provider === "codex" | |
| 80 | + ? { | |
| 81 | + provider, | |
| 82 | + model, | |
| 83 | + ...withModelSelectionOptions(selectionPatch.options as CodexModelOptions | undefined), | |
| 84 | + } | |
| 85 | + : provider === "claudeAgent" | |
| 86 | + ? { | |
| 87 | + provider, | |
| 88 | + model, | |
| 89 | + ...withModelSelectionOptions( | |
| 90 | + selectionPatch.options as ClaudeModelOptions | undefined, | |
| 91 | + ), | |
| 92 | + } | |
| 93 | + : { | |
| 94 | + provider, | |
| 95 | + model, | |
| 96 | + ...withModelSelectionOptions( | |
| 97 | + selectionPatch.options as OpenCodeModelOptions | undefined, | |
| 98 | + ), | |
| 99 | + }, | |
| 71 | 100 | }; |
| 72 | 101 | } |