diff --git a/apps/server/package.json b/apps/server/package.json index 038134bd52f..b5107d1b0a5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -29,6 +29,7 @@ "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts new file mode 100644 index 00000000000..4cf25c9468d --- /dev/null +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts @@ -0,0 +1,259 @@ +import type { ChildProcess } from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Duration, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; +import { beforeEach, expect, vi } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration } from "../Services/TextGeneration.ts"; +import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; + +const runtimeMock = vi.hoisted(() => { + const state = { + startCalls: [] as string[], + promptUrls: [] as string[], + authHeaders: [] as Array, + closeCalls: [] as string[], + promptResult: undefined as { data?: { info?: { structured?: unknown } } } | undefined, + }; + + return { + state, + reset() { + state.startCalls.length = 0; + state.promptUrls.length = 0; + state.authHeaders.length = 0; + state.closeCalls.length = 0; + state.promptResult = undefined; + }, + }; +}); + +vi.mock("../../provider/opencodeRuntime.ts", async () => { + const actual = await vi.importActual( + "../../provider/opencodeRuntime.ts", + ); + + return { + ...actual, + startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { + const index = runtimeMock.state.startCalls.length + 1; + const url = `http://127.0.0.1:${4_300 + index}`; + runtimeMock.state.startCalls.push(binaryPath); + return { + url, + process: {} as ChildProcess, + close: () => { + runtimeMock.state.closeCalls.push(url); + }, + }; + }), + createOpenCodeSdkClient: vi.fn( + ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ + session: { + create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })), + prompt: vi.fn(async () => { + runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return ( + runtimeMock.state.promptResult ?? { + data: { + info: { + structured: { + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }, + }, + }, + } + ); + }), + }, + }), + ), + }; +}); + +const DEFAULT_TEST_MODEL_SELECTION = { + provider: "opencode" as const, + model: "openai/gpt-5", +}; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + }, + }, + }), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe( + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-existing-server-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const advanceIdleClock = Effect.gen(function* () { + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); + yield* Effect.yieldNow; +}); + +it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4301", + ]); + expect(runtimeMock.state.closeCalls).toEqual([]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("starts a new server after the warm server idles out", () => + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + yield* advanceIdleClock; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4302", + ]); + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("returns a typed missing-output error when OpenCode omits info.structured", () => + Effect.gen(function* () { + runtimeMock.state.promptResult = { data: {} }; + const textGeneration = yield* TextGeneration; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode returned no structured output."); + }), + ); +}); + +it.layer(OpenCodeTextGenerationExistingServerTestLayer)( + "OpenCodeTextGenerationLive with configured server URL", + (it) => { + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + `Basic ${btoa("opencode:secret-password")}`, + ]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual([]); + }).pipe(Effect.provide(TestClock.layer())), + ); + }, +); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts new file mode 100644 index 00000000000..7721354e4da --- /dev/null +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -0,0 +1,422 @@ +import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; +import * as Semaphore from "effect/Semaphore"; + +import { + TextGenerationError, + type ChatAttachment, + type OpenCodeModelSelection, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { ServerConfig } from "../../config.ts"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "../Prompts.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "../Utils.ts"; +import { + createOpenCodeSdkClient, + type OpenCodeServerConnection, + type OpenCodeServerProcess, + parseOpenCodeModelSlug, + startOpenCodeServerProcess, + toOpenCodeFileParts, +} from "../../provider/opencodeRuntime.ts"; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +interface SharedOpenCodeTextGenerationServerState { + server: OpenCodeServerProcess | null; + binaryPath: string | null; + activeRequests: number; + idleCloseFiber: Fiber.Fiber | null; +} + +const makeOpenCodeTextGeneration = Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettingsService = yield* ServerSettingsService; + const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const sharedServerMutex = yield* Semaphore.make(1); + const sharedServerState: SharedOpenCodeTextGenerationServerState = { + server: null, + binaryPath: null, + activeRequests: 0, + idleCloseFiber: null, + }; + + const closeSharedServer = (server: OpenCodeServerProcess) => { + if (sharedServerState.server === server) { + sharedServerState.server = null; + sharedServerState.binaryPath = null; + } + server.close(); + }; + + const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { + const idleCloseFiber = sharedServerState.idleCloseFiber; + sharedServerState.idleCloseFiber = null; + if (idleCloseFiber !== null) { + yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); + } + }); + + const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( + server: OpenCodeServerProcess, + ) { + yield* cancelIdleCloseFiber(); + const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe( + Effect.andThen( + sharedServerMutex.withPermit( + Effect.sync(() => { + if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { + return; + } + sharedServerState.idleCloseFiber = null; + closeSharedServer(server); + }), + ), + ), + Effect.forkIn(idleFiberScope), + ); + sharedServerState.idleCloseFiber = fiber; + }); + + const acquireSharedServer = (input: { + readonly binaryPath: string; + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + }) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + + const existingServer = sharedServerState.server; + if (existingServer !== null) { + if ( + sharedServerState.binaryPath !== input.binaryPath && + sharedServerState.activeRequests === 0 + ) { + closeSharedServer(existingServer); + } else { + if (sharedServerState.binaryPath !== input.binaryPath) { + yield* Effect.logWarning( + "OpenCode shared server binary path mismatch: requested " + + input.binaryPath + + " but active server uses " + + sharedServerState.binaryPath + + "; reusing existing server because there are active requests", + ); + } + sharedServerState.activeRequests += 1; + return existingServer; + } + } + + const server = yield* Effect.tryPromise({ + try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }), + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.", + cause, + }), + }); + + sharedServerState.server = server; + sharedServerState.binaryPath = input.binaryPath; + sharedServerState.activeRequests = 1; + return server; + }), + ); + + const releaseSharedServer = (server: OpenCodeServerProcess) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + if (sharedServerState.server !== server) { + return; + } + sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); + if (sharedServerState.activeRequests === 0) { + yield* scheduleIdleClose(server); + } + }), + ); + + yield* Effect.addFinalizer(() => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + const server = sharedServerState.server; + sharedServerState.server = null; + sharedServerState.binaryPath = null; + sharedServerState.activeRequests = 0; + if (server !== null) { + server.close(); + } + }), + ), + ); + + const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: OpenCodeModelSelection; + readonly attachments?: ReadonlyArray | undefined; + }) { + const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + if (!parsedModel) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const settings = yield* serverSettingsService.getSettings.pipe( + Effect.map( + (value) => + value.providers?.opencode ?? { + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + }, + ), + Effect.orElseSucceed(() => ({ + enabled: true, + binaryPath: "opencode", + serverUrl: "", + serverPassword: "", + customModels: [], + })), + ); + + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + + const runAgainstServer = (server: Pick) => + Effect.tryPromise({ + try: async () => { + const client = createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(settings.serverUrl.length > 0 && settings.serverPassword + ? { serverPassword: settings.serverPassword } + : {}), + }); + const session = await client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }); + if (!session.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + + const result = await client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(input.modelSelection.options?.agent + ? { agent: input.modelSelection.options.agent } + : {}), + ...(input.modelSelection.options?.variant + ? { variant: input.modelSelection.options.variant } + : {}), + format: { + type: "json_schema", + schema: toJsonSchemaObject(input.outputSchemaJson) as Record, + }, + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }); + const structured = result.data?.info?.structured; + if (structured === undefined) { + throw new Error("OpenCode returned no structured output."); + } + return structured; + }, + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: + cause instanceof Error ? cause.message : "OpenCode text generation request failed.", + cause, + }), + }); + + const structuredOutput = + settings.serverUrl.length > 0 + ? yield* runAgainstServer({ url: settings.serverUrl }) + : yield* Effect.acquireUseRelease( + acquireSharedServer({ + binaryPath: settings.binaryPath, + operation: input.operation, + }), + runAgainstServer, + releaseSharedServer, + ); + + return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "OpenCodeTextGeneration.generateCommitMessage", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "OpenCodeTextGeneration.generatePrContent", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generatePrContent", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runOpenCodeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "OpenCodeTextGeneration.generateBranchName", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "OpenCodeTextGeneration.generateThreadTitle", + )(function* (input) { + if (input.modelSelection.provider !== "opencode") { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Invalid model selection.", + }); + } + + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); + +export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 5372bc13491..f0d658b69ca 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -18,6 +18,7 @@ import { } from "../Services/TextGeneration.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; // --------------------------------------------------------------------------- // Internal service tags so both concrete layers can coexist. @@ -31,6 +32,10 @@ class ClaudeTextGen extends Context.Service( "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", ) {} +class OpenCodeTextGen extends Context.Service()( + "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", +) {} + // --------------------------------------------------------------------------- // Routing implementation // --------------------------------------------------------------------------- @@ -38,9 +43,10 @@ class ClaudeTextGen extends Context.Service( const makeRoutingTextGeneration = Effect.gen(function* () { const codex = yield* CodexTextGen; const claude = yield* ClaudeTextGen; + const openCode = yield* OpenCodeTextGen; const route = (provider?: TextGenerationProvider): TextGenerationShape => - provider === "claudeAgent" ? claude : codex; + provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex; return { generateCommitMessage: (input) => @@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect( }), ).pipe(Layer.provide(ClaudeTextGenerationLive)); +const InternalOpenCodeLayer = Layer.effect( + OpenCodeTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(OpenCodeTextGenerationLive)); + export const RoutingTextGenerationLive = Layer.effect( TextGeneration, makeRoutingTextGeneration, -).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); +).pipe( + Layer.provide(InternalCodexLayer), + Layer.provide(InternalClaudeLayer), + Layer.provide(InternalOpenCodeLayer), +); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 6062d552d95..2833741fb13 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "@t3tools/contracts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts new file mode 100644 index 00000000000..98691082cf2 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -0,0 +1,486 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; +import { beforeEach, vi } from "vitest"; + +import { ThreadId } from "@t3tools/contracts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { + appendOpenCodeAssistantTextDelta, + makeOpenCodeAdapterLive, + mergeOpenCodeAssistantText, +} from "./OpenCodeAdapter.ts"; + +const asThreadId = (value: string): ThreadId => ThreadId.make(value); + +const runtimeMock = vi.hoisted(() => { + type MessageEntry = { + info: { + id: string; + role: "user" | "assistant"; + }; + parts: Array; + }; + + const state = { + startCalls: [] as string[], + sessionCreateUrls: [] as string[], + authHeaders: [] as Array, + abortCalls: [] as string[], + closeCalls: [] as string[], + revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, + promptAsyncError: null as Error | null, + closeError: null as Error | null, + messages: [] as MessageEntry[], + subscribedEvents: [] as unknown[], + }; + + return { + state, + reset() { + state.startCalls.length = 0; + state.sessionCreateUrls.length = 0; + state.authHeaders.length = 0; + state.abortCalls.length = 0; + state.closeCalls.length = 0; + state.revertCalls.length = 0; + state.promptAsyncError = null; + state.closeError = null; + state.messages = []; + state.subscribedEvents = []; + }, + }; +}); + +vi.mock("../opencodeRuntime.ts", async () => { + const actual = + await vi.importActual("../opencodeRuntime.ts"); + + return { + ...actual, + startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { + runtimeMock.state.startCalls.push(binaryPath); + return { + url: "http://127.0.0.1:4301", + process: { + once() {}, + }, + close() {}, + }; + }), + connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ + url: serverUrl ?? "http://127.0.0.1:4301", + process: null, + external: Boolean(serverUrl), + close() { + runtimeMock.state.closeCalls.push(serverUrl ?? "http://127.0.0.1:4301"); + if (runtimeMock.state.closeError) { + throw runtimeMock.state.closeError; + } + }, + })), + createOpenCodeSdkClient: vi.fn( + ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ + session: { + create: vi.fn(async () => { + runtimeMock.state.sessionCreateUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return { data: { id: `${baseUrl}/session` } }; + }), + abort: vi.fn(async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.abortCalls.push(sessionID); + }), + promptAsync: vi.fn(async () => { + if (runtimeMock.state.promptAsyncError) { + throw runtimeMock.state.promptAsyncError; + } + }), + messages: vi.fn(async () => ({ data: runtimeMock.state.messages })), + revert: vi.fn( + async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { + runtimeMock.state.revertCalls.push({ + sessionID, + ...(messageID ? { messageID } : {}), + }); + if (!messageID) { + runtimeMock.state.messages = []; + return; + } + + const targetIndex = runtimeMock.state.messages.findIndex( + (entry) => entry.info.id === messageID, + ); + runtimeMock.state.messages = + targetIndex >= 0 + ? runtimeMock.state.messages.slice(0, targetIndex + 1) + : runtimeMock.state.messages; + }, + ), + }, + event: { + subscribe: vi.fn(async () => ({ + stream: (async function* () { + for (const event of runtimeMock.state.subscribedEvents) { + yield event; + } + })(), + })), + }, + }), + ), + }; +}); + +const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { + upsert: () => Effect.void, + getProvider: () => + Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), +}); + +const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), +); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const sleep = (ms: number) => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); + +it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + + const session = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-opencode"), + runtimeMode: "full-access", + }); + + assert.equal(session.provider, "opencode"); + assert.equal(session.threadId, "thread-opencode"); + assert.deepEqual(runtimeMock.state.startCalls, []); + assert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); + assert.deepEqual(runtimeMock.state.authHeaders, [ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + ); + + it.effect("stops a configured-server session without trying to own server lifecycle", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-opencode"), + runtimeMode: "full-access", + }); + + yield* adapter.stopSession(asThreadId("thread-opencode")); + + assert.deepEqual(runtimeMock.state.startCalls, []); + assert.deepEqual( + runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), + true, + ); + }), + ); + + it.effect("clears session state when stopAll cleanup fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-stop-all-a"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-stop-all-b"), + runtimeMode: "full-access", + }); + + runtimeMock.state.closeError = new Error("close failed"); + const error = yield* adapter.stopAll().pipe(Effect.flip); + const sessions = yield* adapter.listSessions(); + + assert.equal(error._tag, "ProviderAdapterProcessError"); + assert.equal(error.detail, "Failed to stop 2 OpenCode sessions."); + assert.deepEqual(runtimeMock.state.closeCalls, [ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + assert.deepEqual(sessions, []); + }), + ); + + it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-send-turn-failure"), + runtimeMode: "full-access", + }); + + runtimeMock.state.promptAsyncError = new Error("prompt failed"); + const error = yield* adapter + .sendTurn({ + threadId: asThreadId("thread-send-turn-failure"), + input: "Fix it", + modelSelection: { + provider: "opencode", + model: "openai/gpt-5", + }, + }) + .pipe(Effect.flip); + const sessions = yield* adapter.listSessions(); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag !== "ProviderAdapterRequestError") { + throw new Error("Unexpected error type"); + } + assert.equal(error.detail, "prompt failed"); + assert.equal( + error.message, + "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", + ); + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.status, "ready"); + assert.equal(sessions[0]?.activeTurnId, undefined); + assert.equal(sessions[0]?.lastError, "prompt failed"); + }), + ); + + it.effect("reverts the full thread when rollback removes every assistant turn", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-rollback-all"); + yield* adapter.startSession({ + provider: "opencode", + threadId, + runtimeMode: "full-access", + }); + + runtimeMock.state.messages = [ + { + info: { id: "assistant-1", role: "assistant" }, + parts: [], + }, + { + info: { id: "assistant-2", role: "assistant" }, + parts: [], + }, + ]; + + const snapshot = yield* adapter.rollbackThread(threadId, 2); + + assert.deepEqual(runtimeMock.state.revertCalls, [ + { sessionID: "http://127.0.0.1:9999/session" }, + ]); + assert.deepEqual(snapshot.turns, []); + }), + ); + + it.effect("deduplicates overlapping assistant text deltas after part updates", () => + Effect.sync(() => { + const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); + const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); + const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hello world!"); + + assert.deepEqual( + [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], + ["Hello", " world", "!"], + ); + assert.equal(secondUpdate.latestText, "Hello world!"); + }), + ); + + it.effect("writes provider-native observability records using the session thread id", () => + Effect.gen(function* () { + const nativeEvents: Array<{ + readonly event?: { + readonly provider?: string; + readonly threadId?: string; + readonly providerThreadId?: string; + readonly type?: string; + }; + }> = []; + const nativeThreadIds: Array = []; + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + info: { + id: "msg-missing-session", + role: "assistant", + }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/other-session", + info: { + id: "msg-other-session", + role: "assistant", + }, + }, + }, + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-native-log", + role: "assistant", + }, + }, + }, + ]; + + const nativeEventLogger = { + filePath: "memory://opencode-native-events", + write: (event: unknown, threadId: ThreadId | null) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + nativeThreadIds.push(threadId ?? null); + return Effect.void; + }, + close: () => Effect.void, + }; + + const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + const session = yield* Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const started = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-native-log"), + runtimeMode: "full-access", + }); + yield* sleep(10); + return started; + }).pipe(Effect.provide(adapterLayer)); + + assert.equal(session.threadId, "thread-native-log"); + assert.equal(nativeEvents.length, 1); + assert.equal( + nativeEvents.some((record) => record.event?.provider === "opencode"), + true, + ); + assert.equal( + nativeEvents.some( + (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session", + ), + true, + ); + assert.equal( + nativeEvents.some((record) => record.event?.threadId === "thread-native-log"), + true, + ); + assert.equal( + nativeEvents.some((record) => record.event?.type === "message.updated"), + true, + ); + assert.equal( + nativeThreadIds.every((threadId) => threadId === "thread-native-log"), + true, + ); + }), + ); + + it.effect("keeps the event pump alive when native event logging fails", () => + Effect.gen(function* () { + runtimeMock.state.subscribedEvents = [ + { + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "msg-native-log-failure", + role: "assistant", + }, + }, + }, + ]; + + const nativeEventLogger = { + filePath: "memory://opencode-native-events", + write: () => Effect.die(new Error("native log write failed")), + close: () => Effect.void, + }; + + const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge( + ServerSettingsService.layerTest({ + providers: { + opencode: { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), + ), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + const sessions = yield* Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-native-log-failure"), + runtimeMode: "full-access", + }); + yield* sleep(10); + return yield* adapter.listSessions(); + }).pipe(Effect.provide(adapterLayer)); + + assert.equal(sessions.length, 1); + assert.equal(sessions[0]?.threadId, "thread-native-log-failure"); + assert.deepEqual(runtimeMock.state.closeCalls, []); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts new file mode 100644 index 00000000000..4e3c12ef5da --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -0,0 +1,1344 @@ +import { randomUUID } from "node:crypto"; + +import { + EventId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + type ToolLifecycleItemType, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { Cause, Effect, Layer, Queue, Stream } from "effect"; +import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { + buildOpenCodePermissionRules, + connectToOpenCodeServer, + createOpenCodeSdkClient, + openCodeQuestionId, + parseOpenCodeModelSlug, + toOpenCodeFileParts, + toOpenCodePermissionReply, + toOpenCodeQuestionAnswers, + type OpenCodeServerConnection, +} from "../opencodeRuntime.ts"; + +const PROVIDER = "opencode" as const; + +interface OpenCodeTurnSnapshot { + readonly id: TurnId; + readonly items: Array; +} + +interface OpenCodeSessionContext { + session: ProviderSession; + readonly client: OpencodeClient; + readonly server: OpenCodeServerConnection; + readonly directory: string; + readonly openCodeSessionId: string; + readonly pendingPermissions: Map; + readonly pendingQuestions: Map; + readonly messageRoleById: Map; + readonly partById: Map; + readonly emittedTextByPartId: Map; + readonly completedAssistantPartIds: Set; + readonly turns: Array; + activeTurnId: TurnId | undefined; + activeAgent: string | undefined; + activeVariant: string | undefined; + stopped: boolean; + readonly eventsAbortController: AbortController; +} + +export interface OpenCodeAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function isProviderAdapterRequestError(cause: unknown): cause is ProviderAdapterRequestError { + return ( + typeof cause === "object" && + cause !== null && + "_tag" in cause && + cause._tag === "ProviderAdapterRequestError" + ); +} + +function buildEventBase(input: { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + readonly createdAt?: string | undefined; + readonly raw?: unknown; +}): Pick< + ProviderRuntimeEvent, + "eventId" | "provider" | "threadId" | "createdAt" | "turnId" | "itemId" | "requestId" | "raw" +> { + return { + eventId: EventId.make(randomUUID()), + provider: PROVIDER, + threadId: input.threadId, + createdAt: input.createdAt ?? nowIso(), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input.raw !== undefined + ? { + raw: { + source: "opencode.sdk.event", + payload: input.raw, + }, + } + : {}), + }; +} + +function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if (normalized.includes("bash") || normalized.includes("command")) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("patch") || + normalized.includes("multiedit") + ) { + return "file_change"; + } + if (normalized.includes("web")) { + return "web_search"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + if (normalized.includes("image")) { + return "image_view"; + } + if ( + normalized.includes("task") || + normalized.includes("agent") || + normalized.includes("subtask") + ) { + return "collab_agent_tool_call"; + } + return "dynamic_tool_call"; +} + +function mapPermissionToRequestType( + permission: string, +): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (permission) { + case "bash": + return "command_execution_approval"; + case "read": + return "file_read_approval"; + case "edit": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function mapPermissionDecision(reply: "once" | "always" | "reject"): string { + switch (reply) { + case "once": + return "accept"; + case "always": + return "acceptForSession"; + case "reject": + default: + return "decline"; + } +} + +function resolveTurnSnapshot( + context: OpenCodeSessionContext, + turnId: TurnId, +): OpenCodeTurnSnapshot { + const existing = context.turns.find((turn) => turn.id === turnId); + if (existing) { + return existing; + } + + const created: OpenCodeTurnSnapshot = { id: turnId, items: [] }; + context.turns.push(created); + return created; +} + +function appendTurnItem( + context: OpenCodeSessionContext, + turnId: TurnId | undefined, + item: unknown, +): void { + if (!turnId) { + return; + } + resolveTurnSnapshot(context, turnId).items.push(item); +} + +function ensureSessionContext( + sessions: ReadonlyMap, + threadId: ThreadId, +): OpenCodeSessionContext { + const session = sessions.get(threadId); + if (!session) { + throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + if (session.stopped) { + throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); + } + return session; +} + +function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray { + return request.questions.map((question, index) => ({ + id: openCodeQuestionId(index, question), + header: question.header, + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + ...(question.multiple ? { multiSelect: true } : {}), + })); +} + +function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" { + return part?.type === "reasoning" ? "reasoning_text" : "assistant_text"; +} + +function textFromPart(part: Part): string | undefined { + switch (part.type) { + case "text": + case "reasoning": + return part.text; + default: + return undefined; + } +} + +function commonPrefixLength(left: string, right: string): number { + let index = 0; + while (index < left.length && index < right.length && left[index] === right[index]) { + index += 1; + } + return index; +} + +function suffixPrefixOverlap(text: string, delta: string): number { + const maxLength = Math.min(text.length, delta.length); + for (let length = maxLength; length > 0; length -= 1) { + if (text.endsWith(delta.slice(0, length))) { + return length; + } + } + return 0; +} + +function resolveLatestAssistantText(previousText: string | undefined, nextText: string): string { + if (previousText && previousText.length > nextText.length && previousText.startsWith(nextText)) { + return previousText; + } + return nextText; +} + +export function mergeOpenCodeAssistantText( + previousText: string | undefined, + nextText: string, +): { + readonly latestText: string; + readonly deltaToEmit: string; +} { + const latestText = resolveLatestAssistantText(previousText, nextText); + return { + latestText, + deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), + }; +} + +export function appendOpenCodeAssistantTextDelta( + previousText: string, + delta: string, +): { + readonly nextText: string; + readonly deltaToEmit: string; +} { + const deltaToEmit = delta.slice(suffixPrefixOverlap(previousText, delta)); + return { + nextText: previousText + deltaToEmit, + deltaToEmit, + }; +} + +function isoFromEpochMs(value: number | undefined): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return undefined; + } + return new Date(value).toISOString(); +} + +function messageRoleForPart( + context: OpenCodeSessionContext, + part: Pick, +): "assistant" | "user" | undefined { + const known = context.messageRoleById.get(part.messageID); + if (known) { + return known; + } + return part.type === "tool" ? "assistant" : undefined; +} + +function detailFromToolPart(part: Extract): string | undefined { + switch (part.state.status) { + case "completed": + return part.state.output; + case "error": + return part.state.error; + case "running": + return part.state.title; + default: + return undefined; + } +} + +function toolStateCreatedAt(part: Extract): string | undefined { + switch (part.state.status) { + case "running": + return isoFromEpochMs(part.state.time.start); + case "completed": + case "error": + return isoFromEpochMs(part.state.time.end); + default: + return undefined; + } +} + +function sessionErrorMessage(error: unknown): string { + if (!error || typeof error !== "object") { + return "OpenCode session failed."; + } + const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null; + const message = data && "message" in data ? data.message : null; + return typeof message === "string" && message.trim().length > 0 + ? message + : "OpenCode session failed."; +} + +function updateProviderSession( + context: OpenCodeSessionContext, + patch: Partial, + options?: { + readonly clearActiveTurnId?: boolean; + readonly clearLastError?: boolean; + }, +): ProviderSession { + const nextSession = { + ...context.session, + ...patch, + updatedAt: nowIso(), + } as ProviderSession & Record; + const mutableSession = nextSession as Record; + if (options?.clearActiveTurnId) { + delete mutableSession.activeTurnId; + } + if (options?.clearLastError) { + delete mutableSession.lastError; + } + context.session = nextSession; + return nextSession; +} + +async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise { + context.stopped = true; + context.eventsAbortController.abort(); + try { + await context.client.session + .abort({ sessionID: context.openCodeSessionId }) + .catch(() => undefined); + } catch {} + context.server.close(); +} + +export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { + return Layer.effect( + OpenCodeAdapter, + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; + const services = yield* Effect.context(); + const nativeEventLogger = + _options?.nativeEventLogger ?? + (_options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(_options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const emitPromise = (event: ProviderRuntimeEvent) => + emit(event).pipe(Effect.runPromiseWith(services)); + const writeNativeEventPromise = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => + (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void).pipe( + Effect.runPromiseWith(services), + ); + const writeNativeEventBestEffort = ( + threadId: ThreadId, + event: { + readonly observedAt: string; + readonly event: Record; + }, + ) => writeNativeEventPromise(threadId, event).catch(() => undefined); + + const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => { + if (context.stopped) { + return; + } + context.stopped = true; + sessions.delete(context.session.threadId); + context.server.close(); + const turnId = context.activeTurnId; + void emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "runtime.error", + payload: { + message, + class: "transport_error", + }, + }).catch(() => undefined); + void emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId }), + type: "session.exited", + payload: { + reason: message, + recoverable: false, + exitKind: "error", + }, + }).catch(() => undefined); + }; + + /** Emit content.delta and item.completed events for an assistant text part. */ + const emitAssistantTextDelta = async ( + context: OpenCodeSessionContext, + part: Part, + turnId: TurnId | undefined, + raw: unknown, + ): Promise => { + const text = textFromPart(part); + if (text === undefined) { + return; + } + const previousText = context.emittedTextByPartId.get(part.id); + const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); + context.emittedTextByPartId.set(part.id, latestText); + if (latestText !== text) { + context.partById.set( + part.id, + (part.type === "text" || part.type === "reasoning" + ? { ...part, text: latestText } + : part) satisfies Part, + ); + } + if (deltaToEmit.length > 0) { + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: + part.type === "text" || part.type === "reasoning" + ? isoFromEpochMs(part.time?.start) + : undefined, + raw, + }), + type: "content.delta", + payload: { + streamKind: resolveTextStreamKind(part), + delta: deltaToEmit, + }, + }); + } + + if ( + part.type === "text" && + part.time?.end !== undefined && + !context.completedAssistantPartIds.has(part.id) + ) { + context.completedAssistantPartIds.add(part.id); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.id, + createdAt: isoFromEpochMs(part.time.end), + raw, + }), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(latestText.length > 0 ? { detail: latestText } : {}), + }, + }); + } + }; + + const startEventPump = (context: OpenCodeSessionContext) => { + void (async () => { + try { + const subscription = await context.client.event.subscribe(undefined, { + signal: context.eventsAbortController.signal, + }); + + for await (const event of subscription.stream) { + const payloadSessionId = + "properties" in event + ? (event.properties as { sessionID?: unknown }).sessionID + : undefined; + if (payloadSessionId !== context.openCodeSessionId) { + continue; + } + + const turnId = context.activeTurnId; + await writeNativeEventBestEffort(context.session.threadId, { + observedAt: nowIso(), + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.openCodeSessionId, + type: event.type, + ...(turnId ? { turnId } : {}), + payload: event, + }, + }); + + switch (event.type) { + case "message.updated": { + context.messageRoleById.set(event.properties.info.id, event.properties.info.role); + if (event.properties.info.role === "assistant") { + for (const part of context.partById.values()) { + if (part.messageID !== event.properties.info.id) { + continue; + } + await emitAssistantTextDelta(context, part, turnId, event); + } + } + break; + } + + case "message.removed": { + context.messageRoleById.delete(event.properties.messageID); + break; + } + + case "message.part.delta": { + const existingPart = context.partById.get(event.properties.partID); + if (!existingPart) { + break; + } + const role = messageRoleForPart(context, existingPart); + if (role !== "assistant") { + break; + } + const streamKind = resolveTextStreamKind(existingPart); + const delta = event.properties.delta; + if (delta.length === 0) { + break; + } + const previousText = + context.emittedTextByPartId.get(event.properties.partID) ?? + textFromPart(existingPart) ?? + ""; + const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta( + previousText, + delta, + ); + if (deltaToEmit.length === 0) { + break; + } + context.emittedTextByPartId.set(event.properties.partID, nextText); + if (existingPart.type === "text" || existingPart.type === "reasoning") { + context.partById.set(event.properties.partID, { + ...existingPart, + text: nextText, + }); + } + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.properties.partID, + raw: event, + }), + type: "content.delta", + payload: { + streamKind, + delta: deltaToEmit, + }, + }); + break; + } + + case "message.part.updated": { + const part = event.properties.part; + context.partById.set(part.id, part); + const messageRole = messageRoleForPart(context, part); + + if (messageRole === "assistant") { + await emitAssistantTextDelta(context, part, turnId, event); + } + + if (part.type === "tool") { + const itemType = toToolLifecycleItemType(part.tool); + const title = + part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + const detail = detailFromToolPart(part); + const payload = { + itemType, + ...(part.state.status === "error" + ? { status: "failed" as const } + : part.state.status === "completed" + ? { status: "completed" as const } + : { status: "inProgress" as const }), + ...(title ? { title } : {}), + ...(detail ? { detail } : {}), + data: { + tool: part.tool, + state: part.state, + }, + }; + const runtimeEvent: ProviderRuntimeEvent = { + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.callID, + createdAt: toolStateCreatedAt(part), + raw: event, + }), + type: + part.state.status === "pending" + ? "item.started" + : part.state.status === "completed" || part.state.status === "error" + ? "item.completed" + : "item.updated", + payload, + }; + appendTurnItem(context, turnId, part); + await emitPromise(runtimeEvent); + } + break; + } + + case "permission.asked": { + context.pendingPermissions.set(event.properties.id, event.properties); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(event.properties.permission), + detail: + event.properties.patterns.length > 0 + ? event.properties.patterns.join("\n") + : event.properties.permission, + args: event.properties.metadata, + }, + }); + break; + } + + case "permission.replied": { + context.pendingPermissions.delete(event.properties.requestID); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: mapPermissionDecision(event.properties.reply), + }, + }); + break; + } + + case "question.asked": { + context.pendingQuestions.set(event.properties.id, event.properties); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "user-input.requested", + payload: { + questions: normalizeQuestionRequest(event.properties), + }, + }); + break; + } + + case "question.replied": { + const request = context.pendingQuestions.get(event.properties.requestID); + context.pendingQuestions.delete(event.properties.requestID); + const answers = Object.fromEntries( + (request?.questions ?? []).map((question, index) => [ + openCodeQuestionId(index, question), + event.properties.answers[index]?.join(", ") ?? "", + ]), + ); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers }, + }); + break; + } + + case "question.rejected": { + context.pendingQuestions.delete(event.properties.requestID); + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers: {} }, + }); + break; + } + + case "session.status": { + if (event.properties.status.type === "busy") { + updateProviderSession(context, { status: "running", activeTurnId: turnId }); + } + + if (event.properties.status.type === "retry") { + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "runtime.warning", + payload: { + message: event.properties.status.message, + detail: event.properties.status, + }, + }); + break; + } + + if (event.properties.status.type === "idle" && turnId) { + context.activeTurnId = undefined; + updateProviderSession( + context, + { status: "ready" }, + { clearActiveTurnId: true }, + ); + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + } + break; + } + + case "session.error": { + const message = sessionErrorMessage(event.properties.error); + const activeTurnId = context.activeTurnId; + context.activeTurnId = undefined; + updateProviderSession( + context, + { + status: "error", + lastError: message, + }, + { clearActiveTurnId: true }, + ); + if (activeTurnId) { + await emitPromise({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: activeTurnId, + raw: event, + }), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: message, + }, + }); + } + await emitPromise({ + ...buildEventBase({ threadId: context.session.threadId, raw: event }), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.properties.error, + }, + }); + break; + } + + default: + break; + } + } + } catch (error) { + if (context.eventsAbortController.signal.aborted || context.stopped) { + return; + } + emitUnexpectedExit( + context, + error instanceof Error ? error.message : "OpenCode event stream failed.", + ); + } + })(); + + context.server.process?.once("exit", (code, signal) => { + if (context.stopped) { + return; + } + emitUnexpectedExit( + context, + `OpenCode server exited unexpectedly (${signal ?? code ?? "unknown"}).`, + ); + }); + }; + + const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( + function* (input) { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to read OpenCode settings.", + cause, + }), + ), + ); + const binaryPath = settings.providers.opencode.binaryPath; + const serverUrl = settings.providers.opencode.serverUrl; + const serverPassword = settings.providers.opencode.serverPassword; + const directory = input.cwd ?? serverConfig.cwd; + const existing = sessions.get(input.threadId); + if (existing) { + yield* Effect.tryPromise({ + try: () => stopOpenCodeContext(existing), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to stop existing OpenCode session.", + cause, + }), + }); + sessions.delete(input.threadId); + } + + const started = yield* Effect.tryPromise({ + try: async () => { + const server = await connectToOpenCodeServer({ binaryPath, serverUrl }); + const client = createOpenCodeSdkClient({ + baseUrl: server.url, + directory, + ...(server.external && serverPassword ? { serverPassword } : {}), + }); + const openCodeSession = await client.session.create({ + title: `T3 Code ${input.threadId}`, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }); + if (!openCodeSession.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + return { server, client, openCodeSession: openCodeSession.data }; + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: + cause instanceof Error ? cause.message : "Failed to start OpenCode session.", + cause, + }), + }); + + // Guard against a concurrent startSession call that may have raced + // and already inserted a session while we were awaiting async work. + const raceWinner = sessions.get(input.threadId); + if (raceWinner) { + // Another call won the race – clean up the session we just created + // (including the remote SDK session) and return the existing one. + yield* Effect.tryPromise({ + try: () => + started.client.session + .abort({ sessionID: started.openCodeSession.id }) + .catch(() => undefined), + catch: () => undefined, + }).pipe(Effect.ignore); + started.server.close(); + return raceWinner.session; + } + + const createdAt = nowIso(); + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: directory, + ...(input.modelSelection ? { model: input.modelSelection.model } : {}), + threadId: input.threadId, + createdAt, + updatedAt: createdAt, + }; + + const context: OpenCodeSessionContext = { + session, + client: started.client, + server: started.server, + directory, + openCodeSessionId: started.openCodeSession.id, + pendingPermissions: new Map(), + pendingQuestions: new Map(), + partById: new Map(), + emittedTextByPartId: new Map(), + messageRoleById: new Map(), + completedAssistantPartIds: new Set(), + turns: [], + activeTurnId: undefined, + activeAgent: undefined, + activeVariant: undefined, + stopped: false, + eventsAbortController: new AbortController(), + }; + sessions.set(input.threadId, context); + startEventPump(context); + + yield* emit({ + ...buildEventBase({ threadId: input.threadId }), + type: "session.started", + payload: { + message: "OpenCode session started", + }, + }); + yield* emit({ + ...buildEventBase({ threadId: input.threadId }), + type: "thread.started", + payload: { + providerThreadId: started.openCodeSession.id, + }, + }); + + return session; + }, + ); + + const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { + const context = ensureSessionContext(sessions, input.threadId); + const turnId = TurnId.make(`opencode-turn-${randomUUID()}`); + const modelSelection = + input.modelSelection ?? + (context.session.model + ? { provider: PROVIDER, model: context.session.model } + : undefined); + const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); + if (!parsedModel) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const text = input.input?.trim(); + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + if ((!text || text.length === 0) && fileParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode turns require text input or at least one attachment.", + }); + } + + const agent = + input.modelSelection?.provider === PROVIDER + ? input.modelSelection.options?.agent + : undefined; + const variant = + input.modelSelection?.provider === PROVIDER + ? input.modelSelection.options?.variant + : undefined; + + context.activeTurnId = turnId; + context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeVariant = variant; + updateProviderSession( + context, + { + status: "running", + activeTurnId: turnId, + model: modelSelection?.model ?? context.session.model, + }, + { clearLastError: true }, + ); + + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.started", + payload: { + model: modelSelection?.model ?? context.session.model, + ...(variant ? { effort: variant } : {}), + }, + }); + + const promptExit = yield* Effect.exit( + Effect.tryPromise({ + try: async () => { + await context.client.session.promptAsync({ + sessionID: context.openCodeSessionId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + }); + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.promptAsync", + detail: cause instanceof Error ? cause.message : "Failed to send OpenCode turn.", + cause, + }), + }), + ); + if (promptExit._tag === "Failure") { + const failure = Cause.squash(promptExit.cause); + const requestError = isProviderAdapterRequestError(failure) + ? failure + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.promptAsync", + detail: + failure instanceof Error ? failure.message : "Failed to send OpenCode turn.", + cause: failure, + }); + const failureMessage = requestError.detail; + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: failureMessage, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.aborted", + payload: { + reason: failureMessage, + }, + }); + return yield* requestError; + } + + return { + threadId: input.threadId, + turnId, + }; + }); + + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( + function* (threadId, turnId) { + const context = ensureSessionContext(sessions, threadId); + yield* Effect.tryPromise({ + try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.abort", + detail: cause instanceof Error ? cause.message : "Failed to abort OpenCode turn.", + cause, + }), + }); + if (turnId ?? context.activeTurnId) { + yield* emit({ + ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + } + }, + ); + + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( + "respondToRequest", + )(function* (threadId, requestId, decision) { + const context = ensureSessionContext(sessions, threadId); + if (!context.pendingPermissions.has(requestId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + yield* Effect.tryPromise({ + try: () => + context.client.permission.reply({ + requestID: requestId, + reply: toOpenCodePermissionReply(decision), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: + cause instanceof Error + ? cause.message + : "Failed to submit OpenCode permission reply.", + cause, + }), + }); + }); + + const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( + "respondToUserInput", + )(function* (threadId, requestId, answers) { + const context = ensureSessionContext(sessions, threadId); + const request = context.pendingQuestions.get(requestId); + if (!request) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + + yield* Effect.tryPromise({ + try: () => + context.client.question.reply({ + requestID: requestId, + answers: toOpenCodeQuestionAnswers(request, answers), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "question.reply", + detail: cause instanceof Error ? cause.message : "Failed to submit OpenCode answers.", + cause, + }), + }); + }); + + const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + yield* Effect.tryPromise({ + try: () => stopOpenCodeContext(context), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode session.", + cause, + }), + }); + sessions.delete(threadId); + yield* emit({ + ...buildEventBase({ threadId }), + type: "session.exited", + payload: { + reason: "Session stopped.", + recoverable: false, + exitKind: "graceful", + }, + }); + }, + ); + + const listSessions: OpenCodeAdapterShape["listSessions"] = () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)); + + const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( + function* (threadId) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* Effect.tryPromise({ + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.messages", + detail: cause instanceof Error ? cause.message : "Failed to read OpenCode thread.", + cause, + }), + }); + + const turns = (messages.data ?? []) + .filter((entry) => entry.info.role === "assistant") + .map((entry) => ({ + id: TurnId.make(entry.info.id), + items: [entry.info, ...entry.parts], + })); + + return { + threadId, + turns, + }; + }, + ); + + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, numTurns) { + const context = ensureSessionContext(sessions, threadId); + const messages = yield* Effect.tryPromise({ + try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.messages", + detail: + cause instanceof Error ? cause.message : "Failed to inspect OpenCode thread.", + cause, + }), + }); + + const assistantMessages = (messages.data ?? []).filter( + (entry) => entry.info.role === "assistant", + ); + const targetIndex = assistantMessages.length - numTurns - 1; + const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; + yield* Effect.tryPromise({ + try: () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + ...(target ? { messageID: target.info.id } : {}), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session.revert", + detail: cause instanceof Error ? cause.message : "Failed to revert OpenCode turn.", + cause, + }), + }); + + return yield* readThread(threadId); + }, + ); + + const stopAll: OpenCodeAdapterShape["stopAll"] = () => + Effect.tryPromise({ + try: async () => { + const contexts = [...sessions.values()]; + sessions.clear(); + const results = await Promise.allSettled( + contexts.map((context) => stopOpenCodeContext(context)), + ); + const errors = results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map((result) => result.reason); + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError( + errors, + `Failed to stop ${errors.length} OpenCode sessions.`, + ); + } + }, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: "*", + detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.", + cause, + }), + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies OpenCodeAdapterShape; + }), + ); +} + +export const OpenCodeAdapterLive = makeOpenCodeAdapterLive(); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts new file mode 100644 index 00000000000..cf3d588d9db --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { beforeEach, vi } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts"; + +const runtimeMock = vi.hoisted(() => { + const state = { + runVersionError: null as Error | null, + inventoryError: null as Error | null, + }; + + return { + state, + reset() { + state.runVersionError = null; + state.inventoryError = null; + }, + }; +}); + +vi.mock("../opencodeRuntime.ts", async () => { + const actual = + await vi.importActual("../opencodeRuntime.ts"); + + return { + ...actual, + runOpenCodeCommand: vi.fn(async () => { + if (runtimeMock.state.runVersionError) { + throw runtimeMock.state.runVersionError; + } + return { stdout: "opencode 1.0.0\n", stderr: "", code: 0 }; + }), + connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ + url: serverUrl ?? "http://127.0.0.1:4301", + process: null, + external: Boolean(serverUrl), + close() {}, + })), + createOpenCodeSdkClient: vi.fn(() => ({})), + loadOpenCodeInventory: vi.fn(async () => { + if (runtimeMock.state.inventoryError) { + throw runtimeMock.state.inventoryError; + } + return { + providerList: { connected: [], all: [] }, + agents: [], + }; + }), + flattenOpenCodeModels: vi.fn(() => []), + }; +}); + +beforeEach(() => { + runtimeMock.reset(); +}); + +const makeTestLayer = (settingsOverrides?: Parameters[0]) => + makeOpenCodeProviderLive().pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)), + Layer.provideMerge(NodeServices.layer), + ); + +it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { + it.effect("shows a codex-style missing binary message", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.message, "OpenCode CLI (`opencode`) is not installed or not on PATH."); + }), + ); + + it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => + Effect.gen(function* () { + runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); + }), + ); +}); + +it.layer( + makeTestLayer({ + providers: { + opencode: { + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + }, + }), +)("OpenCodeProviderLive with configured server URL", (it) => { + it.effect("surfaces a friendly auth error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error("401 Unauthorized"); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal( + snapshot.message, + "OpenCode server rejected authentication. Check the server URL and password.", + ); + }), + ); + + it.effect("surfaces a friendly connection error for configured servers", () => + Effect.gen(function* () { + runtimeMock.state.inventoryError = new Error( + "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", + ); + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, true); + assert.equal( + snapshot.message, + "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.", + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts new file mode 100644 index 00000000000..f1969412572 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -0,0 +1,342 @@ +import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts"; +import { Cause, Effect, Equal, Layer, Stream } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, +} from "../providerSnapshot.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; +import { + connectToOpenCodeServer, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + createOpenCodeSdkClient, + flattenOpenCodeModels, + loadOpenCodeInventory, + runOpenCodeCommand, +} from "../opencodeRuntime.ts"; + +const PROVIDER = "opencode" as const; + +class OpenCodeProbePromiseError extends Error { + override readonly cause: unknown; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause)); + this.cause = cause; + this.name = "OpenCodeProbePromiseError"; + } +} + +function toOpenCodeProbeError(cause: unknown): OpenCodeProbePromiseError { + return new OpenCodeProbePromiseError(cause); +} + +function normalizedErrorMessage(cause: unknown): string | undefined { + if (!(cause instanceof Error)) { + return undefined; + } + + const message = cause.message.trim(); + if (message.length === 0) { + return undefined; + } + if ( + message === "An error occurred in Effect.tryPromise" || + message === "An error occurred in Effect.try" + ) { + return undefined; + } + return message; +} + +function formatOpenCodeProbeError(input: { + readonly cause: unknown; + readonly isExternalServer: boolean; + readonly serverUrl: string; +}): { readonly installed: boolean; readonly message: string } { + const lower = input.cause instanceof Error ? input.cause.message.toLowerCase() : ""; + const detail = normalizedErrorMessage(input.cause); + + if (input.isExternalServer) { + if ( + lower.includes("401") || + lower.includes("403") || + lower.includes("unauthorized") || + lower.includes("forbidden") + ) { + return { + installed: true, + message: "OpenCode server rejected authentication. Check the server URL and password.", + }; + } + + if ( + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("fetch failed") || + lower.includes("networkerror") || + lower.includes("timed out") || + lower.includes("timeout") || + lower.includes("socket hang up") + ) { + return { + installed: true, + message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, + }; + } + + return { + installed: true, + message: detail ?? "Failed to connect to the configured OpenCode server.", + }; + } + + if (input.cause instanceof Error && isCommandMissingCause(input.cause)) { + return { + installed: false, + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + }; + } + + if (lower.includes("quarantine")) { + return { + installed: true, + message: + "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", + }; + } + + if (lower.includes("invalid code signature") || lower.includes("corrupted")) { + return { + installed: true, + message: + "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", + }; + } + + return { + installed: true, + message: detail + ? `Failed to execute OpenCode CLI health check: ${detail}` + : "Failed to execute OpenCode CLI health check.", + }; +} + +const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => { + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings( + [], + PROVIDER, + openCodeSettings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + + if (!openCodeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: + openCodeSettings.serverUrl.trim().length > 0 + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode provider status has not been checked in this session yet.", + }, + }); +}; + +export function checkOpenCodeProviderStatus(input: { + readonly settings: OpenCodeSettings; + readonly cwd: string; +}): Effect.Effect { + const checkedAt = new Date().toISOString(); + const customModels = input.settings.customModels; + const isExternalServer = input.settings.serverUrl.trim().length > 0; + + const fallback = (cause: unknown, version: string | null = null) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + serverUrl: input.settings.serverUrl, + }); + return buildServerProvider({ + provider: PROVIDER, + enabled: input.settings.enabled, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: failure.installed, + version, + status: "error", + auth: { status: "unknown" }, + message: failure.message, + }, + }); + }; + + return Effect.gen(function* () { + if (!input.settings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } + + let version: string | null = null; + if (!isExternalServer) { + const versionExit = yield* Effect.exit( + Effect.tryPromise({ + try: () => + runOpenCodeCommand({ + binaryPath: input.settings.binaryPath, + args: ["--version"], + }), + catch: toOpenCodeProbeError, + }), + ); + if (versionExit._tag === "Failure") { + return fallback(Cause.squash(versionExit.cause)); + } + version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + } + + const inventoryExit = yield* Effect.exit( + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => + connectToOpenCodeServer({ + binaryPath: input.settings.binaryPath, + serverUrl: input.settings.serverUrl, + }), + catch: toOpenCodeProbeError, + }), + (server) => + Effect.tryPromise({ + try: async () => { + const client = createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(isExternalServer && input.settings.serverPassword + ? { serverPassword: input.settings.serverPassword } + : {}), + }); + return await loadOpenCodeInventory(client); + }, + catch: toOpenCodeProbeError, + }), + (server) => Effect.sync(() => server.close()), + ), + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version); + } + + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value), + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const connectedCount = inventoryExit.value.providerList.connected.length; + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", + }, + }); + }); +} + +export function makeOpenCodeProviderLive() { + return Layer.effect( + OpenCodeProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + + const getProviderSettings = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.opencode), + ); + + return yield* makeManagedServerProvider({ + getSettings: getProviderSettings.pipe(Effect.orDie), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.opencode), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + initialSnapshot: makePendingOpenCodeProvider, + checkProvider: getProviderSettings.pipe( + Effect.flatMap((settings) => + checkOpenCodeProviderStatus({ + settings, + cwd: serverConfig.cwd, + }), + ), + ), + }); + }), + ); +} + +export const OpenCodeProviderLive = makeOpenCodeProviderLive(); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 953a49fe2ff..1b9b5561d63 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -8,6 +8,8 @@ import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -47,6 +49,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; +const fakeOpenCodeAdapter: OpenCodeAdapterShape = { + provider: "opencode", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -54,6 +73,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), ), ), NodeServices.layer, @@ -66,11 +86,13 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeAgent"); + const openCode = yield* registry.getByProvider("opencode"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); + assert.equal(openCode, fakeOpenCodeAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "opencode"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index b6c987c64c3..2026923b5be 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -17,6 +17,7 @@ import { } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter]; + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* OpenCodeAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 170521d2d27..6d274a14f4e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -31,6 +31,7 @@ import { } from "./CodexProvider.ts"; import { checkClaudeProviderStatus, parseClaudeAuthStatusFromOutput } from "./ClaudeProvider.ts"; import { haveProvidersChanged, ProviderRegistryLive } from "./ProviderRegistry.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; @@ -38,6 +39,19 @@ import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; // ── Test helpers ──────────────────────────────────────────────────── const encoder = new TextEncoder(); +const fakeOpenCodeSnapshot: ServerProvider = { + provider: "opencode", + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-03-25T00:00:00.000Z", + version: null, + models: [], + slashCommands: [], + skills: [], + message: "OpenCode test stub", +}; function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ @@ -596,12 +610,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ), ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, - ), - ).pipe(Scope.provide(scope)); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; @@ -630,6 +641,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( prefix: "t3-provider-registry-", }), ), + Layer.provideMerge( + Layer.succeed(OpenCodeProvider, { + getSnapshot: Effect.succeed(fakeOpenCodeSnapshot), + refresh: Effect.succeed(fakeOpenCodeSnapshot), + streamChanges: Stream.empty, + }), + ), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { const joined = args.join(" "); @@ -646,12 +664,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( }), ), ); - const runtimeServices = yield* Layer.build( - Layer.mergeAll( - Layer.succeed(ServerSettingsService, serverSettings), - providerRegistryLayer, - ), - ).pipe(Scope.provide(scope)); + const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( + Scope.provide(scope), + ); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 62207ff079e..c5b08ec4dcd 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -9,10 +9,13 @@ import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "eff import { ServerConfig } from "../../config.ts"; import { ClaudeProviderLive } from "./ClaudeProvider.ts"; import { CodexProviderLive } from "./CodexProvider.ts"; +import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider.ts"; import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; import type { CodexProviderShape } from "../Services/CodexProvider.ts"; import { CodexProvider } from "../Services/CodexProvider.ts"; +import type { OpenCodeProviderShape } from "../Services/OpenCodeProvider.ts"; +import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; import { hydrateCachedProvider, @@ -26,10 +29,14 @@ import { const loadProviders = ( codexProvider: CodexProviderShape, claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { - concurrency: "unbounded", - }); + openCodeProvider: OpenCodeProviderShape, +): Effect.Effect => + Effect.all( + [codexProvider.getSnapshot, claudeProvider.getSnapshot, openCodeProvider.getSnapshot], + { + concurrency: "unbounded", + }, + ); export const haveProvidersChanged = ( previousProviders: ReadonlyArray, @@ -41,6 +48,7 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const openCodeProvider = yield* OpenCodeProvider; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -48,7 +56,7 @@ export const ProviderRegistryLive = Layer.effect( PubSub.unbounded>(), PubSub.shutdown, ); - const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider); + const fallbackProviders = yield* loadProviders(codexProvider, claudeProvider, openCodeProvider); const cachePathByProvider = new Map( PROVIDER_CACHE_IDS.map( (provider) => @@ -156,6 +164,10 @@ export const ProviderRegistryLive = Layer.effect( return yield* claudeProvider.refresh.pipe( Effect.flatMap((nextProvider) => syncProvider(nextProvider)), ); + case "opencode": + return yield* openCodeProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ); default: return yield* Effect.all( [ @@ -165,6 +177,9 @@ export const ProviderRegistryLive = Layer.effect( claudeProvider.refresh.pipe( Effect.flatMap((nextProvider) => syncProvider(nextProvider)), ), + openCodeProvider.refresh.pipe( + Effect.flatMap((nextProvider) => syncProvider(nextProvider)), + ), ], { concurrency: "unbounded", @@ -180,6 +195,9 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(claudeProvider.streamChanges, (provider) => syncProvider(provider), ).pipe(Effect.forkScoped); + yield* Stream.runForEach(openCodeProvider.streamChanges, (provider) => + syncProvider(provider), + ).pipe(Effect.forkScoped); return { getProviders: Ref.get(providersRef), @@ -193,4 +211,8 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); +).pipe( + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(OpenCodeProviderLive), +); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b54976589f8..d02578b996b 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -333,6 +333,62 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () ); const routing = makeProviderServiceLayer(); + +it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const canonicalEvents: ProviderRuntimeEvent[] = []; + const canonicalThreadIds: Array = []; + const registry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(codex.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = makeProviderServiceLive({ + canonicalEventLogger: { + filePath: "memory://provider-canonical-events", + write: (event, threadId) => { + canonicalEvents.push(event as ProviderRuntimeEvent); + canonicalThreadIds.push(threadId ?? null); + return Effect.void; + }, + close: () => Effect.void, + }, + }).pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + yield* Effect.gen(function* () { + yield* ProviderService; + yield* sleep(10); + codex.emit({ + eventId: asEventId("evt-canonical-thread-segment"), + provider: "codex", + threadId: asThreadId("thread-canonical-thread-segment"), + createdAt: new Date().toISOString(), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + yield* sleep(20); + }).pipe(Effect.provide(providerLayer)); + + assert.equal(canonicalEvents.length, 1); + assert.equal(canonicalEvents[0]?.threadId, "thread-canonical-thread-segment"); + assert.deepEqual(canonicalThreadIds, ["thread-canonical-thread-segment"]); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", () => Effect.gen(function* () { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-")); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 20479b238c7..a38a24655fe 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -161,7 +161,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Effect.succeed(event).pipe( Effect.tap((canonicalEvent) => - canonicalEventLogger ? canonicalEventLogger.write(canonicalEvent, null) : Effect.void, + canonicalEventLogger + ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) + : Effect.void, ), Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), Effect.asVoid, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index b9d2439eefa..660446a6223 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -24,7 +24,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "opencode") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/OpenCodeAdapter.ts b/apps/server/src/provider/Services/OpenCodeAdapter.ts new file mode 100644 index 00000000000..ad5660022bf --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeAdapter.ts @@ -0,0 +1,12 @@ +import { Context } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface OpenCodeAdapterShape extends ProviderAdapterShape { + readonly provider: "opencode"; +} + +export class OpenCodeAdapter extends Context.Service()( + "t3/provider/Services/OpenCodeAdapter", +) {} diff --git a/apps/server/src/provider/Services/OpenCodeProvider.ts b/apps/server/src/provider/Services/OpenCodeProvider.ts new file mode 100644 index 00000000000..a799830eec4 --- /dev/null +++ b/apps/server/src/provider/Services/OpenCodeProvider.ts @@ -0,0 +1,9 @@ +import { Context } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider.ts"; + +export interface OpenCodeProviderShape extends ServerProviderShape {} + +export class OpenCodeProvider extends Context.Service()( + "t3/provider/Services/OpenCodeProvider", +) {} diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts new file mode 100644 index 00000000000..0ea63f8d534 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; + +import { describe, it, vi } from "vitest"; + +const childProcessMock = vi.hoisted(() => ({ + execFileSync: vi.fn((command: string, args: ReadonlyArray) => { + if (command === "which" && args[0] === "opencode") { + return "/opt/homebrew/bin/opencode\n"; + } + return ""; + }), + spawn: vi.fn(), +})); + +vi.mock("node:child_process", () => childProcessMock); + +describe("resolveOpenCodeBinaryPath", () => { + it("returns absolute binary paths without PATH lookup", async () => { + const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); + + assert.equal(resolveOpenCodeBinaryPath("/usr/local/bin/opencode"), "/usr/local/bin/opencode"); + assert.equal(childProcessMock.execFileSync.mock.calls.length, 0); + }); + + it("resolves command names through PATH", async () => { + const { resolveOpenCodeBinaryPath } = await import("./opencodeRuntime.ts"); + + assert.equal(resolveOpenCodeBinaryPath("opencode"), "/opt/homebrew/bin/opencode"); + assert.deepEqual(childProcessMock.execFileSync.mock.calls[0], [ + "which", + ["opencode"], + { + encoding: "utf8", + timeout: 3_000, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts new file mode 100644 index 00000000000..4778f6eac91 --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -0,0 +1,573 @@ +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; +import * as FS from "node:fs"; +import { createServer, type AddressInfo } from "node:net"; +import * as OS from "node:os"; +import * as Path from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { + ChatAttachment, + ModelCapabilities, + ProviderApprovalDecision, + RuntimeMode, + ServerProviderModel, +} from "@t3tools/contracts"; +import { + createOpencodeClient, + type Agent, + type FilePartInput, + type OpencodeClient, + type PermissionRuleset, + type ProviderListResponse, + type QuestionAnswer, + type QuestionRequest, +} from "@opencode-ai/sdk/v2"; + +const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; +const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; +const DEFAULT_HOSTNAME = "127.0.0.1"; + +const OPENAI_VARIANTS = ["none", "minimal", "low", "medium", "high", "xhigh"]; +const ANTHROPIC_VARIANTS = ["high", "max"]; +const GOOGLE_VARIANTS = ["low", "high"]; + +export const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + +export interface OpenCodeServerProcess { + readonly url: string; + readonly process: ChildProcess; + close(): void; +} + +export interface OpenCodeServerConnection { + readonly url: string; + readonly process: ChildProcess | null; + readonly external: boolean; + close(): void; +} + +function buildOpenCodeBasicAuthorizationHeader(password: string): string { + return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; +} + +export interface OpenCodeCommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface OpenCodeInventory { + readonly providerList: ProviderListResponse; + readonly agents: ReadonlyArray; +} + +export interface ParsedOpenCodeModelSlug { + readonly providerID: string; + readonly modelID: string; +} + +function titleCaseSlug(value: string): string { + return value + .split(/[-_/]+/) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function parseServerUrlFromOutput(output: string): string | null { + for (const line of output.split("\n")) { + if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { + continue; + } + const match = line.match(/on\s+(https?:\/\/[^\s]+)/); + return match?.[1] ?? null; + } + return null; +} + +function isPrimaryAgent(agent: Agent): boolean { + return !agent.hidden && (agent.mode === "primary" || agent.mode === "all"); +} + +function inferVariantValues(providerID: string): ReadonlyArray { + if (providerID === "anthropic") { + return ANTHROPIC_VARIANTS; + } + if (providerID === "openai" || providerID === "opencode") { + return OPENAI_VARIANTS; + } + if (providerID.startsWith("google")) { + return GOOGLE_VARIANTS; + } + return []; +} + +function inferDefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + if (variants.length === 1) { + return variants[0]; + } + if (providerID === "anthropic" || providerID.startsWith("google")) { + return variants.includes("high") ? "high" : undefined; + } + if (providerID === "openai" || providerID === "opencode") { + return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; + } + return undefined; +} + +function buildVariantOptions( + providerID: string, + model: ProviderListResponse["all"][number]["models"][string], +) { + const variantValues = Object.keys(model.variants ?? {}); + const resolvedValues = + variantValues.length > 0 ? variantValues : [...inferVariantValues(providerID)]; + const defaultVariant = inferDefaultVariant(providerID, resolvedValues); + + return resolvedValues.map((value) => { + const option: { value: string; label: string; isDefault?: boolean } = { + value, + label: titleCaseSlug(value), + }; + if (defaultVariant === value) { + option.isDefault = true; + } + return option; + }); +} + +function buildAgentOptions(agents: ReadonlyArray) { + const primaryAgents = agents.filter(isPrimaryAgent); + const defaultAgent = + primaryAgents.find((agent) => agent.name === "build")?.name ?? + primaryAgents[0]?.name ?? + undefined; + return primaryAgents.map((agent) => { + const option: { value: string; label: string; isDefault?: boolean } = { + value: agent.name, + label: titleCaseSlug(agent.name), + }; + if (defaultAgent === agent.name) { + option.isDefault = true; + } + return option; + }); +} + +function openCodeCapabilitiesForModel(input: { + readonly providerID: string; + readonly model: ProviderListResponse["all"][number]["models"][string]; + readonly agents: ReadonlyArray; +}): ModelCapabilities { + const variantOptions = buildVariantOptions(input.providerID, input.model); + const agentOptions = buildAgentOptions(input.agents); + return { + ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ...(variantOptions.length > 0 ? { variantOptions } : {}), + ...(agentOptions.length > 0 ? { agentOptions } : {}), + }; +} + +export function parseOpenCodeModelSlug( + slug: string | null | undefined, +): ParsedOpenCodeModelSlug | null { + if (typeof slug !== "string") { + return null; + } + + const trimmed = slug.trim(); + const separator = trimmed.indexOf("/"); + if (separator <= 0 || separator === trimmed.length - 1) { + return null; + } + + return { + providerID: trimmed.slice(0, separator), + modelID: trimmed.slice(separator + 1), + }; +} + +export function toOpenCodeModelSlug(providerID: string, modelID: string): string { + return `${providerID}/${modelID}`; +} + +export function openCodeQuestionId( + index: number, + question: QuestionRequest["questions"][number], +): string { + const header = question.header + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-"); + return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; +} + +export function toOpenCodeFileParts(input: { + readonly attachments: ReadonlyArray | undefined; + readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; +}): Array { + const parts: Array = []; + + for (const attachment of input.attachments ?? []) { + const attachmentPath = input.resolveAttachmentPath(attachment); + if (!attachmentPath) { + continue; + } + + parts.push({ + type: "file", + mime: attachment.mimeType, + filename: attachment.name, + url: pathToFileURL(attachmentPath).href, + }); + } + + return parts; +} + +export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { + if (runtimeMode === "full-access") { + return [{ permission: "*", pattern: "*", action: "allow" }]; + } + + return [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "ask" }, + { permission: "webfetch", pattern: "*", action: "ask" }, + { permission: "websearch", pattern: "*", action: "ask" }, + { permission: "codesearch", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + { permission: "doom_loop", pattern: "*", action: "ask" }, + { permission: "question", pattern: "*", action: "allow" }, + ]; +} + +export function toOpenCodePermissionReply( + decision: ProviderApprovalDecision, +): "once" | "always" | "reject" { + switch (decision) { + case "accept": + return "once"; + case "acceptForSession": + return "always"; + case "decline": + case "cancel": + default: + return "reject"; + } +} + +export function toOpenCodeQuestionAnswers( + request: QuestionRequest, + answers: Record, +): Array { + return request.questions.map((question, index) => { + const raw = + answers[openCodeQuestionId(index, question)] ?? + answers[question.header] ?? + answers[question.question]; + if (Array.isArray(raw)) { + return raw.filter((value): value is string => typeof value === "string"); + } + if (typeof raw === "string") { + return raw.trim().length > 0 ? [raw] : []; + } + return []; + }); +} + +export async function findAvailablePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, DEFAULT_HOSTNAME, () => resolve()); + }); + const address = server.address() as AddressInfo; + const port = address.port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +export function resolveOpenCodeBinaryPath(binaryPath: string): string { + if (Path.isAbsolute(binaryPath)) { + return binaryPath; + } + return execFileSync("which", [binaryPath], { + encoding: "utf8", + timeout: 3_000, + }).trim(); +} + +export function detectMacosSigkillHint(binaryPath: string): string | null { + try { + // Check for quarantine xattr first. + const resolvedPath = resolveOpenCodeBinaryPath(binaryPath); + const xattr = execFileSync("xattr", ["-l", resolvedPath], { + encoding: "utf8", + timeout: 3_000, + }); + if (xattr.includes("com.apple.quarantine")) { + return ( + `macOS quarantine is blocking the OpenCode binary. ` + + `Run: xattr -d com.apple.quarantine ${resolvedPath}` + ); + } + + // Look for a recent crash report with the termination reason. + const crashDir = Path.join(OS.homedir(), "Library/Logs/DiagnosticReports"); + const binaryName = Path.basename(resolvedPath); + const recentReports = FS.readdirSync(crashDir) + .filter((f) => f.startsWith(binaryName) && f.endsWith(".ips")) + .toSorted() + .toReversed() + .slice(0, 1); + + for (const report of recentReports) { + const content = FS.readFileSync(Path.join(crashDir, report), "utf8"); + if (content.includes('"namespace":"CODESIGNING"')) { + return ( + "macOS killed the process due to an invalid code signature. " + + "The binary may be corrupted — try reinstalling OpenCode." + ); + } + } + } catch { + // Best-effort detection — don't fail the original error path. + } + return null; +} + +export async function startOpenCodeServerProcess(input: { + readonly binaryPath: string; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; +}): Promise { + const hostname = input.hostname ?? DEFAULT_HOSTNAME; + const port = input.port ?? (await findAvailablePort()); + const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + const child = spawn(input.binaryPath, args, { + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({}), + }, + }); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + + let stdout = ""; + let stderr = ""; + let closed = false; + const close = () => { + if (closed) { + return; + } + closed = true; + child.kill(); + }; + + const url = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + close(); + reject(new Error(`Timed out waiting for OpenCode server start after ${timeoutMs}ms.`)); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onStdout); + child.stderr.off("data", onStderr); + child.off("error", onError); + child.off("close", onClose); + }; + + const onStdout = (chunk: string) => { + stdout += chunk; + const parsed = parseServerUrlFromOutput(stdout); + if (!parsed) { + return; + } + cleanup(); + resolve(parsed); + }; + + const onStderr = (chunk: string) => { + stderr += chunk; + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const onClose = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + const exitReason = signal ? `signal: ${signal}` : `code: ${code ?? "unknown"}`; + const hint = + signal === "SIGKILL" && process.platform === "darwin" + ? detectMacosSigkillHint(input.binaryPath) + : null; + reject( + new Error( + [ + `OpenCode server exited before startup completed (${exitReason}).`, + hint, + stdout.trim() ? `stdout:\n${stdout.trim()}` : null, + stderr.trim() ? `stderr:\n${stderr.trim()}` : null, + ] + .filter(Boolean) + .join("\n\n"), + ), + ); + }; + + child.stdout.on("data", onStdout); + child.stderr.on("data", onStderr); + child.once("error", onError); + child.once("close", onClose); + }); + + return { + url, + process: child, + close, + }; +} + +export async function connectToOpenCodeServer(input: { + readonly binaryPath: string; + readonly serverUrl?: string | null; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; +}): Promise { + const serverUrl = input.serverUrl?.trim(); + if (serverUrl) { + return { + url: serverUrl, + process: null, + external: true, + close() {}, + }; + } + + const server = await startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }); + + return { + url: server.url, + process: server.process, + external: false, + close: () => server.close(), + }; +} + +export async function runOpenCodeCommand(input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; +}): Promise { + const child = spawn(input.binaryPath, [...input.args], { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + env: process.env, + }); + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + const stdoutChunks: Array = []; + const stderrChunks: Array = []; + + child.stdout?.on("data", (chunk: string) => stdoutChunks.push(chunk)); + child.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk)); + + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (exitCode) => resolve(exitCode ?? 0)); + }); + + return { + stdout: stdoutChunks.join(""), + stderr: stderrChunks.join(""), + code, + }; +} + +export function createOpenCodeSdkClient(input: { + readonly baseUrl: string; + readonly directory: string; + readonly serverPassword?: string; +}): OpencodeClient { + return createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword + ? { + headers: { + Authorization: buildOpenCodeBasicAuthorizationHeader(input.serverPassword), + }, + } + : {}), + throwOnError: true, + }); +} + +export async function loadOpenCodeInventory(client: OpencodeClient): Promise { + const [providerListResult, agentsResult] = await Promise.all([ + client.provider.list(), + client.app.agents(), + ]); + if (!providerListResult.data) { + throw new Error("OpenCode provider inventory was empty."); + } + return { + providerList: providerListResult.data, + agents: agentsResult.data ?? [], + }; +} + +export function flattenOpenCodeModels( + input: OpenCodeInventory, +): ReadonlyArray { + const connected = new Set(input.providerList.connected); + const models: Array = []; + + for (const provider of input.providerList.all) { + if (!connected.has(provider.id)) { + continue; + } + + for (const model of Object.values(provider.models)) { + models.push({ + slug: toOpenCodeModelSlug(provider.id, model.id), + name: `${provider.name} · ${model.name}`, + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: provider.id, + model, + agents: input.agents, + }), + }); + } + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts new file mode 100644 index 00000000000..0a0d31ccb59 --- /dev/null +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import type { ModelCapabilities } from "@t3tools/contracts"; + +import { providerModelsFromSettings } from "./providerSnapshot.ts"; + +const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + variantOptions: [{ value: "medium", label: "Medium", isDefault: true }], + agentOptions: [{ value: "build", label: "Build", isDefault: true }], +}; + +describe("providerModelsFromSettings", () => { + it("applies the provided capabilities to custom models", () => { + const models = providerModelsFromSettings( + [], + "opencode", + ["openai/gpt-5"], + OPENCODE_CUSTOM_MODEL_CAPABILITIES, + ); + + expect(models).toEqual([ + { + slug: "openai/gpt-5", + name: "openai/gpt-5", + isCustom: true, + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, + }, + ]); + }); +}); diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index a82cb4ae504..6722f1ac04f 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -37,6 +37,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { status: "warning", auth: { status: "unknown" }, }); + const openCodeProvider = makeProvider("opencode", { + status: "warning", + auth: { status: "unknown", type: "opencode" }, + }); const codexPath = resolveProviderStatusCachePath({ cacheDir: tempDir, provider: "codex", @@ -45,6 +49,10 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { cacheDir: tempDir, provider: "claudeAgent", }); + const openCodePath = resolveProviderStatusCachePath({ + cacheDir: tempDir, + provider: "opencode", + }); yield* writeProviderStatusCache({ filePath: codexPath, @@ -54,9 +62,14 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { filePath: claudePath, provider: claudeProvider, }); + yield* writeProviderStatusCache({ + filePath: openCodePath, + provider: openCodeProvider, + }); assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider); assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider); + assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider); }), ); diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index abedf99d138..7e9ea7e9c9a 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -2,9 +2,11 @@ import * as nodePath from "node:path"; import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; import { Cause, Effect, FileSystem, Path, Schema } from "effect"; -export const PROVIDER_CACHE_IDS = ["codex", "claudeAgent"] as const satisfies ReadonlyArray< - ServerProvider["provider"] ->; +export const PROVIDER_CACHE_IDS = [ + "codex", + "claudeAgent", + "opencode", +] as const satisfies ReadonlyArray; const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 50d2d62aa72..4ae4a4fb7c4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -21,6 +21,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter.ts"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter.ts"; +import { makeOpenCodeAdapterLive } from "./provider/Layers/OpenCodeAdapter.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; @@ -154,9 +155,13 @@ const ProviderLayerLive = Layer.unwrap( const claudeAdapterLayer = makeClaudeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const openCodeAdapterLayer = makeOpenCodeAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(openCodeAdapterLayer), Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); return makeProviderServiceLive( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 406e1e85056..655ede9441f 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -184,6 +184,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { claudeAgent: { binaryPath: " /opt/homebrew/bin/claude ", }, + opencode: { + binaryPath: " /opt/homebrew/bin/opencode ", + serverUrl: " http://127.0.0.1:4096 ", + serverPassword: " secret-password ", + }, }, }); @@ -199,6 +204,13 @@ it.layer(NodeServices.layer)("server settings", (it) => { customModels: [], launchArgs: "", }); + assert.deepEqual(next.providers.opencode, { + enabled: true, + binaryPath: "/opt/homebrew/bin/opencode", + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + customModels: [], + }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); @@ -257,6 +269,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + opencode: { + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + }, }, }); @@ -273,6 +289,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { codex: { binaryPath: "/opt/homebrew/bin/codex", }, + opencode: { + serverUrl: "http://127.0.0.1:4096", + serverPassword: "secret-password", + }, }, }); }).pipe(Effect.provide(makeServerSettingsLayer())), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index d5636d5c04f..b18250e4486 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent"]; +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode"]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76431368f30..e39adb559ef 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -25,7 +25,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; -import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; @@ -2530,16 +2530,13 @@ export default function ChatView(props: ChatViewProps) { } } const title = truncate(titleSeed); - const threadCreateModelSelection: ModelSelection = { - provider: ctxSelectedProvider, - model: - ctxSelectedModel || + const threadCreateModelSelection = createModelSelection( + ctxSelectedProvider, + ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL_BY_PROVIDER.codex, - ...(ctxSelectedModelSelection.options - ? { options: ctxSelectedModelSelection.options } - : {}), - }; + ctxSelectedModelSelection.options, + ); // Auto-title from first message if (isFirstMessage && isServerThread) { diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index a1299c3e2cf..9a7ac5fbb98 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -553,8 +553,10 @@ export const IntelliJIdeaIcon: Icon = (props) => { export const OpenCodeIcon: Icon = (props) => ( - - + + + + diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 6449a71587c..c72ebecae8d 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -99,6 +99,13 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, + opencode: { + enabled: true, + binaryPath: "", + serverUrl: "", + serverPassword: "", + customModels: [], + }, }, }, }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f1663901ced..80e80e4f808 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -16,7 +16,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { forwardRef, memo, @@ -70,6 +70,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { + getComposerProviderControls, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -159,6 +160,7 @@ const terminalContextIdListsEqual = ( contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { + showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; showPlanToggle: boolean; @@ -175,25 +177,29 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop <> - - {props.interactionMode === "plan" ? "Plan" : "Build"} - - + {props.showInteractionModeToggle ? ( + <> + - + + + ) : null} + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: { + ...settings.providers[providerCard.provider], + ...(providerCard.provider === "opencode" + ? { serverUrl: event.target.value } + : {}), + }, + }, + }) + } + placeholder={providerCard.serverUrlPlaceholder} + spellCheck={false} + /> + {providerCard.serverUrlDescription ? ( + + {providerCard.serverUrlDescription} + + ) : null} + + + ) : null} + + {providerCard.serverPasswordPlaceholder ? ( +
+ +
+ ) : null} + {providerCard.homePathKey ? (