| author | |
| committer | |
| log | ce94feeea156e77f465c0330e89c0cb77eb0196f |
| tree | 21bfefb5c61776273846f16ad62a81f72c5a7b46 |
| parent | 39ca3ee858e764dba3293aa9cb69452c2265440d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>52 files changed, 4727 insertions(+), 228 deletions(-)
apps/server/package.json+1| ... | @@ -29,6 +29,7 @@ | ... | @@ -29,6 +29,7 @@ |
| 29 | "@effect/platform-bun": "catalog:", | 29 | "@effect/platform-bun": "catalog:", |
| 30 | "@effect/platform-node": "catalog:", | 30 | "@effect/platform-node": "catalog:", |
| 31 | "@effect/sql-sqlite-bun": "catalog:", | 31 | "@effect/sql-sqlite-bun": "catalog:", |
| 32 | "@opencode-ai/sdk": "^1.3.15", | ||
| 32 | "@pierre/diffs": "^1.1.0-beta.16", | 33 | "@pierre/diffs": "^1.1.0-beta.16", |
| 33 | "effect": "catalog:", | 34 | "effect": "catalog:", |
| 34 | "node-pty": "^1.1.0", | 35 | "node-pty": "^1.1.0", |
apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts created+259| ... | @@ -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 | ); | ||
apps/server/src/git/Layers/OpenCodeTextGeneration.ts created+422| ... | @@ -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); | ||
apps/server/src/git/Layers/RoutingTextGeneration.ts+20-2| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | } from "../Services/TextGeneration.ts"; | 18 | } from "../Services/TextGeneration.ts"; |
| 19 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; | 19 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; |
| 20 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; | 20 | import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; |
| 21 | import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; | ||
| 21 | 22 | ||
| 22 | // --------------------------------------------------------------------------- | 23 | // --------------------------------------------------------------------------- |
| 23 | // Internal service tags so both concrete layers can coexist. | 24 | // Internal service tags so both concrete layers can coexist. |
| ... | @@ -31,6 +32,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( | ... | @@ -31,6 +32,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( |
| 31 | "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", | 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 | // Routing implementation | 40 | // Routing implementation |
| 36 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| ... | @@ -38,9 +43,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( | ... | @@ -38,9 +43,10 @@ class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>( |
| 38 | const makeRoutingTextGeneration = Effect.gen(function* () { | 43 | const makeRoutingTextGeneration = Effect.gen(function* () { |
| 39 | const codex = yield* CodexTextGen; | 44 | const codex = yield* CodexTextGen; |
| 40 | const claude = yield* ClaudeTextGen; | 45 | const claude = yield* ClaudeTextGen; |
| 46 | const openCode = yield* OpenCodeTextGen; | ||
| 41 | 47 | ||
| 42 | const route = (provider?: TextGenerationProvider): TextGenerationShape => | 48 | const route = (provider?: TextGenerationProvider): TextGenerationShape => |
| 43 | provider === "claudeAgent" ? claude : codex; | 49 | provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex; |
| 44 | 50 | ||
| 45 | return { | 51 | return { |
| 46 | generateCommitMessage: (input) => | 52 | generateCommitMessage: (input) => |
| ... | @@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect( | ... | @@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect( |
| 67 | }), | 73 | }), |
| 68 | ).pipe(Layer.provide(ClaudeTextGenerationLive)); | 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 | export const RoutingTextGenerationLive = Layer.effect( | 84 | export const RoutingTextGenerationLive = Layer.effect( |
| 71 | TextGeneration, | 85 | TextGeneration, |
| 72 | makeRoutingTextGeneration, | 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 | ); |
apps/server/src/git/Services/TextGeneration.ts+1-1| ... | @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; | ... | @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; |
| 13 | import type { TextGenerationError } from "@t3tools/contracts"; | 13 | import type { TextGenerationError } from "@t3tools/contracts"; |
| 14 | 14 | ||
| 15 | /** Providers that support git text generation (commit messages, PR content, branch names). */ | 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 | export interface CommitMessageGenerationInput { | 18 | export interface CommitMessageGenerationInput { |
| 19 | cwd: string; | 19 | cwd: string; |
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts created+486| ... | @@ -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 | }); | ||
apps/server/src/provider/Layers/OpenCodeAdapter.ts created+1344| ... | @@ -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(); | ||
apps/server/src/provider/Layers/OpenCodeProvider.test.ts created+138| ... | @@ -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 | }); | ||
apps/server/src/provider/Layers/OpenCodeProvider.ts created+342| ... | @@ -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(); | ||
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+23-1| ... | @@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; | ... | @@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; |
| 8 | import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; | 8 | import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; |
| 9 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | 9 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 10 | import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; | 10 | import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 11 | import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; | ||
| 12 | import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; | ||
| 11 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | 13 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 12 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; | 14 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; |
| 13 | import { ProviderUnsupportedError } from "../Errors.ts"; | 15 | import { ProviderUnsupportedError } from "../Errors.ts"; |
| ... | @@ -47,6 +49,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { | ... | @@ -47,6 +49,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { |
| 47 | streamEvents: Stream.empty, | 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 | const layer = it.layer( | 69 | const layer = it.layer( |
| 51 | Layer.mergeAll( | 70 | Layer.mergeAll( |
| 52 | Layer.provide( | 71 | Layer.provide( |
| ... | @@ -54,6 +73,7 @@ const layer = it.layer( | ... | @@ -54,6 +73,7 @@ const layer = it.layer( |
| 54 | Layer.mergeAll( | 73 | Layer.mergeAll( |
| 55 | Layer.succeed(CodexAdapter, fakeCodexAdapter), | 74 | Layer.succeed(CodexAdapter, fakeCodexAdapter), |
| 56 | Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), | 75 | Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), |
| 76 | Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), | ||
| 57 | ), | 77 | ), |
| 58 | ), | 78 | ), |
| 59 | NodeServices.layer, | 79 | NodeServices.layer, |
| ... | @@ -66,11 +86,13 @@ layer("ProviderAdapterRegistryLive", (it) => { | ... | @@ -66,11 +86,13 @@ layer("ProviderAdapterRegistryLive", (it) => { |
| 66 | const registry = yield* ProviderAdapterRegistry; | 86 | const registry = yield* ProviderAdapterRegistry; |
| 67 | const codex = yield* registry.getByProvider("codex"); | 87 | const codex = yield* registry.getByProvider("codex"); |
| 68 | const claude = yield* registry.getByProvider("claudeAgent"); | 88 | const claude = yield* registry.getByProvider("claudeAgent"); |
| 89 | const openCode = yield* registry.getByProvider("opencode"); | ||
| 69 | assert.equal(codex, fakeCodexAdapter); | 90 | assert.equal(codex, fakeCodexAdapter); |
| 70 | assert.equal(claude, fakeClaudeAdapter); | 91 | assert.equal(claude, fakeClaudeAdapter); |
| 92 | assert.equal(openCode, fakeOpenCodeAdapter); | ||
| 71 | 93 | ||
| 72 | const providers = yield* registry.listProviders(); | 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 |
apps/server/src/provider/Layers/ProviderAdapterRegistry.ts+2-1| ... | @@ -17,6 +17,7 @@ import { | ... | @@ -17,6 +17,7 @@ import { |
| 17 | } from "../Services/ProviderAdapterRegistry.ts"; | 17 | } from "../Services/ProviderAdapterRegistry.ts"; |
| 18 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; | 18 | import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; |
| 19 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; | 19 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 20 | import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; | ||
| 20 | 21 | ||
| 21 | export interface ProviderAdapterRegistryLiveOptions { | 22 | export interface ProviderAdapterRegistryLiveOptions { |
| 22 | readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>; | 23 | readonly adapters?: ReadonlyArray<ProviderAdapterShape<ProviderAdapterError>>; |
| ... | @@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun | ... | @@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun |
| 28 | const adapters = | 29 | const adapters = |
| 29 | options?.adapters !== undefined | 30 | options?.adapters !== undefined |
| 30 | ? options.adapters | 31 | ? options.adapters |
| 31 | : [yield* CodexAdapter, yield* ClaudeAdapter]; | 32 | : [yield* CodexAdapter, yield* ClaudeAdapter, yield* OpenCodeAdapter]; |
| 32 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); | 33 | const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); |
| 33 | 34 | ||
| 34 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { | 35 | const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+27-12| ... | @@ -31,6 +31,7 @@ import { | ... | @@ -31,6 +31,7 @@ import { |
| 31 | } from "./CodexProvider.ts"; | 31 | } from "./CodexProvider.ts"; |
| 32 | import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; | 32 | import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; |
| 33 | import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; | 33 | import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; |
| 34 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | ||
| 34 | import { ServerConfig } from "../../config.ts"; | 35 | import { ServerConfig } from "../../config.ts"; |
| 35 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; | 36 | import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; |
| 36 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; | 37 | import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; |
| ... | @@ -38,6 +39,19 @@ import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; | ... | @@ -38,6 +39,19 @@ import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; |
| 38 | // ── Test helpers ──────────────────────────────────────────────────── | 39 | // ── Test helpers ──────────────────────────────────────────────────── |
| 39 | 40 | ||
| 40 | const encoder = new TextEncoder(); | 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 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { | 56 | function mockHandle(result: { stdout: string; stderr: string; code: number }) { |
| 43 | return ChildProcessSpawner.makeHandle({ | 57 | return ChildProcessSpawner.makeHandle({ |
| ... | @@ -596,12 +610,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -596,12 +610,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 596 | }), | 610 | }), |
| 597 | ), | 611 | ), |
| 598 | ); | 612 | ); |
| 599 | const runtimeServices = yield* Layer.build( | 613 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( |
| 600 | Layer.mergeAll( | 614 | Scope.provide(scope), |
| 601 | Layer.succeed(ServerSettingsService, serverSettings), | 615 | ); |
| 602 | providerRegistryLayer, | ||
| 603 | ), | ||
| 604 | ).pipe(Scope.provide(scope)); | ||
| 605 | 616 | ||
| 606 | yield* Effect.gen(function* () { | 617 | yield* Effect.gen(function* () { |
| 607 | const registry = yield* ProviderRegistry; | 618 | const registry = yield* ProviderRegistry; |
| ... | @@ -630,6 +641,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -630,6 +641,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 630 | prefix: "t3-provider-registry-", | 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 | Layer.provideMerge( | 651 | Layer.provideMerge( |
| 634 | mockCommandSpawnerLayer((command, args) => { | 652 | mockCommandSpawnerLayer((command, args) => { |
| 635 | const joined = args.join(" "); | 653 | const joined = args.join(" "); |
| ... | @@ -646,12 +664,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( | ... | @@ -646,12 +664,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( |
| 646 | }), | 664 | }), |
| 647 | ), | 665 | ), |
| 648 | ); | 666 | ); |
| 649 | const runtimeServices = yield* Layer.build( | 667 | const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( |
| 650 | Layer.mergeAll( | 668 | Scope.provide(scope), |
| 651 | Layer.succeed(ServerSettingsService, serverSettings), | 669 | ); |
| 652 | providerRegistryLayer, | ||
| 653 | ), | ||
| 654 | ).pipe(Scope.provide(scope)); | ||
| 655 | 670 | ||
| 656 | yield* Effect.gen(function* () { | 671 | yield* Effect.gen(function* () { |
| 657 | const registry = yield* ProviderRegistry; | 672 | const registry = yield* ProviderRegistry; |
apps/server/src/provider/Layers/ProviderRegistry.ts+28-6| ... | @@ -9,10 +9,13 @@ import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "eff | ... | @@ -9,10 +9,13 @@ import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "eff |
| 9 | import { ServerConfig } from "../../config.ts"; | 9 | import { ServerConfig } from "../../config.ts"; |
| 10 | import { ClaudeProviderLive } from "./ClaudeProvider.ts"; | 10 | import { ClaudeProviderLive } from "./ClaudeProvider.ts"; |
| 11 | import { CodexProviderLive } from "./CodexProvider.ts"; | 11 | import { CodexProviderLive } from "./CodexProvider.ts"; |
| 12 | import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; | ||
| 12 | import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts"; | 13 | import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts"; |
| 13 | import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; | 14 | import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; |
| 14 | import type { CodexProviderShape } from "../Services/CodexProvider.ts"; | 15 | import type { CodexProviderShape } from "../Services/CodexProvider.ts"; |
| 15 | import { CodexProvider } from "../Services/CodexProvider.ts"; | 16 | import { CodexProvider } from "../Services/CodexProvider.ts"; |
| 17 | import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider.ts"; | ||
| 18 | import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; | ||
| 16 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; | 19 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; |
| 17 | import { | 20 | import { |
| 18 | hydrateCachedProvider, | 21 | hydrateCachedProvider, |
| ... | @@ -26,10 +29,14 @@ import { | ... | @@ -26,10 +29,14 @@ import { |
| 26 | const loadProviders = ( | 29 | const loadProviders = ( |
| 27 | codexProvider: CodexProviderShape, | 30 | codexProvider: CodexProviderShape, |
| 28 | claudeProvider: ClaudeProviderShape, | 31 | claudeProvider: ClaudeProviderShape, |
| 29 | ): Effect.Effect<readonly [ServerProvider, ServerProvider]> => | 32 | openCodeProvider: OpenCodeProviderShape, |
| 30 | Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { | 33 | ): Effect.Effect<readonly [ServerProvider, ServerProvider, ServerProvider]> => |
| 31 | concurrency: "unbounded", | 34 | Effect.all( |
| 32 | }); | 35 | [codexProvider.getSnapshot, claudeProvider.getSnapshot, openCodeProvider.getSnapshot], |
| 36 | { | ||
| 37 | concurrency: "unbounded", | ||
| 38 | }, | ||
| 39 | ); | ||
| 33 | 40 | ||
| 34 | export const haveProvidersChanged = ( | 41 | export const haveProvidersChanged = ( |
| 35 | previousProviders: ReadonlyArray<ServerProvider>, | 42 | previousProviders: ReadonlyArray<ServerProvider>, |
| ... | @@ -41,6 +48,7 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -41,6 +48,7 @@ export const ProviderRegistryLive = Layer.effect( |
| 41 | Effect.gen(function* () { | 48 | Effect.gen(function* () { |
| 42 | const codexProvider = yield* CodexProvider; | 49 | const codexProvider = yield* CodexProvider; |
| 43 | const claudeProvider = yield* ClaudeProvider; | 50 | const claudeProvider = yield* ClaudeProvider; |
| 51 | const openCodeProvider = yield* OpenCodeProvider; | ||
| 44 | const config = yield* ServerConfig; | 52 | const config = yield* ServerConfig; |
| 45 | const fileSystem = yield* FileSystem.FileSystem; | 53 | const fileSystem = yield* FileSystem.FileSystem; |
| 46 | const path = yield* Path.Path; | 54 | const path = yield* Path.Path; |
| ... | @@ -48,7 +56,7 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -48,7 +56,7 @@ export const ProviderRegistryLive = Layer.effect( |
| 48 | PubSub.unbounded<ReadonlyArray<ServerProvider>>(), | 56 | PubSub.unbounded<ReadonlyArray<ServerProvider>>(), |
| 49 | PubSub.shutdown, | 57 | PubSub.shutdown, |
| 50 | ); | 58 | ); |
| 51 | const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider); | 59 | const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider, openCodeProvider); |
| 52 | const cachePathByProvider = new Map( | 60 | const cachePathByProvider = new Map( |
| 53 | PROVIDER_CACHE_IDS.map( | 61 | PROVIDER_CACHE_IDS.map( |
| 54 | (provider) => | 62 | (provider) => |
| ... | @@ -156,6 +164,10 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -156,6 +164,10 @@ export const ProviderRegistryLive = Layer.effect( |
| 156 | return yield* claudeProvider.refresh.pipe( | 164 | return yield* claudeProvider.refresh.pipe( |
| 157 | Effect.flatMap((nextProvider) => syncProvider(nextProvider)), | 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 | default: | 171 | default: |
| 160 | return yield* Effect.all( | 172 | return yield* Effect.all( |
| 161 | [ | 173 | [ |
| ... | @@ -165,6 +177,9 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -165,6 +177,9 @@ export const ProviderRegistryLive = Layer.effect( |
| 165 | claudeProvider.refresh.pipe( | 177 | claudeProvider.refresh.pipe( |
| 166 | Effect.flatMap((nextProvider) => syncProvider(nextProvider)), | 178 | Effect.flatMap((nextProvider) => syncProvider(nextProvider)), |
| 167 | ), | 179 | ), |
| 180 | openCodeProvider.refresh.pipe( | ||
| 181 | Effect.flatMap((nextProvider) => syncProvider(nextProvider)), | ||
| 182 | ), | ||
| 168 | ], | 183 | ], |
| 169 | { | 184 | { |
| 170 | concurrency: "unbounded", | 185 | concurrency: "unbounded", |
| ... | @@ -180,6 +195,9 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -180,6 +195,9 @@ export const ProviderRegistryLive = Layer.effect( |
| 180 | yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => | 195 | yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => |
| 181 | syncProvider(provider), | 196 | syncProvider(provider), |
| 182 | ).pipe(Effect.forkScoped); | 197 | ).pipe(Effect.forkScoped); |
| 198 | yield* Stream.runForEach(openCodeProvider.streamChanges, (provider) => | ||
| 199 | syncProvider(provider), | ||
| 200 | ).pipe(Effect.forkScoped); | ||
| 183 | 201 | ||
| 184 | return { | 202 | return { |
| 185 | getProviders: Ref.get(providersRef), | 203 | getProviders: Ref.get(providersRef), |
| ... | @@ -193,4 +211,8 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -193,4 +211,8 @@ export const ProviderRegistryLive = Layer.effect( |
| 193 | }, | 211 | }, |
| 194 | } satisfies ProviderRegistryShape; | 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 | ); |
apps/server/src/provider/Layers/ProviderService.test.ts+56| ... | @@ -333,6 +333,62 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () | ... | @@ -333,6 +333,62 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () |
| 333 | ); | 333 | ); |
| 334 | 334 | ||
| 335 | const routing = makeProviderServiceLayer(); | 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 | it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => | 392 | it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => |
| 337 | Effect.gen(function* () { | 393 | Effect.gen(function* () { |
| 338 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); | 394 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); |
apps/server/src/provider/Layers/ProviderService.ts+3-1| ... | @@ -161,7 +161,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -161,7 +161,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 161 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | 161 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 162 | Effect.succeed(event).pipe( | 162 | Effect.succeed(event).pipe( |
| 163 | Effect.tap((canonicalEvent) => | 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 | Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), | 168 | Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), |
| 167 | Effect.asVoid, | 169 | Effect.asVoid, |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+1-1| ... | @@ -24,7 +24,7 @@ function decodeProviderKind( | ... | @@ -24,7 +24,7 @@ function decodeProviderKind( |
| 24 | providerName: string, | 24 | providerName: string, |
| 25 | operation: string, | 25 | operation: string, |
| 26 | ): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> { | 26 | ): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> { |
| 27 | if (providerName === "codex" || providerName === "claudeAgent") { | 27 | if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") { |
| 28 | return Effect.succeed(providerName); | 28 | return Effect.succeed(providerName); |
| 29 | } | 29 | } |
| 30 | return Effect.fail( | 30 | return Effect.fail( |
apps/server/src/provider/Services/OpenCodeAdapter.ts created+12| ... | @@ -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 | ) {} | ||
apps/server/src/provider/Services/OpenCodeProvider.ts created+9| ... | @@ -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 | ) {} | ||
apps/server/src/provider/opencodeRuntime.test.ts created+38| ... | @@ -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 | }); | ||
apps/server/src/provider/opencodeRuntime.ts created+573| ... | @@ -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 | } | ||
apps/server/src/provider/providerSnapshot.test.ts created+34| ... | @@ -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 | }); | ||
apps/server/src/provider/providerStatusCache.test.ts+13| ... | @@ -37,6 +37,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { | ... | @@ -37,6 +37,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 37 | status: "warning", | 37 | status: "warning", |
| 38 | auth: { status: "unknown" }, | 38 | auth: { status: "unknown" }, |
| 39 | }); | 39 | }); |
| 40 | const openCodeProvider = makeProvider("opencode", { | ||
| 41 | status: "warning", | ||
| 42 | auth: { status: "unknown", type: "opencode" }, | ||
| 43 | }); | ||
| 40 | const codexPath = resolveProviderStatusCachePath({ | 44 | const codexPath = resolveProviderStatusCachePath({ |
| 41 | cacheDir: tempDir, | 45 | cacheDir: tempDir, |
| 42 | provider: "codex", | 46 | provider: "codex", |
| ... | @@ -45,6 +49,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { | ... | @@ -45,6 +49,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 45 | cacheDir: tempDir, | 49 | cacheDir: tempDir, |
| 46 | provider: "claudeAgent", | 50 | provider: "claudeAgent", |
| 47 | }); | 51 | }); |
| 52 | const openCodePath = resolveProviderStatusCachePath({ | ||
| 53 | cacheDir: tempDir, | ||
| 54 | provider: "opencode", | ||
| 55 | }); | ||
| 48 | 56 | ||
| 49 | yield* writeProviderStatusCache({ | 57 | yield* writeProviderStatusCache({ |
| 50 | filePath: codexPath, | 58 | filePath: codexPath, |
| ... | @@ -54,9 +62,14 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { | ... | @@ -54,9 +62,14 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { |
| 54 | filePath: claudePath, | 62 | filePath: claudePath, |
| 55 | provider: claudeProvider, | 63 | provider: claudeProvider, |
| 56 | }); | 64 | }); |
| 65 | yield* writeProviderStatusCache({ | ||
| 66 | filePath: openCodePath, | ||
| 67 | provider: openCodeProvider, | ||
| 68 | }); | ||
| 57 | 69 | ||
| 58 | assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); | 70 | assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); |
| 59 | assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); | 71 | assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); |
| 72 | assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider); | ||
| 60 | }), | 73 | }), |
| 61 | ); | 74 | ); |
| 62 | 75 |
apps/server/src/provider/providerStatusCache.ts+5-3| ... | @@ -2,9 +2,11 @@ import * as nodePath from "node:path"; | ... | @@ -2,9 +2,11 @@ import * as nodePath from "node:path"; |
| 2 | import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; | 2 | import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; |
| 3 | import { Cause, Effect, FileSystem, Path, Schema } from "effect"; | 3 | import { Cause, Effect, FileSystem, Path, Schema } from "effect"; |
| 4 | 4 | ||
| 5 | export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray< | 5 | export const PROVIDER_CACHE_IDS = [ |
| 6 | ServerProvider["provider"] | 6 | "codex", |
| 7 | >; | 7 | "claudeAgent", |
| 8 | "opencode", | ||
| 9 | ] as const satisfies ReadonlyArray<ServerProvider["provider"]>; | ||
| 8 | 10 | ||
| 9 | const decodeProviderStatusCache = Schema.decodeUnknownEffect( | 11 | const decodeProviderStatusCache = Schema.decodeUnknownEffect( |
| 10 | Schema.fromJsonString(ServerProviderSchema), | 12 | Schema.fromJsonString(ServerProviderSchema), |
apps/server/src/server.ts+5| ... | @@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD | ... | @@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD |
| 21 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; | 21 | import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; |
| 22 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; | 22 | import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; |
| 23 | import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; | 23 | import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; |
| 24 | import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts"; | ||
| 24 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; | 25 | import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; |
| 25 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; | 26 | import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 26 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; | 27 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; |
| ... | @@ -154,9 +155,13 @@ const ProviderLayerLive = Layer.unwrap( | ... | @@ -154,9 +155,13 @@ const ProviderLayerLive = Layer.unwrap( |
| 154 | const claudeAdapterLayer = makeClaudeAdapterLive( | 155 | const claudeAdapterLayer = makeClaudeAdapterLive( |
| 155 | nativeEventLogger ? { nativeEventLogger } : undefined, | 156 | nativeEventLogger ? { nativeEventLogger } : undefined, |
| 156 | ); | 157 | ); |
| 158 | const openCodeAdapterLayer = makeOpenCodeAdapterLive( | ||
| 159 | nativeEventLogger ? { nativeEventLogger } : undefined, | ||
| 160 | ); | ||
| 157 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( | 161 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( |
| 158 | Layer.provide(codexAdapterLayer), | 162 | Layer.provide(codexAdapterLayer), |
| 159 | Layer.provide(claudeAdapterLayer), | 163 | Layer.provide(claudeAdapterLayer), |
| 164 | Layer.provide(openCodeAdapterLayer), | ||
| 160 | Layer.provideMerge(ProviderSessionDirectoryLayerLive), | 165 | Layer.provideMerge(ProviderSessionDirectoryLayerLive), |
| 161 | ); | 166 | ); |
| 162 | return makeProviderServiceLive( | 167 | return makeProviderServiceLive( |
apps/server/src/serverSettings.test.ts+20| ... | @@ -184,6 +184,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -184,6 +184,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 184 | claudeAgent: { | 184 | claudeAgent: { |
| 185 | binaryPath: " /opt/homebrew/bin/claude ", | 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) => { | ... | @@ -199,6 +204,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 199 | customModels: [], | 204 | customModels: [], |
| 200 | launchArgs: "", | 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 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 214 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 203 | ); | 215 | ); |
| 204 | 216 | ||
| ... | @@ -257,6 +269,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -257,6 +269,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 257 | codex: { | 269 | codex: { |
| 258 | binaryPath: "/opt/homebrew/bin/codex", | 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) => { | ... | @@ -273,6 +289,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 273 | codex: { | 289 | codex: { |
| 274 | binaryPath: "/opt/homebrew/bin/codex", | 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 | }).pipe(Effect.provide(makeServerSettingsLayer())), | 298 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
apps/server/src/serverSettings.ts+1-1| ... | @@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< | ... | @@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< |
| 105 | 105 | ||
| 106 | const ServerSettingsJson = fromLenientJson(ServerSettings); | 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 | * Ensure the `textGenerationModelSelection` points to an enabled provider. | 111 | * Ensure the `textGenerationModelSelection` points to an enabled provider. |
apps/web/src/components/ChatView.tsx+6-9| ... | @@ -26,7 +26,7 @@ import { | ... | @@ -26,7 +26,7 @@ import { |
| 26 | scopeProjectRef, | 26 | scopeProjectRef, |
| 27 | scopeThreadRef, | 27 | scopeThreadRef, |
| 28 | } from "@t3tools/client-runtime"; | 28 | } from "@t3tools/client-runtime"; |
| 29 | import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; | 29 | import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; |
| 30 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 30 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 31 | import { truncate } from "@t3tools/shared/String"; | 31 | import { truncate } from "@t3tools/shared/String"; |
| 32 | import { Debouncer } from "@tanstack/react-pacer"; | 32 | import { Debouncer } from "@tanstack/react-pacer"; |
| ... | @@ -2534,16 +2534,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -2534,16 +2534,13 @@ export default function ChatView(props: ChatViewProps) { |
| 2534 | } | 2534 | } |
| 2535 | } | 2535 | } |
| 2536 | const title = truncate(titleSeed); | 2536 | const title = truncate(titleSeed); |
| 2537 | const threadCreateModelSelection: ModelSelection = { | 2537 | const threadCreateModelSelection = createModelSelection( |
| 2538 | provider: ctxSelectedProvider, | 2538 | ctxSelectedProvider, |
| 2539 | model: | 2539 | ctxSelectedModel || |
| 2540 | ctxSelectedModel || | ||
| 2541 | activeProject.defaultModelSelection?.model || | 2540 | activeProject.defaultModelSelection?.model || |
| 2542 | DEFAULT_MODEL_BY_PROVIDER.codex, | 2541 | DEFAULT_MODEL_BY_PROVIDER.codex, |
| 2543 | ...(ctxSelectedModelSelection.options | 2542 | ctxSelectedModelSelection.options, |
| 2544 | ? { options: ctxSelectedModelSelection.options } | 2543 | ); |
| 2545 | : {}), | ||
| 2546 | }; | ||
| 2547 | 2544 | ||
| 2548 | // Auto-title from first message | 2545 | // Auto-title from first message |
| 2549 | if (isFirstMessage && isServerThread) { | 2546 | if (isFirstMessage && isServerThread) { |
apps/web/src/components/Icons.tsx+4-2| ... | @@ -553,8 +553,10 @@ export const IntelliJIdeaIcon: Icon = (props) => { | ... | @@ -553,8 +553,10 @@ export const IntelliJIdeaIcon: Icon = (props) => { |
| 553 | export const OpenCodeIcon: Icon = (props) => ( | 553 | export const OpenCodeIcon: Icon = (props) => ( |
| 554 | <svg {...props} viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg"> | 554 | <svg {...props} viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 555 | <g clipPath="url(#opencode__clip0_1311_94969)"> | 555 | <g clipPath="url(#opencode__clip0_1311_94969)"> |
| 556 | <path d="M24 32H8V16H24V32Z" fill="#BCBBBB" /> | 556 | <path className="dark:hidden" d="M24 32H8V16H24V32Z" fill="#CFCECD" /> |
| 557 | <path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E" /> | 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 | </g> | 560 | </g> |
| 559 | <defs> | 561 | <defs> |
| 560 | <clipPath id="opencode__clip0_1311_94969"> | 562 | <clipPath id="opencode__clip0_1311_94969"> |
apps/web/src/components/KeybindingsToast.browser.tsx+7| ... | @@ -99,6 +99,13 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -99,6 +99,13 @@ function createBaseServerConfig(): ServerConfig { |
| 99 | providers: { | 99 | providers: { |
| 100 | codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, | 100 | codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, |
| 101 | claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, | 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 | }; |
apps/web/src/components/chat/ChatComposer.tsx+36-24| ... | @@ -16,7 +16,7 @@ import { | ... | @@ -16,7 +16,7 @@ import { |
| 16 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 16 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 17 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 17 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 18 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 19 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 19 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 20 | import { | 20 | import { |
| 21 | forwardRef, | 21 | forwardRef, |
| 22 | memo, | 22 | memo, |
| ... | @@ -70,6 +70,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; | ... | @@ -70,6 +70,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; |
| 70 | import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; | 70 | import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; |
| 71 | import { searchSlashCommandItems } from "./composerSlashCommandSearch"; | 71 | import { searchSlashCommandItems } from "./composerSlashCommandSearch"; |
| 72 | import { | 72 | import { |
| 73 | getComposerProviderControls, | ||
| 73 | getComposerProviderState, | 74 | getComposerProviderState, |
| 74 | renderProviderTraitsMenuContent, | 75 | renderProviderTraitsMenuContent, |
| 75 | renderProviderTraitsPicker, | 76 | renderProviderTraitsPicker, |
| ... | @@ -159,6 +160,7 @@ const terminalContextIdListsEqual = ( | ... | @@ -159,6 +160,7 @@ const terminalContextIdListsEqual = ( |
| 159 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); | 160 | contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); |
| 160 | 161 | ||
| 161 | const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { | 162 | const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { |
| 163 | showInteractionModeToggle: boolean; | ||
| 162 | interactionMode: ProviderInteractionMode; | 164 | interactionMode: ProviderInteractionMode; |
| 163 | runtimeMode: RuntimeMode; | 165 | runtimeMode: RuntimeMode; |
| 164 | showPlanToggle: boolean; | 166 | showPlanToggle: boolean; |
| ... | @@ -175,25 +177,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop | ... | @@ -175,25 +177,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 175 | <> | 177 | <> |
| 176 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> | 178 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| 177 | 179 | ||
| 178 | <Button | 180 | {props.showInteractionModeToggle ? ( |
| 179 | variant="ghost" | 181 | <> |
| 180 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" | 182 | <Button |
| 181 | size="sm" | 183 | variant="ghost" |
| 182 | type="button" | 184 | className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" |
| 183 | onClick={props.onToggleInteractionMode} | 185 | size="sm" |
| 184 | title={ | 186 | type="button" |
| 185 | props.interactionMode === "plan" | 187 | onClick={props.onToggleInteractionMode} |
| 186 | ? "Plan mode — click to return to normal build mode" | 188 | title={ |
| 187 | : "Default mode — click to enter plan mode" | 189 | props.interactionMode === "plan" |
| 188 | } | 190 | ? "Plan mode — click to return to normal build mode" |
| 189 | > | 191 | : "Default mode — click to enter plan mode" |
| 190 | <BotIcon /> | 192 | } |
| 191 | <span className="sr-only sm:not-sr-only"> | 193 | > |
| 192 | {props.interactionMode === "plan" ? "Plan" : "Build"} | 194 | <BotIcon /> |
| 193 | </span> | 195 | <span className="sr-only sm:not-sr-only"> |
| 194 | </Button> | 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 | <Select | 204 | <Select |
| 199 | value={props.runtimeMode} | 205 | value={props.runtimeMode} |
| ... | @@ -588,12 +594,12 @@ export const ChatComposer = memo( | ... | @@ -588,12 +594,12 @@ export const ChatComposer = memo( |
| 588 | 594 | ||
| 589 | const selectedPromptEffort = composerProviderState.promptEffort; | 595 | const selectedPromptEffort = composerProviderState.promptEffort; |
| 590 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; | 596 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; |
| 597 | const composerProviderControls = useMemo( | ||
| 598 | () => getComposerProviderControls(selectedProvider), | ||
| 599 | [selectedProvider], | ||
| 600 | ); | ||
| 591 | const selectedModelSelection = useMemo<ModelSelection>( | 601 | const selectedModelSelection = useMemo<ModelSelection>( |
| 592 | () => ({ | 602 | () => createModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), |
| 593 | provider: selectedProvider, | ||
| 594 | model: selectedModel, | ||
| 595 | ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), | ||
| 596 | }), | ||
| 597 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], | 603 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], |
| 598 | ); | 604 | ); |
| 599 | const selectedModelForPicker = selectedModel; | 605 | const selectedModelForPicker = selectedModel; |
| ... | @@ -604,6 +610,8 @@ export const ChatComposer = memo( | ... | @@ -604,6 +610,8 @@ export const ChatComposer = memo( |
| 604 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], | 610 | codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], |
| 605 | claudeAgent: | 611 | claudeAgent: |
| 606 | providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], | 612 | providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], |
| 613 | opencode: | ||
| 614 | providerStatuses.find((provider) => provider.provider === "opencode")?.models ?? [], | ||
| 607 | }), | 615 | }), |
| 608 | [providerStatuses], | 616 | [providerStatuses], |
| 609 | ); | 617 | ); |
| ... | @@ -1933,6 +1941,7 @@ export const ChatComposer = memo( | ... | @@ -1933,6 +1941,7 @@ export const ChatComposer = memo( |
| 1933 | planSidebarLabel={planSidebarLabel} | 1941 | planSidebarLabel={planSidebarLabel} |
| 1934 | planSidebarOpen={planSidebarOpen} | 1942 | planSidebarOpen={planSidebarOpen} |
| 1935 | runtimeMode={runtimeMode} | 1943 | runtimeMode={runtimeMode} |
| 1944 | showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} | ||
| 1936 | traitsMenuContent={providerTraitsMenuContent} | 1945 | traitsMenuContent={providerTraitsMenuContent} |
| 1937 | onToggleInteractionMode={toggleInteractionMode} | 1946 | onToggleInteractionMode={toggleInteractionMode} |
| 1938 | onTogglePlanSidebar={togglePlanSidebar} | 1947 | onTogglePlanSidebar={togglePlanSidebar} |
| ... | @@ -1950,6 +1959,9 @@ export const ChatComposer = memo( | ... | @@ -1950,6 +1959,9 @@ export const ChatComposer = memo( |
| 1950 | </> | 1959 | </> |
| 1951 | ) : null} | 1960 | ) : null} |
| 1952 | <ComposerFooterModeControls | 1961 | <ComposerFooterModeControls |
| 1962 | showInteractionModeToggle={ | ||
| 1963 | composerProviderControls.showInteractionModeToggle | ||
| 1964 | } | ||
| 1953 | interactionMode={interactionMode} | 1965 | interactionMode={interactionMode} |
| 1954 | runtimeMode={runtimeMode} | 1966 | runtimeMode={runtimeMode} |
| 1955 | showPlanToggle={showPlanSidebarToggle} | 1967 | showPlanToggle={showPlanSidebarToggle} |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+35| ... | @@ -126,6 +126,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str | ... | @@ -126,6 +126,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 126 | planSidebarLabel="Plan" | 126 | planSidebarLabel="Plan" |
| 127 | planSidebarOpen={false} | 127 | planSidebarOpen={false} |
| 128 | runtimeMode="approval-required" | 128 | runtimeMode="approval-required" |
| 129 | showInteractionModeToggle | ||
| 129 | traitsMenuContent={ | 130 | traitsMenuContent={ |
| 130 | <TraitsMenuContent | 131 | <TraitsMenuContent |
| 131 | provider={provider} | 132 | provider={provider} |
| ... | @@ -267,4 +268,38 @@ describe("CompactComposerControlsMenu", () => { | ... | @@ -267,4 +268,38 @@ describe("CompactComposerControlsMenu", () => { |
| 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 | }); |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+17-12| ... | @@ -18,6 +18,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls | ... | @@ -18,6 +18,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 18 | planSidebarLabel: string; | 18 | planSidebarLabel: string; |
| 19 | planSidebarOpen: boolean; | 19 | planSidebarOpen: boolean; |
| 20 | runtimeMode: RuntimeMode; | 20 | runtimeMode: RuntimeMode; |
| 21 | showInteractionModeToggle: boolean; | ||
| 21 | traitsMenuContent?: ReactNode; | 22 | traitsMenuContent?: ReactNode; |
| 22 | onToggleInteractionMode: () => void; | 23 | onToggleInteractionMode: () => void; |
| 23 | onTogglePlanSidebar: () => void; | 24 | onTogglePlanSidebar: () => void; |
| ... | @@ -44,18 +45,22 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls | ... | @@ -44,18 +45,22 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 44 | <MenuDivider /> | 45 | <MenuDivider /> |
| 45 | </> | 46 | </> |
| 46 | ) : null} | 47 | ) : null} |
| 47 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div> | 48 | {props.showInteractionModeToggle ? ( |
| 48 | <MenuRadioGroup | 49 | <> |
| 49 | value={props.interactionMode} | 50 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Mode</div> |
| 50 | onValueChange={(value) => { | 51 | <MenuRadioGroup |
| 51 | if (!value || value === props.interactionMode) return; | 52 | value={props.interactionMode} |
| 52 | props.onToggleInteractionMode(); | 53 | onValueChange={(value) => { |
| 53 | }} | 54 | if (!value || value === props.interactionMode) return; |
| 54 | > | 55 | props.onToggleInteractionMode(); |
| 55 | <MenuRadioItem value="default">Chat</MenuRadioItem> | 56 | }} |
| 56 | <MenuRadioItem value="plan">Plan</MenuRadioItem> | 57 | > |
| 57 | </MenuRadioGroup> | 58 | <MenuRadioItem value="default">Chat</MenuRadioItem> |
| 58 | <MenuDivider /> | 59 | <MenuRadioItem value="plan">Plan</MenuRadioItem> |
| 60 | </MenuRadioGroup> | ||
| 61 | <MenuDivider /> | ||
| 62 | </> | ||
| 63 | ) : null} | ||
| 59 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div> | 64 | <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Access</div> |
| 60 | <MenuRadioGroup | 65 | <MenuRadioGroup |
| 61 | value={props.runtimeMode} | 66 | value={props.runtimeMode} |
apps/web/src/components/chat/ProviderModelPicker.tsx+2-4| ... | @@ -33,15 +33,13 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o | ... | @@ -33,15 +33,13 @@ function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): o |
| 33 | const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = { | 33 | const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = { |
| 34 | codex: OpenAI, | 34 | codex: OpenAI, |
| 35 | claudeAgent: ClaudeAI, | 35 | claudeAgent: ClaudeAI, |
| 36 | opencode: OpenCodeIcon, | ||
| 36 | cursor: CursorIcon, | 37 | cursor: CursorIcon, |
| 37 | }; | 38 | }; |
| 38 | 39 | ||
| 39 | export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); | 40 | export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); |
| 40 | const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); | 41 | const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); |
| 41 | const COMING_SOON_PROVIDER_OPTIONS = [ | 42 | const COMING_SOON_PROVIDER_OPTIONS = [{ id: "gemini", label: "Gemini", icon: Gemini }] as const; |
| 42 | { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, | ||
| 43 | { id: "gemini", label: "Gemini", icon: Gemini }, | ||
| 44 | ] as const; | ||
| 45 | 43 | ||
| 46 | function providerIconClassName( | 44 | function providerIconClassName( |
| 47 | provider: ProviderKind | ProviderPickerKind, | 45 | provider: ProviderKind | ProviderPickerKind, |
apps/web/src/components/chat/TraitsPicker.browser.tsx+128-1| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | CodexModelOptions, | 6 | CodexModelOptions, |
| 7 | DEFAULT_MODEL_BY_PROVIDER, | 7 | DEFAULT_MODEL_BY_PROVIDER, |
| 8 | DEFAULT_SERVER_SETTINGS, | 8 | DEFAULT_SERVER_SETTINGS, |
| 9 | OpenCodeModelOptions, | ||
| 9 | EnvironmentId, | 10 | EnvironmentId, |
| 10 | type ServerProvider, | 11 | type ServerProvider, |
| 11 | ThreadId, | 12 | ThreadId, |
| ... | @@ -64,6 +65,39 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | ... | @@ -64,6 +65,39 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 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 | provider: "claudeAgent", | 102 | provider: "claudeAgent", |
| 69 | enabled: true, | 103 | enabled: true, |
| ... | @@ -125,6 +159,13 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ | ... | @@ -125,6 +159,13 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [ |
| 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 | function ClaudeTraitsPickerHarness(props: { | 170 | function ClaudeTraitsPickerHarness(props: { |
| 130 | model: string; | 171 | model: string; |
| ... | @@ -154,7 +195,7 @@ function ClaudeTraitsPickerHarness(props: { | ... | @@ -154,7 +195,7 @@ function ClaudeTraitsPickerHarness(props: { |
| 154 | return ( | 195 | return ( |
| 155 | <TraitsPicker | 196 | <TraitsPicker |
| 156 | provider="claudeAgent" | 197 | provider="claudeAgent" |
| 157 | models={TEST_PROVIDERS[1]!.models} | 198 | models={TEST_PROVIDERS[2]!.models} |
| 158 | threadRef={CLAUDE_THREAD_REF} | 199 | threadRef={CLAUDE_THREAD_REF} |
| 159 | model={selectedModel ?? props.model} | 200 | model={selectedModel ?? props.model} |
| 160 | prompt={prompt} | 201 | prompt={prompt} |
| ... | @@ -501,3 +542,89 @@ describe("TraitsPicker (Codex)", () => { | ... | @@ -501,3 +542,89 @@ describe("TraitsPicker (Codex)", () => { |
| 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 | }); |
apps/web/src/components/chat/TraitsPicker.tsx+93-9| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { | 1 | import { |
| 2 | type ClaudeModelOptions, | 2 | type ClaudeModelOptions, |
| 3 | type CodexModelOptions, | 3 | type CodexModelOptions, |
| 4 | type OpenCodeModelOptions, | ||
| 4 | type ProviderKind, | 5 | type ProviderKind, |
| 5 | type ProviderModelOptions, | 6 | type ProviderModelOptions, |
| 6 | type ScopedThreadRef, | 7 | type ScopedThreadRef, |
| ... | @@ -33,6 +34,12 @@ import { getProviderModelCapabilities } from "../../providerModels"; | ... | @@ -33,6 +34,12 @@ import { getProviderModelCapabilities } from "../../providerModels"; |
| 33 | import { cn } from "~/lib/utils"; | 34 | import { cn } from "~/lib/utils"; |
| 34 | 35 | ||
| 35 | type ProviderOptions = ProviderModelOptions[ProviderKind]; | 36 | type ProviderOptions = ProviderModelOptions[ProviderKind]; |
| 37 | type NamedOption = { | ||
| 38 | value: string; | ||
| 39 | label: string; | ||
| 40 | isDefault?: boolean | undefined; | ||
| 41 | }; | ||
| 42 | |||
| 36 | type TraitsPersistence = | 43 | type TraitsPersistence = |
| 37 | | { | 44 | | { |
| 38 | threadRef?: ScopedThreadRef; | 45 | threadRef?: ScopedThreadRef; |
| ... | @@ -53,9 +60,29 @@ function getRawEffort( | ... | @@ -53,9 +60,29 @@ function getRawEffort( |
| 53 | if (provider === "codex") { | 60 | if (provider === "codex") { |
| 54 | return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); | 61 | return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); |
| 55 | } | 62 | } |
| 63 | if (provider === "opencode") { | ||
| 64 | return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant); | ||
| 65 | } | ||
| 56 | return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); | 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 | function getRawContextWindow( | 86 | function getRawContextWindow( |
| 60 | provider: ProviderKind, | 87 | provider: ProviderKind, |
| 61 | modelOptions: ProviderOptions | null | undefined, | 88 | modelOptions: ProviderOptions | null | undefined, |
| ... | @@ -74,6 +101,12 @@ function buildNextOptions( | ... | @@ -74,6 +101,12 @@ function buildNextOptions( |
| 74 | if (provider === "codex") { | 101 | if (provider === "codex") { |
| 75 | return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; | 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 | return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; | 110 | return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; |
| 78 | } | 111 | } |
| 79 | 112 | ||
| ... | @@ -86,15 +119,21 @@ function getSelectedTraits( | ... | @@ -86,15 +119,21 @@ function getSelectedTraits( |
| 86 | allowPromptInjectedEffort: boolean, | 119 | allowPromptInjectedEffort: boolean, |
| 87 | ) { | 120 | ) { |
| 88 | const caps = getProviderModelCapabilities(models, model, provider); | 121 | const caps = getProviderModelCapabilities(models, model, provider); |
| 89 | const effortLevels = allowPromptInjectedEffort | 122 | const effortLevels = |
| 90 | ? caps.reasoningEffortLevels | 123 | provider === "opencode" |
| 91 | : caps.reasoningEffortLevels.filter( | 124 | ? (caps.variantOptions ?? []) |
| 92 | (option) => !caps.promptInjectedEffortLevels.includes(option.value), | 125 | : allowPromptInjectedEffort |
| 93 | ); | 126 | ? caps.reasoningEffortLevels |
| 127 | : caps.reasoningEffortLevels.filter( | ||
| 128 | (option) => !caps.promptInjectedEffortLevels.includes(option.value), | ||
| 129 | ); | ||
| 94 | 130 | ||
| 95 | // Resolve effort from options (provider-specific key) | 131 | // Resolve effort from options (provider-specific key) |
| 96 | const rawEffort = getRawEffort(provider, modelOptions); | 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 | // Thinking toggle (only for models that support it) | 138 | // Thinking toggle (only for models that support it) |
| 100 | const thinkingEnabled = caps.supportsThinkingToggle | 139 | const thinkingEnabled = caps.supportsThinkingToggle |
| ... | @@ -125,6 +164,10 @@ function getSelectedTraits( | ... | @@ -125,6 +164,10 @@ function getSelectedTraits( |
| 125 | const ultrathinkInBodyText = | 164 | const ultrathinkInBodyText = |
| 126 | ultrathinkPromptControlled && isClaudeUltrathinkPrompt(prompt.replace(/^Ultrathink:\s*/i, "")); | 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 | return { | 171 | return { |
| 129 | caps, | 172 | caps, |
| 130 | effort, | 173 | effort, |
| ... | @@ -136,6 +179,9 @@ function getSelectedTraits( | ... | @@ -136,6 +179,9 @@ function getSelectedTraits( |
| 136 | defaultContextWindow, | 179 | defaultContextWindow, |
| 137 | ultrathinkPromptControlled, | 180 | ultrathinkPromptControlled, |
| 138 | ultrathinkInBodyText, | 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({ | ... | @@ -189,6 +235,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 189 | defaultContextWindow, | 235 | defaultContextWindow, |
| 190 | ultrathinkPromptControlled, | 236 | ultrathinkPromptControlled, |
| 191 | ultrathinkInBodyText, | 237 | ultrathinkInBodyText, |
| 238 | agentOptions, | ||
| 239 | selectedAgent, | ||
| 192 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); | 240 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); |
| 193 | const defaultEffort = getDefaultEffort(caps); | 241 | const defaultEffort = getDefaultEffort(caps); |
| 194 | 242 | ||
| ... | @@ -197,6 +245,10 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -197,6 +245,10 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 197 | if (!value) return; | 245 | if (!value) return; |
| 198 | const nextOption = effortLevels.find((option) => option.value === value); | 246 | const nextOption = effortLevels.find((option) => option.value === value); |
| 199 | if (!nextOption) return; | 247 | if (!nextOption) return; |
| 248 | if (provider === "opencode") { | ||
| 249 | updateModelOptions(buildNextOptions(provider, modelOptions, { variant: nextOption.value })); | ||
| 250 | return; | ||
| 251 | } | ||
| 200 | if (caps.promptInjectedEffortLevels.includes(nextOption.value)) { | 252 | if (caps.promptInjectedEffortLevels.includes(nextOption.value)) { |
| 201 | const nextPrompt = | 253 | const nextPrompt = |
| 202 | prompt.trim().length === 0 | 254 | prompt.trim().length === 0 |
| ... | @@ -228,7 +280,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -228,7 +280,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 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 | return null; | 289 | return null; |
| 233 | } | 290 | } |
| 234 | 291 | ||
| ... | @@ -237,7 +294,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -237,7 +294,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 237 | {effort ? ( | 294 | {effort ? ( |
| 238 | <> | 295 | <> |
| 239 | <MenuGroup> | 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 | {ultrathinkInBodyText ? ( | 300 | {ultrathinkInBodyText ? ( |
| 242 | <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs"> | 301 | <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs"> |
| 243 | Your prompt contains &quot;ultrathink&quot; in the text. Remove it to change effort. | 302 | Your prompt contains &quot;ultrathink&quot; in the text. Remove it to change effort. |
| ... | @@ -254,7 +313,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -254,7 +313,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 254 | disabled={ultrathinkInBodyText} | 313 | disabled={ultrathinkInBodyText} |
| 255 | > | 314 | > |
| 256 | {option.label} | 315 | {option.label} |
| 257 | {option.value === defaultEffort ? " (default)" : ""} | 316 | {(provider === "opencode" ? option.isDefault : option.value === defaultEffort) |
| 317 | ? " (default)" | ||
| 318 | : ""} | ||
| 258 | </MenuRadioItem> | 319 | </MenuRadioItem> |
| 259 | ))} | 320 | ))} |
| 260 | </MenuRadioGroup> | 321 | </MenuRadioGroup> |
| ... | @@ -322,6 +383,27 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ | ... | @@ -322,6 +383,27 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ |
| 322 | </MenuGroup> | 383 | </MenuGroup> |
| 323 | </> | 384 | </> |
| 324 | ) : null} | 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({ | ... | @@ -349,6 +431,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 349 | contextWindow, | 431 | contextWindow, |
| 350 | defaultContextWindow, | 432 | defaultContextWindow, |
| 351 | ultrathinkPromptControlled, | 433 | ultrathinkPromptControlled, |
| 434 | selectedAgentLabel, | ||
| 352 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); | 435 | } = getSelectedTraits(provider, models, model, prompt, modelOptions, allowPromptInjectedEffort); |
| 353 | 436 | ||
| 354 | const effortLabel = effort | 437 | const effortLabel = effort |
| ... | @@ -368,6 +451,7 @@ export const TraitsPicker = memo(function TraitsPicker({ | ... | @@ -368,6 +451,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 368 | : `Thinking ${thinkingEnabled ? "On" : "Off"}`, | 451 | : `Thinking ${thinkingEnabled ? "On" : "Off"}`, |
| 369 | ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), | 452 | ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), |
| 370 | ...(contextWindowLabel ? [contextWindowLabel] : []), | 453 | ...(contextWindowLabel ? [contextWindowLabel] : []), |
| 454 | ...(selectedAgentLabel ? [selectedAgentLabel] : []), | ||
| 371 | ] | 455 | ] |
| 372 | .filter(Boolean) | 456 | .filter(Boolean) |
| 373 | .join(" · "); | 457 | .join(" · "); |
apps/web/src/components/chat/composerProviderRegistry.test.tsx+65| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | 2 | import type { ServerProviderModel } from "@t3tools/contracts"; |
| 3 | import { | 3 | import { |
| 4 | getComposerProviderControls, | ||
| 4 | getComposerProviderState, | 5 | getComposerProviderState, |
| 5 | renderProviderTraitsMenuContent, | 6 | renderProviderTraitsMenuContent, |
| 6 | renderProviderTraitsPicker, | 7 | renderProviderTraitsPicker, |
| ... | @@ -110,6 +111,29 @@ const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray<ServerProviderModel> = [ | ... | @@ -110,6 +111,29 @@ const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray<ServerProviderModel> = [ |
| 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 | describe("getComposerProviderState", () => { | 137 | describe("getComposerProviderState", () => { |
| 114 | it("returns codex defaults when no codex draft options exist", () => { | 138 | it("returns codex defaults when no codex draft options exist", () => { |
| 115 | const state = getComposerProviderState({ | 139 | const state = getComposerProviderState({ |
| ... | @@ -420,6 +444,47 @@ describe("getComposerProviderState", () => { | ... | @@ -420,6 +444,47 @@ describe("getComposerProviderState", () => { |
| 420 | 444 | ||
| 421 | expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); | 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 | describe("provider traits render guards", () => { | 490 | describe("provider traits render guards", () => { |
apps/web/src/components/chat/composerProviderRegistry.tsx+54-68| ... | @@ -4,15 +4,17 @@ import { | ... | @@ -4,15 +4,17 @@ import { |
| 4 | type ScopedThreadRef, | 4 | type ScopedThreadRef, |
| 5 | type ServerProviderModel, | 5 | type ServerProviderModel, |
| 6 | } from "@t3tools/contracts"; | 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 | import type { ReactNode } from "react"; | 13 | import type { ReactNode } from "react"; |
| 14 | |||
| 9 | import type { DraftId } from "../../composerDraftStore"; | 15 | import type { DraftId } from "../../composerDraftStore"; |
| 10 | import { getProviderModelCapabilities } from "../../providerModels"; | 16 | import { getProviderModelCapabilities } from "../../providerModels"; |
| 11 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; | 17 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; |
| 12 | import { | ||
| 13 | normalizeClaudeModelOptionsWithCapabilities, | ||
| 14 | normalizeCodexModelOptionsWithCapabilities, | ||
| 15 | } from "@t3tools/shared/model"; | ||
| 16 | 18 | ||
| 17 | export type ComposerProviderStateInput = { | 19 | export type ComposerProviderStateInput = { |
| 18 | provider: ProviderKind; | 20 | provider: ProviderKind; |
| ... | @@ -31,7 +33,12 @@ export type ComposerProviderState = { | ... | @@ -31,7 +33,12 @@ export type ComposerProviderState = { |
| 31 | modelPickerIconClassName?: string; | 33 | modelPickerIconClassName?: string; |
| 32 | }; | 34 | }; |
| 33 | 35 | ||
| 36 | export type ComposerProviderControls = { | ||
| 37 | showInteractionModeToggle: boolean; | ||
| 38 | }; | ||
| 39 | |||
| 34 | type ProviderRegistryEntry = { | 40 | type ProviderRegistryEntry = { |
| 41 | controls: ComposerProviderControls; | ||
| 35 | getState: (input: ComposerProviderStateInput) => ComposerProviderState; | 42 | getState: (input: ComposerProviderStateInput) => ComposerProviderState; |
| 36 | renderTraitsMenuContent: (input: { | 43 | renderTraitsMenuContent: (input: { |
| 37 | threadRef?: ScopedThreadRef; | 44 | threadRef?: ScopedThreadRef; |
| ... | @@ -66,25 +73,26 @@ function getProviderStateFromCapabilities( | ... | @@ -66,25 +73,26 @@ function getProviderStateFromCapabilities( |
| 66 | const { provider, model, models, prompt, modelOptions } = input; | 73 | const { provider, model, models, prompt, modelOptions } = input; |
| 67 | const caps = getProviderModelCapabilities(models, model, provider); | 74 | const caps = getProviderModelCapabilities(models, model, provider); |
| 68 | const providerOptions = modelOptions?.[provider]; | 75 | const providerOptions = modelOptions?.[provider]; |
| 69 | |||
| 70 | // Resolve effort | ||
| 71 | const rawEffort = providerOptions | 76 | const rawEffort = providerOptions |
| 72 | ? "effort" in providerOptions | 77 | ? "effort" in providerOptions |
| 73 | ? providerOptions.effort | 78 | ? providerOptions.effort |
| 74 | : "reasoningEffort" in providerOptions | 79 | : "reasoningEffort" in providerOptions |
| 75 | ? providerOptions.reasoningEffort | 80 | ? providerOptions.reasoningEffort |
| 76 | : null | 81 | : "variant" in providerOptions |
| 82 | ? providerOptions.variant | ||
| 83 | : null | ||
| 77 | : null; | 84 | : null; |
| 78 | 85 | const normalizedOptions = normalizeProviderModelOptionsWithCapabilities( | |
| 79 | const promptEffort = resolveEffort(caps, rawEffort) ?? null; | 86 | provider, |
| 80 | 87 | caps, | |
| 81 | // Normalize options for dispatch | 88 | providerOptions, |
| 82 | const normalizedOptions = | 89 | ); |
| 83 | provider === "codex" | 90 | const promptEffort = |
| 84 | ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) | 91 | provider === "opencode" |
| 85 | : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); | 92 | ? (trimOrNull( |
| 86 | 93 | normalizedOptions && "variant" in normalizedOptions ? normalizedOptions.variant : null, | |
| 87 | // Ultrathink styling (driven by capabilities data, not provider identity) | 94 | ) ?? null) |
| 95 | : (resolveEffort(caps, rawEffort) ?? null); | ||
| 88 | const ultrathinkActive = | 96 | const ultrathinkActive = |
| 89 | caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); | 97 | caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); |
| 90 | 98 | ||
| ... | @@ -100,53 +108,19 @@ function getProviderStateFromCapabilities( | ... | @@ -100,53 +108,19 @@ function getProviderStateFromCapabilities( |
| 100 | }; | 108 | }; |
| 101 | } | 109 | } |
| 102 | 110 | ||
| 103 | const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | 111 | const DEFAULT_PROVIDER_CONTROLS: ComposerProviderControls = { |
| 104 | codex: { | 112 | showInteractionModeToggle: true, |
| 105 | getState: (input) => getProviderStateFromCapabilities(input), | 113 | }; |
| 106 | renderTraitsMenuContent: ({ | 114 | |
| 107 | threadRef, | 115 | function createProviderRegistryEntry( |
| 108 | draftId, | 116 | provider: ProviderKind, |
| 109 | model, | 117 | controls?: Partial<ComposerProviderControls>, |
| 110 | models, | 118 | ): ProviderRegistryEntry { |
| 111 | modelOptions, | 119 | return { |
| 112 | prompt, | 120 | controls: { |
| 113 | onPromptChange, | 121 | ...DEFAULT_PROVIDER_CONTROLS, |
| 114 | }) => | 122 | ...controls, |
| 115 | !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( | 123 | }, |
| 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: { | ||
| 150 | getState: (input) => getProviderStateFromCapabilities(input), | 124 | getState: (input) => getProviderStateFromCapabilities(input), |
| 151 | renderTraitsMenuContent: ({ | 125 | renderTraitsMenuContent: ({ |
| 152 | threadRef, | 126 | threadRef, |
| ... | @@ -159,7 +133,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | ... | @@ -159,7 +133,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 159 | }) => | 133 | }) => |
| 160 | !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( | 134 | !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( |
| 161 | <TraitsMenuContent | 135 | <TraitsMenuContent |
| 162 | provider="claudeAgent" | 136 | provider={provider} |
| 163 | models={models} | 137 | models={models} |
| 164 | {...(threadRef ? { threadRef } : {})} | 138 | {...(threadRef ? { threadRef } : {})} |
| 165 | {...(draftId ? { draftId } : {})} | 139 | {...(draftId ? { draftId } : {})} |
| ... | @@ -180,7 +154,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | ... | @@ -180,7 +154,7 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 180 | }) => | 154 | }) => |
| 181 | !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( | 155 | !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( |
| 182 | <TraitsPicker | 156 | <TraitsPicker |
| 183 | provider="claudeAgent" | 157 | provider={provider} |
| 184 | models={models} | 158 | models={models} |
| 185 | {...(threadRef ? { threadRef } : {})} | 159 | {...(threadRef ? { threadRef } : {})} |
| 186 | {...(draftId ? { draftId } : {})} | 160 | {...(draftId ? { draftId } : {})} |
| ... | @@ -190,13 +164,25 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | ... | @@ -190,13 +164,25 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 190 | onPromptChange={onPromptChange} | 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 | export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { | 178 | export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { |
| 197 | return composerProviderRegistry[input.provider].getState(input); | 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 | export function renderProviderTraitsMenuContent(input: { | 186 | export function renderProviderTraitsMenuContent(input: { |
| 201 | provider: ProviderKind; | 187 | provider: ProviderKind; |
| 202 | threadRef?: ScopedThreadRef; | 188 | threadRef?: ScopedThreadRef; |
apps/web/src/components/settings/SettingsPanels.browser.tsx+17| ... | @@ -701,4 +701,21 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -701,4 +701,21 @@ describe("GeneralSettingsPanel observability", () => { |
| 701 | 701 | ||
| 702 | expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor"); | 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 | }); |
apps/web/src/components/settings/SettingsPanels.tsx+119-7| ... | @@ -21,6 +21,7 @@ import { | ... | @@ -21,6 +21,7 @@ import { |
| 21 | import { scopeThreadRef } from "@t3tools/client-runtime"; | 21 | import { scopeThreadRef } from "@t3tools/client-runtime"; |
| 22 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 22 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 23 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 23 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 24 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 24 | import { Equal } from "effect"; | 25 | import { Equal } from "effect"; |
| 25 | import { APP_VERSION } from "../../branding"; | 26 | import { APP_VERSION } from "../../branding"; |
| 26 | import { | 27 | import { |
| ... | @@ -104,6 +105,10 @@ type InstallProviderSettings = { | ... | @@ -104,6 +105,10 @@ type InstallProviderSettings = { |
| 104 | title: string; | 105 | title: string; |
| 105 | binaryPlaceholder: string; | 106 | binaryPlaceholder: string; |
| 106 | binaryDescription: ReactNode; | 107 | binaryDescription: ReactNode; |
| 108 | serverUrlPlaceholder?: string; | ||
| 109 | serverUrlDescription?: ReactNode; | ||
| 110 | serverPasswordPlaceholder?: string; | ||
| 111 | serverPasswordDescription?: ReactNode; | ||
| 107 | homePathKey?: "codexHomePath"; | 112 | homePathKey?: "codexHomePath"; |
| 108 | homePlaceholder?: string; | 113 | homePlaceholder?: string; |
| 109 | homeDescription?: ReactNode; | 114 | homeDescription?: ReactNode; |
| ... | @@ -125,6 +130,17 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ | ... | @@ -125,6 +130,17 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ |
| 125 | binaryPlaceholder: "Claude binary path", | 130 | binaryPlaceholder: "Claude binary path", |
| 126 | binaryDescription: "Path to the Claude binary", | 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 | ] as const; | 144 | ] as const; |
| 129 | 145 | ||
| 130 | const PROVIDER_STATUS_STYLES = { | 146 | const PROVIDER_STATUS_STYLES = { |
| ... | @@ -514,12 +530,22 @@ export function GeneralSettingsPanel() { | ... | @@ -514,12 +530,22 @@ export function GeneralSettingsPanel() { |
| 514 | settings.providers.claudeAgent.customModels.length > 0 || | 530 | settings.providers.claudeAgent.customModels.length > 0 || |
| 515 | settings.providers.claudeAgent.launchArgs !== "", | 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 | const [customModelInputByProvider, setCustomModelInputByProvider] = useState< | 543 | const [customModelInputByProvider, setCustomModelInputByProvider] = useState< |
| 519 | Record<ProviderKind, string> | 544 | Record<ProviderKind, string> |
| 520 | >({ | 545 | >({ |
| 521 | codex: "", | 546 | codex: "", |
| 522 | claudeAgent: "", | 547 | claudeAgent: "", |
| 548 | opencode: "", | ||
| 523 | }); | 549 | }); |
| 524 | const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< | 550 | const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< |
| 525 | Partial<Record<ProviderKind, string | null>> | 551 | Partial<Record<ProviderKind, string | null>> |
| ... | @@ -732,10 +758,16 @@ export function GeneralSettingsPanel() { | ... | @@ -732,10 +758,16 @@ export function GeneralSettingsPanel() { |
| 732 | title: providerSettings.title, | 758 | title: providerSettings.title, |
| 733 | binaryPlaceholder: providerSettings.binaryPlaceholder, | 759 | binaryPlaceholder: providerSettings.binaryPlaceholder, |
| 734 | binaryDescription: providerSettings.binaryDescription, | 760 | binaryDescription: providerSettings.binaryDescription, |
| 761 | serverUrlPlaceholder: providerSettings.serverUrlPlaceholder, | ||
| 762 | serverUrlDescription: providerSettings.serverUrlDescription, | ||
| 763 | serverPasswordPlaceholder: providerSettings.serverPasswordPlaceholder, | ||
| 764 | serverPasswordDescription: providerSettings.serverPasswordDescription, | ||
| 735 | homePathKey: providerSettings.homePathKey, | 765 | homePathKey: providerSettings.homePathKey, |
| 736 | homePlaceholder: providerSettings.homePlaceholder, | 766 | homePlaceholder: providerSettings.homePlaceholder, |
| 737 | homeDescription: providerSettings.homeDescription, | 767 | homeDescription: providerSettings.homeDescription, |
| 738 | binaryPathValue: providerConfig.binaryPath, | 768 | binaryPathValue: providerConfig.binaryPath, |
| 769 | serverUrlValue: "serverUrl" in providerConfig ? providerConfig.serverUrl : "", | ||
| 770 | serverPasswordValue: "serverPassword" in providerConfig ? providerConfig.serverPassword : "", | ||
| 739 | isDirty: !Equal.equals(providerConfig, defaultProviderConfig), | 771 | isDirty: !Equal.equals(providerConfig, defaultProviderConfig), |
| 740 | liveProvider, | 772 | liveProvider, |
| 741 | models, | 773 | models, |
| ... | @@ -1035,7 +1067,7 @@ export function GeneralSettingsPanel() { | ... | @@ -1035,7 +1067,7 @@ export function GeneralSettingsPanel() { |
| 1035 | textGenerationModelSelection: resolveAppModelSelectionState( | 1067 | textGenerationModelSelection: resolveAppModelSelectionState( |
| 1036 | { | 1068 | { |
| 1037 | ...settings, | 1069 | ...settings, |
| 1038 | textGenerationModelSelection: { provider, model }, | 1070 | textGenerationModelSelection: createModelSelection(provider, model), |
| 1039 | }, | 1071 | }, |
| 1040 | serverProviders, | 1072 | serverProviders, |
| 1041 | ), | 1073 | ), |
| ... | @@ -1060,11 +1092,11 @@ export function GeneralSettingsPanel() { | ... | @@ -1060,11 +1092,11 @@ export function GeneralSettingsPanel() { |
| 1060 | textGenerationModelSelection: resolveAppModelSelectionState( | 1092 | textGenerationModelSelection: resolveAppModelSelectionState( |
| 1061 | { | 1093 | { |
| 1062 | ...settings, | 1094 | ...settings, |
| 1063 | textGenerationModelSelection: { | 1095 | textGenerationModelSelection: createModelSelection( |
| 1064 | provider: textGenProvider, | 1096 | textGenProvider, |
| 1065 | model: textGenModel, | 1097 | textGenModel, |
| 1066 | ...(nextOptions ? { options: nextOptions } : {}), | 1098 | nextOptions, |
| 1067 | }, | 1099 | ), |
| 1068 | }, | 1100 | }, |
| 1069 | serverProviders, | 1101 | serverProviders, |
| 1070 | ), | 1102 | ), |
| ... | @@ -1243,6 +1275,84 @@ export function GeneralSettingsPanel() { | ... | @@ -1243,6 +1275,84 @@ export function GeneralSettingsPanel() { |
| 1243 | </label> | 1275 | </label> |
| 1244 | </div> | 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 | {providerCard.homePathKey ? ( | 1356 | {providerCard.homePathKey ? ( |
| 1247 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> | 1357 | <div className="border-t border-border/60 px-4 py-3 sm:px-5"> |
| 1248 | <label | 1358 | <label |
| ... | @@ -1422,7 +1532,9 @@ export function GeneralSettingsPanel() { | ... | @@ -1422,7 +1532,9 @@ export function GeneralSettingsPanel() { |
| 1422 | placeholder={ | 1532 | placeholder={ |
| 1423 | providerCard.provider === "codex" | 1533 | providerCard.provider === "codex" |
| 1424 | ? "gpt-6.7-codex-ultra-preview" | 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 | spellCheck={false} | 1539 | spellCheck={false} |
| 1428 | /> | 1540 | /> |
apps/web/src/composerDraftStore.ts+56-45| ... | @@ -25,7 +25,7 @@ import { | ... | @@ -25,7 +25,7 @@ import { |
| 25 | import * as Schema from "effect/Schema"; | 25 | import * as Schema from "effect/Schema"; |
| 26 | import * as Equal from "effect/Equal"; | 26 | import * as Equal from "effect/Equal"; |
| 27 | import { DeepMutable } from "effect/Types"; | 27 | import { DeepMutable } from "effect/Types"; |
| 28 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 28 | import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; |
| 29 | import { useMemo } from "react"; | 29 | import { useMemo } from "react"; |
| 30 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; | 30 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; |
| 31 | import { resolveAppModelSelection } from "./modelSelection"; | 31 | import { resolveAppModelSelection } from "./modelSelection"; |
| ... | @@ -527,7 +527,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { | ... | @@ -527,7 +527,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { |
| 527 | } | 527 | } |
| 528 | 528 | ||
| 529 | function normalizeProviderKind(value: unknown): ProviderKind | null { | 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 | function normalizeProviderModelOptions( | 533 | function normalizeProviderModelOptions( |
| ... | @@ -544,6 +544,10 @@ function normalizeProviderModelOptions( | ... | @@ -544,6 +544,10 @@ function normalizeProviderModelOptions( |
| 544 | candidate?.claudeAgent && typeof candidate.claudeAgent === "object" | 544 | candidate?.claudeAgent && typeof candidate.claudeAgent === "object" |
| 545 | ? (candidate.claudeAgent as Record<string, unknown>) | 545 | ? (candidate.claudeAgent as Record<string, unknown>) |
| 546 | : null; | 546 | : null; |
| 547 | const openCodeCandidate = | ||
| 548 | candidate?.opencode && typeof candidate.opencode === "object" | ||
| 549 | ? (candidate.opencode as Record<string, unknown>) | ||
| 550 | : null; | ||
| 547 | 551 | ||
| 548 | const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort) | 552 | const codexReasoningEffort = Schema.is(CodexReasoningEffort)(codexCandidate?.reasoningEffort) |
| 549 | ? codexCandidate.reasoningEffort | 553 | ? codexCandidate.reasoningEffort |
| ... | @@ -600,13 +604,29 @@ function normalizeProviderModelOptions( | ... | @@ -600,13 +604,29 @@ function normalizeProviderModelOptions( |
| 600 | ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}), | 604 | ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}), |
| 601 | } | 605 | } |
| 602 | : undefined; | 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 | return null; | 624 | return null; |
| 606 | } | 625 | } |
| 607 | return { | 626 | return { |
| 608 | ...(codex ? { codex } : {}), | 627 | ...(codex ? { codex } : {}), |
| 609 | ...(claude ? { claudeAgent: claude } : {}), | 628 | ...(claude ? { claudeAgent: claude } : {}), |
| 629 | ...(opencode ? { opencode } : {}), | ||
| 610 | }; | 630 | }; |
| 611 | } | 631 | } |
| 612 | 632 | ||
| ... | @@ -637,12 +657,8 @@ function normalizeModelSelection( | ... | @@ -637,12 +657,8 @@ function normalizeModelSelection( |
| 637 | provider, | 657 | provider, |
| 638 | provider === "codex" ? legacy?.legacyCodex : undefined, | 658 | provider === "codex" ? legacy?.legacyCodex : undefined, |
| 639 | ); | 659 | ); |
| 640 | const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; | 660 | const options = modelOptions?.[provider]; |
| 641 | return { | 661 | return createModelSelection(provider, model, options); |
| 642 | provider, | ||
| 643 | model, | ||
| 644 | ...(options ? { options } : {}), | ||
| 645 | }; | ||
| 646 | } | 662 | } |
| 647 | 663 | ||
| 648 | // ── Legacy sync helpers (used only during migration from v2 storage) ── | 664 | // ── Legacy sync helpers (used only during migration from v2 storage) ── |
| ... | @@ -655,11 +671,7 @@ function legacySyncModelSelectionOptions( | ... | @@ -655,11 +671,7 @@ function legacySyncModelSelectionOptions( |
| 655 | return null; | 671 | return null; |
| 656 | } | 672 | } |
| 657 | const options = modelOptions?.[modelSelection.provider]; | 673 | const options = modelOptions?.[modelSelection.provider]; |
| 658 | return { | 674 | return createModelSelection(modelSelection.provider, modelSelection.model, options); |
| 659 | provider: modelSelection.provider, | ||
| 660 | model: modelSelection.model, | ||
| 661 | ...(options ? { options } : {}), | ||
| 662 | }; | ||
| 663 | } | 675 | } |
| 664 | 676 | ||
| 665 | function legacyMergeModelSelectionIntoProviderModelOptions( | 677 | function legacyMergeModelSelectionIntoProviderModelOptions( |
| ... | @@ -703,17 +715,16 @@ function legacyToModelSelectionByProvider( | ... | @@ -703,17 +715,16 @@ function legacyToModelSelectionByProvider( |
| 703 | const result: Partial<Record<ProviderKind, ModelSelection>> = {}; | 715 | const result: Partial<Record<ProviderKind, ModelSelection>> = {}; |
| 704 | // Add entries from the options bag (for non-active providers) | 716 | // Add entries from the options bag (for non-active providers) |
| 705 | if (modelOptions) { | 717 | if (modelOptions) { |
| 706 | for (const provider of ["codex", "claudeAgent"] as const) { | 718 | for (const provider of ["codex", "claudeAgent", "opencode"] as const) { |
| 707 | const options = modelOptions[provider]; | 719 | const options = modelOptions[provider]; |
| 708 | if (options && Object.keys(options).length > 0) { | 720 | if (options && Object.keys(options).length > 0) { |
| 709 | result[provider] = { | 721 | result[provider] = createModelSelection( |
| 710 | provider, | 722 | provider, |
| 711 | model: | 723 | modelSelection?.provider === provider |
| 712 | modelSelection?.provider === provider | 724 | ? modelSelection.model |
| 713 | ? modelSelection.model | 725 | : DEFAULT_MODEL_BY_PROVIDER[provider], |
| 714 | : DEFAULT_MODEL_BY_PROVIDER[provider], | ||
| 715 | options, | 726 | options, |
| 716 | }; | 727 | ); |
| 717 | } | 728 | } |
| 718 | } | 729 | } |
| 719 | } | 730 | } |
| ... | @@ -2205,11 +2216,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2205,11 +2216,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2205 | nextMap[normalized.provider] = normalized; | 2216 | nextMap[normalized.provider] = normalized; |
| 2206 | } else { | 2217 | } else { |
| 2207 | // No options in selection → preserve existing options, update provider+model | 2218 | // No options in selection → preserve existing options, update provider+model |
| 2208 | nextMap[normalized.provider] = { | 2219 | nextMap[normalized.provider] = createModelSelection( |
| 2209 | provider: normalized.provider, | 2220 | normalized.provider, |
| 2210 | model: normalized.model, | 2221 | normalized.model, |
| 2211 | ...(current?.options ? { options: current.options } : {}), | 2222 | current?.options, |
| 2212 | }; | 2223 | ); |
| 2213 | } | 2224 | } |
| 2214 | } | 2225 | } |
| 2215 | const nextActiveProvider = normalized?.provider ?? base.activeProvider; | 2226 | const nextActiveProvider = normalized?.provider ?? base.activeProvider; |
| ... | @@ -2246,17 +2257,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2246,17 +2257,17 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2246 | } | 2257 | } |
| 2247 | const base = existing ?? createEmptyThreadDraft(); | 2258 | const base = existing ?? createEmptyThreadDraft(); |
| 2248 | const nextMap = { ...base.modelSelectionByProvider }; | 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 | // Only touch providers explicitly present in the input | 2261 | // Only touch providers explicitly present in the input |
| 2251 | if (!normalizedOpts || !(provider in normalizedOpts)) continue; | 2262 | if (!normalizedOpts || !(provider in normalizedOpts)) continue; |
| 2252 | const opts = normalizedOpts[provider]; | 2263 | const opts = normalizedOpts[provider]; |
| 2253 | const current = nextMap[provider]; | 2264 | const current = nextMap[provider]; |
| 2254 | if (opts) { | 2265 | if (opts) { |
| 2255 | nextMap[provider] = { | 2266 | nextMap[provider] = createModelSelection( |
| 2256 | provider, | 2267 | provider, |
| 2257 | model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], | 2268 | current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], |
| 2258 | options: opts, | 2269 | opts, |
| 2259 | }; | 2270 | ); |
| 2260 | } else if (current?.options) { | 2271 | } else if (current?.options) { |
| 2261 | // Remove options but keep the selection | 2272 | // Remove options but keep the selection |
| 2262 | const { options: _, ...rest } = current; | 2273 | const { options: _, ...rest } = current; |
| ... | @@ -2303,11 +2314,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2303,11 +2314,11 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2303 | const nextMap = { ...base.modelSelectionByProvider }; | 2314 | const nextMap = { ...base.modelSelectionByProvider }; |
| 2304 | const currentForProvider = nextMap[normalizedProvider]; | 2315 | const currentForProvider = nextMap[normalizedProvider]; |
| 2305 | if (providerOpts) { | 2316 | if (providerOpts) { |
| 2306 | nextMap[normalizedProvider] = { | 2317 | nextMap[normalizedProvider] = createModelSelection( |
| 2307 | provider: normalizedProvider, | 2318 | normalizedProvider, |
| 2308 | model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | 2319 | currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], |
| 2309 | options: providerOpts, | 2320 | providerOpts, |
| 2310 | }; | 2321 | ); |
| 2311 | } else if (currentForProvider?.options) { | 2322 | } else if (currentForProvider?.options) { |
| 2312 | const { options: _, ...rest } = currentForProvider; | 2323 | const { options: _, ...rest } = currentForProvider; |
| 2313 | nextMap[normalizedProvider] = rest as ModelSelection; | 2324 | nextMap[normalizedProvider] = rest as ModelSelection; |
| ... | @@ -2321,16 +2332,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -2321,16 +2332,16 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2321 | const stickyBase = | 2332 | const stickyBase = |
| 2322 | nextStickyMap[normalizedProvider] ?? | 2333 | nextStickyMap[normalizedProvider] ?? |
| 2323 | base.modelSelectionByProvider[normalizedProvider] ?? | 2334 | base.modelSelectionByProvider[normalizedProvider] ?? |
| 2324 | ({ | 2335 | createModelSelection( |
| 2325 | provider: normalizedProvider, | 2336 | normalizedProvider, |
| 2326 | model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], | 2337 | DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], |
| 2327 | } as ModelSelection); | 2338 | ); |
| 2328 | if (providerOpts) { | 2339 | if (providerOpts) { |
| 2329 | nextStickyMap[normalizedProvider] = { | 2340 | nextStickyMap[normalizedProvider] = createModelSelection( |
| 2330 | ...stickyBase, | 2341 | normalizedProvider, |
| 2331 | provider: normalizedProvider, | 2342 | stickyBase.model, |
| 2332 | options: providerOpts, | 2343 | providerOpts, |
| 2333 | }; | 2344 | ); |
| 2334 | } else if (stickyBase.options) { | 2345 | } else if (stickyBase.options) { |
| 2335 | const { options: _, ...rest } = stickyBase; | 2346 | const { options: _, ...rest } = stickyBase; |
| 2336 | nextStickyMap[normalizedProvider] = rest as ModelSelection; | 2347 | nextStickyMap[normalizedProvider] = rest as ModelSelection; |
apps/web/src/modelSelection.ts+19-6| ... | @@ -4,7 +4,11 @@ import { | ... | @@ -4,7 +4,11 @@ import { |
| 4 | type ProviderKind, | 4 | type ProviderKind, |
| 5 | type ServerProvider, | 5 | type ServerProvider, |
| 6 | } from "@t3tools/contracts"; | 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 | import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; | 12 | import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; |
| 9 | import { UnifiedSettings } from "@t3tools/contracts/settings"; | 13 | import { UnifiedSettings } from "@t3tools/contracts/settings"; |
| 10 | import { | 14 | import { |
| ... | @@ -45,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf | ... | @@ -45,6 +49,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record<ProviderKind, ProviderCustomModelConf |
| 45 | placeholder: "your-claude-model-slug", | 49 | placeholder: "your-claude-model-slug", |
| 46 | example: "claude-sonnet-5-0", | 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 | export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); | 61 | export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); |
| ... | @@ -165,6 +176,12 @@ export function getCustomModelOptionsByProvider( | ... | @@ -165,6 +176,12 @@ export function getCustomModelOptionsByProvider( |
| 165 | "claudeAgent", | 176 | "claudeAgent", |
| 166 | selectedProvider === "claudeAgent" ? selectedModel : undefined, | 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( | ... | @@ -192,9 +209,5 @@ export function resolveAppModelSelectionState( |
| 192 | }, | 209 | }, |
| 193 | }); | 210 | }); |
| 194 | 211 | ||
| 195 | return { | 212 | return createModelSelection(provider, model, modelOptionsForDispatch); |
| 196 | provider, | ||
| 197 | model, | ||
| 198 | ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}), | ||
| 199 | }; | ||
| 200 | } | 213 | } |
apps/web/src/session-logic.test.ts+8-1| ... | @@ -1326,12 +1326,14 @@ describe("deriveActiveWorkStartedAt", () => { | ... | @@ -1326,12 +1326,14 @@ describe("deriveActiveWorkStartedAt", () => { |
| 1326 | }); | 1326 | }); |
| 1327 | 1327 | ||
| 1328 | describe("PROVIDER_OPTIONS", () => { | 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 | const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); | 1330 | const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); |
| 1331 | const opencode = PROVIDER_OPTIONS.find((option) => option.value === "opencode"); | ||
| 1331 | const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); | 1332 | const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); |
| 1332 | expect(PROVIDER_OPTIONS).toEqual([ | 1333 | expect(PROVIDER_OPTIONS).toEqual([ |
| 1333 | { value: "codex", label: "Codex", available: true }, | 1334 | { value: "codex", label: "Codex", available: true }, |
| 1334 | { value: "claudeAgent", label: "Claude", available: true }, | 1335 | { value: "claudeAgent", label: "Claude", available: true }, |
| 1336 | { value: "opencode", label: "OpenCode", available: true }, | ||
| 1335 | { value: "cursor", label: "Cursor", available: false }, | 1337 | { value: "cursor", label: "Cursor", available: false }, |
| 1336 | ]); | 1338 | ]); |
| 1337 | expect(claude).toEqual({ | 1339 | expect(claude).toEqual({ |
| ... | @@ -1339,6 +1341,11 @@ describe("PROVIDER_OPTIONS", () => { | ... | @@ -1339,6 +1341,11 @@ describe("PROVIDER_OPTIONS", () => { |
| 1339 | label: "Claude", | 1341 | label: "Claude", |
| 1340 | available: true, | 1342 | available: true, |
| 1341 | }); | 1343 | }); |
| 1344 | expect(opencode).toEqual({ | ||
| 1345 | value: "opencode", | ||
| 1346 | label: "OpenCode", | ||
| 1347 | available: true, | ||
| 1348 | }); | ||
| 1342 | expect(cursor).toEqual({ | 1349 | expect(cursor).toEqual({ |
| 1343 | value: "cursor", | 1350 | value: "cursor", |
| 1344 | label: "Cursor", | 1351 | label: "Cursor", |
apps/web/src/session-logic.ts+1| ... | @@ -29,6 +29,7 @@ export const PROVIDER_OPTIONS: Array<{ | ... | @@ -29,6 +29,7 @@ export const PROVIDER_OPTIONS: Array<{ |
| 29 | }> = [ | 29 | }> = [ |
| 30 | { value: "codex", label: "Codex", available: true }, | 30 | { value: "codex", label: "Codex", available: true }, |
| 31 | { value: "claudeAgent", label: "Claude", available: true }, | 31 | { value: "claudeAgent", label: "Claude", available: true }, |
| 32 | { value: "opencode", label: "OpenCode", available: true }, | ||
| 32 | { value: "cursor", label: "Cursor", available: false }, | 33 | { value: "cursor", label: "Cursor", available: false }, |
| 33 | ]; | 34 | ]; |
| 34 | 35 |
apps/web/src/store.ts+2-2| ... | @@ -129,7 +129,7 @@ function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { | ... | @@ -129,7 +129,7 @@ function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { |
| 129 | return left.length === right.length && left.every((value, index) => value === right[index]); | 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 | selection: T, | 133 | selection: T, |
| 134 | ): T { | 134 | ): T { |
| 135 | return { | 135 | return { |
| ... | @@ -1001,7 +1001,7 @@ function toLegacySessionStatus( | ... | @@ -1001,7 +1001,7 @@ function toLegacySessionStatus( |
| 1001 | } | 1001 | } |
| 1002 | 1002 | ||
| 1003 | function toLegacyProvider(providerName: string | null): ProviderKind { | 1003 | function toLegacyProvider(providerName: string | null): ProviderKind { |
| 1004 | if (providerName === "codex" || providerName === "claudeAgent") { | 1004 | if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") { |
| 1005 | return providerName; | 1005 | return providerName; |
| 1006 | } | 1006 | } |
| 1007 | return "codex"; | 1007 | return "codex"; |
bun.lock+3| ... | @@ -51,6 +51,7 @@ | ... | @@ -51,6 +51,7 @@ |
| 51 | "@effect/platform-bun": "catalog:", | 51 | "@effect/platform-bun": "catalog:", |
| 52 | "@effect/platform-node": "catalog:", | 52 | "@effect/platform-node": "catalog:", |
| 53 | "@effect/sql-sqlite-bun": "catalog:", | 53 | "@effect/sql-sqlite-bun": "catalog:", |
| 54 | "@opencode-ai/sdk": "^1.3.15", | ||
| 54 | "@pierre/diffs": "^1.1.0-beta.16", | 55 | "@pierre/diffs": "^1.1.0-beta.16", |
| 55 | "effect": "catalog:", | 56 | "effect": "catalog:", |
| 56 | "node-pty": "^1.1.0", | 57 | "node-pty": "^1.1.0", |
| ... | @@ -536,6 +537,8 @@ | ... | @@ -536,6 +537,8 @@ |
| 536 | 537 | ||
| 537 | "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], | 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 | "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], | 542 | "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], |
| 540 | 543 | ||
| 541 | "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], | 544 | "@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="], |
packages/contracts/src/model.ts+13| ... | @@ -29,9 +29,16 @@ export const ClaudeModelOptions = Schema.Struct({ | ... | @@ -29,9 +29,16 @@ export const ClaudeModelOptions = Schema.Struct({ |
| 29 | }); | 29 | }); |
| 30 | export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; | 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 | export const ProviderModelOptions = Schema.Struct({ | 38 | export const ProviderModelOptions = Schema.Struct({ |
| 33 | codex: Schema.optional(CodexModelOptions), | 39 | codex: Schema.optional(CodexModelOptions), |
| 34 | claudeAgent: Schema.optional(ClaudeModelOptions), | 40 | claudeAgent: Schema.optional(ClaudeModelOptions), |
| 41 | opencode: Schema.optional(OpenCodeModelOptions), | ||
| 35 | }); | 42 | }); |
| 36 | export type ProviderModelOptions = typeof ProviderModelOptions.Type; | 43 | export type ProviderModelOptions = typeof ProviderModelOptions.Type; |
| 37 | 44 | ||
| ... | @@ -55,12 +62,15 @@ export const ModelCapabilities = Schema.Struct({ | ... | @@ -55,12 +62,15 @@ export const ModelCapabilities = Schema.Struct({ |
| 55 | supportsThinkingToggle: Schema.Boolean, | 62 | supportsThinkingToggle: Schema.Boolean, |
| 56 | contextWindowOptions: Schema.Array(ContextWindowOption), | 63 | contextWindowOptions: Schema.Array(ContextWindowOption), |
| 57 | promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), | 64 | promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), |
| 65 | variantOptions: Schema.optional(Schema.Array(EffortOption)), | ||
| 66 | agentOptions: Schema.optional(Schema.Array(EffortOption)), | ||
| 58 | }); | 67 | }); |
| 59 | export type ModelCapabilities = typeof ModelCapabilities.Type; | 68 | export type ModelCapabilities = typeof ModelCapabilities.Type; |
| 60 | 69 | ||
| 61 | export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = { | 70 | export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, string> = { |
| 62 | codex: "gpt-5.4", | 71 | codex: "gpt-5.4", |
| 63 | claudeAgent: "claude-sonnet-4-6", | 72 | claudeAgent: "claude-sonnet-4-6", |
| 73 | opencode: "openai/gpt-5", | ||
| 64 | }; | 74 | }; |
| 65 | 75 | ||
| 66 | export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; | 76 | export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; |
| ... | @@ -69,6 +79,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; | ... | @@ -69,6 +79,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; |
| 69 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = { | 79 | export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record<ProviderKind, string> = { |
| 70 | codex: "gpt-5.4-mini", | 80 | codex: "gpt-5.4-mini", |
| 71 | claudeAgent: "claude-haiku-4-5", | 81 | claudeAgent: "claude-haiku-4-5", |
| 82 | opencode: "openai/gpt-5", | ||
| 72 | }; | 83 | }; |
| 73 | 84 | ||
| 74 | export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, string>> = { | 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, | ... | @@ -96,6 +107,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, |
| 96 | "claude-haiku-4.5": "claude-haiku-4-5", | 107 | "claude-haiku-4.5": "claude-haiku-4-5", |
| 97 | "claude-haiku-4-5-20251001": "claude-haiku-4-5", | 108 | "claude-haiku-4-5-20251001": "claude-haiku-4-5", |
| 98 | }, | 109 | }, |
| 110 | opencode: {}, | ||
| 99 | }; | 111 | }; |
| 100 | 112 | ||
| 101 | // ── Provider display names ──────────────────────────────────────────── | 113 | // ── Provider display names ──────────────────────────────────────────── |
| ... | @@ -103,4 +115,5 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, | ... | @@ -103,4 +115,5 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, |
| 103 | export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = { | 115 | export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = { |
| 104 | codex: "Codex", | 116 | codex: "Codex", |
| 105 | claudeAgent: "Claude", | 117 | claudeAgent: "Claude", |
| 118 | opencode: "OpenCode", | ||
| 106 | }; | 119 | }; |
packages/contracts/src/orchestration.ts+14-3| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; | 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 | import { RepositoryIdentity } from "./environment.ts"; | 3 | import { RepositoryIdentity } from "./environment.ts"; |
| 4 | import { | 4 | import { |
| 5 | ApprovalRequestId, | 5 | ApprovalRequestId, |
| ... | @@ -25,7 +25,7 @@ export const ORCHESTRATION_WS_METHODS = { | ... | @@ -25,7 +25,7 @@ export const ORCHESTRATION_WS_METHODS = { |
| 25 | subscribeThread: "orchestration.subscribeThread", | 25 | subscribeThread: "orchestration.subscribeThread", |
| 26 | } as const; | 26 | } as const; |
| 27 | 27 | ||
| 28 | export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); | 28 | export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "opencode"]); |
| 29 | export type ProviderKind = typeof ProviderKind.Type; | 29 | export type ProviderKind = typeof ProviderKind.Type; |
| 30 | export const ProviderApprovalPolicy = Schema.Literals([ | 30 | export const ProviderApprovalPolicy = Schema.Literals([ |
| 31 | "untrusted", | 31 | "untrusted", |
| ... | @@ -57,7 +57,18 @@ export const ClaudeModelSelection = Schema.Struct({ | ... | @@ -57,7 +57,18 @@ export const ClaudeModelSelection = Schema.Struct({ |
| 57 | }); | 57 | }); |
| 58 | export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; | 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 | export type ModelSelection = typeof ModelSelection.Type; | 72 | export type ModelSelection = typeof ModelSelection.Type; |
| 62 | 73 | ||
| 63 | export const RuntimeMode = Schema.Literals([ | 74 | export const RuntimeMode = Schema.Literals([ |
packages/contracts/src/providerRuntime.ts+1| ... | @@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Literals([ | ... | @@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Literals([ |
| 24 | "claude.sdk.message", | 24 | "claude.sdk.message", |
| 25 | "claude.sdk.permission", | 25 | "claude.sdk.permission", |
| 26 | "codex.sdk.thread-event", | 26 | "codex.sdk.thread-event", |
| 27 | "opencode.sdk.event", | ||
| 27 | ]); | 28 | ]); |
| 28 | export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; | 29 | export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; |
| 29 | 30 |
packages/contracts/src/settings.ts+30| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | ClaudeModelOptions, | 6 | ClaudeModelOptions, |
| 7 | CodexModelOptions, | 7 | CodexModelOptions, |
| 8 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, | 8 | DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, |
| 9 | OpenCodeModelOptions, | ||
| 9 | } from "./model.ts"; | 10 | } from "./model.ts"; |
| 10 | import { ModelSelection } from "./orchestration.ts"; | 11 | import { ModelSelection } from "./orchestration.ts"; |
| 11 | 12 | ||
| ... | @@ -89,6 +90,15 @@ export const ClaudeSettings = Schema.Struct({ | ... | @@ -89,6 +90,15 @@ export const ClaudeSettings = Schema.Struct({ |
| 89 | }); | 90 | }); |
| 90 | export type ClaudeSettings = typeof ClaudeSettings.Type; | 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 | export const ObservabilitySettings = Schema.Struct({ | 102 | export const ObservabilitySettings = Schema.Struct({ |
| 93 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | 103 | otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| 94 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | 104 | otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), |
| ... | @@ -114,6 +124,7 @@ export const ServerSettings = Schema.Struct({ | ... | @@ -114,6 +124,7 @@ export const ServerSettings = Schema.Struct({ |
| 114 | providers: Schema.Struct({ | 124 | providers: Schema.Struct({ |
| 115 | codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 125 | codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 116 | claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 126 | claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 127 | opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | ||
| 117 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 128 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 118 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | 129 | observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), |
| 119 | }); | 130 | }); |
| ... | @@ -156,6 +167,11 @@ const ClaudeModelOptionsPatch = Schema.Struct({ | ... | @@ -156,6 +167,11 @@ const ClaudeModelOptionsPatch = Schema.Struct({ |
| 156 | contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow), | 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 | const ModelSelectionPatch = Schema.Union([ | 175 | const ModelSelectionPatch = Schema.Union([ |
| 160 | Schema.Struct({ | 176 | Schema.Struct({ |
| 161 | provider: Schema.optionalKey(Schema.Literal("codex")), | 177 | provider: Schema.optionalKey(Schema.Literal("codex")), |
| ... | @@ -167,6 +183,11 @@ const ModelSelectionPatch = Schema.Union([ | ... | @@ -167,6 +183,11 @@ const ModelSelectionPatch = Schema.Union([ |
| 167 | model: Schema.optionalKey(TrimmedNonEmptyString), | 183 | model: Schema.optionalKey(TrimmedNonEmptyString), |
| 168 | options: Schema.optionalKey(ClaudeModelOptionsPatch), | 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 | const CodexSettingsPatch = Schema.Struct({ | 193 | const CodexSettingsPatch = Schema.Struct({ |
| ... | @@ -183,6 +204,14 @@ const ClaudeSettingsPatch = Schema.Struct({ | ... | @@ -183,6 +204,14 @@ const ClaudeSettingsPatch = Schema.Struct({ |
| 183 | launchArgs: Schema.optionalKey(Schema.String), | 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 | export const ServerSettingsPatch = Schema.Struct({ | 215 | export const ServerSettingsPatch = Schema.Struct({ |
| 187 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), | 216 | enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), |
| 188 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), | 217 | defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), |
| ... | @@ -198,6 +227,7 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -198,6 +227,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 198 | Schema.Struct({ | 227 | Schema.Struct({ |
| 199 | codex: Schema.optionalKey(CodexSettingsPatch), | 228 | codex: Schema.optionalKey(CodexSettingsPatch), |
| 200 | claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), | 229 | claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), |
| 230 | opencode: Schema.optionalKey(OpenCodeSettingsPatch), | ||
| 201 | }), | 231 | }), |
| 202 | ), | 232 | ), |
| 203 | }); | 233 | }); |
packages/shared/src/model.ts+73| ... | @@ -6,7 +6,9 @@ import { | ... | @@ -6,7 +6,9 @@ import { |
| 6 | type CodexModelOptions, | 6 | type CodexModelOptions, |
| 7 | type ModelCapabilities, | 7 | type ModelCapabilities, |
| 8 | type ModelSelection, | 8 | type ModelSelection, |
| 9 | type OpenCodeModelOptions, | ||
| 9 | type ProviderKind, | 10 | type ProviderKind, |
| 11 | type ProviderModelOptions, | ||
| 10 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 11 | 13 | ||
| 12 | export interface SelectableModelOption { | 14 | export interface SelectableModelOption { |
| ... | @@ -117,6 +119,50 @@ export function normalizeClaudeModelOptionsWithCapabilities( | ... | @@ -117,6 +119,50 @@ export function normalizeClaudeModelOptionsWithCapabilities( |
| 117 | return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; | 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 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { | 166 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { |
| 121 | return typeof text === "string" && /\bultrathink\b/i.test(text); | 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 | ... | @@ -196,6 +242,33 @@ export function trimOrNull<T extends string>(value: T | null | undefined): T | n |
| 196 | return trimmed || null; | 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 | * Resolve the actual API model identifier from a model selection. | 273 | * Resolve the actual API model identifier from a model selection. |
| 201 | * | 274 | * |
packages/shared/src/serverSettings.test.ts+26| ... | @@ -109,4 +109,30 @@ describe("serverSettings helpers", () => { | ... | @@ -109,4 +109,30 @@ describe("serverSettings helpers", () => { |
| 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 | }); |
packages/shared/src/serverSettings.ts+35-6| ... | @@ -1,4 +1,10 @@ | ... | @@ -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 | import { Schema } from "effect"; | 8 | import { Schema } from "effect"; |
| 3 | import { deepMerge } from "./Struct.ts"; | 9 | import { deepMerge } from "./Struct.ts"; |
| 4 | import { fromLenientJson } from "./schemaJson.ts"; | 10 | import { fromLenientJson } from "./schemaJson.ts"; |
| ... | @@ -46,6 +52,9 @@ function shouldReplaceTextGenerationModelSelection( | ... | @@ -46,6 +52,9 @@ function shouldReplaceTextGenerationModelSelection( |
| 46 | return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); | 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 | * Applies a server settings patch while treating textGenerationModelSelection as | 59 | * Applies a server settings patch while treating textGenerationModelSelection as |
| 51 | * replace-on-provider/model updates. This prevents stale nested options from | 60 | * replace-on-provider/model updates. This prevents stale nested options from |
| ... | @@ -61,12 +70,32 @@ export function applyServerSettingsPatch( | ... | @@ -61,12 +70,32 @@ export function applyServerSettingsPatch( |
| 61 | return next; | 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 | return { | 76 | return { |
| 65 | ...next, | 77 | ...next, |
| 66 | textGenerationModelSelection: { | 78 | textGenerationModelSelection: |
| 67 | provider: selectionPatch.provider ?? current.textGenerationModelSelection.provider, | 79 | provider === "codex" |
| 68 | model: selectionPatch.model ?? current.textGenerationModelSelection.model, | 80 | ? { |
| 69 | ...(selectionPatch.options ? { options: selectionPatch.options } : {}), | 81 | provider, |
| 70 | }, | 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 | } |