diff --git a/apps/server/package.json b/apps/server/package.json index eb4dc7dd35ec..d13c628cc910 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,7 +28,6 @@ "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", - "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", "msgpackr-extract": "3.0.4", diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a01e414f8116..f0bdbae9ef11 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -1,39 +1,23 @@ -/** - * OpenCodeDriver — `ProviderDriver` for the OpenCode runtime. - * - * Mirrors the Codex / Claude drivers: a plain value whose `create()` - * bundles `snapshot` / `adapter` / `textGeneration` closures over the - * per-instance `OpenCodeSettings`. - * - * Two instances with different `serverUrl`s therefore talk to independent - * OpenCode servers; when no `serverUrl` is set, the adapter + text-generation - * shares spin up their own scoped child processes, and those child - * processes are released when the registry scope closes. - * - * @module provider/Drivers/OpenCodeDriver - */ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; -import * as Crypto from "effect/Crypto"; +import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { + buildInitialOpenCodeProviderSnapshot, checkOpenCodeProviderStatus, - makePendingOpenCodeProvider, } from "../Layers/OpenCodeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { OpenCodeRuntime } from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -41,53 +25,36 @@ import { } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { - enrichProviderSnapshotWithVersionAdvisory, - makePackageManagedProviderMaintenanceResolver, - normalizeCommandPath, - resolveProviderMaintenanceCapabilitiesEffect, -} from "../providerMaintenance.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); +const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); - -function isOpenCodeNativeCommandPath(commandPath: string): boolean { - const normalized = normalizeCommandPath(commandPath); - return ( - normalized.endsWith("/.opencode/bin/opencode") || - normalized.endsWith("/.opencode/bin/opencode.exe") - ); -} - -const UPDATE = makePackageManagedProviderMaintenanceResolver({ +const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, - npmPackageName: "opencode-ai", - homebrewFormula: "anomalyco/tap/opencode", - nativeUpdate: { - executable: "opencode", - args: ["upgrade"], - lockKey: "opencode-native", - isCommandPath: isOpenCodeNativeCommandPath, - }, + packageName: null, }); export type OpenCodeDriverEnv = | BackgroundPolicy.BackgroundPolicy - | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem - | HttpClient.HttpClient - | OpenCodeRuntime + | OpenCodeRuntime.OpenCodeRuntime | Path.Path | ProviderEventLoggers | ServerConfig | ServerSettingsService; +function nativeBinaryPath(binaryPath: string): string { + return /^opencode(?:\.exe)?$/iu.test(binaryPath) + ? binaryPath.replace(/opencode(?=(?:\.exe)?$)/iu, "opencode2") + : binaryPath; +} + const withInstanceIdentity = (input: { readonly instanceId: ProviderInstance["instanceId"]; @@ -108,15 +75,13 @@ export const OpenCodeDriver: ProviderDriver driverKind: DRIVER_KIND, metadata: { displayName: "OpenCode", - supportsMultipleInstances: true, + supportsMultipleInstances: false, }, configSchema: OpenCodeSettings, defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { - const openCodeRuntime = yield* OpenCodeRuntime; - const serverConfig = yield* ServerConfig; - const httpClient = yield* HttpClient.HttpClient; + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); @@ -130,42 +95,35 @@ export const OpenCodeDriver: ProviderDriver accentColor, continuationGroupKey: continuationIdentity.continuationKey, }); - const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings; - const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { - binaryPath: effectiveConfig.binaryPath, - env: processEnv, - }); + const effectiveConfig = { + ...config, + enabled, + binaryPath: nativeBinaryPath(config.binaryPath), + } satisfies OpenCodeSettings; const adapter = yield* makeOpenCodeAdapter(effectiveConfig, { instanceId, environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - }); - const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); - - const checkProvider = checkOpenCodeProviderStatus( - effectiveConfig, - serverConfig.cwd, - processEnv, - ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + }).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); + const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + ); + const checkProvider = checkOpenCodeProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( { - maintenanceCapabilities, + maintenanceCapabilities: MAINTENANCE, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + buildInitialOpenCodeProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { - enableProviderUpdateChecks: settings.enableProviderUpdateChecks, - }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), - ), }, ).pipe( Effect.mapError( @@ -173,7 +131,7 @@ export const OpenCodeDriver: ProviderDriver new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build OpenCode snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build the OpenCode provider snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..1e6bd913ea20 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,1444 +1,1190 @@ import * as NodeAssert from "node:assert/strict"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import * as Context from "effect/Context"; +import { + ApprovalRequestId, + OpenCodeSettings, + ProviderInstanceId, + type ProviderRuntimeEvent, + ThreadId, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; +import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { beforeEach } from "vite-plus/test"; -import { - OpenCodeSettings, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; -import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../opencodeRuntime.ts"; -import { - appendOpenCodeAssistantTextDelta, - isOpenCodeNotFound, - isSameOpenCodeDirectory, - makeOpenCodeAdapter, - mergeOpenCodeAssistantText, -} from "./OpenCodeAdapter.ts"; - -// Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. -class OpenCodeAdapter extends Context.Service()( - "t3/provider/Layers/OpenCodeAdapter.test/OpenCodeAdapter", -) {} - -const asThreadId = (value: string): ThreadId => ThreadId.make(value); - -type MessageEntry = { - info: { - id: string; - role: "user" | "assistant"; - }; - parts: Array; -}; - -const runtimeMock = { - state: { - startCalls: [] as string[], - sessionCreateUrls: [] as string[], - sessionCreateInputs: [] as Array>, - authHeaders: [] as Array, - abortCalls: [] as string[], - closeCalls: [] as string[], - revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, - promptCalls: [] as Array, - promptAsyncError: null as Error | null, - closeError: null as Error | null, - messages: [] as MessageEntry[], - subscribedEvents: [] as unknown[], - sessionGetIds: [] as string[], - missingSessionIds: new Set(), - transientErrorSessionIds: new Set(), - sessionDirectoryById: new Map(), - sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, - forkCalls: [] as Array<{ sessionID: string; directory?: string }>, - }, - reset() { - this.state.startCalls.length = 0; - this.state.sessionCreateUrls.length = 0; - this.state.sessionCreateInputs.length = 0; - this.state.authHeaders.length = 0; - this.state.abortCalls.length = 0; - this.state.closeCalls.length = 0; - this.state.revertCalls.length = 0; - this.state.promptCalls.length = 0; - this.state.promptAsyncError = null; - this.state.closeError = null; - this.state.messages = []; - this.state.subscribedEvents = []; - this.state.sessionGetIds.length = 0; - this.state.missingSessionIds.clear(); - this.state.transientErrorSessionIds.clear(); - this.state.sessionDirectoryById.clear(); - this.state.sessionUpdateCalls.length = 0; - this.state.forkCalls.length = 0; - }, -}; +import { isSameOpenCodeDirectory, makeOpenCodeAdapter } from "./OpenCodeAdapter.ts"; + +interface RequestCall { + readonly method: OpenCodeRuntime.OpenCodeHttpMethod; + readonly path: string; + readonly operation: string; + readonly body?: unknown; +} + +interface Harness { + readonly adapter: OpenCodeAdapterShape; + readonly calls: Array; + readonly attachCount: () => number; + readonly failNextRequest: (operation: string) => void; + readonly failNextEventId: () => void; + readonly missNextRequest: (operation: string) => void; + readonly blockNextRequest: (operation: string, blocker: Effect.Effect) => void; + readonly queueSessionIds: (...sessionIds: ReadonlyArray) => void; + readonly setSessionDirectory: (sessionId: string, directory: string) => void; + readonly publish: (event: OpenCodeRuntime.OpenCodeEvent) => Effect.Effect; + readonly failEvents: (error: OpenCodeRuntime.OpenCodeRuntimeFailure) => Effect.Effect; +} + +const settings = Schema.decodeSync(OpenCodeSettings)({ + enabled: true, + binaryPath: "fake-opencode2", + customModels: [], +}); -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => - Effect.gen(function* () { - runtimeMock.state.startCalls.push(binaryPath); - const url = "http://127.0.0.1:4301"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls.push(url); - if (runtimeMock.state.closeError) { - throw runtimeMock.state.closeError; - } - }), - ); - return { - url, - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.gen(function* () { - const url = serverUrl ?? "http://127.0.0.1:4301"; - // Always register a finalizer so the closeCalls/closeError probes fire; - // production attaches none for external servers. - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls.push(url); - if (runtimeMock.state.closeError) { - throw runtimeMock.state.closeError; - } - }), - ); - return { - url, - exitCode: null, - external: Boolean(serverUrl), - }; - }), - runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => - ({ - session: { - create: async (input: Record) => { - runtimeMock.state.sessionCreateUrls.push(baseUrl); - runtimeMock.state.sessionCreateInputs.push(input); - runtimeMock.state.authHeaders.push( - serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, - ); - return { data: { id: `${baseUrl}/session` } }; - }, - get: async ({ sessionID }: { sessionID: string }) => { - runtimeMock.state.sessionGetIds.push(sessionID); - // The real client is `throwOnError: true`: non-2xx rejects rather - // than resolving, so missing → 404 throw, transient → 500 throw. - if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { - throw new Error("opencode server error", { cause: { status: 500 } }); - } - if (runtimeMock.state.missingSessionIds.has(sessionID)) { - throw new Error(`Session not found: ${sessionID}`, { - cause: { status: 404, body: { name: "NotFoundError" } }, - }); - } - const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); - return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; - }, - update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { - runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); - return { data: { id: sessionID } }; - }, - fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => { - // Fork clones history into a new session bound to the directory. - const forkedId = `${sessionID}_fork`; - runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) }); - if (directory) { - runtimeMock.state.sessionDirectoryById.set(forkedId, directory); - } - return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; - }, - abort: async ({ sessionID }: { sessionID: string }) => { - runtimeMock.state.abortCalls.push(sessionID); - }, - promptAsync: async (input: unknown) => { - runtimeMock.state.promptCalls.push(input); - if (runtimeMock.state.promptAsyncError) { - throw runtimeMock.state.promptAsyncError; - } - }, - messages: async () => ({ data: runtimeMock.state.messages }), - revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { - runtimeMock.state.revertCalls.push({ - sessionID, - ...(messageID ? { messageID } : {}), +const threadId = ThreadId.make("thread-opencode2-test"); + +function event(id: string, type: string, data: unknown): OpenCodeRuntime.OpenCodeEvent { + return { id, type, data }; +} + +function withHarness( + promptShape: "flat" | "nested", + run: (harness: Harness) => Effect.Effect, + enabled = true, + failFirstHandshake = false, +) { + return Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const events = yield* Queue.unbounded< + OpenCodeRuntime.OpenCodeEvent | OpenCodeRuntime.OpenCodeRuntimeFailure + >(); + const calls: Array = []; + const failedOperations = new Set(); + const missingOperations = new Set(); + const blockedOperations = new Map>(); + const queuedSessionIds: Array = []; + const sessionDirectories = new Map(); + let shouldFailNextEventId = false; + let attachCalls = 0; + const request = (( + method: OpenCodeRuntime.OpenCodeHttpMethod, + path: string, + input: OpenCodeRuntime.OpenCodeRequestInput, + ) => + Effect.gen(function* () { + calls.push({ + method, + path, + operation: input.operation, + ...(input.body !== undefined ? { body: input.body } : {}), + }); + const blocker = blockedOperations.get(input.operation); + if (blocker) { + blockedOperations.delete(input.operation); + yield* blocker; + } + if (failedOperations.delete(input.operation)) { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: input.operation, + reason: "transport", }); - 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: async () => ({ - stream: (async function* () { - for (const event of runtimeMock.state.subscribedEvents) { - yield event; - } - })(), - }), - }, - }) as unknown as ReturnType, - loadOpenCodeInventory: () => - Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", - cause: null, - }), - ), - loadInventoryFromCli: () => - Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", - cause: null, + } + if (missingOperations.delete(input.operation)) { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: input.operation, + reason: "http-status", + status: 404, + }); + } + if (path === "/api/session" || (method === "GET" && path.startsWith("/api/session/"))) { + const id = + path === "/api/session" + ? (queuedSessionIds.shift() ?? "ses_test") + : path.slice("/api/session/".length); + return { + data: { + id, + ...(method === "GET" + ? { location: { directory: sessionDirectories.get(id) ?? process.cwd() } } + : {}), + }, + }; + } + if (path.endsWith("/message")) return { data: [], cursor: {} }; + if (path.endsWith("/prompt")) return { data: { id: "msg_user" } }; + return undefined; + })) as OpenCodeRuntime.OpenCodeConnection["request"]; + const connection: OpenCodeRuntime.OpenCodeConnection = { + url: "http://127.0.0.1:4096", + protocol: { promptShape }, + request, + globalEvents: Stream.make(event("connected", "server.connected", {})).pipe( + Stream.concat( + Stream.fromQueue(events).pipe( + Stream.mapEffect((next) => + OpenCodeRuntime.isOpenCodeRuntimeError(next) || + OpenCodeRuntime.isOpenCodeUnsupportedPreviewError(next) || + OpenCodeRuntime.isOpenCodeCommandNotFoundError(next) || + OpenCodeRuntime.isOpenCodeTimeoutError(next) + ? Effect.fail(next) + : Effect.succeed(next), + ), + ), + ), + ), + }; + const runtime: OpenCodeRuntime.OpenCodeRuntime["Service"] = { + attach: () => + Effect.sync(() => { + attachCalls += 1; + return attachCalls; + }).pipe( + Effect.map((attempt) => + failFirstHandshake && attempt === 1 + ? { + ...connection, + globalEvents: Stream.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "event.subscribe", + reason: "connection-ended", + }), + ), + } + : connection, + ), + ), + }; + const uuidError = PlatformError.systemError({ + _tag: "Unknown", + module: "Crypto", + method: "randomUUIDv4", + description: "UUID generation unavailable", + }); + const testCrypto = { + ...crypto, + randomUUIDv4: Effect.suspend(() => { + if (!shouldFailNextEventId) return crypto.randomUUIDv4; + shouldFailNextEventId = false; + return Effect.fail(uuidError); }), + }; + const adapter = yield* makeOpenCodeAdapter({ ...settings, enabled }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + Effect.provideService(Crypto.Crypto, testCrypto), + Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode2-adapter-" })), + ); + yield* Effect.yieldNow; + return yield* run({ + adapter, + calls, + attachCount: () => attachCalls, + failNextRequest: (operation) => failedOperations.add(operation), + failNextEventId: () => { + shouldFailNextEventId = true; + }, + missNextRequest: (operation) => missingOperations.add(operation), + blockNextRequest: (operation, blocker) => blockedOperations.set(operation, blocker), + queueSessionIds: (...sessionIds) => queuedSessionIds.push(...sessionIds), + setSessionDirectory: (sessionId, directory) => sessionDirectories.set(sessionId, directory), + publish: (next) => Queue.offer(events, next).pipe(Effect.asVoid), + failEvents: (error) => Queue.offer(events, error).pipe(Effect.asVoid), + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); +} + +function collectEvents( + adapter: OpenCodeAdapterShape, + target: Array, + onEvent?: (event: ProviderRuntimeEvent) => Effect.Effect, +) { + return adapter.streamEvents.pipe( + Stream.runForEach((next) => + Effect.sync(() => { + target.push(next); + }).pipe(Effect.andThen(onEvent?.(next) ?? Effect.void)), ), -}; + Effect.forkScoped, + ); +} + +it.effect("matches symlink-equivalent OpenCode session directories", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode2-cwd-" }); + const directory = path.join(root, "directory"); + const alias = path.join(root, "alias"); + yield* fileSystem.makeDirectory(directory); + yield* fileSystem.symlink(directory, alias); + + NodeAssert.equal(yield* isSameOpenCodeDirectory(fileSystem, path, directory, alias), true); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); -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([]), -}); +it.effect("generates unique event ids when an adapter is recreated", () => + withHarness("flat", ({ adapter: first }) => + withHarness("flat", ({ adapter: second }) => + Effect.gen(function* () { + const firstEventId = yield* Deferred.make(); + const secondEventId = yield* Deferred.make(); + yield* collectEvents(first, [], (next) => + next.type === "session.started" + ? Deferred.succeed(firstEventId, next.eventId).pipe(Effect.ignore) + : Effect.void, + ); + yield* collectEvents(second, [], (next) => + next.type === "session.started" + ? Deferred.succeed(secondEventId, next.eventId).pipe(Effect.ignore) + : Effect.void, + ); -// The adapter now receives its settings as a plain argument (the old design -// read from `ServerSettingsService` internally). The test-only -// `ServerSettingsService` below is still kept because other dependencies in -// the layer graph reach for it — but the routing values the assertions -// probe (serverUrl, serverPassword) must be threaded directly through the -// decoded `OpenCodeSettings`. -const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", -}); + yield* first.startSession({ threadId, runtimeMode: "full-access" }); + yield* second.startSession({ threadId, runtimeMode: "full-access" }); -const OpenCodeAdapterTestLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings), -).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - 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", - }, - }, - }), + NodeAssert.notEqual( + yield* Deferred.await(firstEventId), + yield* Deferred.await(secondEventId), + ); + }), + ), ), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), ); -beforeEach(() => { - runtimeMock.reset(); -}); - -const advanceTestClock = (ms: number) => - TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); - -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: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-opencode"), - runtimeMode: "full-access", - }); - - NodeAssert.equal(session.provider, "opencode"); - NodeAssert.equal(session.threadId, "thread-opencode"); - NodeAssert.deepEqual(runtimeMock.state.startCalls, []); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); - NodeAssert.deepEqual(runtimeMock.state.authHeaders, [ - `Basic ${btoa("opencode:secret-password")}`, - ]); - }), - ); - - it.effect("returns a durable resume cursor for a freshly created session", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-cursor"); - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - - // Without a persisted cursor, a session is created and its id is - // surfaced as a resume cursor so the upper layer can persist it. - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "http://127.0.0.1:9999/session", - }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("resumes the persisted OpenCode session instead of creating a new one", () => +it.effect("attaches once and maps native deltas, tools, and terminal events", () => + withHarness("flat", ({ adapter, attachCount, calls, publish }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-resume"); - + const observed: Array = []; + const firstCompleted = yield* Deferred.make(); + const secondCompleted = yield* Deferred.make(); + const publicDelta = yield* Deferred.make(); + let completedCount = 0; + yield* collectEvents(adapter, observed, (next) => + Effect.gen(function* () { + if (next.type === "turn.completed") { + completedCount += 1; + yield* Deferred.succeed( + completedCount === 1 ? firstCompleted : secondCompleted, + undefined, + ).pipe(Effect.ignore); + } + if (next.type === "content.delta" && next.payload.delta === "public") { + yield* Deferred.succeed(publicDelta, undefined).pipe(Effect.ignore); + } + }), + ); + NodeAssert.equal(attachCount(), 0); const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), threadId, runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, - }); - - // The adapter validates the persisted id with session.get and re-adopts - // it — no new session is minted (issue #3604). - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_persisted"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "ses_persisted", - }); - // Resume re-asserts the permission ruleset for the current runtimeMode. - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_persisted"); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("sends follow-up turns to the resumed session id", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-resume-turn"); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, - }); - - const result = yield* adapter.sendTurn({ - threadId, - input: "continue where we left off", - modelSelection: createModelSelection( - ProviderInstanceId.make("opencode"), - "anthropic/sonnet", - ), + cwd: "/tmp/opencode2-project", }); + NodeAssert.equal(attachCount(), 1); + const turn = yield* adapter.sendTurn({ threadId, input: "hello" }); + + const promptCall = calls.find((call) => call.operation === "session.prompt"); + NodeAssert.deepEqual(promptCall?.body, { text: "hello", delivery: "steer" }); + NodeAssert.equal(session.resumeCursor !== undefined, true); + + yield* publish( + event("foreign", "session.next.text.delta", { + sessionID: "ses_foreign", + textID: "txt_foreign", + delta: "ignore me", + }), + ); + yield* publish( + event("reasoning-started", "session.next.reasoning.started", { + sessionID: "ses_test", + reasoningID: "reasoning-1", + }), + ); + yield* publish( + event("reasoning", "session.next.reasoning.delta", { + sessionID: "ses_test", + reasoningID: "reasoning-1", + delta: "thinking", + }), + ); + yield* publish( + event("reasoning-ended", "session.next.reasoning.ended", { + sessionID: "ses_test", + reasoningID: "reasoning-1", + text: "thinking", + }), + ); + yield* publish( + event("text-started", "session.next.text.started", { + sessionID: "ses_test", + textID: "text-1", + }), + ); + yield* publish( + event("text", "session.next.text.delta", { + sessionID: "ses_test", + textID: "text-1", + delta: "answer", + }), + ); + yield* publish( + event("tool-input-started", "session.next.tool.input.started", { + sessionID: "ses_test", + callID: "call-1", + name: "shell", + }), + ); + yield* publish( + event("tool-input-delta", "session.next.tool.input.delta", { + sessionID: "ses_test", + callID: "call-1", + delta: '{"command":', + }), + ); + yield* publish( + event("tool-input-ended", "session.next.tool.input.ended", { + sessionID: "ses_test", + callID: "call-1", + text: '{"command":"pwd"}', + }), + ); + yield* publish( + event("tool-called", "session.next.tool.called", { + sessionID: "ses_test", + callID: "call-1", + tool: "shell", + input: { command: "pwd" }, + }), + ); + yield* publish( + event("tool-progress", "session.next.tool.progress", { + sessionID: "ses_test", + callID: "call-1", + content: [{ type: "text", text: "/tmp" }], + }), + ); + yield* publish( + event("tool-success", "session.next.tool.success", { + sessionID: "ses_test", + callID: "call-1", + content: [{ type: "text", text: "done" }], + }), + ); + yield* publish( + event("settled", "session.next.execution.settled", { + sessionID: "ses_test", + outcome: "success", + }), + ); + yield* Deferred.await(firstCompleted); - // The prompt targets the resumed id, and the turn re-surfaces the cursor. + const deltas = observed.filter((next) => next.type === "content.delta"); NodeAssert.deepEqual( - (runtimeMock.state.promptCalls[0] as { sessionID: string }).sessionID, - "ses_persisted", + deltas.map((next) => next.payload), + [ + { streamKind: "reasoning_text", delta: "thinking" }, + { streamKind: "assistant_text", delta: "answer" }, + ], + ); + NodeAssert.equal( + observed.some((next) => next.type === "item.started"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "item.updated"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "item.completed"), + true, + ); + const toolUpdates = observed.filter( + (next) => next.itemId === "call-1" && next.type === "item.updated", + ); + const endedUpdate = toolUpdates[1]; + NodeAssert.ok(endedUpdate); + NodeAssert.equal((endedUpdate.payload as Record).detail, "pwd"); + NodeAssert.equal( + observed.some((next) => next.type === "turn.completed" && next.turnId === turn.turnId), + true, ); - NodeAssert.deepEqual(result.resumeCursor, { - schemaVersion: 1, - sessionId: "ses_persisted", - }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("falls back to a fresh session when the persisted session is gone", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-stale"); - runtimeMock.state.missingSessionIds.add("ses_stale"); - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_stale" }, - }); - - // get probed the stale id, found nothing, then created a new session and - // emitted a fresh cursor rather than wedging the thread. - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_stale"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "http://127.0.0.1:9999/session", - }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("ignores a malformed or wrong-version resume cursor", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-badcursor"); - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 99, sessionId: "ses_persisted" }, - }); - - // A foreign/stale-shaped cursor is treated as "no resume": never probed, - // a fresh session is created. - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "http://127.0.0.1:9999/session", - }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("surfaces a non-not-found resume probe error instead of silently starting fresh", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-transient"); - // session.get returns a 500 (not a 404) for this id. - runtimeMock.state.transientErrorSessionIds.add("ses_transient"); - const exit = yield* Effect.exit( - adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_transient" }, + yield* adapter.sendTurn({ threadId, input: "public dev alias" }); + NodeAssert.equal(attachCount(), 1); + yield* publish( + event("public-text", "session.text.delta", { + sessionID: "ses_test", + assistantMessageID: "msg_public", + ordinal: 0, + delta: "public", }), ); - - // A transient/transport/auth failure must propagate — NOT be masked as a - // brand-new empty session (the #3604 class of silent context loss). - NodeAssert.equal(Exit.isFailure(exit), true); - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_transient"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); - }), - ); - - it.effect("re-applies the current runtimeMode permissions when resuming", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-perms"); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - // A different runtimeMode than the original create — resume must not - // leave the upstream session on stale permissions. - runtimeMode: "approval-required", - threadId, - resumeCursor: { schemaVersion: 1, sessionId: "ses_perms" }, - }); - - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_perms"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_perms"); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect( - "forks the resumed session into the requested directory instead of losing context", - () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-cwd"); - // The persisted session still exists but was created in another working dir - // (e.g. the thread moved from the project root into a git worktree). - runtimeMock.state.sessionDirectoryById.set("ses_otherdir", "/some/other/worktree"); - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_otherdir" }, - }); - - // A cwd change must not mint an empty session: the adapter forks the - // persisted session into the requested cwd, carrying history forward. - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_otherdir"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); - NodeAssert.equal(runtimeMock.state.forkCalls.length, 1); - NodeAssert.equal(runtimeMock.state.forkCalls[0]?.sessionID, "ses_otherdir"); - NodeAssert.equal(typeof runtimeMock.state.forkCalls[0]?.directory, "string"); - // Permission ruleset re-asserted on the fork for the current runtimeMode. - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_otherdir_fork"); - // Durable cursor now points at the history-complete fork in the new directory. - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "ses_otherdir_fork", - }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("reuses the resumed session when the stored directory differs only lexically", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-samedir"); - // Same working tree, different spelling (trailing slash) — must reuse, - // not fork. - runtimeMock.state.sessionDirectoryById.set("ses_samedir", `${process.cwd()}/`); - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_samedir" }, - }); - - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_samedir"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); - NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "ses_samedir", - }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("fails sendTurn for missing sessions through the typed error channel", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const result = yield* adapter - .sendTurn({ - threadId: asThreadId("thread-opencode-missing-send"), - input: "hello", - attachments: [], - }) - .pipe(Effect.result); - - NodeAssert.equal(result._tag, "Failure"); - NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); - NodeAssert.equal(result.failure.provider, "opencode"); - NodeAssert.equal(result.failure.threadId, "thread-opencode-missing-send"); - }), - ); - - it.effect("fails stopSession for missing sessions through the typed error channel", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const result = yield* adapter - .stopSession(asThreadId("thread-opencode-missing-stop")) - .pipe(Effect.result); - - NodeAssert.equal(result._tag, "Failure"); - NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); - NodeAssert.equal(result.failure.provider, "opencode"); - NodeAssert.equal(result.failure.threadId, "thread-opencode-missing-stop"); - }), - ); - - it.effect("stops a configured-server session without trying to own server lifecycle", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-opencode"), - runtimeMode: "full-access", - }); - - yield* adapter.stopSession(asThreadId("thread-opencode")); - - NodeAssert.deepEqual(runtimeMock.state.startCalls, []); - NodeAssert.deepEqual( - runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), + yield* publish( + event("public-terminal", "session.execution.succeeded", { + sessionID: "ses_test", + }), + ); + yield* Deferred.await(publicDelta); + yield* Deferred.await(secondCompleted); + NodeAssert.equal( + observed.some((next) => next.type === "content.delta" && next.payload.delta === "public"), true, ); + NodeAssert.equal(observed.filter((next) => next.type === "turn.completed").length, 2); }), - ); + ), +); - it.effect("emits one session.exited event when stopping a session", () => +it.effect("maps OpenCode 2 tool calls to friendly titles and structured data", () => + withHarness("flat", ({ adapter, publish }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-stop-event"); - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.take(3), - Stream.runCollect, - Effect.forkChild, + const observed: Array = []; + const completed = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => + next.type === "turn.completed" + ? Deferred.succeed(completed, undefined).pipe(Effect.ignore) + : Effect.void, ); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - yield* adapter.stopSession(threadId); - - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - NodeAssert.deepEqual( - events.map((event) => event.type), - ["session.started", "thread.started", "session.exited"], + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "search" }); + + yield* publish( + event("grep-started", "session.next.tool.input.started", { + sessionID: "ses_test", + callID: "call-grep", + name: "grep", + }), ); - }), - ); + yield* publish( + event("grep-called", "session.next.tool.called", { + sessionID: "ses_test", + callID: "call-grep", + input: { pattern: "toolCall" }, + }), + ); + yield* publish( + event("shell-input-started", "session.next.tool.input.started", { + sessionID: "ses_test", + callID: "call-shell", + name: "shell", + }), + ); + yield* publish( + event("shell-input-ended", "session.next.tool.input.ended", { + sessionID: "ses_test", + callID: "call-shell", + text: '{"command":"git status"}', + }), + ); + yield* publish( + event("shell-success-quiet", "session.next.tool.success", { + sessionID: "ses_test", + callID: "call-shell", + content: [{ type: "text", text: "(no output)\nCommand exited with code 0." }], + }), + ); + yield* publish( + event("settled", "session.next.execution.settled", { + sessionID: "ses_test", + outcome: "success", + }), + ); + yield* Deferred.await(completed); - it.effect("clears session state even when cleanup finalizers throw", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-stop-all-a"), - runtimeMode: "full-access", - }); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-stop-all-b"), - runtimeMode: "full-access", + const itemEvents = observed.filter((next) => next.type.startsWith("item.")); + const payloadOf = (next: ProviderRuntimeEvent | undefined) => + next?.payload as Record; + const grepStarted = itemEvents.find( + (next) => next.itemId === "call-grep" && next.type === "item.started", + ); + NodeAssert.equal(payloadOf(grepStarted).title, "Searching files"); + const grepUpdated = itemEvents.find( + (next) => next.itemId === "call-grep" && next.type === "item.updated", + ); + NodeAssert.equal(payloadOf(grepUpdated).detail, "toolCall"); + NodeAssert.deepEqual(payloadOf(grepUpdated).data, { + toolCallId: "call-grep", + tool: "grep", + input: { pattern: "toolCall" }, }); - - runtimeMock.state.closeError = new Error("close failed"); - // `stopAll` relies on `stopOpenCodeContext`, which is typed as - // never-failing. A throwing finalizer surfaces as a defect — `Effect.exit` - // captures it so the assertions can still run. The key invariant we're - // validating is "the sessions map and close-call probes reflect cleanup - // attempts regardless of finalizer outcome". - yield* Effect.exit(adapter.stopAll()); - const sessions = yield* adapter.listSessions(); - - NodeAssert.deepEqual(runtimeMock.state.closeCalls, [ - "http://127.0.0.1:9999", - "http://127.0.0.1:9999", - ]); - NodeAssert.deepEqual(sessions, []); - }), - ); - - it.effect("completes streamEvents when the adapter scope closes", () => - Effect.gen(function* () { - const scope = yield* Scope.make("sequential"); - let scopeClosed = false; - - try { - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - const context = yield* Layer.buildWithScope(adapterLayer, scope); - const adapter = yield* Effect.service(OpenCodeAdapter).pipe(Effect.provide(context)); - const eventsFiber = yield* adapter.streamEvents.pipe(Stream.runCollect, Effect.forkChild); - - yield* Scope.close(scope, Exit.void); - scopeClosed = true; - - const exit = yield* Fiber.await(eventsFiber).pipe(Effect.timeout("1 second")); - NodeAssert.equal(Exit.hasInterrupts(exit), true); - } finally { - if (!scopeClosed) { - yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); - } - } + const shellUpdated = itemEvents.find( + (next) => next.itemId === "call-shell" && next.type === "item.updated", + ); + NodeAssert.equal(payloadOf(shellUpdated).title, "Running command"); + NodeAssert.equal(payloadOf(shellUpdated).detail, "git status"); + NodeAssert.equal( + (payloadOf(shellUpdated).data as Record).command, + "git status", + ); + const shellCompleted = itemEvents.find( + (next) => next.itemId === "call-shell" && next.type === "item.completed", + ); + // "(no output)\nCommand exited with code 0." is shell padding; quiet + // commands complete without detail. + NodeAssert.equal(payloadOf(shellCompleted).detail, undefined); + NodeAssert.equal(payloadOf(shellCompleted).status, "completed"); }), - ); + ), +); - it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => +it.effect("keeps non-zero exit codes in OpenCode 2 tool output detail", () => + withHarness("flat", ({ adapter, publish }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("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: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", - }, - }) - .pipe(Effect.flip); - const sessions = yield* adapter.listSessions(); + const observed: Array = []; + const completed = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => + next.type === "turn.completed" + ? Deferred.succeed(completed, undefined).pipe(Effect.ignore) + : Effect.void, + ); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "run" }); + yield* publish( + event("failed-shell", "session.next.tool.failed", { + sessionID: "ses_test", + callID: "call-fail", + name: "shell", + content: [{ type: "text", text: "(no output)\nCommand exited with code 1." }], + }), + ); + yield* publish( + event("settled", "session.next.execution.settled", { + sessionID: "ses_test", + outcome: "success", + }), + ); + yield* Deferred.await(completed); - NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); - if (error._tag !== "ProviderAdapterRequestError") { - throw new Error("Unexpected error type"); - } - NodeAssert.equal(error.detail, "prompt failed"); + const failed = observed.find((next) => next.itemId === "call-fail"); + NodeAssert.ok(failed); NodeAssert.equal( - error.message, - "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", + (failed.payload as Record).detail, + "Command exited with code 1.", ); - NodeAssert.equal(sessions.length, 1); - NodeAssert.equal(sessions[0]?.status, "ready"); - NodeAssert.equal(sessions[0]?.activeTurnId, undefined); - NodeAssert.equal(sessions[0]?.lastError, "prompt failed"); }), - ); + ), +); - it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => +it.effect("translates form answers into OpenCode 2 option values", () => + withHarness("nested", ({ adapter, calls, publish }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-steer"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); + const observed: Array = []; + const formOpened = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => + next.type === "user-input.requested" + ? Deferred.succeed(formOpened, undefined).pipe(Effect.ignore) + : Effect.void, + ); + yield* adapter.startSession({ threadId, runtimeMode: "approval-required" }); + yield* publish( + event("form", "form.created", { + form: { + id: "frm_values", + sessionID: "ses_test", + title: "Choices", + fields: [ + { + key: "choice", + type: "string", + title: "Choice", + description: "Pick one", + options: [ + { value: "allow", label: "Allow search" }, + { value: "choose", label: "Choose another provider" }, + { value: "disable", label: "Disable web search" }, + ], + }, + { + key: "mode", + type: "string", + title: "Mode", + description: "Pick a mode", + options: [ + { value: "auto", label: "Auto" }, + { value: "manual", label: "Manual" }, + ], + }, + { + key: "notes", + type: "string", + title: "Notes", + description: "Add notes", + }, + ], + }, + }), + ); + yield* Deferred.await(formOpened); - const turn = yield* adapter.sendTurn({ - threadId, - input: "run 5 commands", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", - }, + // Labels map to values, display indices ("2") map to their option. + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("frm_values"), { + choice: "Choose another provider", + mode: "2", + notes: " indented text ", }); - // Steer: OpenCode queues the prompt into the busy session, so the - // active turn id is reused instead of opening a new turn. - const steeredTurn = yield* adapter.sendTurn({ - threadId, - input: "actually run 15", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", + NodeAssert.deepEqual( + calls.find((call) => call.operation === "form.reply"), + { + method: "POST", + path: "/api/session/ses_test/form/frm_values/reply", + operation: "form.reply", + body: { + answer: { + choice: "choose", + mode: "manual", + notes: " indented text ", + }, + }, }, - }); - NodeAssert.equal(String(steeredTurn.turnId), String(turn.turnId)); - - const sessions = yield* adapter.listSessions(); - const session = sessions.find((entry) => entry.threadId === threadId); - NodeAssert.equal(session?.status, "running"); - NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); - NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + ); }), - ); + ), +); - it.effect("keeps the running turn when a steer prompt fails", () => +it.effect("replies to permissions and forms and interrupts through native routes", () => + withHarness("nested", ({ adapter, calls, publish }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-steer-failure"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "run 5 commands", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", - }, - }); - - runtimeMock.state.promptAsyncError = new Error("steer failed"); - const error = yield* adapter - .sendTurn({ - threadId, - input: "actually run 15", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", - }, - }) - .pipe(Effect.flip); - - // The original turn keeps running — only the steer prompt failed. - NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); - const sessions = yield* adapter.listSessions(); - const session = sessions.find((entry) => entry.threadId === threadId); - NodeAssert.equal(session?.status, "running"); - NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); - }), - ); - - it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-custom-instance"), - runtimeMode: "full-access", - }); - - yield* adapter.sendTurn({ - threadId: asThreadId("thread-custom-instance"), - input: "Fix it", - modelSelection: createModelSelection( - ProviderInstanceId.make("opencode_zen"), - "anthropic/claude-sonnet-4-5", - [ - { id: "agent", value: "github-copilot" }, - { id: "variant", value: "high" }, - ], - ), - }); - - NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { - sessionID: "http://127.0.0.1:9999/session", - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - }, - agent: "github-copilot", - variant: "high", - parts: [{ type: "text", text: "Fix it" }], - }); - }).pipe(Effect.provide(adapterLayer)); - }); - - it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-custom-instance-fallback-model"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - modelSelection: createModelSelection( - ProviderInstanceId.make("opencode_zen"), - "anthropic/claude-sonnet-4-5", - ), - }); - - yield* adapter.sendTurn({ - threadId, - input: "Fix it", - }); - - NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { - sessionID: "http://127.0.0.1:9999/session", - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - }, - parts: [{ type: "text", text: "Fix it" }], - }); - }).pipe(Effect.provide(adapterLayer)); - }); - - it.effect("rejects sendTurn model selections for another instance id", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-custom-instance-wrong-selection"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", + const observed: Array = []; + const permissionOpened = yield* Deferred.make(); + const formOpened = yield* Deferred.make(); + const turnAborted = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => { + if (next.type === "request.opened") { + return Deferred.succeed(permissionOpened, undefined).pipe(Effect.ignore); + } + if (next.type === "user-input.requested") { + return Deferred.succeed(formOpened, undefined).pipe(Effect.ignore); + } + if (next.type === "turn.aborted") { + return Deferred.succeed(turnAborted, undefined).pipe(Effect.ignore); + } + return Effect.void; }); + yield* adapter.startSession({ threadId, runtimeMode: "approval-required" }); + yield* adapter.sendTurn({ threadId, input: "inspect" }); - const error = yield* adapter - .sendTurn({ - threadId, - input: "Fix it", - modelSelection: createModelSelection( - ProviderInstanceId.make("opencode"), - "anthropic/claude-sonnet-4-5", - ), - }) - .pipe(Effect.flip); + const promptCall = calls.find((call) => call.operation === "session.prompt"); + NodeAssert.deepEqual(promptCall?.body, { + prompt: { text: "inspect" }, + delivery: "steer", + }); - NodeAssert.equal(error._tag, "ProviderAdapterValidationError"); - if (error._tag !== "ProviderAdapterValidationError") { - throw new Error("Unexpected error type"); - } - NodeAssert.equal( - error.issue, - "OpenCode model selection is bound to instance 'opencode', expected 'opencode_zen'.", + yield* publish( + event("permission", "permission.asked", { + id: "per_test", + sessionID: "ses_test", + action: "read", + resources: [".env"], + }), + ); + yield* publish( + event("form", "form.created", { + form: { + id: "frm_test", + sessionID: "ses_test", + title: "Choose", + fields: [ + { + key: "choice", + type: "string", + title: "Choice", + description: "Pick one", + options: [{ value: "one", label: "One" }], + }, + ], + }, + }), ); - NodeAssert.deepEqual(runtimeMock.state.promptCalls, []); - }).pipe(Effect.provide(adapterLayer)); - }); + yield* Deferred.await(permissionOpened); + yield* Deferred.await(formOpened); - 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: ProviderDriverKind.make("opencode"), + yield* adapter.respondToRequest( threadId, - runtimeMode: "full-access", + ApprovalRequestId.make("per_test"), + "acceptForSession", + ); + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("frm_test"), { + choice: "one", }); + yield* adapter.interruptTurn(threadId); + yield* Deferred.await(turnAborted); - runtimeMock.state.messages = [ + NodeAssert.deepEqual( + calls.find((call) => call.operation === "permission.reply"), { - info: { id: "assistant-1", role: "assistant" }, - parts: [], + method: "POST", + path: "/api/session/ses_test/permission/per_test/reply", + operation: "permission.reply", + body: { reply: "always" }, }, + ); + NodeAssert.deepEqual( + calls.find((call) => call.operation === "form.reply"), { - info: { id: "assistant-2", role: "assistant" }, - parts: [], + method: "POST", + path: "/api/session/ses_test/form/frm_test/reply", + operation: "form.reply", + body: { answer: { choice: "one" } }, }, - ]; - - const snapshot = yield* adapter.rollbackThread(threadId, 2); - - NodeAssert.deepEqual(runtimeMock.state.revertCalls, [ - { sessionID: "http://127.0.0.1:9999/session" }, - ]); - NodeAssert.deepEqual(snapshot.turns, []); - }), - ); - - it.effect("classifies a confirmed not-found across the shapes the SDK/runtime can produce", () => - Effect.sync(() => { - // The real production shape: runOpenCodeSdk wraps the thrown Error - // (cause = { body, status }) under OpenCodeRuntimeError. - const wrappedError = new Error("Session not found: ses_x", { - cause: { body: { name: "NotFoundError" }, status: 404 }, - }); + ); NodeAssert.equal( - isOpenCodeNotFound({ - _tag: "OpenCodeRuntimeError", - operation: "session.get", - detail: "Session not found: ses_x", - cause: wrappedError, - }), + calls.some( + (call) => + call.operation === "session.interrupt" && + call.path === "/api/session/ses_test/interrupt", + ), true, ); - - // 404 expressed only via response.status (the bot's flagged shape). - NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 404 } } }), true); - // 404 via a bare numeric status / statusCode. - NodeAssert.equal(isOpenCodeNotFound(new Error("x", { cause: { status: 404 } })), true); - NodeAssert.equal(isOpenCodeNotFound({ statusCode: 404 }), true); - // OpenCode NotFoundError body name with no status. - NodeAssert.equal(isOpenCodeNotFound({ body: { name: "NotFoundError" } }), true); - - // NOT a miss: only structured signals count, never free text. A non-404 - // error whose message/detail merely contains "not found" must propagate, - // not be misread as a missing session and silently start fresh. NodeAssert.equal( - isOpenCodeNotFound(new Error("upstream provider not found", { cause: { status: 500 } })), - false, + observed.some((next) => next.type === "request.opened"), + true, ); - NodeAssert.equal(isOpenCodeNotFound({ detail: "status=500 body={...not found...}" }), false); - // An explicit non-404 status seals its subtree: a 500 whose serialized - // body echoes a NotFoundError name — or that is itself named - // *NotFound* — is a real failure, never a miss. - NodeAssert.equal(isOpenCodeNotFound({ status: 500, body: { name: "NotFoundError" } }), false); - NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError", status: 500 }), false); - // A "NotFound"-flavored name that isn't OpenCode's exact `NotFoundError` - // is not a confirmed miss even without a sealing status. - NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError" }), false); - NodeAssert.equal(isOpenCodeNotFound({ cause: { name: "ProviderNotFoundError" } }), false); NodeAssert.equal( - isOpenCodeNotFound( - new Error("x", { cause: { status: 502, body: { name: "NotFoundError" } } }), - ), - false, + observed.some((next) => next.type === "user-input.requested"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "turn.aborted"), + true, ); - // Other transient/auth/network failures must propagate too. - NodeAssert.equal(isOpenCodeNotFound(new Error("boom", { cause: { status: 500 } })), false); - NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 401 } } }), false); - NodeAssert.equal(isOpenCodeNotFound(new Error("network error (no response)")), false); - NodeAssert.equal(isOpenCodeNotFound(undefined), false); }), - ); + ), +); - it.effect("treats lexically or physically identical directories as the same", () => +it.effect("auto-accepts native permissions and falls back to approval on failure", () => + withHarness("flat", ({ adapter, attachCount, calls, failNextRequest, publish }) => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sameDirectory = (left: string, right: string) => - isSameOpenCodeDirectory(fileSystem, path, left, right); - - // Lexical-only differences (trailing slash, dot segments) short-circuit - // without touching the filesystem — the paths need not exist. - NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true); - NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true); - // Nonexistent paths degrade to the lexical comparison instead of failing. - NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false); - - // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to - // the directory it points at, so the two spellings compare equal. - const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" }); - const real = path.join(base, "real"); - const link = path.join(base, "link"); - yield* fileSystem.makeDirectory(real); - yield* fileSystem.symlink(real, link); - NodeAssert.equal(yield* sameDirectory(link, real), true); - NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false); - }).pipe(Effect.scoped), - ); - - it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => - Effect.sync(() => { - const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); - const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); - const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); + const observed: Array = []; + const permissionResolved = yield* Deferred.make(); + const fallbackOpened = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => { + if (next.type === "request.resolved") { + return Deferred.succeed(permissionResolved, undefined).pipe(Effect.ignore); + } + if (next.type === "request.opened") { + return Deferred.succeed(fallbackOpened, undefined).pipe(Effect.ignore); + } + return Effect.void; + }); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "edit this" }); + + yield* publish( + event("permission", "permission.asked", { + id: "per_test", + sessionID: "ses_test", + action: "edit", + resources: ["src/index.ts"], + }), + ); + yield* publish( + event("permission-replied", "permission.replied", { + requestID: "per_test", + sessionID: "ses_test", + reply: "once", + }), + ); + yield* Deferred.await(permissionResolved); NodeAssert.deepEqual( - [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], - ["Hello", "lo world", ""], + calls.find((call) => call.operation === "permission.reply"), + { + method: "POST", + path: "/api/session/ses_test/permission/per_test/reply", + operation: "permission.reply", + body: { reply: "once" }, + }, + ); + NodeAssert.equal( + observed.some((next) => next.type === "request.opened"), + false, + ); + failNextRequest("permission.reply"); + yield* publish( + event("fallback-permission", "permission.asked", { + id: "per_fallback", + sessionID: "ses_test", + action: "edit", + resources: ["src/other.ts"], + }), ); - NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); + yield* Deferred.await(fallbackOpened); + + NodeAssert.equal(attachCount(), 1); + NodeAssert.equal(calls.filter((call) => call.operation === "permission.reply").length, 2); }), - ); + ), +); - it.effect("does not strip coincidental prefix overlap from OpenCode part deltas", () => +it.effect("resumes existing sessions and detaches without stop or delete requests", () => + withHarness("flat", ({ adapter, calls }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-raw-delta"); - const part = { - id: "part-raw-delta", - sessionID: "http://127.0.0.1:9999/session", - messageID: "msg-raw-delta", - type: "text", - text: "A B", - time: { start: 1 }, - }; - runtimeMock.state.subscribedEvents = [ - { - type: "message.updated", - properties: { - sessionID: "http://127.0.0.1:9999/session", - info: { - id: "msg-raw-delta", - role: "assistant", - }, - }, - }, - { - type: "message.part.updated", - properties: { - sessionID: "http://127.0.0.1:9999/session", - part, - time: 1, - }, - }, - { - type: "message.part.delta", - properties: { - sessionID: "http://127.0.0.1:9999/session", - messageID: "msg-raw-delta", - partID: "part-raw-delta", - field: "text", - delta: "Bonus", - }, - }, + yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/claude-sonnet", + [{ id: "variant", value: "low" }], + ), + }); + NodeAssert.equal( + calls.some((call) => call.method === "GET" && call.path === "/api/session/ses_existing"), + true, + ); + yield* adapter.sendTurn({ + threadId, + input: "change effort", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/claude-sonnet", + [{ id: "variant", value: "high" }], + ), + }); + NodeAssert.deepEqual( + calls.findLast((call) => call.operation === "session.switchModel")?.body, { - type: "message.part.updated", - properties: { - sessionID: "http://127.0.0.1:9999/session", - part: { - ...part, - text: "A BBonus", - time: { start: 1, end: 2 }, - }, - time: 2, + model: { + providerID: "anthropic", + id: "claude-sonnet", + variant: "high", }, }, - ]; - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.take(5), - Stream.runCollect, - Effect.forkChild, ); - + yield* adapter.stopSession(threadId); yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, + threadId: ThreadId.make("thread-opencode2-second"), runtimeMode: "full-access", }); + yield* adapter.sendTurn({ + threadId: ThreadId.make("thread-opencode2-second"), + input: "active", + }); + yield* adapter.stopAll(); - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - const deltas = events.filter((event) => event.type === "content.delta"); - NodeAssert.deepEqual( - deltas.map((event) => (event.type === "content.delta" ? event.payload.delta : "")), - ["A B", "Bonus"], + NodeAssert.equal( + calls.some((call) => call.method === "DELETE"), + false, ); - NodeAssert.equal(events.at(-1)?.type, "item.completed"); - const completed = events.at(-1); - if (completed?.type === "item.completed") { - NodeAssert.equal(completed.payload.detail, "A BBonus"); - } + NodeAssert.equal( + calls.some((call) => call.path.includes("/service/stop")), + false, + ); + NodeAssert.equal(calls.filter((call) => call.operation === "session.interrupt").length, 2); + NodeAssert.deepEqual(yield* adapter.listSessions(), []); }), - ); + ), +); - it.effect("lets OpenCode own session title generation and emits title metadata updates", () => +it.effect("starts a fresh native session when a stored resume cursor no longer exists", () => + withHarness("flat", ({ adapter, attachCount, calls, missNextRequest }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-title-sync"); - runtimeMock.state.subscribedEvents = [ - { - type: "session.updated", - properties: { - info: { - id: "http://127.0.0.1:9999/session", - title: "Investigate OpenCode title sync", - }, - }, - }, - ]; - - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.take(3), - Stream.runCollect, - Effect.forkChild, - ); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), + missNextRequest("session.get"); + const session = yield* adapter.startSession({ threadId, runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_from_v1" }, }); - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); - NodeAssert.equal("title" in (runtimeMock.state.sessionCreateInputs[0] ?? {}), false); - - const metadataUpdated = events.find((event) => event.type === "thread.metadata.updated"); - NodeAssert.ok(metadataUpdated); - if (metadataUpdated.type === "thread.metadata.updated") { - NodeAssert.equal(metadataUpdated.payload.name, "Investigate OpenCode title sync"); - } + NodeAssert.deepEqual( + calls + .filter((call) => call.operation === "session.get" || call.operation === "session.create") + .map((call) => [call.method, call.path]), + [ + ["GET", "/api/session/ses_from_v1"], + ["POST", "/api/session"], + ], + ); + NodeAssert.deepEqual(session.resumeCursor, { schemaVersion: 1, sessionId: "ses_test" }); + NodeAssert.equal(attachCount(), 1); }), - ); + ), +); - it.effect("passes the thread title to session.create when provided", () => +it.effect("starts a fresh native session when the stored session uses another directory", () => + withHarness("flat", ({ adapter, calls, queueSessionIds, setSessionDirectory }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-title-provided"); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), + setSessionDirectory("ses_from_v1", "/tmp/opencode2-old"); + queueSessionIds("ses_fresh"); + const session = yield* adapter.startSession({ threadId, runtimeMode: "full-access", - title: "Investigate reconnect failures", + cwd: "/tmp/opencode2-new", + resumeCursor: { schemaVersion: 1, sessionId: "ses_from_v1" }, }); - NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); - NodeAssert.equal( - runtimeMock.state.sessionCreateInputs[0]?.title, - "Investigate reconnect failures", + NodeAssert.deepEqual( + calls + .filter((call) => call.operation === "session.get" || call.operation === "session.create") + .map((call) => [call.method, call.path]), + [ + ["GET", "/api/session/ses_from_v1"], + ["POST", "/api/session"], + ], ); + NodeAssert.deepEqual(calls.find((call) => call.operation === "session.create")?.body, { + location: { directory: "/tmp/opencode2-new" }, + }); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_fresh", + }); }), - ); + ), +); - it.effect("does not mirror OpenCode's default placeholder session titles", () => +it.effect("serializes concurrent starts for the same thread", () => + withHarness("flat", ({ adapter, blockNextRequest, calls, queueSessionIds }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-placeholder-title"); - runtimeMock.state.subscribedEvents = [ - { - type: "session.updated", - properties: { - info: { - id: "http://127.0.0.1:9999/session", - title: "New session - 2026-08-09T10:20:30.456Z", - }, - }, - }, - { - type: "session.updated", - properties: { - info: { - id: "http://127.0.0.1:9999/session", - title: "Investigate reconnect failures", - }, - }, - }, - ]; - - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.take(3), - Stream.runCollect, - Effect.forkChild, + const firstCreateStarted = yield* Deferred.make(); + const releaseFirstCreate = yield* Deferred.make(); + queueSessionIds("ses_first", "ses_second"); + blockNextRequest( + "session.create", + Deferred.succeed(firstCreateStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstCreate)), + ), ); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); + const first = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.forkScoped); + yield* Deferred.await(firstCreateStarted); + const second = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - const metadataUpdated = events.filter((event) => event.type === "thread.metadata.updated"); - NodeAssert.equal(metadataUpdated.length, 1); - if (metadataUpdated[0]?.type === "thread.metadata.updated") { - NodeAssert.equal(metadataUpdated[0].payload.name, "Investigate reconnect failures"); - } + NodeAssert.equal(calls.filter((call) => call.operation === "session.create").length, 1); + + yield* Deferred.succeed(releaseFirstCreate, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + const sessions = yield* adapter.listSessions(); + NodeAssert.equal(sessions.length, 1); + NodeAssert.deepEqual(sessions[0]?.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_second", + }); + yield* adapter.stopSession(threadId); + NodeAssert.deepEqual(yield* adapter.listSessions(), []); }), - ); + ), +); - 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", - }, - }, - }, - ]; +it.effect("does not attach when OpenCode 2 is disabled", () => + withHarness( + "flat", + ({ adapter, attachCount }) => + Effect.gen(function* () { + const result = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit); + NodeAssert.equal(Exit.isFailure(result), true); + NodeAssert.equal(attachCount(), 0); + }), + false, + ), +); - 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, - }; +it.effect("retries after the initial event handshake fails", () => + withHarness( + "flat", + ({ adapter, attachCount }) => + Effect.gen(function* () { + const first = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit); + NodeAssert.equal(Exit.isFailure(first), true); + + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + NodeAssert.equal(attachCount(), 2); + }), + true, + true, + ), +); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { - nativeEventLogger, +it.effect("reconnects after the native event stream fails", () => + withHarness("flat", ({ adapter, attachCount, failEvents }) => + Effect.gen(function* () { + const runtimeError = yield* Deferred.make(); + const secondTurnStarted = yield* Deferred.make(); + const observed: Array = []; + let startedTurns = 0; + yield* collectEvents(adapter, observed, (next) => + Effect.gen(function* () { + if (next.type === "runtime.error") { + yield* Deferred.succeed(runtimeError, undefined).pipe(Effect.ignore); + } + if (next.type === "turn.started") { + startedTurns += 1; + if (startedTurns === 2) { + yield* Deferred.succeed(secondTurnStarted, undefined).pipe(Effect.ignore); + } + } }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - 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: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-native-log"), - runtimeMode: "full-access", - }); - yield* advanceTestClock(10); - return started; - }).pipe(Effect.provide(adapterLayer)); - - NodeAssert.equal(session.threadId, "thread-native-log"); - NodeAssert.equal(nativeEvents.length, 1); - NodeAssert.equal( - nativeEvents.some((record) => record.event?.provider === "opencode"), - true, + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ threadId, input: "before disconnect" }); + yield* failEvents( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "event.subscribe", + reason: "connection-ended", + }), ); + yield* Deferred.await(runtimeError); + NodeAssert.equal((yield* adapter.listSessions())[0]?.activeTurnId, undefined); NodeAssert.equal( - nativeEvents.some( - (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session", + observed.some( + (event) => + event.type === "turn.completed" && + event.turnId === firstTurn.turnId && + event.payload.state === "failed", ), true, ); - NodeAssert.equal( - nativeEvents.some((record) => record.event?.threadId === "thread-native-log"), - true, + + yield* adapter.sendTurn({ threadId, input: "reconnect" }); + yield* Deferred.await(secondTurnStarted); + NodeAssert.equal(attachCount(), 2); + NodeAssert.equal(observed.filter((event) => event.type === "turn.started").length, 2); + }), + ), +); + +it.effect("reconnects after native event processing fails", () => + withHarness("flat", ({ adapter, attachCount, failNextEventId, publish }) => + Effect.gen(function* () { + const runtimeError = yield* Deferred.make(); + const observed: Array = []; + yield* collectEvents(adapter, observed, (next) => + next.type === "runtime.error" + ? Deferred.succeed(runtimeError, undefined).pipe(Effect.ignore) + : Effect.void, ); - NodeAssert.equal( - nativeEvents.some((record) => record.event?.type === "message.updated"), - true, + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ threadId, input: "before event failure" }); + failNextEventId(); + yield* publish( + event("text", "session.text.delta", { + sessionID: "ses_test", + assistantMessageID: "msg_assistant", + ordinal: 0, + delta: "partial", + }), ); + yield* Deferred.await(runtimeError); + + NodeAssert.equal((yield* adapter.listSessions())[0]?.activeTurnId, undefined); NodeAssert.equal( - nativeThreadIds.every((threadId) => threadId === "thread-native-log"), + observed.some( + (next) => + next.type === "turn.completed" && + next.turnId === firstTurn.turnId && + next.payload.state === "failed", + ), true, ); + yield* adapter.sendTurn({ threadId, input: "after event failure" }); + NodeAssert.equal(attachCount(), 2); }), - ); + ), +); - it.effect("keeps the event pump alive when native event logging fails", () => +it.effect("restores the regular agent after a plan turn", () => + withHarness("flat", ({ adapter, calls }) => 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", - }, - }, - }, - ]; + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "plan this", + interactionMode: "plan", + }); + yield* adapter.sendTurn({ threadId, input: "build this" }); - const nativeEventLogger = { - filePath: "memory://opencode-native-events", - write: () => Effect.die(new Error("native log write failed")), - close: () => Effect.void, - }; + NodeAssert.deepEqual( + calls.filter((call) => call.operation === "session.switchAgent").map((call) => call.body), + [{ agent: "plan" }, { agent: "build" }], + ); + }), + ), +); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { - nativeEventLogger, - }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - 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), +it.effect("moves a resumed provider session to its latest T3 thread", () => + withHarness("flat", ({ adapter }) => + Effect.gen(function* () { + const firstThread = ThreadId.make("thread-opencode2-first"); + const secondThread = ThreadId.make("thread-opencode2-second"); + const resumeCursor = { schemaVersion: 1, sessionId: "ses_shared" } as const; + yield* adapter.startSession({ + threadId: firstThread, + runtimeMode: "full-access", + resumeCursor, + }); + yield* adapter.startSession({ + threadId: secondThread, + runtimeMode: "full-access", + resumeCursor, + }); + + NodeAssert.deepEqual( + (yield* adapter.listSessions()).map((session) => session.threadId), + [secondThread], ); + yield* adapter.stopSession(secondThread); + NodeAssert.deepEqual(yield* adapter.listSessions(), []); + }), + ), +); - // Capture closeCalls *inside* the provided layer scope: the adapter's - // layer finalizer now tears down any live sessions when the layer - // closes (which is exactly what we want for leak prevention), so - // inspecting closeCalls after `Effect.provide` completes would observe - // the teardown — not the behavior under test. We care that the event - // pump kept the session alive while logging was failing. - const { sessions, closeCallsDuringRun } = yield* Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-native-log-failure"), +it.effect("keeps the existing session when resumed setup fails", () => + withHarness("flat", ({ adapter, failNextRequest }) => + Effect.gen(function* () { + const firstThread = ThreadId.make("thread-opencode2-first"); + const secondThread = ThreadId.make("thread-opencode2-second"); + const resumeCursor = { schemaVersion: 1, sessionId: "ses_shared" } as const; + yield* adapter.startSession({ + threadId: firstThread, + runtimeMode: "full-access", + resumeCursor, + }); + failNextRequest("session.switchModel"); + const error = yield* Effect.flip( + adapter.startSession({ + threadId: secondThread, runtimeMode: "full-access", - }); - yield* advanceTestClock(10); - return { - sessions: yield* adapter.listSessions(), - closeCallsDuringRun: [...runtimeMock.state.closeCalls], - }; - }).pipe(Effect.provide(adapterLayer)); + resumeCursor, + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/claude-sonnet", + ), + }), + ); - NodeAssert.equal(sessions.length, 1); - NodeAssert.equal(sessions[0]?.threadId, "thread-native-log-failure"); - NodeAssert.deepEqual(closeCallsDuringRun, []); + NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + NodeAssert.equal(error.method, "session.switchModel"); + } + NodeAssert.deepEqual( + (yield* adapter.listSessions()).map((session) => session.threadId), + [firstThread], + ); }), - ); -}); + ), +); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..c6b4abd37d74 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeURL from "node:url"; + import { EventId, type OpenCodeSettings, @@ -12,46 +15,33 @@ import { TurnId, type UserInputQuestion, } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { - buildOpenCodePermissionRules, - OpenCodeRuntime, - OpenCodeRuntimeError, - openCodeQuestionId, - openCodeRuntimeErrorDetail, - parseOpenCodeModelSlug, - runOpenCodeSdk, - toOpenCodeFileParts, - toOpenCodePermissionReply, - toOpenCodeQuestionAnswers, - type OpenCodeServerConnection, -} from "../opencodeRuntime.ts"; -import * as Option from "effect/Option"; +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import type { ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = ProviderDriverKind.make("opencode"); @@ -60,88 +50,130 @@ const PROVIDER = ProviderDriverKind.make("opencode"); * shape changes so stale-shaped cursors written by older builds are ignored * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION). */ -const OPENCODE_RESUME_VERSION = 1 as const; +const RESUME_VERSION = 1 as const; + +const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); +const UnknownRecordArraySchema = Schema.Array(UnknownRecordSchema); +const SessionResponseSchema = Schema.Struct({ + data: Schema.Struct({ + id: Schema.String, + location: Schema.optionalKey( + Schema.Struct({ + directory: Schema.String, + }), + ), + }), +}); +const MessagesResponseSchema = Schema.Struct({ + data: Schema.Array(Schema.Unknown), +}); -/** - * Decode a persisted resume cursor into the upstream `ses_…` id. Anything - * that isn't a current-version cursor with a non-empty id means "no resume" - * rather than an error. Re-adopting the session id IS the resume mechanism — - * OpenCode scopes a conversation's history by session id. - */ -function parseOpenCodeResume(raw: unknown): { readonly sessionId: string } | undefined { - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { - return undefined; - } - const record = raw as Record; - if (record.schemaVersion !== OPENCODE_RESUME_VERSION) { - return undefined; - } - if (typeof record.sessionId !== "string" || record.sessionId.trim().length === 0) { - return undefined; - } - return { sessionId: record.sessionId.trim() }; +const decodeUnknownRecord = Schema.decodeUnknownOption(UnknownRecordSchema); +const decodeUnknownRecordArray = Schema.decodeUnknownOption(UnknownRecordArraySchema); +const decodeUnknownJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +interface OpenCodeTurnSnapshot { + readonly id: TurnId; + readonly items: Array; } -/** - * Whether an error definitively reports a missing session. Only a confirmed - * miss may silently start a fresh session; any other failure (the SDK client - * is `throwOnError: true`, so `session.get` rejects on every non-2xx) must - * propagate, or a transient blip resets a live thread to an empty one — the - * #3604 silent context loss. Decides on structured signals only, never free - * text: a numeric 404 or the exact `NotFoundError` name, found via a bounded walk - * over `cause`/`body`/`error`/`data`. An explicit non-404 status seals its - * subtree so a wrapped "NotFound" name can't reclassify a real failure. - * Exported for unit testing. - */ -export function isOpenCodeNotFound(cause: unknown): boolean { - const seen = new Set(); - const queue: Array = [cause]; - for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { - const node = queue.shift(); - if (node === null || typeof node !== "object" || seen.has(node)) { - continue; - } - seen.add(node); - const record = node as Record; - - const response = record.response; - const statuses = [ - record.status, - record.statusCode, - response !== null && typeof response === "object" - ? (response as { readonly status?: unknown }).status - : undefined, - ].filter((status): status is number => typeof status === "number"); - if (statuses.includes(404)) { - return true; - } - if (statuses.length > 0) { - continue; - } +interface OpenCodeToolCallState { + readonly name: string; + input?: unknown; + inputText?: string; +} - const name = record.name; - if (typeof name === "string" && name.toLowerCase() === "notfounderror") { - return true; - } +interface OpenCodeSessionContext { + session: ProviderSession; + readonly providerSessionId: string; + readonly pendingPermissions: Set; + readonly pendingForms: Map>>; + readonly toolsByCallId: Map; + activeTurnId: TurnId | undefined; + currentModel: string | undefined; + currentVariant: string | undefined; + currentAgent: string | undefined; + regularAgent: string; +} - for (const key of ["cause", "body", "error", "data"] as const) { - if (record[key] !== undefined) { - queue.push(record[key]); - } - } +export interface OpenCodeAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface ModelRef { + readonly providerID: string; + readonly id: string; + readonly variant?: string; +} + +interface EventBaseInput { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string; + readonly requestId?: string; + readonly raw?: OpenCodeRuntime.OpenCodeEvent; +} + +interface OpenCodeActiveConnection { + readonly connection: OpenCodeRuntime.OpenCodeConnection; + readonly interrupt: Effect.Effect; +} + +function recordFromUnknown(value: unknown): Readonly> | undefined { + return Option.getOrUndefined(decodeUnknownRecord(value)); +} + +function recordsFromUnknown(value: unknown): ReadonlyArray>> { + return Option.getOrElse(decodeUnknownRecordArray(value), () => []); +} + +function stringField( + record: Readonly> | undefined, + ...keys: ReadonlyArray +): string | undefined { + for (const key of keys) { + const value = record?.[key]; + if (typeof value === "string" && value.trim().length > 0) return value; } - return false; + return undefined; +} + +function eventData( + event: OpenCodeRuntime.OpenCodeEvent, +): Readonly> | undefined { + const outer = recordFromUnknown(event.data); + return recordFromUnknown(outer?.properties) ?? outer; +} + +function eventSessionId(event: OpenCodeRuntime.OpenCodeEvent): string | undefined { + const data = eventData(event); + const form = recordFromUnknown(data?.form) ?? recordFromUnknown(data?.info); + return ( + stringField(data, "sessionID", "sessionId") ?? + stringField(form, "sessionID", "sessionId") ?? + event.durable?.aggregateID + ); +} + +function normalizeEventType(type: string): string { + const withoutVersion = type.replace(/\.\d+$/u, ""); + return withoutVersion.startsWith("session.next.") + ? `session.${withoutVersion.slice("session.next.".length)}` + : withoutVersion; +} + +function parseResumeCursor(raw: unknown): string | undefined { + const record = recordFromUnknown(raw); + if (record?.schemaVersion !== RESUME_VERSION) return undefined; + return stringField(record, "sessionId"); } /** - * Whether two directory spellings name the same location. Raw string - * equality misreads a trailing slash, `.`/`..` segment, or symlinked cwd - * (macOS `/tmp` → `/private/tmp`) as a cwd change, needlessly forking the - * session on every resume. Lexically equal paths short-circuit; otherwise - * both sides go through `realPath`, each falling back to its lexical form - * on failure (deleted directory, external-server path) — so the probe can - * only widen matches, never split them. Takes the services as arguments so - * adapter methods stay service-free. Exported for unit testing. + * Whether two directory spellings name the same location. Lexically equal + * paths short-circuit; otherwise both sides use `realPath`, falling back to + * their lexical forms on failure, so the probe can only widen matches. */ export function isSameOpenCodeDirectory( fileSystem: FileSystem.FileSystem, @@ -151,11 +183,9 @@ export function isSameOpenCodeDirectory( ): Effect.Effect { const lexicalLeft = path.resolve(left); const lexicalRight = path.resolve(right); - if (lexicalLeft === lexicalRight) { - return Effect.succeed(true); - } - const canonicalize = (lexical: string) => - fileSystem.realPath(lexical).pipe(Effect.orElseSucceed(() => lexical)); + if (lexicalLeft === lexicalRight) return Effect.succeed(true); + const canonicalize = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); return Effect.zipWith( canonicalize(lexicalLeft), canonicalize(lexicalRight), @@ -163,1423 +193,1286 @@ export function isSameOpenCodeDirectory( ); } -interface OpenCodeTurnSnapshot { - readonly id: TurnId; - readonly items: Array; +function parseModelRef(model: string, variant?: string): ModelRef | undefined { + const separator = model.indexOf("/"); + if (separator <= 0 || separator === model.length - 1) return undefined; + return { + providerID: model.slice(0, separator), + id: model.slice(separator + 1), + ...(variant ? { variant } : {}), + }; } -type OpenCodeSubscribedEvent = - Awaited> extends { - readonly stream: AsyncIterable; +function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("command") + ) { + return "command_execution"; } - ? TEvent - : never; - -function trimText(value: string | undefined | null): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; -} - -function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined { - const properties = "properties" in event ? event.properties : undefined; - if (!properties || typeof properties !== "object") { - return undefined; + if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { + return "file_change"; } - - const sessionID = (properties as { readonly sessionID?: unknown }).sessionID; - const sessionIDFromProperties = typeof sessionID === "string" ? sessionID : undefined; - if (sessionIDFromProperties) { - return sessionIDFromProperties; + 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")) { + return "collab_agent_tool_call"; } + return "dynamic_tool_call"; +} - const info = (properties as { readonly info?: { readonly id?: unknown } }).info; - return info && typeof info.id === "string" ? info.id : undefined; +/** Human title for the common OpenCode 2 built-in tools; unknown tools keep their raw name. */ +function openCodeToolTitle(toolName: string): string { + const normalized = toolName.toLowerCase(); + if (normalized === "read") return "Reading file"; + if (normalized === "grep" || normalized.includes("search")) return "Searching files"; + if (normalized === "glob" || normalized.includes("find")) return "Finding files"; + if (normalized === "shell" || normalized.includes("bash")) return "Running command"; + if (normalized.includes("edit") || normalized.includes("patch")) return "Editing file"; + if (normalized.includes("write")) return "Writing file"; + if (normalized.includes("fetch")) return "Fetching page"; + if (normalized.includes("task") || normalized.includes("agent")) return "Running agent"; + return toolName; } -function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | undefined { - if (event.type !== "session.updated") { - return undefined; +/** + * Short human summary of a tool call's input for the row preview. OpenCode 2 + * streams inputs as raw JSON text; parse out the field a human would scan. + */ +function openCodeToolInputDetail(toolName: string, input: unknown): string | undefined { + const record = recordFromUnknown(input); + if (!record) return undefined; + const firstString = (...keys: ReadonlyArray) => stringField(record, ...keys); + const normalized = toolName.toLowerCase(); + if (normalized === "grep" || normalized === "glob" || /search|find|list/u.test(normalized)) { + return firstString("pattern", "query", "include"); } - - const title = trimText(event.properties.info.title); - // OpenCode mints a placeholder title at session.create when no title was - // provided, and re-emits it on every `session.updated`. Mirroring it would - // overwrite the thread's real title (openCodeEventSessionTitle feeds the - // `thread.metadata.updated` mirror). Ignore OpenCode's auto-generated - // placeholders so the thread isn't locked onto them. - if (!title || isOpenCodeDefaultTitle(title)) { - return undefined; + if (/fetch|web/u.test(normalized)) return firstString("url"); + if (/read|edit|write|patch|apply/u.test(normalized)) { + return firstString("filePath", "path", "file"); } - - return title; -} - -const OPENCODE_DEFAULT_TITLE_PATTERN = - /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - -function isOpenCodeDefaultTitle(title: string): boolean { - return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); -} - -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; - /** - * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. - * The session lifecycle is owned by `sessionScope`; this Ref exists only - * so concurrent callers can race the transition safely via `getAndSet`. - */ - readonly stopped: Ref.Ref; - /** - * Sole lifecycle handle for the session. Closing this scope: - * - aborts the `AbortController` registered as a finalizer - * (cancels the in-flight `event.subscribe` fetch), - * - interrupts the event-pump and server-exit fibers forked - * via `Effect.forkIn(sessionScope)`, - * - tears down the OpenCode server process for scope-owned servers. - */ - readonly sessionScope: Scope.Closeable; -} - -export interface OpenCodeAdapterLiveOptions { - readonly instanceId?: ProviderInstanceId; - readonly environment?: NodeJS.ProcessEnv; - readonly nativeEventLogPath?: string; - readonly nativeEventLogger?: EventNdjsonLogger; + return firstString("command", "script", "url", "pattern", "filePath", "path", "file", "query"); } -const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - /** - * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into - * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call - * sites pipe through this in `Effect.mapError` so they never build the error - * shape by hand. + * Shape clients already understand: `toolCallId` keys started/updated/completed + * merges, and a flattened `command` lets them show live command previews. */ -const toRequestError = (cause: OpenCodeRuntimeError): ProviderAdapterRequestError => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: cause.operation, - detail: cause.detail, - cause: cause.cause, - }); +function toolItemData(callId: string, state: OpenCodeToolCallState): Record { + const inputRecord = recordFromUnknown(state.input); + const command = + typeof inputRecord?.command === "string" && inputRecord.command.trim().length > 0 + ? inputRecord.command + : undefined; + return { + toolCallId: callId, + tool: state.name, + ...(state.input !== undefined ? { input: state.input } : {}), + ...(command ? { command } : {}), + }; +} /** - * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The - * typed cause is usually an `OpenCodeRuntimeError` (from {@link runOpenCodeSdk}), - * in which case we preserve its `detail`; otherwise we fall back to - * {@link openCodeRuntimeErrorDetail} for unknown causes (defects, etc.). + * OpenCode 2's shell tool pads empty output with "(no output)" and always + * appends an exit-code line; drop the placeholder and the exit-0 line so quiet + * commands render as quietly succeeding. */ -const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause), - cause, - }); - -type EventBaseInput = { - readonly threadId: ThreadId; - readonly turnId?: TurnId | undefined; - readonly itemId?: string | undefined; - readonly requestId?: string | undefined; - readonly createdAt?: string | undefined; - readonly raw?: unknown; -}; - -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"; +function cleanToolOutputText(text: string): string | undefined { + let cleaned = text.replace(/^\(no output\)\s*/u, ""); + const match = /(?:^|\n)\s*Command exited with code (\d+)\.\s*$/u.exec(cleaned); + if (match?.[1] === "0") { + cleaned = cleaned.slice(0, match.index).trimEnd(); } - 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"; + cleaned = cleaned.trim(); + return cleaned.length > 0 ? cleaned : undefined; } -function mapPermissionToRequestType( - permission: string, +function permissionRequestType( + action: string, ): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { - switch (permission) { + switch (action) { case "bash": + case "shell": return "command_execution_approval"; case "read": return "file_read_approval"; case "edit": + case "write": return "file_change_approval"; default: return "unknown"; } } -function mapPermissionDecision(reply: "once" | "always" | "reject"): string { +function permissionReply(decision: "accept" | "acceptForSession" | "decline" | "cancel") { + switch (decision) { + case "accept": + return "once" as const; + case "acceptForSession": + return "always" as const; + case "decline": + case "cancel": + return "reject" as const; + } +} + +function permissionDecision(reply: string): 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 formFromEvent( + event: OpenCodeRuntime.OpenCodeEvent, +): Readonly> | undefined { + const data = eventData(event); + return recordFromUnknown(data?.form) ?? recordFromUnknown(data?.info) ?? data; } -const ensureSessionContext = Effect.fn("ensureSessionContext")(function* ( - sessions: ReadonlyMap, - threadId: ThreadId, -) { - const session = sessions.get(threadId); - if (!session) { - return yield* new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }); - } - if (yield* Ref.get(session.stopped)) { - return yield* new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, +function formQuestions(form: Readonly>): ReadonlyArray { + return recordsFromUnknown(form.fields).map((field) => { + const id = stringField(field, "key", "id") ?? "answer"; + const title = stringField(field, "title", "label") ?? id; + const description = stringField(field, "description") ?? title; + const type = stringField(field, "type"); + const options = recordsFromUnknown(field.options).map((option) => { + const label = stringField(option, "label", "value") ?? "Option"; + return { + label, + description: stringField(option, "description") ?? label, + }; }); - } - 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"; + if (type === "boolean" && options.length === 0) { + options.push({ label: "Yes", description: "Yes" }, { label: "No", description: "No" }); + } + return { + id, + header: title, + question: description, + options, + ...(type === "multiselect" ? { multiSelect: true } : {}), + }; + }); } -function textFromPart(part: Part): string | undefined { - switch (part.type) { - case "text": - case "reasoning": - return part.text; - default: - return undefined; +/** + * Clients reply with option labels, display indices ("2"), or raw values. + * OpenCode 2 validates against the field's option `value` ("choose"), so + * translate through the options captured from form.created before replying. + */ +function normalizeFormAnswers( + form: Readonly>, + answers: Readonly>, +): Record { + const fields = recordsFromUnknown(form.fields); + const toOptionValue = + (options: ReadonlyArray>>) => + (candidate: unknown): unknown => { + if (options.length === 0) return candidate; + if (typeof candidate !== "string") return candidate; + const text = candidate.trim(); + if (text.length === 0 || options.some((option) => stringField(option, "value") === text)) { + return text; + } + const byLabel = options.find( + (option) => stringField(option, "label", "value")?.toLowerCase() === text.toLowerCase(), + ); + if (byLabel) return stringField(byLabel, "value"); + if (/^\d+$/u.test(text)) { + const index = Number.parseInt(text, 10); + const option = index >= 1 && index <= options.length ? options[index - 1] : undefined; + if (option) return stringField(option, "value"); + } + return text; + }; + const normalized: Record = {}; + for (const [key, value] of Object.entries(answers)) { + const options = recordsFromUnknown( + fields.find((field) => stringField(field, "key", "id") === key)?.options, + ); + const translate = toOptionValue(options); + normalized[key] = Array.isArray(value) ? value.map(translate) : translate(value); } + return normalized; } -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 contentItemId( + data: Readonly>, + kind: "text" | "reasoning", +): string { + const explicit = stringField(data, kind === "text" ? "textID" : "reasoningID", "id"); + if (explicit) return explicit; + const messageId = stringField(data, "assistantMessageID", "messageID") ?? "message"; + const ordinal = typeof data.ordinal === "number" ? data.ordinal : 0; + return `${messageId}-${kind}-${ordinal}`; } -function resolveLatestAssistantText(previousText: string | undefined, nextText: string): string { - if (previousText && previousText.length > nextText.length && previousText.startsWith(nextText)) { - return previousText; +function toolDetail(data: Readonly>): string | undefined { + const error = recordFromUnknown(data.error); + const errorMessage = stringField(error, "message", "detail"); + if (errorMessage) return errorMessage; + if (typeof data.error === "string" && data.error.trim().length > 0) return data.error; + const texts = recordsFromUnknown(data.content) + .map((item) => stringField(item, "text")) + .filter((value): value is string => value !== undefined); + if (texts.length > 0) return cleanToolOutputText(texts.join("\n")); + if (typeof data.result === "string" && data.result.trim().length > 0) { + return cleanToolOutputText(data.result); } - 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; -} { - return { - nextText: previousText + delta, - deltaToEmit: delta, - }; + return undefined; } -const isoFromEpochMs = (value: number) => - DateTime.make(value).pipe( - Option.match({ - onNone: () => undefined, - onSome: DateTime.formatIso, - }), - ); - -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 toRequestError(method: string, cause: OpenCodeRuntime.OpenCodeRuntimeFailure) { + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `OpenCode 2 request failed during '${method}'.`, + cause, + }); } -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 toProcessError(threadId: ThreadId, cause: OpenCodeRuntime.OpenCodeRuntimeFailure) { + return new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: `OpenCode 2 could not attach the requested session during '${cause.operation}'.`, + cause, + }); } -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 encodedPathSegment(value: string): string { + return encodeURIComponent(value); } -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 isMissingOpenCodeSession(cause: OpenCodeRuntime.OpenCodeRuntimeFailure): boolean { + return ( + OpenCodeRuntime.isOpenCodeRuntimeError(cause) && + cause.reason === "http-status" && + cause.status === 404 + ); } -function updateProviderSession( - context: OpenCodeSessionContext, - patch: Partial, - options?: { - readonly clearActiveTurnId?: boolean; - readonly clearLastError?: boolean; - }, -): Effect.Effect { - return Effect.gen(function* () { - const updatedAt = yield* nowIso; - const nextSession = { - ...context.session, - ...patch, - updatedAt, - } 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; - }); +function shouldInvalidateConnection(cause: OpenCodeRuntime.OpenCodeRuntimeFailure): boolean { + return ( + OpenCodeRuntime.isOpenCodeRuntimeError(cause) && + (cause.reason === "transport" || + cause.reason === "connection-ended" || + (cause.reason === "http-status" && cause.status === 401)) + ); } -const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( - context: OpenCodeSessionContext, -) { - // Race-safe one-shot: first caller flips the flag, everyone else no-ops. - if (yield* Ref.getAndSet(context.stopped, true)) { - return false; - } - - // Best-effort remote abort. The scope close below tears down the local - // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), - // but we still want to tell OpenCode that this session is done. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); - - // Closing the session scope interrupts every fiber forked into it and - // runs each finalizer we registered — the `AbortController.abort()` call, - // the child-process termination, etc. - yield* Scope.close(context.sessionScope, Exit.void); - return true; -}); - export function makeOpenCodeAdapter( - openCodeSettings: OpenCodeSettings, + settings: OpenCodeSettings, options?: OpenCodeAdapterLiveOptions, ) { return Effect.gen(function* () { - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; const serverConfig = yield* ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const sameDirectory = (left: string, right: string) => - isSameOpenCodeDirectory(fileSystem, path, left, right); - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { - stream: "native", - }) - : undefined); - // Only close loggers we created. If the caller passed one in via - // `options.nativeEventLogger`, they own its lifecycle. - const managedNativeEventLogger = - options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const runtimeEvents = yield* Queue.unbounded(); + const scope = yield* Effect.scope; + const instanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); + const events = yield* Queue.unbounded(); const sessions = new Map(); + const sessionByProviderId = new Map(); + const connectionLock = yield* Semaphore.make(1); + const sessionStartLock = yield* Semaphore.make(1); + let activeConnection: OpenCodeActiveConnection | undefined; const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => new ProviderAdapterRequestError({ provider: PROVIDER, method: "crypto/randomUUIDv4", - detail: "Failed to generate OpenCode runtime identifier.", + detail: "Failed to generate an OpenCode runtime identifier.", cause, }), ), ); - const buildEventBase = (input: EventBaseInput) => - Effect.all({ - eventId: randomUUIDv4.pipe(Effect.map(EventId.make)), - createdAt: input.createdAt === undefined ? nowIso : Effect.succeed(input.createdAt), - }).pipe( - Effect.map(({ eventId, createdAt }) => ({ - eventId, - provider: PROVIDER, - threadId: input.threadId, - createdAt, - ...(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" as const, - payload: input.raw, - }, - } - : {}), - })), - ); + const nextEventId = Effect.map(randomUUIDv4, EventId.make); + const nextTurnId = Effect.map(randomUUIDv4, TurnId.make); - // Layer-level finalizer: when the adapter layer shuts down, stop every - // session. Each session's `Scope.close` tears down its spawned OpenCode - // server (via the `ChildProcessSpawner` finalizer installed in - // `startOpenCodeServerProcess`) and interrupts the forked event/exit - // fibers. Consumers that can't reason about Effect scopes therefore - // cannot leak OpenCode child processes by forgetting to call `stopAll`. - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const contexts = [...sessions.values()]; - sessions.clear(); - // `ignoreCause` swallows both typed failures (none here) and defects - // from throwing scope finalizers so a sibling's death can't interrupt - // the remaining cleanups. - yield* Effect.forEach( - contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), - { concurrency: "unbounded", discard: true }, - ); - // Close the logger AFTER session teardown so any final lifecycle - // events emitted during shutdown still get written. `close` flushes - // the `Logger.batched` window and closes each per-thread - // `RotatingFileSink` handle owned by the logger's internal scope. - if (managedNativeEventLogger !== undefined) { - yield* managedNativeEventLogger.close(); - } - }).pipe(Effect.ensuring(Queue.shutdown(runtimeEvents))), - ); + const buildEventBase = Effect.fn("OpenCodeAdapter.buildEventBase")(function* ( + input: EventBaseInput, + ) { + return { + eventId: yield* nextEventId, + provider: PROVIDER, + providerInstanceId: instanceId, + threadId: input.threadId, + createdAt: DateTime.formatIso(yield* DateTime.now), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input.raw + ? { + raw: { + source: "opencode.api.event" as const, + payload: input.raw, + }, + } + : {}), + }; + }); - const emit = (event: ProviderRuntimeEvent) => - Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); - const writeNativeEvent = ( - threadId: ThreadId, - event: { - readonly observedAt: string; - readonly event: Record; - }, - ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void); - const writeNativeEventBestEffort = ( + const emit = (event: ProviderRuntimeEvent) => Queue.offer(events, event).pipe(Effect.asVoid); + + const ensureSession = Effect.fn("OpenCodeAdapter.ensureSession")(function* ( threadId: ThreadId, - event: { - readonly observedAt: string; - readonly event: Record; - }, - ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + ) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return context; + }); - const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( + const updateSession = Effect.fn("OpenCodeAdapter.updateSession")(function* ( context: OpenCodeSessionContext, - message: string, + patch: Partial, + clear?: "activeTurnId" | "lastError", + ) { + const next = { + ...context.session, + ...patch, + updatedAt: DateTime.formatIso(yield* DateTime.now), + } as ProviderSession & Record; + if (clear) delete next[clear]; + context.session = next; + return next; + }); + + const completeTurn = Effect.fn("OpenCodeAdapter.completeTurn")(function* ( + context: OpenCodeSessionContext, + state: "completed" | "failed" | "interrupted", + raw: OpenCodeRuntime.OpenCodeEvent | undefined, + errorMessage?: string, ) { - // Atomic one-shot: two fibers can race here (the event-pump on stream - // failure and the server-exit watcher). `getAndSet` flips the flag in - // a single step so the loser observes `true` and returns; a plain - // `Ref.get` would let both racers slip past and emit duplicates. - if (yield* Ref.getAndSet(context.stopped, true)) { - return; - } const turnId = context.activeTurnId; - sessions.delete(context.session.threadId); - // Emit lifecycle events BEFORE tearing down the scope. Both call sites - // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; - // closing that scope triggers the fiber-interrupt finalizer, so any - // subsequent yield point would unwind and silently drop these emits. - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - })), - type: "runtime.error", - payload: { - message, - class: "transport_error", + if (!turnId) return; + context.activeTurnId = undefined; + yield* updateSession( + context, + { + status: state === "failed" ? "error" : "ready", + ...(errorMessage ? { lastError: errorMessage } : {}), }, - }).pipe(Effect.ignore); + "activeTurnId", + ); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, + ...(raw ? { raw } : {}), })), - type: "session.exited", + type: "turn.completed", payload: { - reason: message, - recoverable: false, - exitKind: "error", + state, + ...(errorMessage ? { errorMessage } : {}), }, - }).pipe(Effect.ignore); - // Inline the teardown that `stopOpenCodeContext` would do; we can't - // delegate to it because our `getAndSet` above already flipped the - // one-shot guard, so the call would no-op. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); - yield* Scope.close(context.sessionScope, Exit.void); + }); }); - /** Emit content.delta and item.completed events for an assistant text part. */ - const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( + const writeNativeEvent = ( context: OpenCodeSessionContext, - part: Part, - turnId: TurnId | undefined, - raw: unknown, + event: OpenCodeRuntime.OpenCodeEvent, + observedAt: string, + ) => + options?.nativeEventLogger + ? options.nativeEventLogger + .write( + { + observedAt, + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.providerSessionId, + type: event.type, + payload: event, + }, + }, + context.session.threadId, + ) + .pipe(Effect.catchCause(() => Effect.void)) + : Effect.void; + + const handleEvent = Effect.fn("OpenCodeAdapter.handleEvent")(function* ( + event: OpenCodeRuntime.OpenCodeEvent, + connection: OpenCodeRuntime.OpenCodeConnection, ) { - const text = textFromPart(part); - if (text === undefined) { + if (normalizeEventType(event.type) === "server.connected") { 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) { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - itemId: part.id, - createdAt: - (part.type === "text" || part.type === "reasoning") && part.time !== undefined - ? isoFromEpochMs(part.time.start) - : undefined, - raw, - })), - type: "content.delta", - payload: { - streamKind: resolveTextStreamKind(part), - delta: deltaToEmit, - }, - }); - } + const providerSessionId = eventSessionId(event); + if (!providerSessionId) return; + const context = sessionByProviderId.get(providerSessionId); + if (!context) return; + const data = eventData(event); + if (!data) return; + const type = normalizeEventType(event.type); + const turnId = context.activeTurnId; + yield* writeNativeEvent(context, event, DateTime.formatIso(yield* DateTime.now)); - if ( - part.type === "text" && - part.time?.end !== undefined && - !context.completedAssistantPartIds.has(part.id) - ) { - context.completedAssistantPartIds.add(part.id); - yield* emit({ - ...(yield* 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 handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( - context: OpenCodeSessionContext, - event: OpenCodeSubscribedEvent, - ) { - const payloadSessionId = openCodeEventSessionId(event); - if (payloadSessionId !== context.openCodeSessionId) { - return; - } - - const turnId = context.activeTurnId; - yield* writeNativeEventBestEffort(context.session.threadId, { - observedAt: yield* nowIso, - event: { - provider: PROVIDER, - threadId: context.session.threadId, - providerThreadId: context.openCodeSessionId, - type: event.type, - ...(turnId ? { turnId } : {}), - payload: event, - }, - }); - - switch (event.type) { - case "session.updated": { - const title = openCodeEventSessionTitle(event); - if (title) { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - raw: event, - })), - type: "thread.metadata.updated", - payload: { - name: title, - metadata: { - sessionID: context.openCodeSessionId, - }, - }, - }); - } - break; - } - - 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; - } - yield* emitAssistantTextDelta(context, part, turnId, event); - } - } + switch (type) { + case "session.text.started": { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: contentItemId(data, "text"), + raw: event, + })), + type: "item.started", + payload: { + itemType: "assistant_message", + status: "inProgress", + title: "Assistant message", + }, + }); break; } - case "message.removed": { - context.messageRoleById.delete(event.properties.messageID); + case "session.reasoning.started": { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: contentItemId(data, "reasoning"), + raw: event, + })), + type: "item.started", + payload: { + itemType: "reasoning", + status: "inProgress", + title: "Reasoning", + }, + }); 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, - }); - } + case "session.text.delta": + case "session.reasoning.delta": { + const delta = stringField(data, "delta"); + if (!delta) break; + const kind = type === "session.text.delta" ? "text" : "reasoning"; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - itemId: event.properties.partID, + itemId: contentItemId(data, kind), raw: event, })), type: "content.delta", payload: { - streamKind, - delta: deltaToEmit, + streamKind: kind === "text" ? "assistant_text" : "reasoning_text", + delta, }, }); break; } - case "message.part.updated": { - const part = event.properties.part; - context.partById.set(part.id, part); - const messageRole = messageRoleForPart(context, part); - - if (messageRole === "assistant") { - yield* 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 = { - ...(yield* 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); - yield* emit(runtimeEvent); - } + case "session.reasoning.ended": { + const text = stringField(data, "text"); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: contentItemId(data, "reasoning"), + raw: event, + })), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Reasoning", + ...(text ? { detail: text } : {}), + }, + }); break; } - case "permission.asked": { - context.pendingPermissions.set(event.properties.id, event.properties); + case "session.text.ended": { + const text = stringField(data, "text"); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.id, + itemId: contentItemId(data, "text"), raw: event, })), - type: "request.opened", + type: "item.completed", payload: { - requestType: mapPermissionToRequestType(event.properties.permission), - detail: - event.properties.patterns.length > 0 - ? event.properties.patterns.join("\n") - : event.properties.permission, - args: event.properties.metadata, + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(text ? { detail: text } : {}), }, }); break; } - case "permission.replied": { - context.pendingPermissions.delete(event.properties.requestID); + case "session.tool.input.started": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = stringField(data, "tool", "name") ?? "tool"; + context.toolsByCallId.set(callId, { name: tool }); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.requestID, + itemId: callId, raw: event, })), - type: "request.resolved", + type: "item.started", payload: { - requestType: "unknown", - decision: mapPermissionDecision(event.properties.reply), + itemType: toToolLifecycleItemType(tool), + status: "inProgress", + title: openCodeToolTitle(tool), }, }); break; } - case "question.asked": { - context.pendingQuestions.set(event.properties.id, event.properties); + case "session.tool.input.delta": + case "session.tool.input.ended": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const state = context.toolsByCallId.get(callId); + if (!state) break; + if (type === "session.tool.input.delta") { + const delta = stringField(data, "delta", "text"); + if (delta) state.inputText = `${state.inputText ?? ""}${delta}`; + } else if (state.input === undefined) { + // input.ended normally carries the full JSON text. Fall back to + // accumulated deltas for compatible servers that omit it. + const rawInput = stringField(data, "text") ?? state.inputText; + if (rawInput) { + state.input = Option.getOrElse(decodeUnknownJsonString(rawInput), () => rawInput); + } + } + const detail = openCodeToolInputDetail(state.name, state.input); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.id, + itemId: callId, raw: event, })), - type: "user-input.requested", + type: "item.updated", payload: { - questions: normalizeQuestionRequest(event.properties), + itemType: toToolLifecycleItemType(state.name), + status: "inProgress", + title: openCodeToolTitle(state.name), + ...(detail ? { detail } : {}), + data: toolItemData(callId, state), }, }); 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(", ") ?? "", - ]), - ); + case "session.tool.called": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = stringField(data, "tool", "name") ?? "tool"; + const state = context.toolsByCallId.get(callId); + if (state && data.input !== undefined) { + state.input = data.input; + } else if (!state) { + context.toolsByCallId.set(callId, { name: tool, input: data.input }); + } + const resolved = context.toolsByCallId.get(callId)!; + const detail = openCodeToolInputDetail(resolved.name, resolved.input); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.requestID, + itemId: callId, raw: event, })), - type: "user-input.resolved", - payload: { answers }, + type: state ? "item.updated" : "item.started", + payload: { + itemType: toToolLifecycleItemType(resolved.name), + status: "inProgress", + title: openCodeToolTitle(resolved.name), + ...(detail ? { detail } : {}), + data: toolItemData(callId, resolved), + }, }); break; } - case "question.rejected": { - context.pendingQuestions.delete(event.properties.requestID); + case "session.tool.progress": + case "session.tool.success": + case "session.tool.failed": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const state = context.toolsByCallId.get(callId); + const tool = state?.name ?? stringField(data, "tool", "name") ?? "tool"; + const terminal = type !== "session.tool.progress"; + const output = terminal ? toolDetail(data) : undefined; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.requestID, + itemId: callId, raw: event, })), - type: "user-input.resolved", - payload: { answers: {} }, + type: terminal ? "item.completed" : "item.updated", + payload: { + itemType: toToolLifecycleItemType(tool), + status: + type === "session.tool.failed" ? "failed" : terminal ? "completed" : "inProgress", + title: openCodeToolTitle(tool), + ...(output ? { detail: output } : {}), + data: { + ...toolItemData(callId, state ?? { name: tool }), + ...(output ? { output } : {}), + }, + }, }); + if (terminal) context.toolsByCallId.delete(callId); break; } - case "session.status": { - if (event.properties.status.type === "busy") { - yield* updateProviderSession(context, { - status: "running", - activeTurnId: turnId, - }); - } - - if (event.properties.status.type === "retry") { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - raw: event, - })), - type: "runtime.warning", - payload: { - message: event.properties.status.message, - detail: event.properties.status, - }, - }); - break; + case "permission.asked": { + const requestId = stringField(data, "id", "requestID"); + if (!requestId) break; + const action = stringField(data, "action", "permission") ?? "unknown"; + const resources = Array.isArray(data.resources) + ? data.resources.filter((value): value is string => typeof value === "string") + : Array.isArray(data.patterns) + ? data.patterns.filter((value): value is string => typeof value === "string") + : []; + if (context.session.runtimeMode === "full-access") { + const replied = yield* connection + .request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/permission/${encodedPathSegment(requestId)}/reply`, + { + operation: "permission.reply", + schema: Schema.Void, + body: { reply: "once" }, + }, + ) + .pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ); + if (replied) break; } + context.pendingPermissions.add(requestId); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId, + raw: event, + })), + type: "request.opened", + payload: { + requestType: permissionRequestType(action), + detail: resources.length > 0 ? resources.join("\n") : action, + args: data.metadata ?? data.source, + }, + }); + break; + } - if (event.properties.status.type === "idle" && turnId) { - context.activeTurnId = undefined; - yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - raw: event, - })), - type: "turn.completed", - payload: { - state: "completed", - }, - }); - } + case "permission.replied": { + const requestId = stringField(data, "requestID", "id"); + if (!requestId) break; + context.pendingPermissions.delete(requestId); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId, + raw: event, + })), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: permissionDecision(stringField(data, "reply") ?? "reject"), + }, + }); break; } - case "session.error": { - const message = sessionErrorMessage(event.properties.error); - const activeTurnId = context.activeTurnId; - context.activeTurnId = undefined; - yield* updateProviderSession( - context, - { - status: "error", - lastError: message, + case "form.created": { + const form = formFromEvent(event); + if (!form) break; + const formId = stringField(form, "id", "formID"); + if (!formId) break; + context.pendingForms.set(formId, form); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: formId, + raw: event, + })), + type: "user-input.requested", + payload: { + questions: formQuestions(form), }, - { clearActiveTurnId: true }, - ); - if (activeTurnId) { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: activeTurnId, - raw: event, - })), - type: "turn.completed", - payload: { - state: "failed", - errorMessage: message, - }, - }); - } + }); + break; + } + + case "form.replied": + case "form.cancelled": { + const form = formFromEvent(event); + const formId = form ? stringField(form, "id", "formID") : undefined; + if (!formId) break; + context.pendingForms.delete(formId); + const answer = recordFromUnknown(data.answer) ?? recordFromUnknown(data.answers) ?? {}; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, + turnId, + requestId: formId, raw: event, })), - type: "runtime.error", + type: "user-input.resolved", payload: { - message, - class: "provider_error", - detail: event.properties.error, + answers: type === "form.cancelled" ? {} : answer, }, }); break; } + case "session.execution.settled": { + const outcome = stringField(data, "outcome", "state") ?? "success"; + const error = recordFromUnknown(data.error); + const message = stringField(error, "message", "detail") ?? stringField(data, "message"); + yield* completeTurn( + context, + outcome === "failure" || outcome === "failed" + ? "failed" + : outcome === "interrupted" || outcome === "cancelled" + ? "interrupted" + : "completed", + event, + message, + ); + break; + } + + case "session.execution.succeeded": + case "session.idle": + yield* completeTurn(context, "completed", event); + break; + + case "session.execution.interrupted": + yield* completeTurn(context, "interrupted", event); + break; + + case "session.execution.failed": + case "session.error": { + const error = recordFromUnknown(data.error); + const message = + stringField(error, "message", "detail") ?? + stringField(data, "message") ?? + "OpenCode 2 session failed."; + yield* completeTurn(context, "failed", event, message); + break; + } + default: break; } }); - const startEventPump = Effect.fn("startEventPump")(function* (context: OpenCodeSessionContext) { - // One AbortController per session scope. The finalizer fires when - // the scope closes (explicit stop, unexpected exit, or layer - // shutdown) and cancels the in-flight `event.subscribe` fetch so - // the async iterable unwinds cleanly. - const eventsAbortController = new AbortController(); - yield* Scope.addFinalizer( - context.sessionScope, - Effect.sync(() => eventsAbortController.abort()), - ); - - // Fibers forked into `context.sessionScope` are interrupted - // automatically when the scope closes — no bookkeeping required. - yield* Effect.flatMap( - runOpenCodeSdk("event.subscribe", () => - context.client.event.subscribe(undefined, { - signal: eventsAbortController.signal, - }), - ), - (subscription) => - Stream.fromAsyncIterable( - subscription.stream, - (cause) => - new OpenCodeRuntimeError({ - operation: "event.subscribe", - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), - ).pipe( - Effect.exit, - Effect.flatMap((exit) => + const reportConnectionFailure = (cause: OpenCodeRuntime.OpenCodeRuntimeFailure) => + Effect.forEach( + sessions.values(), + (context) => Effect.gen(function* () { - // Expected paths: caller aborted the fetch or the session - // has already been marked stopped. Treat as a clean exit. - if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { - return; - } - if (Exit.isFailure(exit)) { - yield* emitUnexpectedExit( - context, - openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), - ); + if (context.activeTurnId) { + yield* completeTurn(context, "failed", undefined, cause.message); + } else { + yield* updateSession(context, { + status: "error", + lastError: cause.message, + }); } + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "runtime.error", + payload: { + message: cause.message, + class: "transport_error", + }, + }); }), - ), - Effect.forkIn(context.sessionScope), + { discard: true }, ); - if (!context.server.external && context.server.exitCode !== null) { - yield* context.server.exitCode.pipe( - Effect.flatMap((code) => - Effect.gen(function* () { - if (yield* Ref.get(context.stopped)) { - return; - } - yield* emitUnexpectedExit(context, `OpenCode server exited unexpectedly (${code}).`); - }), - ), - Effect.forkIn(context.sessionScope), - ); - } - }); - - const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( - function* (input) { - const binaryPath = openCodeSettings.binaryPath; - const serverUrl = openCodeSettings.serverUrl; - const serverPassword = openCodeSettings.serverPassword; - const directory = input.cwd ?? serverConfig.cwd; - const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; - const existing = sessions.get(input.threadId); - if (existing) { - yield* stopOpenCodeContext(existing); - sessions.delete(input.threadId); - } - - const started = yield* Effect.gen(function* () { - const sessionScope = yield* Scope.make(); - const startedExit = yield* Effect.exit( - Effect.gen(function* () { - // The runtime binds the server's lifetime to the Scope.Scope - // we provide below — closing `sessionScope` kills the child - // process automatically. No manual `server.close()` needed. - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath, - serverUrl, - ...(options?.environment ? { environment: options.environment } : {}), - }); - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory, - ...(server.external && serverPassword ? { serverPassword } : {}), - }); - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - if (mcpSession && !server.external) { - yield* runOpenCodeSdk("mcp.add", () => - client.mcp.add({ - name: "t3-code", - config: { - type: "remote", - url: mcpSession.endpoint, - headers: { - Authorization: mcpSession.authorizationHeader, - }, - oauth: false, - }, - }), - ); - } - // Resume: re-adopt the session named by the durable cursor — - // OpenCode scopes history by session id. The probe recovers only - // a confirmed not-found (start fresh); transport/auth/server - // errors propagate instead of masking as a new empty session. - const resolved = yield* Effect.gen(function* () { - const adopted = resumeSessionId - ? yield* runOpenCodeSdk("session.get", () => - client.session.get({ sessionID: resumeSessionId }), - ).pipe( - Effect.map((response) => response.data), - Effect.catchIf( - (cause) => isOpenCodeNotFound(cause), - () => Effect.void, - ), - ) - : undefined; - - // Reuse in place only when the session still matches the - // requested cwd; on a cwd change it is forked below instead. - const reusable = - adopted && - (!adopted.directory || (yield* sameDirectory(adopted.directory, directory))) - ? adopted - : undefined; - - if (reusable) { - // Resume skips `session.create`, so re-assert the ruleset — - // a runtime-mode change would otherwise leave the session on - // its original permissions. - yield* runOpenCodeSdk("session.update", () => - client.session.update({ - sessionID: reusable.id, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - return { openCodeSession: reusable, created: false }; - } - - // The session lives under a different cwd (e.g. the thread - // moved into a git worktree). Fork it into the requested - // directory instead of minting an empty one — the fork carries - // the full history, so the follow-up keeps its context (#3604). - if (adopted) { - yield* Effect.logInfo( - `OpenCode session '${adopted.id}' was created under a different working directory; forking into '${directory}' to preserve conversation history.`, - ); - const forkedSession = yield* runOpenCodeSdk("session.fork", () => - client.session.fork({ sessionID: adopted.id, directory }), - ); - const forked = forkedSession.data; - if (!forked) { - return yield* new OpenCodeRuntimeError({ - operation: "session.fork", - detail: "OpenCode session.fork returned no session payload.", - }); - } - yield* runOpenCodeSdk("session.update", () => - client.session.update({ - sessionID: forked.id, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - return { openCodeSession: forked, created: true }; - } + const invalidateConnection = (entry: OpenCodeActiveConnection) => + Effect.gen(function* () { + if (activeConnection !== entry) return; + activeConnection = undefined; + yield* entry.interrupt; + }); - if (resumeSessionId) { - yield* Effect.logWarning( - `OpenCode session '${resumeSessionId}' no longer exists; starting a fresh session.`, - ); - } - const createdSession = yield* runOpenCodeSdk("session.create", () => - client.session.create({ - ...(input.title ? { title: input.title } : {}), - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - if (!createdSession.data) { - return yield* new OpenCodeRuntimeError({ - operation: "session.create", - detail: "OpenCode session.create returned no session payload.", - }); - } - return { openCodeSession: createdSession.data, created: true }; - }); + const ensureConnection = connectionLock.withPermit( + Effect.gen(function* () { + if (activeConnection) return activeConnection; - return { - sessionScope, - server, - client, - openCodeSession: resolved.openCodeSession, - created: resolved.created, - }; - }).pipe(Effect.provideService(Scope.Scope, sessionScope)), - ); - if (Exit.isFailure(startedExit)) { - yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); - return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause)); - } - return startedExit.value; + const connection = yield* runtime.attach({ + binaryPath: settings.binaryPath, + ...(options?.environment ? { environment: options.environment } : {}), }); - - // 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. Only abort the remote - // session if we created it here; a resumed one is shared upstream - // state the winner is now using. - if (started.created) { - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSession.id, + const connected = yield* Deferred.make(); + const streamFailure = yield* Ref.make< + Option.Option + >(Option.none()); + const handleStreamFailure = (cause: OpenCodeRuntime.OpenCodeRuntimeFailure) => + Effect.gen(function* () { + yield* Ref.set(streamFailure, Option.some(cause)); + yield* Deferred.fail(connected, cause).pipe(Effect.ignore); + if (activeConnection?.connection === connection) activeConnection = undefined; + yield* reportConnectionFailure(cause); + }); + const eventFiber = yield* connection.globalEvents.pipe( + Stream.runForEach((event) => + normalizeEventType(event.type) === "server.connected" + ? Deferred.succeed(connected, undefined).pipe(Effect.ignore) + : handleEvent(event, connection).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "event.process", + reason: "transport", + cause, + }), + ), + ), + ), + Effect.flatMap(() => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "event.subscribe", + reason: "connection-ended", }), - ).pipe(Effect.ignore); - } - yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); - return raceWinner.session; + ), + ), + Effect.catchTags({ + OpenCodeRuntimeError: handleStreamFailure, + OpenCodeUnsupportedPreviewError: handleStreamFailure, + OpenCodeCommandNotFoundError: handleStreamFailure, + OpenCodeTimeoutError: handleStreamFailure, + }), + Effect.forkIn(scope), + ); + const interrupt = Fiber.interrupt(eventFiber).pipe(Effect.asVoid); + const handshake = yield* Deferred.await(connected).pipe(Effect.timeoutOption("10 seconds")); + if (Option.isNone(handshake)) { + yield* interrupt; + return yield* new OpenCodeRuntime.OpenCodeTimeoutError({ + operation: "event.subscribe", + }); } - const createdAt = yield* nowIso; - const session: ProviderSession = { - provider: PROVIDER, - providerInstanceId: boundInstanceId, - status: "ready", - runtimeMode: input.runtimeMode, - cwd: directory, - ...(input.modelSelection ? { model: input.modelSelection.model } : {}), - threadId: input.threadId, - // ProviderService persists this cursor and feeds it back into - // `startSession` after the in-memory session is lost (reaper / - // restart), so follow-ups continue the same conversation (#3604). - resumeCursor: { - schemaVersion: OPENCODE_RESUME_VERSION, - sessionId: started.openCodeSession.id, - }, - createdAt, - updatedAt: createdAt, - }; + const entry = { connection, interrupt } satisfies OpenCodeActiveConnection; + const failedBeforeActivation = yield* Ref.get(streamFailure); + if (Option.isSome(failedBeforeActivation)) { + yield* interrupt; + return yield* failedBeforeActivation.value; + } + activeConnection = entry; + const failedDuringActivation = yield* Ref.get(streamFailure); + if (Option.isSome(failedDuringActivation)) { + activeConnection = undefined; + yield* interrupt; + return yield* failedDuringActivation.value; + } + return entry; + }), + ); - 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: yield* Ref.make(false), - sessionScope: started.sessionScope, - }; - sessions.set(input.threadId, context); - yield* startEventPump(context); + const request = ( + method: "GET" | "POST", + path: string, + input: OpenCodeRuntime.OpenCodeRequestInput, + ) => + ensureConnection.pipe( + Effect.flatMap((entry) => + entry.connection + .request(method, path, input) + .pipe( + Effect.tapError((cause) => + shouldInvalidateConnection(cause) ? invalidateConnection(entry) : Effect.void, + ), + ), + ), + ); + const interruptContext = Effect.fn("OpenCodeAdapter.interruptContext")(function* ( + context: OpenCodeSessionContext, + turnId?: TurnId, + ) { + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/interrupt`, + { + operation: "session.interrupt", + schema: Schema.Void, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.interrupt", cause))); + const interruptedTurnId = turnId ?? context.activeTurnId; + context.activeTurnId = undefined; + yield* updateSession(context, { status: "ready" }, "activeTurnId"); + if (interruptedTurnId) { yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId })), - type: "session.started", - payload: { - message: "OpenCode session started", - }, - }); - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId })), - type: "thread.started", - payload: { - providerThreadId: started.openCodeSession.id, - }, + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: interruptedTurnId, + })), + type: "turn.aborted", + payload: { reason: "Interrupted by user." }, }); + } + }); - return session; - }, - ); + const detachContext = Effect.fn("OpenCodeAdapter.detachContext")(function* ( + context: OpenCodeSessionContext, + ) { + if (context.activeTurnId) { + yield* interruptContext(context, context.activeTurnId); + } + sessions.delete(context.session.threadId); + sessionByProviderId.delete(context.providerSessionId); + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "session.exited", + payload: { + reason: "Session detached.", + recoverable: true, + exitKind: "graceful", + }, + }); + }); - const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - const context = yield* ensureSessionContext(sessions, input.threadId); - // A sendTurn while a turn is active is a steer: OpenCode queues the - // prompt into the busy session and the work continues as one turn, so - // the active turn id is reused instead of opening a new turn. - const steeringTurnId = context.activeTurnId; - const turnId = steeringTurnId ?? TurnId.make(`opencode-turn-${yield* randomUUIDv4}`); - const modelSelection = - input.modelSelection ?? - (context.session.model - ? { instanceId: boundInstanceId, model: context.session.model } - : undefined); - if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) { + const startSessionUnlocked: OpenCodeAdapterShape["startSession"] = Effect.fn( + "OpenCodeAdapter.startSession", + )(function* (input) { + if (!settings.enabled) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, - operation: "sendTurn", - issue: `OpenCode model selection is bound to instance '${modelSelection?.instanceId}', expected '${boundInstanceId}'.`, + operation: "startSession", + issue: "OpenCode 2 is disabled in provider settings.", }); } - const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); - if (!parsedModel) { + const directory = input.cwd ?? serverConfig.cwd; + const selectedModel = input.modelSelection?.model; + if (input.modelSelection && input.modelSelection.instanceId !== instanceId) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, - operation: "sendTurn", - issue: "OpenCode model selection must use the 'provider/model' format.", + operation: "startSession", + issue: `OpenCode 2 model selection is bound to instance '${input.modelSelection.instanceId}', expected '${instanceId}'.`, }); } - - 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) { + const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const modelRef = selectedModel ? parseModelRef(selectedModel, variant) : undefined; + if (selectedModel && !modelRef) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, - operation: "sendTurn", - issue: "OpenCode turns require text input or at least one attachment.", + operation: "startSession", + issue: "OpenCode 2 model selection must use the 'provider/model' format.", }); } - - const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); - const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); - - context.activeTurnId = turnId; - context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); - context.activeVariant = variant; - yield* updateProviderSession( - context, - { - status: "running", - activeTurnId: turnId, - model: modelSelection?.model ?? context.session.model, + const agent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const resumeSessionId = parseResumeCursor(input.resumeCursor); + const createSession = request("POST", "/api/session", { + operation: "session.create", + schema: SessionResponseSchema, + body: { + ...(input.title ? { title: input.title } : {}), + location: { directory }, + ...(modelRef ? { model: modelRef } : {}), + ...(agent ? { agent } : {}), }, - { clearLastError: true }, - ); - - if (steeringTurnId === undefined) { - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId, turnId })), - type: "turn.started", - payload: { - model: modelSelection?.model ?? context.session.model, - ...(variant ? { effort: variant } : {}), - }, - }); + }).pipe(Effect.map((resolved) => ({ resolved, resumedExistingSession: false }))); + const resolution = yield* ( + resumeSessionId + ? request("GET", `/api/session/${encodedPathSegment(resumeSessionId)}`, { + operation: "session.get", + schema: SessionResponseSchema, + }).pipe( + Effect.flatMap((resolved) => + resolved.data.location === undefined + ? Effect.succeed({ resolved, resumedExistingSession: true }) + : isSameOpenCodeDirectory( + fileSystem, + path, + resolved.data.location.directory, + directory, + ).pipe( + Effect.flatMap((sameDirectory) => + sameDirectory + ? Effect.succeed({ resolved, resumedExistingSession: true }) + : createSession, + ), + ), + ), + Effect.catchIf(isMissingOpenCodeSession, () => createSession), + ) + : createSession + ).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); + const providerSessionId = resolution.resolved.data.id; + const staleContexts = new Set(); + const previousForThread = sessions.get(input.threadId); + const previousForProvider = sessionByProviderId.get(providerSessionId); + if (previousForThread) staleContexts.add(previousForThread); + if (previousForProvider) staleContexts.add(previousForProvider); + if (resolution.resumedExistingSession && modelRef) { + yield* request("POST", `/api/session/${encodedPathSegment(providerSessionId)}/model`, { + operation: "session.switchModel", + schema: Schema.Void, + body: { model: modelRef }, + }).pipe(Effect.mapError((cause) => toRequestError("session.switchModel", cause))); } - - yield* runOpenCodeSdk("session.promptAsync", () => - 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], - }), - ).pipe( - Effect.mapError(toRequestError), - // On failure of a fresh turn: clear active-turn state, flip the - // session back to ready with lastError set, emit turn.aborted, then - // let the typed error propagate. We don't need to rebuild the error - // here — `toRequestError` already produced the right shape. A failed - // steer leaves the still-running original turn untouched. - Effect.tapError((requestError) => - steeringTurnId !== undefined - ? Effect.void - : Effect.gen(function* () { - context.activeTurnId = undefined; - context.activeAgent = undefined; - context.activeVariant = undefined; - yield* updateProviderSession( - context, - { - status: "ready", - model: modelSelection?.model ?? context.session.model, - lastError: requestError.detail, - }, - { clearActiveTurnId: true }, - ); - yield* emit({ - ...(yield* buildEventBase({ - threadId: input.threadId, - turnId, - })), - type: "turn.aborted", - payload: { - reason: requestError.detail, - }, - }); - }), - ), - ); - - return { + if (resolution.resumedExistingSession && agent) { + yield* request("POST", `/api/session/${encodedPathSegment(providerSessionId)}/agent`, { + operation: "session.switchAgent", + schema: Schema.Void, + body: { agent }, + }).pipe(Effect.mapError((cause) => toRequestError("session.switchAgent", cause))); + } + for (const stale of staleContexts) { + yield* detachContext(stale); + } + const createdAt = DateTime.formatIso(yield* DateTime.now); + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: directory, + ...(selectedModel ? { model: selectedModel } : {}), threadId: input.threadId, - turnId, - // Re-surface the durable cursor on every turn so the persisted binding - // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex). - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), + resumeCursor: { + schemaVersion: RESUME_VERSION, + sessionId: providerSessionId, + }, + createdAt, + updatedAt: createdAt, + }; + const context: OpenCodeSessionContext = { + session, + providerSessionId, + pendingPermissions: new Set(), + pendingForms: new Map(), + toolsByCallId: new Map(), + activeTurnId: undefined, + currentModel: selectedModel, + currentVariant: variant, + currentAgent: agent ?? "build", + regularAgent: agent ?? "build", }; + sessions.set(input.threadId, context); + sessionByProviderId.set(providerSessionId, context); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.started", + payload: { + message: "OpenCode 2 session attached", + resume: session.resumeCursor, + }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "thread.started", + payload: { + providerThreadId: providerSessionId, + }, + }); + return session; }); + const startSession: OpenCodeAdapterShape["startSession"] = (input) => + sessionStartLock.withPermit(startSessionUnlocked(input)); - const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId, turnId) { - const context = yield* ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { + const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("OpenCodeAdapter.sendTurn")( + function* (input) { + const context = yield* ensureSession(input.threadId); + const connection = yield* ensureConnection.pipe( + Effect.map((entry) => entry.connection), + Effect.mapError((cause) => toRequestError("connection.attach", cause)), + ); + if (input.modelSelection && input.modelSelection.instanceId !== instanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `OpenCode 2 model selection is bound to instance '${input.modelSelection.instanceId}', expected '${instanceId}'.`, + }); + } + const text = input.input?.trim() ?? ""; + const attachments = input.attachments ?? []; + if (text.length === 0 && attachments.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode 2 turns require text input or at least one attachment.", + }); + } + const files: Array<{ readonly uri: string; readonly name: string }> = []; + for (const attachment of attachments) { + const path = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!path) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `OpenCode 2 attachment '${attachment.name}' is unavailable.`, + }); + } + files.push({ + uri: NodeURL.pathToFileURL(path).href, + name: attachment.name, + }); + } + + const selectedModel = input.modelSelection?.model ?? context.session.model; + const variant = input.modelSelection + ? getModelSelectionStringOptionValue(input.modelSelection, "variant") + : context.currentVariant; + const modelRef = selectedModel ? parseModelRef(selectedModel, variant) : undefined; + if (selectedModel && !modelRef) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode 2 model selection must use the 'provider/model' format.", + }); + } + if ( + modelRef && + (selectedModel !== context.currentModel || variant !== context.currentVariant) + ) { + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/model`, + { + operation: "session.switchModel", + schema: Schema.Void, + body: { model: modelRef }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.switchModel", cause))); + context.currentModel = selectedModel; + context.currentVariant = variant; + } + + const explicitAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedAgent = + input.interactionMode === "plan" ? "plan" : (explicitAgent ?? context.regularAgent); + if (input.interactionMode !== "plan" && explicitAgent) { + context.regularAgent = explicitAgent; + } + if (selectedAgent && selectedAgent !== context.currentAgent) { + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/agent`, + { + operation: "session.switchAgent", + schema: Schema.Void, + body: { agent: selectedAgent }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.switchAgent", cause))); + context.currentAgent = selectedAgent; + } + + const steeringTurnId = context.activeTurnId; + const turnId = steeringTurnId ?? (yield* nextTurnId); + context.activeTurnId = turnId; + yield* updateSession( + context, + { + status: "running", + activeTurnId: turnId, + ...(selectedModel ? { model: selectedModel } : {}), + }, + "lastError", + ); + if (!steeringTurnId) { yield* emit({ - ...(yield* buildEventBase({ - threadId, - turnId: turnId ?? context.activeTurnId, - })), - type: "turn.aborted", + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", payload: { - reason: "Interrupted by user.", + ...(selectedModel ? { model: selectedModel } : {}), + ...(variant ? { effort: variant } : {}), }, }); } + + const prompt = { + text, + ...(files.length > 0 ? { files } : {}), + }; + const body = + connection.protocol.promptShape === "flat" + ? { ...prompt, delivery: "steer" as const } + : { prompt, delivery: "steer" as const }; + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/prompt`, + { + operation: "session.prompt", + schema: Schema.Unknown, + body, + }, + ).pipe( + Effect.mapError((cause) => toRequestError("session.prompt", cause)), + Effect.tapError((cause) => + steeringTurnId + ? Effect.void + : Effect.gen(function* () { + context.activeTurnId = undefined; + yield* updateSession( + context, + { status: "ready", lastError: cause.detail }, + "activeTurnId", + ); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.aborted", + payload: { reason: cause.detail }, + }); + }), + ), + ); + return { + threadId: input.threadId, + turnId, + resumeCursor: context.session.resumeCursor, + }; }, ); + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn( + "OpenCodeAdapter.interruptTurn", + )(function* (threadId, turnId) { + const context = yield* ensureSession(threadId); + yield* interruptContext(context, turnId); + }); + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( - "respondToRequest", + "OpenCodeAdapter.respondToRequest", )(function* (threadId, requestId, decision) { - const context = yield* ensureSessionContext(sessions, threadId); + const context = yield* ensureSession(threadId); if (!context.pendingPermissions.has(requestId)) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, @@ -1587,134 +1480,127 @@ export function makeOpenCodeAdapter( detail: `Unknown pending permission request: ${requestId}`, }); } - - yield* runOpenCodeSdk("permission.reply", () => - context.client.permission.reply({ - requestID: requestId, - reply: toOpenCodePermissionReply(decision), - }), - ).pipe(Effect.mapError(toRequestError)); + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/permission/${encodedPathSegment(requestId)}/reply`, + { + operation: "permission.reply", + schema: Schema.Void, + body: { reply: permissionReply(decision) }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("permission.reply", cause))); }); const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( - "respondToUserInput", + "OpenCodeAdapter.respondToUserInput", )(function* (threadId, requestId, answers) { - const context = yield* ensureSessionContext(sessions, threadId); - const request = context.pendingQuestions.get(requestId); - if (!request) { + const context = yield* ensureSession(threadId); + const form = context.pendingForms.get(requestId); + if (!form) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, - method: "question.reply", - detail: `Unknown pending user-input request: ${requestId}`, + method: "form.reply", + detail: `Unknown pending form request: ${requestId}`, }); } + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/form/${encodedPathSegment(requestId)}/reply`, + { + operation: "form.reply", + schema: Schema.Void, + body: { answer: normalizeFormAnswers(form, answers) }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("form.reply", cause))); + }); - yield* runOpenCodeSdk("question.reply", () => - context.client.question.reply({ - requestID: requestId, - answers: toOpenCodeQuestionAnswers(request, answers), - }), - ).pipe(Effect.mapError(toRequestError)); + const detachSession = Effect.fn("OpenCodeAdapter.detachSession")(function* ( + threadId: ThreadId, + ) { + const context = yield* ensureSession(threadId); + yield* detachContext(context); }); - const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("OpenCodeAdapter.readThread")( function* (threadId) { - const context = sessions.get(threadId); - if (!context) { - return yield* new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }); - } - const stopped = yield* stopOpenCodeContext(context); - sessions.delete(threadId); - if (!stopped) { - return; - } - yield* emit({ - ...(yield* buildEventBase({ threadId })), - type: "session.exited", - payload: { - reason: "Session stopped.", - recoverable: false, - exitKind: "graceful", + const context = yield* ensureSession(threadId); + const response = yield* request( + "GET", + `/api/session/${encodedPathSegment(context.providerSessionId)}/message`, + { + operation: "message.list", + schema: MessagesResponseSchema, + query: { order: "asc" }, }, - }); - }, - ); - - 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 = yield* ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ).pipe(Effect.mapError(toRequestError)); - + ).pipe(Effect.mapError((cause) => toRequestError("message.list", cause))); const turns: Array = []; - for (const entry of messages.data ?? []) { - if (entry.info.role === "assistant") { - turns.push({ - id: TurnId.make(entry.info.id), - items: [entry.info, ...entry.parts], - }); - } + for (const message of response.data) { + const record = recordFromUnknown(message); + if (!record || stringField(record, "type", "role") !== "assistant") continue; + const id = stringField(record, "id"); + if (!id) continue; + turns.push({ + id: TurnId.make(id), + items: Array.isArray(record.content) ? [...record.content] : [record], + }); } - - return { - threadId, - turns, - }; + return { threadId, turns } satisfies ProviderThreadSnapshot; }, ); - const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( - function* (threadId, numTurns) { - const context = yield* ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ).pipe(Effect.mapError(toRequestError)); - - const assistantMessages = (messages.data ?? []).filter( - (entry) => entry.info.role === "assistant", - ); - const targetIndex = assistantMessages.length - numTurns - 1; - const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; - yield* runOpenCodeSdk("session.revert", () => - context.client.session.revert({ - sessionID: context.openCodeSessionId, - ...(target ? { messageID: target.info.id } : {}), - }), - ).pipe(Effect.mapError(toRequestError)); - - return yield* readThread(threadId); - }, - ); + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn( + "OpenCodeAdapter.rollbackThread", + )(function* (threadId, numTurns) { + const context = yield* ensureSession(threadId); + const snapshot = yield* readThread(threadId); + if (numTurns <= 0) return snapshot; + const target = snapshot.turns[snapshot.turns.length - numTurns]; + if (!target) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: `Cannot roll back ${numTurns} OpenCode 2 turns from a thread with ${snapshot.turns.length} turns.`, + }); + } + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/revert/stage`, + { + operation: "session.revert.stage", + schema: Schema.Unknown, + body: { messageID: target.id }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.revert.stage", cause))); + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/revert/commit`, + { + operation: "session.revert.commit", + schema: Schema.Void, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.revert.commit", cause))); + return yield* readThread(threadId); + }); const stopAll: OpenCodeAdapterShape["stopAll"] = () => - Effect.gen(function* () { - const contexts = [...sessions.values()]; - sessions.clear(); - // `stopOpenCodeContext` is typed as never-failing — SDK aborts are - // already `Effect.ignore`'d inside it. `ignoreCause` here also - // swallows defects from throwing finalizers so one bad close can't - // interrupt the sibling fibers. Same pattern as the layer finalizer. - yield* Effect.forEach( - contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), - { concurrency: "unbounded", discard: true }, - ); + Effect.forEach([...sessions.keys()], detachSession, { + concurrency: "unbounded", + discard: true, }); + yield* Effect.addFinalizer(() => + stopAll().pipe( + Effect.ignoreCause, + Effect.ensuring( + Effect.sync(() => { + sessions.clear(); + sessionByProviderId.clear(); + }), + ), + Effect.ensuring(Queue.shutdown(events)), + ), + ); + return { provider: PROVIDER, capabilities: { @@ -1725,14 +1611,15 @@ export function makeOpenCodeAdapter( interruptTurn, respondToRequest, respondToUserInput, - stopSession, - listSessions, - hasSession, + stopSession: detachSession, + listSessions: () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)), + hasSession: (threadId) => Effect.sync(() => sessions.has(threadId)), readThread, rollbackThread, stopAll, get streamEvents() { - return Stream.fromQueue(runtimeEvents); + return Stream.fromQueue(events); }, } satisfies OpenCodeAdapterShape; }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 93f4b97995dc..b15575daae9b 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -1,353 +1,310 @@ import * as NodeAssert from "node:assert/strict"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { beforeEach } from "vite-plus/test"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; -import { OpenCodeSettings } from "@t3tools/contracts"; -import { ServerConfig } from "../../config.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; -import type { OpenCodeInventory } from "../opencodeRuntime.ts"; -const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); -const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; +const settings = { + enabled: true, + binaryPath: "opencode2", + customModels: [], +} as const; -/** - * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag - * are deleted. The snapshot-producing logic they wrapped now lives in the - * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which - * drivers call directly when building their per-instance snapshot - * `ServerProviderShape`. Tests mirror that shape: build a settings payload, - * invoke the check, assert on the returned snapshot. - */ - -const runtimeMock = { - state: { - runVersionError: null as Error | null, - versionStdout: DEFAULT_VERSION_STDOUT, - inventoryError: null as Error | null, - inventoryCwd: null as string | null, - closeCalls: 0, - inventory: { - providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, - agents: [] as unknown[], - skills: [] as unknown[], - } as unknown, - }, - reset() { - this.state.runVersionError = null; - this.state.versionStdout = DEFAULT_VERSION_STDOUT; - this.state.inventoryError = null; - this.state.inventoryCwd = null; - this.state.closeCalls = 0; - this.state.inventory = { - providerList: { connected: [], all: [] as unknown[], default: {} }, - agents: [] as unknown[], - skills: [] as unknown[], - }; - }, -}; - -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: () => - Effect.succeed({ - url: "http://127.0.0.1:4301", - exitCode: Effect.never, - }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.gen(function* () { - if (!serverUrl) { - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls += 1; +function runtimeLayer(input: { + readonly responses?: Readonly>; + readonly attachError?: OpenCodeRuntime.OpenCodeRuntimeFailure; + readonly failedPaths?: ReadonlySet; + readonly paths?: Array; +}) { + const connection: OpenCodeRuntime.OpenCodeConnection = { + url: "http://127.0.0.1:49374/", + protocol: { promptShape: "flat" }, + request: ((method: string, path: string, _requestInput: { readonly schema: unknown }) => { + input.paths?.push(`${method} ${path}`); + if (input.failedPaths?.has(path)) { + return Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: `probe${path}`, + reason: "http-status", + status: 404, }), ); } - return { - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), - }; - }), - runOpenCodeCommand: () => - runtimeMock.state.runVersionError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: runtimeMock.state.runVersionError.message, - cause: runtimeMock.state.runVersionError, - }), - ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), - createOpenCodeSdkClient: () => - ({}) as unknown as ReturnType, - loadOpenCodeInventory: () => - runtimeMock.state.inventoryError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: runtimeMock.state.inventoryError.message, - cause: runtimeMock.state.inventoryError, - }), - ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), - loadInventoryFromCli: ({ cwd }) => { - runtimeMock.state.inventoryCwd = cwd; - return runtimeMock.state.inventoryError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: runtimeMock.state.inventoryError.message, - cause: runtimeMock.state.inventoryError, - }), - ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); - }, -}; - -beforeEach(() => { - runtimeMock.reset(); -}); - -const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(NodeServices.layer), -); - -const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSettings => - decodeOpenCodeSettings({ - enabled: true, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], - ...overrides, - }); - -it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { - it.effect("shows a codex-style missing binary message", () => - Effect.gen(function* () { - runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, false); - NodeAssert.equal( - snapshot.message, - "OpenCode CLI (`opencode`) is not installed or not on PATH.", - ); + return Effect.succeed(input.responses?.[path]); + }) as OpenCodeRuntime.OpenCodeConnection["request"], + globalEvents: Stream.empty, + }; + return Layer.succeed( + OpenCodeRuntime.OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime.of({ + attach: () => + input.attachError ? Effect.fail(input.attachError) : Effect.succeed(connection), }), ); +} - 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 snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); - }), - ); +it.effect("builds model inventory from the attached OpenCode 2 service", () => { + const paths: Array = []; + return Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); - it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => - Effect.gen(function* () { - runtimeMock.state.inventory = { - providerList: { - connected: ["openai"], - all: [ - { - id: "openai", - name: "OpenAI", - models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", - variants: { - none: {}, - low: {}, - medium: {}, - high: {}, - xhigh: {}, - }, - }, + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "0.0.0-beta-17823"); + NodeAssert.equal(snapshot.auth.status, "authenticated"); + NodeAssert.deepEqual(paths.toSorted(), [ + "GET /api/agent", + "GET /api/health", + "GET /api/model", + "GET /api/model/default", + "GET /api/skill", + ]); + NodeAssert.deepEqual( + snapshot.models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), + [ + { slug: "anthropic/claude-opus-4-6", isDefault: undefined }, + { slug: "openai/gpt-5.6", isDefault: true }, + ], + ); + const selected = snapshot.models.find((model) => model.slug === "openai/gpt-5.6"); + NodeAssert.deepEqual( + selected?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id) ?? [], + ["variant", "agent"], + ); + NodeAssert.deepEqual(snapshot.skills, [ + { + name: "ship", + path: "/work/.agents/skills/ship/SKILL.md", + enabled: true, + description: "Ship a change.", + shortDescription: "Ship a change.", + }, + ]); + }).pipe( + Effect.provide( + runtimeLayer({ + paths, + responses: { + "/api/health": { healthy: true, version: "0.0.0-beta-17823", pid: 42 }, + "/api/model": { + data: [ + { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + variants: { low: {}, high: {} }, }, - }, - ], - default: {}, - }, - agents: [ - { name: "build", hidden: false, mode: "primary" }, - { name: "plan", hidden: false, mode: "primary" }, - ], - }; - - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); - - NodeAssert.ok(model); - const variantDescriptor = model.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.id === "variant" && descriptor.type === "select", - ); - NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); - NodeAssert.equal( - variantDescriptor.options.find((option) => option.isDefault === true)?.id, - "medium", - ); - const agentDescriptor = model.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.id === "agent" && descriptor.type === "select", - ); - NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); - NodeAssert.equal( - agentDescriptor.options.find((option) => option.isDefault === true)?.id, - "build", - ); - }), - ); - - it.effect("includes OpenCode skills in the provider snapshot", () => - Effect.gen(function* () { - runtimeMock.state.inventory = { - providerList: { - connected: ["openai"], - all: [ - { - id: "openai", - name: "OpenAI", - models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", - variants: {}, - }, + { + id: "claude-opus-4-6", + providerID: "anthropic", + name: "Claude Opus 4.6", + variants: [], }, + ], + }, + "/api/model/default": { + data: { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + variants: { low: {}, high: {} }, }, - ], - default: {}, - }, - agents: [], - skills: [ - { - name: "openclaw-review", - description: "Review OpenClaw workflow changes.", - location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - content: "---\nname: openclaw-review\n---\n", }, - { - name: "openclaw-triage", - description: "Triage OpenClaw routing issues.", - location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - content: "---\nname: openclaw-triage\n---\n", + "/api/agent": { + data: [ + { id: "build", name: "Build" }, + { id: "explore", name: "Explore", mode: "subagent" }, + ], }, - { - name: "missing-location", - description: "This incomplete SDK row should be skipped.", - location: "", - content: "---\nname: missing-location\n---\n", + "/api/skill": { + data: [ + { + name: "ship", + description: "Ship a change.", + location: "/work/.agents/skills/ship/SKILL.md", + }, + ], }, - ], - }; + }, + }), + ), + ); +}); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); +it.effect("keeps core discovery ready when optional metadata endpoints fail", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); - NodeAssert.deepEqual( - snapshot.skills.map((skill) => ({ - name: skill.name, - path: skill.path, - enabled: skill.enabled, - shortDescription: skill.shortDescription, - })), - [ - { - name: "openclaw-review", - path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - enabled: true, - shortDescription: "Review OpenClaw workflow changes.", - }, - { - name: "openclaw-triage", - path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - enabled: true, - shortDescription: "Triage OpenClaw routing issues.", + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.deepEqual( + snapshot.models.map((model) => model.slug), + ["openai/gpt-5.6"], + ); + NodeAssert.deepEqual(snapshot.skills, []); + NodeAssert.deepEqual( + snapshot.models[0]?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id) ?? [], + [], + ); + }).pipe( + Effect.provide( + runtimeLayer({ + failedPaths: new Set(["/api/model/default", "/api/agent", "/api/skill"]), + responses: { + "/api/health": { healthy: true, version: "0.0.0-beta-17823" }, + "/api/model": { + data: [ + { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + }, + ], }, - ], - ); - }), - ); - - it.effect("does not spawn a local server for health check (uses CLI instead)", () => - Effect.gen(function* () { - yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + }, + }), + ), + ), +); - NodeAssert.equal(runtimeMock.state.closeCalls, 0); - NodeAssert.equal(runtimeMock.state.inventoryCwd, process.cwd()); - }), - ); +it.effect("reports unsupported adjacent preview protocols without hanging", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); - it.effect("reports local model inventory failures without treating them as empty", () => - Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error("opencode models failed"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.match(snapshot.message ?? "", /preview is not supported/i); + }).pipe( + Effect.provide( + runtimeLayer({ + attachError: new OpenCodeRuntime.OpenCodeUnsupportedPreviewError({ + operation: "openapi.detect", + }), + }), + ), + ), +); - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal(snapshot.models.length, 0); - NodeAssert.equal( - snapshot.message, - "Failed to execute OpenCode CLI health check: opencode models failed", - ); - }), - ); -}); +it.effect("does not report an HTTP 404 as a missing CLI", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); -it.layer(testLayer)("checkOpenCodeProviderStatus 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 snapshot = yield* checkOpenCodeProviderStatus( - makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.doesNotMatch(snapshot.message ?? "", /not installed/i); + }).pipe( + Effect.provide( + runtimeLayer({ + attachError: new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + reason: "http-status", + status: 404, }), - process.cwd(), - ); + }), + ), + ), +); - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal( - snapshot.message, - "OpenCode server rejected authentication. Check the server URL and password.", - ); - }), - ); +it.effect("reports a structurally missing CLI", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); - 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 snapshot = yield* checkOpenCodeProviderStatus( - makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.match(snapshot.message ?? "", /not installed or not on PATH/i); + }).pipe( + Effect.provide( + runtimeLayer({ + attachError: new OpenCodeRuntime.OpenCodeCommandNotFoundError({ + operation: "service.start", + cause: new Error("Command was unavailable."), }), - process.cwd(), - ); + }), + ), + ), +); - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.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.", - ); - }), +it.effect("warns when the attached OpenCode 2 service reports unhealthy", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.match(snapshot.message ?? "", /reported that it is unhealthy/i); + }).pipe( + Effect.provide( + runtimeLayer({ + responses: { + "/api/health": { healthy: false, version: "0.0.0-beta-17823" }, + "/api/model": { + data: [ + { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + }, + ], + }, + "/api/model/default": { data: null }, + "/api/agent": { data: [] }, + "/api/skill": { data: [] }, + }, + }), + ), + ), +); + +it.effect("describes custom models without counting deprecated upstream providers", () => { + const customSettings = { + ...settings, + customModels: ["custom/local-model"], + }; + return Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(customSettings); + + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.match(snapshot.message ?? "", /custom models configured/i); + NodeAssert.doesNotMatch(snapshot.message ?? "", /0 upstream providers/i); + }).pipe( + Effect.provide( + runtimeLayer({ + responses: { + "/api/health": { healthy: true, version: "0.0.0-beta-17823" }, + "/api/model": { + data: [ + { + id: "old-model", + providerID: "legacy", + name: "Old Model", + status: "deprecated", + }, + ], + }, + "/api/model/default": { data: null }, + "/api/agent": { data: [] }, + "/api/skill": { data: [] }, + }, + }), + ), ); }); + +it.effect("finishes the provider check when attachment is wedged", () => { + const layer = Layer.succeed( + OpenCodeRuntime.OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime.of({ attach: () => Effect.never }), + ); + return Effect.gen(function* () { + const fiber = yield* checkOpenCodeProviderStatus(settings).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust("15 seconds"); + const snapshot = yield* Fiber.join(fiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.match(snapshot.message ?? "", /discovery timed out/i); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, TestClock.layer()))); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 62f29c47eb38..be3b5bb8bbe5 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -1,215 +1,170 @@ -import { - type ModelCapabilities, - type OpenCodeSettings, - type ServerProviderModel, - type ServerProviderSkill, +import type { + ModelCapabilities, + ServerProviderModel, + ServerProviderSkill, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { compareSemverVersions } from "@t3tools/shared/semver"; import { buildServerProvider, nonEmptyTrimmed, - parseGenericCliVersion, providerModelsFromSettings, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { - OpenCodeRuntime, - openCodeRuntimeErrorDetail, - type OpenCodeInventory, -} from "../opencodeRuntime.ts"; -import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; + +export interface OpenCodeProviderSettings { + readonly enabled: boolean; + readonly binaryPath: string; + readonly customModels: ReadonlyArray; +} const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, } as const; -const MINIMUM_OPENCODE_VERSION = "1.14.19"; - -class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ - readonly cause: unknown; - readonly detail: string; -}> {} - -function normalizeProbeMessage(message: string): string | undefined { - const trimmed = message.trim(); - if (trimmed.length === 0) { - return undefined; - } - if ( - trimmed === "An error occurred in Effect.tryPromise" || - trimmed === "An error occurred in Effect.try" - ) { - return undefined; - } - return trimmed; -} - -function normalizedErrorMessage(cause: unknown): string | undefined { - if (cause instanceof OpenCodeProbeError) { - return normalizeProbeMessage(cause.detail); - } +const OPENCODE_PROVIDER_PROBE_TIMEOUT = "15 seconds"; - if (!(cause instanceof Error)) { - return undefined; - } - - return normalizeProbeMessage(cause.message); -} +const OpenCodeHealthSchema = Schema.Struct({ + healthy: Schema.optionalKey(Schema.Boolean), + version: Schema.optionalKey(Schema.String), + pid: Schema.optionalKey(Schema.Number), +}); -function formatOpenCodeProbeError(input: { - readonly cause: unknown; - readonly isExternalServer: boolean; - readonly serverUrl: string; -}): { readonly installed: boolean; readonly message: string } { - const detail = normalizedErrorMessage(input.cause); - const lower = detail?.toLowerCase() ?? ""; +const OpenCodeVariantSchema = Schema.Union([Schema.String, Schema.Struct({ id: Schema.String })]); + +const OpenCodeModelSchema = Schema.Struct({ + id: Schema.String, + providerID: Schema.String, + name: Schema.String, + enabled: Schema.optionalKey(Schema.Boolean), + status: Schema.optionalKey(Schema.String), + variants: Schema.optionalKey( + Schema.Union([ + Schema.Array(OpenCodeVariantSchema), + Schema.Record(Schema.String, Schema.Unknown), + ]), + ), +}); - 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.", - }; - } +const OpenCodeModelListSchema = Schema.Union([ + Schema.Array(OpenCodeModelSchema), + Schema.Struct({ data: Schema.Array(OpenCodeModelSchema) }), +]); + +const OpenCodeDefaultModelSchema = Schema.Union([ + Schema.NullOr(OpenCodeModelSchema), + Schema.Struct({ data: Schema.NullOr(OpenCodeModelSchema) }), +]); + +const OpenCodeAgentSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + mode: Schema.optionalKey(Schema.String), + hidden: Schema.optionalKey(Schema.Boolean), + description: Schema.optionalKey(Schema.String), +}); - 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.`, - }; - } +const OpenCodeAgentListSchema = Schema.Union([ + Schema.Array(OpenCodeAgentSchema), + Schema.Struct({ data: Schema.Array(OpenCodeAgentSchema) }), +]); - return { - installed: true, - message: detail ?? "Failed to connect to the configured OpenCode server.", - }; - } +const OpenCodeSkillSchema = Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), + location: Schema.String, +}); - if (lower.includes("enoent") || lower.includes("notfound")) { - return { - installed: false, - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", - }; - } +const OpenCodeSkillListSchema = Schema.Union([ + Schema.Array(OpenCodeSkillSchema), + Schema.Struct({ data: Schema.Array(OpenCodeSkillSchema) }), +]); - 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.", - }; - } +type OpenCodeModel = typeof OpenCodeModelSchema.Type; +type OpenCodeAgent = typeof OpenCodeAgentSchema.Type; +type OpenCodeSkill = typeof OpenCodeSkillSchema.Type; - 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.", - }; - } +const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); - return { - installed: true, - message: detail - ? `Failed to execute OpenCode CLI health check: ${detail}` - : "Failed to execute OpenCode CLI health check.", - }; +function responseList(value: ReadonlyArray | { readonly data: ReadonlyArray }) { + return "data" in value ? value.data : value; } -function titleCaseSlug(value: string): string { - const segments: Array = []; - for (const segment of value.split(/[-_/]+/)) { - if (segment.length > 0) { - segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); - } - } - return segments.join(" "); +function responseValue(value: A | null | { readonly data: A | null }): A | null { + return value !== null && typeof value === "object" && "data" in value ? value.data : value; } -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 titleCase(value: string): string { + return value + .split(/[-_/]+/u) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); } -function inferDefaultAgent(agents: ReadonlyArray): string | undefined { - return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; +function variantIds(model: OpenCodeModel): ReadonlyArray { + if (!model.variants) return []; + if (Array.isArray(model.variants)) { + return model.variants.map((variant) => (typeof variant === "string" ? variant : variant.id)); + } + return Object.keys(model.variants); } -const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -function openCodeCapabilitiesForModel(input: { - readonly providerID: string; - readonly model: ProviderListResponse["all"][number]["models"][string]; - readonly agents: ReadonlyArray; -}): ModelCapabilities { - const variantValues = Object.keys(input.model.variants ?? {}); - const defaultVariant = inferDefaultVariant(input.providerID, variantValues); - const variantOptions = variantValues.map((value) => - defaultVariant === value - ? { id: value, label: titleCaseSlug(value), isDefault: true as const } - : { id: value, label: titleCaseSlug(value) }, - ); - const primaryAgents = input.agents.filter( - (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), - ); - const defaultAgent = inferDefaultAgent(primaryAgents); - const agentOptions = primaryAgents.map((agent) => - defaultAgent === agent.name - ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } - : { id: agent.name, label: titleCaseSlug(agent.name) }, +function modelCapabilities( + model: OpenCodeModel, + agents: ReadonlyArray, +): ModelCapabilities { + const variants = variantIds(model); + const defaultVariant = variants.includes("default") + ? "default" + : variants.includes("medium") + ? "medium" + : variants.length === 1 + ? variants[0] + : undefined; + const visibleAgents = agents.filter( + (agent) => agent.hidden !== true && agent.mode !== "subagent", ); + const defaultAgent = + visibleAgents.find((agent) => agent.id === "build")?.id ?? visibleAgents[0]?.id; + return createModelCapabilities({ optionDescriptors: [ - ...(variantOptions.length > 0 + ...(variants.length > 0 ? [ { id: "variant", label: "Variant", type: "select" as const, - options: variantOptions, + options: variants.map((variant) => ({ + id: variant, + label: titleCase(variant), + ...(variant === defaultVariant ? { isDefault: true as const } : {}), + })), ...(defaultVariant ? { currentValue: defaultVariant } : {}), }, ] : []), - ...(agentOptions.length > 0 + ...(visibleAgents.length > 0 ? [ { id: "agent", label: "Agent", type: "select" as const, - options: agentOptions, + options: visibleAgents.map((agent) => ({ + id: agent.id, + label: nonEmptyTrimmed(agent.name) ?? titleCase(agent.id), + ...(agent.id === defaultAgent ? { isDefault: true as const } : {}), + })), ...(defaultAgent ? { currentValue: defaultAgent } : {}), }, ] @@ -218,247 +173,202 @@ function openCodeCapabilitiesForModel(input: { }); } -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)) { - const name = nonEmptyTrimmed(model.name); - if (!name) { - continue; - } - - const subProvider = nonEmptyTrimmed(provider.name); - models.push({ - slug: `${provider.id}/${model.id}`, - name, - ...(subProvider ? { subProvider } : {}), +function providerModels(input: { + readonly models: ReadonlyArray; + readonly defaultModel: OpenCodeModel | null; + readonly agents: ReadonlyArray; +}): ReadonlyArray { + const defaultSlug = input.defaultModel + ? `${input.defaultModel.providerID}/${input.defaultModel.id}` + : null; + return input.models + .filter((model) => model.enabled !== false && model.status !== "deprecated") + .map((model) => { + const slug = `${model.providerID}/${model.id}`; + return { + slug, + name: nonEmptyTrimmed(model.name) ?? model.id, + subProvider: model.providerID, isCustom: false, - capabilities: openCodeCapabilitiesForModel({ - providerID: provider.id, - model, - agents: input.agents, - }), - }); - } - } - - return models.toSorted((left, right) => left.name.localeCompare(right.name)); + ...(slug === defaultSlug ? { isDefault: true as const } : {}), + capabilities: modelCapabilities(model, input.agents), + }; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); } -function trimOptional(value: string | null | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; +function providerSkills(skills: ReadonlyArray): ReadonlyArray { + return skills + .flatMap((skill) => { + const name = nonEmptyTrimmed(skill.name); + const path = nonEmptyTrimmed(skill.location); + if (!name || !path) return []; + const description = nonEmptyTrimmed(skill.description ?? ""); + return [ + { + name, + path, + enabled: true, + ...(description ? { description, shortDescription: description } : {}), + }, + ]; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); } -function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray { - const skills: ServerProviderSkill[] = []; - for (const skill of input.skills ?? []) { - const name = trimOptional(skill.name); - const path = trimOptional(skill.location); - if (!name || !path) { - continue; - } - - const description = trimOptional(skill.description); - skills.push({ - name, - path, - enabled: true, - ...(description ? { description, shortDescription: description } : {}), - }); - } - - return skills.toSorted((left, right) => left.name.localeCompare(right.name)); +function failureSnapshot(input: { + readonly settings: OpenCodeProviderSettings; + readonly checkedAt: string; + readonly cause: unknown; + readonly version?: string | null; +}): ServerProviderDraft { + const unsupported = OpenCodeRuntime.isOpenCodeUnsupportedPreviewError(input.cause); + const missing = OpenCodeRuntime.isOpenCodeCommandNotFoundError(input.cause); + const timedOut = OpenCodeRuntime.isOpenCodeTimeoutError(input.cause); + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: input.settings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + [], + input.settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: !missing, + version: input.version ?? null, + status: "error", + auth: { status: "unknown" }, + message: unsupported + ? "This OpenCode 2 preview is not supported by T3 Code." + : missing + ? "OpenCode 2 CLI (`opencode2`) is not installed or not on PATH." + : timedOut + ? "OpenCode 2 provider discovery timed out." + : input.cause instanceof Error + ? input.cause.message + : "Failed to connect to the OpenCode 2 background service.", + }, + }); } -export const makePendingOpenCodeProvider = ( - openCodeSettings: OpenCodeSettings, +export const buildInitialOpenCodeProviderSnapshot = ( + settings: OpenCodeProviderSettings, ): Effect.Effect => Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = providerModelsFromSettings( - [], - openCodeSettings.customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - 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.", - }, - }); - } - + const checkedAt = DateTime.formatIso(yield* DateTime.now); return buildServerProvider({ presentation: OPENCODE_PRESENTATION, - enabled: true, + enabled: settings.enabled, checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "OpenCode provider status has not been checked in this session yet.", - }, + models: providerModelsFromSettings( + [], + settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: settings.enabled + ? { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode 2 provider status has not been checked in this session yet.", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode 2 is disabled in T3 Code settings.", + }, }); }); export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( - openCodeSettings: OpenCodeSettings, - cwd: string, + settings: OpenCodeProviderSettings, environment?: NodeJS.ProcessEnv, -): Effect.fn.Return { - const openCodeRuntime = yield* OpenCodeRuntime; - const resolvedEnvironment = environment ?? process.env; +): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const customModels = openCodeSettings.customModels; - const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; - - const fallback = (cause: unknown, version: string | null = null) => { - const failure = formatOpenCodeProbeError({ - cause, - isExternalServer, - serverUrl: openCodeSettings.serverUrl, - }); - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, + if (!settings.enabled) return yield* buildInitialOpenCodeProviderSnapshot(settings); + + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const attached = yield* Effect.exit( + Effect.gen(function* () { + const connection = yield* runtime.attach({ + binaryPath: settings.binaryPath || "opencode2", + ...(environment ? { environment } : {}), + }); + const [health, modelsResponse, defaultResponse, agentsResponse, skillsResponse] = + yield* Effect.all( + [ + connection.request("GET", "/api/health", { + operation: "health.get", + schema: OpenCodeHealthSchema, + }), + connection.request("GET", "/api/model", { + operation: "model.list", + schema: OpenCodeModelListSchema, + }), + connection + .request("GET", "/api/model/default", { + operation: "model.default", + schema: OpenCodeDefaultModelSchema, + }) + .pipe(Effect.orElseSucceed(() => null)), + connection + .request("GET", "/api/agent", { + operation: "agent.list", + schema: OpenCodeAgentListSchema, + }) + .pipe(Effect.orElseSucceed(() => [])), + connection + .request("GET", "/api/skill", { + operation: "skill.list", + schema: OpenCodeSkillListSchema, + }) + .pipe(Effect.orElseSucceed(() => [])), + ], + { concurrency: "unbounded" }, + ); + return { + health, + models: responseList(modelsResponse), + defaultModel: responseValue(defaultResponse), + agents: responseList(agentsResponse), + skills: responseList(skillsResponse), + }; + }).pipe(Effect.timeoutOption(OPENCODE_PROVIDER_PROBE_TIMEOUT)), + ); + if (Exit.isFailure(attached)) { + return failureSnapshot({ + settings, checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: failure.installed, - version, - status: "error", - auth: { status: "unknown" }, - message: failure.message, - }, + cause: Cause.squash(attached.cause), }); - }; + } - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, + if (Option.isNone(attached.value)) { + return failureSnapshot({ + settings, checkedAt, - models: providerModelsFromSettings([], 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.", - }, + cause: new OpenCodeRuntime.OpenCodeTimeoutError({ + operation: "provider.probe", + }), }); } - let version: string | null = null; - if (!isExternalServer) { - const versionExit = yield* Effect.exit( - openCodeRuntime - .runOpenCodeCommand({ - binaryPath: openCodeSettings.binaryPath, - args: ["--version"], - environment: resolvedEnvironment, - }) - .pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ), - ); - if (versionExit._tag === "Failure") { - return fallback(Cause.squash(versionExit.cause)); - } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; - - if (!version) { - return fallback( - new Error( - `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - ), - null, - ); - } - if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, - }, - }); - } - } - - const inventoryExit = yield* Effect.exit( - (isExternalServer - ? Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath: openCodeSettings.binaryPath, - serverUrl: openCodeSettings.serverUrl, - environment: resolvedEnvironment, - }); - return yield* openCodeRuntime.loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }), - ); - }), - ) - : openCodeRuntime.loadInventoryFromCli({ - binaryPath: openCodeSettings.binaryPath, - cwd, - environment: resolvedEnvironment, - }) - ).pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ), - ); - if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version); - } - + const value = attached.value.value; + const discoveredModels = providerModels(value); + const skills = providerSkills(value.skills); const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value), - customModels, + discoveredModels, + settings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); - const skills = flattenOpenCodeSkills(inventoryExit.value); - const connectedCount = inventoryExit.value.providerList.connected.length; + const providerCount = new Set( + discoveredModels.map((model) => model.subProvider).filter((provider) => provider !== undefined), + ).size; + const healthy = value.health.healthy !== false; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, @@ -467,18 +377,16 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu skills, 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.", + version: value.health.version ?? null, + status: healthy && models.length > 0 ? "ready" : "warning", + auth: { status: "authenticated", type: "opencode" }, + message: !healthy + ? "The OpenCode 2 background service reported that it is unhealthy." + : providerCount > 0 + ? `${providerCount} upstream provider${providerCount === 1 ? "" : "s"} available through the OpenCode 2 background service.` + : models.length > 0 + ? "Connected to OpenCode 2 using the custom models configured in T3 Code." + : "Connected to OpenCode 2, but it did not report any available models.", }, }); }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb0..97f49cc8ca42 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -18,7 +18,7 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` + * without trying to spawn real provider binaries * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -43,12 +43,9 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; -import { CursorDriver } from "../Drivers/CursorDriver.ts"; -import { GrokDriver } from "../Drivers/GrokDriver.ts"; -import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; -import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -126,9 +123,7 @@ const makeGrokConfig = (overrides: Partial): GrokSettings => ({ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", + binaryPath: "opencode2", customModels: [], ...overrides, }); @@ -293,17 +288,8 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { describe("ProviderInstanceRegistryLive — all drivers slice", () => { // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem + // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` - // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. - // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via - // `Layer.provide`, so the only external requirement it still exposes is - // `ChildProcessSpawner` — resolved here by piping it through - // `provideMerge(NodeServices.layer)`. - // - // The nested `provideMerge`s read bottom-up: `NodeServices.layer` - // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs - // surfaced; that merged layer then provides `ServerConfig.layerTest`'s - // `FileSystem` dep while keeping everything else surfaced to the test. - const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); + // at construction time, so we wire the shared native runtime into the stack. + const infraLayer = OpenCodeRuntime.layer.pipe(Layer.provideMerge(NodeServices.layer)); const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-all-drivers-test", }).pipe( @@ -365,7 +351,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: BUILT_IN_DRIVERS, configMap, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9a72ea83d3c0..5ff8929eaa70 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1419,7 +1419,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will @@ -1512,7 +1512,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { spawnedCommands.push((command as { readonly command: string }).command); @@ -1634,7 +1634,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), ); @@ -1672,6 +1672,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te grok: { enabled: false, }, + opencode: { + enabled: false, + }, }, }), ), @@ -1696,7 +1699,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e8..9c3badf0cb1a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -83,8 +83,8 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => return true; } - // OpenCode's initial snapshot is deliberately non-authoritative while its - // first probe is still running. A probe error from an installed CLI/server + // OpenCode snapshots are deliberately non-authoritative while their first + // probe is still running. A probe error from an installed CLI/server // is likewise partial: it could not establish the current inventory. // Conversely, disabled and missing-CLI snapshots are authoritative removals, // as are successful ready/warning inventories (including an empty one after diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts deleted file mode 100644 index 8d5ba353389d..000000000000 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import { describe, it } from "vite-plus/test"; - -import { - parseAgentListCliOutput, - parseModelsCliOutput, - parseSkillsCliOutput, -} from "./opencodeRuntime.ts"; - -describe("parseModelsCliOutput", () => { - it("parses a single model from a single provider", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - JSON.stringify({ - id: "claude-sonnet-4-5", - providerID: "anthropic", - name: "Claude Sonnet 4.5", - capabilities: { temperature: true, reasoning: true, toolcall: true }, - cost: { input: 3, output: 15 }, - limit: { context: 200000, output: 8192 }, - status: "active", - options: {}, - headers: {}, - release_date: "2025-01-01", - }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - NodeAssert.equal(result.connected.length, 1); - NodeAssert.equal(result.connected[0], "anthropic"); - - const provider = result.providers.get("anthropic")!; - NodeAssert.ok(provider); - NodeAssert.equal(provider.id, "anthropic"); - NodeAssert.equal(provider.name, "anthropic"); - NodeAssert.equal(Object.keys(provider.models).length, 1); - - const model = provider.models["claude-sonnet-4-5"]!; - NodeAssert.ok(model); - NodeAssert.equal(model.id, "claude-sonnet-4-5"); - NodeAssert.equal(model.providerID, "anthropic"); - NodeAssert.equal(model.name, "Claude Sonnet 4.5"); - }); - - it("parses multiple models from multiple providers", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), - "anthropic/claude-haiku-4-5", - JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), - "openai/gpt-4o", - JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 2); - NodeAssert.equal(result.connected.length, 2); - NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); - NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); - NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); - }); - - it("handles empty input", () => { - const result = parseModelsCliOutput(""); - NodeAssert.equal(result.providers.size, 0); - NodeAssert.equal(result.connected.length, 0); - }); - - it("skips unparseable JSON blocks", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - "this is not valid json {{{", - "anthropic/claude-haiku-4-5", - JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - const provider = result.providers.get("anthropic")!; - NodeAssert.equal(Object.keys(provider.models).length, 1); - NodeAssert.ok(provider.models["claude-haiku-4-5"]); - }); - - it("handles Windows-style CRLF line endings", () => { - const stdout = - "anthropic/claude-sonnet-4-5\r\n" + - JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + - "\r\n"; - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); - }); - - it("handles model JSON with variants and nested fields", () => { - const stdout = [ - "opencode/gpt-5.4", - JSON.stringify({ - id: "gpt-5.4", - providerID: "opencode", - name: "GPT-5.4", - family: "gpt", - capabilities: { - temperature: true, - reasoning: true, - attachment: false, - toolcall: true, - input: { text: true, audio: false, image: false, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 200000, input: 160000, output: 32000 }, - status: "active", - options: {}, - headers: {}, - release_date: "2025-01-01", - variants: { none: {}, low: {}, medium: {}, high: {} }, - }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - const model = result.providers.get("opencode")!.models["gpt-5.4"]!; - NodeAssert.ok(model); - NodeAssert.ok(model.capabilities); - NodeAssert.equal(model.capabilities!.reasoning, true); - NodeAssert.ok(model.variants); - NodeAssert.equal(model.variants!["medium"] !== undefined, true); - }); - - it("keeps a model whose JSON body has a slash and no interior whitespace", () => { - // OpenRouter-style: the model id contains a `/` and no string value has a - // space, so the JSON body line itself matches the slug regex. It must still - // be treated as the body of the preceding slug, not a new slug. - const stdout = [ - "openrouter/qwen/qwen3-coder", - JSON.stringify({ - id: "qwen/qwen3-coder", - providerID: "openrouter", - name: "qwen3-coder", - status: "active", - }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - NodeAssert.deepEqual([...result.connected], ["openrouter"]); - const provider = result.providers.get("openrouter")!; - NodeAssert.ok(provider); - const model = provider.models["qwen/qwen3-coder"]!; - NodeAssert.ok(model); - NodeAssert.equal(model.id, "qwen/qwen3-coder"); - NodeAssert.equal(model.providerID, "openrouter"); - }); -}); - -describe("parseAgentListCliOutput", () => { - it("parses a single agent", () => { - const stdout = [ - "build (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.name, "build"); - NodeAssert.equal(result[0]!.mode, "primary"); - NodeAssert.equal(result[0]!.permission.length, 1); - }); - - it("parses multiple agents", () => { - const stdout = [ - "build (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - "explore (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - "plan (primary)", - " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 3); - NodeAssert.equal(result[0]!.name, "build"); - NodeAssert.equal(result[0]!.mode, "primary"); - NodeAssert.equal(result[1]!.name, "explore"); - NodeAssert.equal(result[1]!.mode, "subagent"); - NodeAssert.equal(result[2]!.name, "plan"); - NodeAssert.equal(result[2]!.mode, "primary"); - }); - - it("handles empty input", () => { - const result = parseAgentListCliOutput(""); - NodeAssert.equal(result.length, 0); - }); - - it("skips agents with unparseable permission JSON", () => { - const stdout = [ - "build (primary)", - " not valid json {", - "explore (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.name, "explore"); - }); - - it("handles real-world permission blocks with nested paths", () => { - const permissions = [ - { permission: "*", action: "allow", pattern: "*" }, - { - permission: "external_directory", - pattern: "C:\\Users\\test\\.local\\*", - action: "allow", - }, - { permission: "read", pattern: "*.env", action: "ask" }, - ]; - const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.permission.length, 3); - NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); - NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); - }); - - it("handles agent names with spaces", () => { - const stdout = [ - "code reviewer (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - "my custom agent (primary)", - " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 2); - NodeAssert.equal(result[0]!.name, "code reviewer"); - NodeAssert.equal(result[0]!.mode, "subagent"); - NodeAssert.equal(result[1]!.name, "my custom agent"); - NodeAssert.equal(result[1]!.mode, "primary"); - }); - - it("marks known hidden agents", () => { - const stdout = [ - "compaction (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - "build (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result[0]!.hidden, true); - NodeAssert.equal(result[1]!.hidden, false); - }); -}); - -describe("parseSkillsCliOutput", () => { - it("parses skill metadata from the CLI JSON output", () => { - const result = parseSkillsCliOutput( - JSON.stringify([ - { - name: "review-pr", - description: "Review a pull request.", - location: "/tmp/review-pr/SKILL.md", - content: "---\nname: review-pr\n---\n", - }, - ]), - ); - - NodeAssert.deepEqual(result, [ - { - name: "review-pr", - description: "Review a pull request.", - location: "/tmp/review-pr/SKILL.md", - content: "---\nname: review-pr\n---\n", - }, - ]); - }); - - it("degrades malformed output to an empty skill list", () => { - NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []); - }); -}); diff --git a/apps/server/src/provider/opencodeRuntime.environment.test.ts b/apps/server/src/provider/opencodeRuntime.environment.test.ts deleted file mode 100644 index b56921a686fb..000000000000 --- a/apps/server/src/provider/opencodeRuntime.environment.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveOpenCodeConfigContent } from "./opencodeRuntime.ts"; - -describe("resolveOpenCodeConfigContent", () => { - it("prefers the caller environment over the inherited environment", () => { - expect( - resolveOpenCodeConfigContent( - { OPENCODE_CONFIG_CONTENT: '{"source":"caller"}' }, - { OPENCODE_CONFIG_CONTENT: '{"source":"process"}' }, - ), - ).toBe('{"source":"caller"}'); - }); - - it("falls back to the inherited environment and then an empty config", () => { - expect( - resolveOpenCodeConfigContent(undefined, { - OPENCODE_CONFIG_CONTENT: '{"source":"process"}', - }), - ).toBe('{"source":"process"}'); - expect(resolveOpenCodeConfigContent(undefined, {})).toBe("{}"); - }); -}); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts deleted file mode 100644 index 8b22a52a2060..000000000000 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { OpencodeClient } from "@opencode-ai/sdk/v2"; -import { it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; - -const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); - -it.layer(testLayer)("loadOpenCodeInventory", (it) => { - it.effect("keeps provider inventory when skill discovery fails", () => - Effect.gen(function* () { - const runtime = yield* OpenCodeRuntime; - const client = { - provider: { - list: () => - Promise.resolve({ - data: { - connected: ["openai"], - all: [], - default: {}, - }, - }), - }, - app: { - agents: () => Promise.resolve({ data: [] }), - skills: () => Promise.reject(new Error("skills endpoint unavailable")), - }, - } as unknown as OpencodeClient; - - const inventory = yield* runtime.loadOpenCodeInventory(client); - - NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); - NodeAssert.deepEqual(inventory.agents, []); - NodeAssert.deepEqual(inventory.skills, []); - }), - ); -}); diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts new file mode 100644 index 000000000000..ed6c61185fff --- /dev/null +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -0,0 +1,363 @@ +import * as NodeAssert from "node:assert/strict"; + +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; + +interface RecordedCommand { + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly environment: NodeJS.ProcessEnv; +} + +function commandHandle(result: OpenCodeRuntime.OpenCodeCommandResult) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(result.stdout)), + stderr: Stream.encodeText(Stream.make(result.stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function openApi( + promptProperty: "text" | "prompt", + operationId: string | null = "v2.session.prompt", +) { + return { + paths: { + "/api/session/{sessionID}/prompt": { + post: { + ...(operationId === null ? {} : { operationId }), + requestBody: { + content: { + "application/json": { + schema: { $ref: "#/components/schemas/SessionPrompt" }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + SessionPrompt: { + type: "object", + properties: { [promptProperty]: { type: "string" } }, + }, + }, + }, + }; +} + +function fakeRuntime(input?: { + readonly document?: unknown; + readonly onCommand?: ( + args: ReadonlyArray, + ) => OpenCodeRuntime.OpenCodeCommandResult | undefined; + readonly onRequest?: (request: HttpClientRequest.HttpClientRequest) => Response; + readonly onExecute?: (request: HttpClientRequest.HttpClientRequest) => Effect.Effect; +}) { + const commands: RecordedCommand[] = []; + const requests: HttpClientRequest.HttpClientRequest[] = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const value = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { readonly env: NodeJS.ProcessEnv }; + }; + commands.push({ + binaryPath: value.command, + args: value.args, + environment: value.options.env, + }); + return commandHandle( + input?.onCommand?.(value.args) ?? + (value.args.join(" ") === "service start" + ? { stdout: "http://127.0.0.1:49374\n", stderr: "", code: 0 } + : { stdout: "private-password\n", stderr: "", code: 0 }), + ); + }), + ); + const httpClient = HttpClient.make((request) => { + requests.push(request); + if (input?.onExecute) { + return input + .onExecute(request) + .pipe(Effect.map((response) => HttpClientResponse.fromWeb(request, response))); + } + const pathname = new URL(request.url).pathname; + const response = + pathname === "/openapi.json" + ? Response.json(input?.document ?? openApi("text")) + : (input?.onRequest?.(request) ?? Response.json({ ok: true })); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }); + return { + commands, + requests, + runtime: OpenCodeRuntime.make().pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + }; +} + +it.effect("memoizes one service attachment per binary and environment", () => { + const fake = fakeRuntime(); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const first = yield* runtime.attach({ + binaryPath: "opencode2", + environment: { PATH: "/one" }, + }); + const second = yield* runtime.attach({ + binaryPath: "opencode2", + environment: { PATH: "/one" }, + }); + + NodeAssert.equal(first, second); + NodeAssert.deepEqual( + fake.commands.map((command) => command.args), + [ + ["service", "start"], + ["service", "get", "password"], + ], + ); + NodeAssert.equal( + fake.requests.filter((request) => request.url.endsWith("/openapi.json")).length, + 1, + ); + + yield* runtime.attach({ + binaryPath: "opencode2", + environment: { PATH: "/two" }, + }); + NodeAssert.equal(fake.commands.length, 4); + }); +}); + +it.effect("supports the current OpenCode 2 service password command", () => { + const fake = fakeRuntime({ + onCommand: (args) => + args.join(" ") === "service get password" + ? { stdout: "", stderr: "Unknown subcommand", code: 1 } + : undefined, + }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + yield* runtime.attach({ binaryPath: "opencode2" }); + + NodeAssert.deepEqual( + fake.commands.map((command) => command.args), + [ + ["service", "start"], + ["service", "get", "password"], + ["service", "password"], + ], + ); + }); +}); + +it.effect("refreshes a cached attachment when the shared service is no longer healthy", () => { + let healthFails = false; + const fake = fakeRuntime({ + onRequest: (request) => { + if (new URL(request.url).pathname === "/api/health" && healthFails) { + return Response.json({ healthy: false }, { status: 503 }); + } + return Response.json({ healthy: true }); + }, + }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const first = yield* runtime.attach({ binaryPath: "opencode2" }); + healthFails = true; + const second = yield* runtime.attach({ binaryPath: "opencode2" }); + + NodeAssert.notEqual(first, second); + NodeAssert.deepEqual( + fake.commands.map((command) => command.args), + [ + ["service", "start"], + ["service", "get", "password"], + ["service", "start"], + ["service", "get", "password"], + ], + ); + }); +}); + +it.effect("uses private Basic auth for typed JSON and global SSE requests", () => { + const fake = fakeRuntime({ + onRequest: (request) => { + const pathname = new URL(request.url).pathname; + if (pathname === "/api/event") { + return new Response( + 'data: {"id":"evt_1",\n' + + 'data: "type":"server.connected","data":{}}\n\n' + + 'data: {"id":"evt_2","type":"session.text.delta","data":{"delta":"hi"}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + } + return Response.json({ value: "accepted" }); + }, + }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const connection = yield* runtime.attach({ binaryPath: "opencode2" }); + const response = yield* connection.request("POST", "/api/example", { + operation: "example.post", + schema: Schema.Struct({ value: Schema.String }), + query: { count: 2 }, + body: { text: "hello" }, + }); + const events = yield* connection.globalEvents.pipe(Stream.runCollect); + + NodeAssert.deepEqual(response, { value: "accepted" }); + NodeAssert.deepEqual( + Array.from(events).map((event) => event.type), + ["server.connected", "session.text.delta"], + ); + const authenticated = fake.requests.filter( + (request) => new URL(request.url).pathname !== "/openapi.json", + ); + NodeAssert.ok(authenticated.length >= 2); + for (const request of authenticated) { + NodeAssert.equal( + request.headers.authorization, + `Basic ${Buffer.from("opencode:private-password").toString("base64")}`, + ); + } + NodeAssert.equal(new URL(authenticated[0]!.url).searchParams.get("count"), "2"); + }); +}); + +it.effect("decodes empty 204 responses against the requested schema", () => { + const fake = fakeRuntime({ + onRequest: () => new Response(null, { status: 204 }), + }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const connection = yield* runtime.attach({ binaryPath: "opencode2" }); + const response = yield* connection.request("POST", "/api/session/ses_1/interrupt", { + operation: "session.interrupt", + schema: Schema.Undefined, + }); + + NodeAssert.equal(response, undefined); + }); +}); + +it.effect("fails explicitly when the preview OpenAPI shape is unknown", () => { + const fake = fakeRuntime({ document: { openapi: "3.1.0", paths: {} } }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const error = yield* Effect.flip(runtime.attach({ binaryPath: "opencode2" })); + + NodeAssert.equal(OpenCodeRuntime.isOpenCodeUnsupportedPreviewError(error), true); + NodeAssert.equal(error.operation, "openapi.detect"); + NodeAssert.match(error.message, /preview is not supported/i); + }); +}); + +it.effect("times out a wedged service command", () => { + const httpClient = HttpClient.make(() => Effect.die("HTTP should not be reached")); + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.never, + stderr: Stream.never, + all: Stream.never, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ); + return Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime.make().pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const fiber = yield* runtime.attach({ binaryPath: "opencode2" }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust("10 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + NodeAssert.equal(OpenCodeRuntime.isOpenCodeTimeoutError(error), true); + if (!OpenCodeRuntime.isOpenCodeTimeoutError(error)) return; + NodeAssert.equal(error.operation, "service.start"); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())); +}); + +it.effect("times out a wedged OpenAPI probe and releases the attachment lock", () => { + let openApiCalls = 0; + const fake = fakeRuntime({ + onExecute: (request) => { + if (new URL(request.url).pathname !== "/openapi.json") { + return Effect.succeed(Response.json({ ok: true })); + } + openApiCalls += 1; + return openApiCalls === 1 ? Effect.never : Effect.succeed(Response.json(openApi("text"))); + }, + }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const first = yield* runtime + .attach({ binaryPath: "opencode2", environment: { PATH: "/one" } }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust("10 seconds"); + const error = yield* Fiber.join(first).pipe(Effect.flip); + + NodeAssert.equal(OpenCodeRuntime.isOpenCodeTimeoutError(error), true); + if (!OpenCodeRuntime.isOpenCodeTimeoutError(error)) return; + NodeAssert.equal(error.operation, "openapi.get"); + + const connection = yield* runtime.attach({ + binaryPath: "opencode2", + environment: { PATH: "/two" }, + }); + NodeAssert.equal(connection.url, "http://127.0.0.1:49374/"); + NodeAssert.equal(openApiCalls, 2); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())); +}); + +it("detects both adjacent preview prompt shapes", () => { + NodeAssert.deepEqual(OpenCodeRuntime.detectOpenCodeProtocol(openApi("text")), { + promptShape: "flat", + }); + NodeAssert.deepEqual(OpenCodeRuntime.detectOpenCodeProtocol(openApi("prompt")), { + promptShape: "nested", + }); + NodeAssert.deepEqual( + OpenCodeRuntime.detectOpenCodeProtocol(openApi("prompt", "session.prompt")), + { + promptShape: "nested", + }, + ); + NodeAssert.deepEqual(OpenCodeRuntime.detectOpenCodeProtocol(openApi("prompt", null)), { + promptShape: "nested", + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..6a83acd952cb 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -1,107 +1,136 @@ -import * as NodeURL from "node:url"; - -import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; -import { - createOpencodeClient, - type Agent, - type FilePartInput, - type Model, - type OpencodeClient, - type PermissionRuleset, - type ProviderListResponse, - type QuestionAnswer, - type QuestionRequest, -} from "@opencode-ai/sdk/v2"; -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as P from "effect/Predicate"; -import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; -import * as Scope from "effect/Scope"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as Sse from "effect/unstable/encoding/Sse"; -import { isWindowsCommandNotFound } from "../processRunner.ts"; -import { collectStreamAsString } from "./providerSnapshot.ts"; -import * as NetService from "@t3tools/shared/Net"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); -const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; +import { collectStreamAsString } from "./providerSnapshot.ts"; -export function resolveOpenCodeConfigContent( - inputEnvironment: Readonly> | undefined, - inheritedEnvironment: Readonly> = process.env, -): string { - return ( - inputEnvironment?.OPENCODE_CONFIG_CONTENT ?? - inheritedEnvironment.OPENCODE_CONFIG_CONTENT ?? - OPENCODE_EMPTY_CONFIG_CONTENT - ); +export type OpenCodeHttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + +export interface OpenCodeProtocol { + readonly promptShape: "flat" | "nested"; } -const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; -const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; -const DEFAULT_HOSTNAME = "127.0.0.1"; -export interface OpenCodeServerProcess { - readonly url: string; - readonly exitCode: Effect.Effect; +export interface OpenCodeEvent { + readonly id: string; + readonly type: string; + readonly data: unknown; + readonly durable?: { + readonly aggregateID: string; + readonly seq: number; + readonly version: number; + }; + readonly location?: { + readonly directory: string; + readonly workspaceID?: string; + }; + readonly metadata?: Readonly>; +} + +export interface OpenCodeRequestInput { + readonly operation: string; + readonly schema: S; + readonly body?: unknown; + readonly query?: Readonly>; } -export interface OpenCodeServerConnection { +export interface OpenCodeConnection { readonly url: string; - readonly exitCode: Effect.Effect | null; - readonly external: boolean; + readonly protocol: OpenCodeProtocol; + readonly request: ( + method: OpenCodeHttpMethod, + path: string, + input: OpenCodeRequestInput, + ) => Effect.Effect; + readonly globalEvents: Stream.Stream; } -const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; -export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ - readonly operation: string; - readonly cause?: unknown; - readonly detail: string; -}> { - static readonly is = (u: unknown): u is OpenCodeRuntimeError => - P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); +const OpenCodeRuntimeFailureReason = Schema.Literals([ + "command-exit", + "connection-ended", + "http-status", + "transport", +]); + +export class OpenCodeRuntimeError extends Schema.TaggedErrorClass()( + "OpenCodeRuntimeError", + { + operation: Schema.String, + reason: OpenCodeRuntimeFailureReason, + status: Schema.optionalKey(Schema.Number), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const status = this.status === undefined ? "" : `, HTTP ${this.status}`; + const exitCode = this.exitCode === undefined ? "" : `, exit ${this.exitCode}`; + return `OpenCode 2 operation '${this.operation}' failed (${this.reason}${status}${exitCode}).`; + } } -function encodeJsonStringForDiagnostics(input: unknown): string | undefined { - const result = encodeUnknownJsonStringExit(input); - return Exit.isSuccess(result) ? result.value : undefined; +export class OpenCodeUnsupportedPreviewError extends Schema.TaggedErrorClass()( + "OpenCodeUnsupportedPreviewError", + { + operation: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `This OpenCode 2 preview is not supported during '${this.operation}'.`; + } } -export function openCodeRuntimeErrorDetail(cause: unknown): string { - if (OpenCodeRuntimeError.is(cause)) return cause.detail; - if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); - if (cause && typeof cause === "object") { - // SDK v2 throws { response, request, error? } shapes — extract what's useful - const anyCause = cause as Record; - const status = (anyCause.response as { status?: number } | undefined)?.status; - const body = anyCause.error ?? anyCause.data ?? anyCause.body; - const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); - if (encodedBody) { - return `status=${status ?? "?"} body=${encodedBody}`; - } +export class OpenCodeCommandNotFoundError extends Schema.TaggedErrorClass()( + "OpenCodeCommandNotFoundError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode 2 command was not found during '${this.operation}'.`; } - return String(cause); } -export const runOpenCodeSdk = ( - operation: string, - fn: () => Promise, -): Effect.Effect => - Effect.tryPromise({ - try: fn, - catch: (cause) => - new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), - }).pipe(Effect.withSpan(`opencode.${operation}`)); +export class OpenCodeTimeoutError extends Schema.TaggedErrorClass()( + "OpenCodeTimeoutError", + { operation: Schema.String }, +) { + override get message(): string { + return `OpenCode 2 operation '${this.operation}' timed out.`; + } +} + +export const isOpenCodeRuntimeError = Schema.is(OpenCodeRuntimeError); +export const isOpenCodeUnsupportedPreviewError = Schema.is(OpenCodeUnsupportedPreviewError); +export const isOpenCodeCommandNotFoundError = Schema.is(OpenCodeCommandNotFoundError); +export const isOpenCodeTimeoutError = Schema.is(OpenCodeTimeoutError); + +export type OpenCodeRuntimeFailure = + | OpenCodeRuntimeError + | OpenCodeUnsupportedPreviewError + | OpenCodeCommandNotFoundError + | OpenCodeTimeoutError; + +export class OpenCodeRuntime extends Context.Service< + OpenCodeRuntime, + { + readonly attach: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; + } +>()("t3/provider/opencodeRuntime") {} export interface OpenCodeCommandResult { readonly stdout: string; @@ -109,720 +138,468 @@ export interface OpenCodeCommandResult { readonly code: number; } -export interface OpenCodeInventory { - readonly providerList: ProviderListResponse; - readonly agents: ReadonlyArray; - readonly skills: ReadonlyArray; +interface OpenCodeCommandInput { + readonly operation: string; + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly environment: NodeJS.ProcessEnv; } -export interface ParsedOpenCodeModelSlug { - readonly providerID: string; - readonly modelID: string; -} +const OpenCodeEventSchema = Schema.Struct({ + id: Schema.String, + type: Schema.String, + data: Schema.Unknown, + durable: Schema.optionalKey( + Schema.Struct({ + aggregateID: Schema.String, + seq: Schema.Number, + version: Schema.Number, + }), + ), + location: Schema.optionalKey( + Schema.Struct({ + directory: Schema.String, + workspaceID: Schema.optionalKey(Schema.String), + }), + ), + metadata: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}); + +const OPENCODE_ATTACH_REQUEST_TIMEOUT = "10 seconds"; -export interface OpenCodeSkill { - readonly name?: string | null; - readonly description?: string | null; - readonly location?: string | null; - readonly content?: string | null; +function environmentKey(environment: NodeJS.ProcessEnv): string { + return JSON.stringify( + Object.entries(environment) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .toSorted(([left], [right]) => left.localeCompare(right)), + ); } -const OpenCodeSkillSchema = Schema.Struct({ - name: Schema.optionalKey(Schema.NullOr(Schema.String)), - description: Schema.optionalKey(Schema.NullOr(Schema.String)), - location: Schema.optionalKey(Schema.NullOr(Schema.String)), - content: Schema.optionalKey(Schema.NullOr(Schema.String)), -}); -const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( - Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), -); - -export interface OpenCodeRuntimeShape { - /** - * Spawns a local OpenCode server process. Its lifetime is bound to the caller's - * `Scope.Scope` — the child is killed automatically when that scope closes. - * Consumers that want a long-lived server must create and hold a scope explicitly - * (see {@link Scope.make}) and close it when done. - */ - readonly startOpenCodeServerProcess: (input: { - readonly binaryPath: string; - readonly environment?: NodeJS.ProcessEnv; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; - }) => Effect.Effect; - /** - * Returns a handle to either an externally-managed OpenCode server (when - * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a - * freshly spawned local server whose lifetime is bound to the caller's scope. - */ - readonly connectToOpenCodeServer: (input: { - readonly binaryPath: string; - readonly serverUrl?: string | null; - readonly environment?: NodeJS.ProcessEnv; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; - }) => Effect.Effect; - readonly runOpenCodeCommand: (input: { - readonly binaryPath: string; - readonly args: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; - readonly cwd?: string; - }) => Effect.Effect; - readonly createOpenCodeSdkClient: (input: { - readonly baseUrl: string; - readonly directory: string; - readonly serverPassword?: string; - }) => OpencodeClient; - readonly loadOpenCodeInventory: ( - client: OpencodeClient, - ) => Effect.Effect; - readonly loadInventoryFromCli: (input: { - readonly binaryPath: string; - readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv; - }) => Effect.Effect; +function attachKey(input: { + readonly binaryPath: string; + readonly environment: NodeJS.ProcessEnv; +}): string { + return `${input.binaryPath}\n${environmentKey(input.environment)}`; } -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; +function parseServiceUrl(stdout: string): string | null { + for (const line of stdout.trim().split(/\r?\n/u).toReversed()) { + const candidate = line.trim(); + if (!URL.canParse(candidate)) continue; + const url = new URL(candidate); + if (url.protocol === "http:" || url.protocol === "https:") return url.toString(); } return null; } -const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; -const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; - -// Agents that are always hidden in OpenCode but the CLI "agent list" command -// does not expose the hidden flag. Keep in sync with OpenCode agent -// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). -const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); - -/** @internal */ -export function parseModelsCliOutput(stdout: string): { - readonly providers: ReadonlyMap< - string, - { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } - >; - readonly connected: ReadonlyArray; -} { - const providers = new Map< - string, - { id: string; name: string; models: { [key: string]: Model } } - >(); - const lines = stdout.split("\n"); - let currentSlug: string | null = null; - const jsonLines: Array = []; - - const flushModel = () => { - if (currentSlug !== null && jsonLines.length > 0) { - const jsonStr = jsonLines.join("\n").trim(); - if (jsonStr.length > 0) { - try { - const model = JSON.parse(jsonStr) as Model; - const separator = currentSlug.indexOf("/"); - if (separator > 0) { - const providerID = currentSlug.slice(0, separator); - const modelID = currentSlug.slice(separator + 1); - let provider = providers.get(providerID); - if (!provider) { - provider = { id: providerID, name: providerID, models: {} }; - providers.set(providerID, provider); - } - provider.models[modelID] = model; - } - } catch { - // Skip unparseable model JSON - } - } - } - currentSlug = null; - jsonLines.length = 0; - }; - - for (const line of lines) { - // A model's JSON body is a single `JSON.stringify` line starting with `{`, - // while a provider/model slug is a bare `provider/model` header. Only the - // latter can be a slug: without this guard a body line with no interior - // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose - // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against - // an empty body and the model is silently dropped. - const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); - if (slugMatch) { - flushModel(); - currentSlug = slugMatch[1]!; - } else if (currentSlug !== null) { - jsonLines.push(line); - } - } - flushModel(); - - return { providers, connected: [...providers.keys()] }; +function isRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === "object" && !Array.isArray(value); } -/** @internal */ -export function parseAgentListCliOutput(stdout: string): ReadonlyArray { - const agents: Array = []; - const lines = stdout.split("\n"); - let currentHeader: { name: string; mode: string } | null = null; - const blockLines: Array = []; - - const flushAgent = () => { - if (currentHeader !== null) { - const jsonStr = blockLines.join("\n").trim(); - if (jsonStr.length > 0) { - try { - const permission = JSON.parse(jsonStr); - agents.push({ - name: currentHeader.name, - mode: currentHeader.mode as Agent["mode"], - hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), - permission, - options: {}, - }); - } catch { - // Skip unparseable agent - } - } - } - currentHeader = null; - blockLines.length = 0; - }; - - for (const line of lines) { - const match = AGENT_HEADER_RE.exec(line); - if (match) { - flushAgent(); - currentHeader = { name: match[1]!, mode: match[2]! }; - } else if (currentHeader !== null) { - blockLines.push(line); - } +function localRef(document: unknown, ref: string): unknown { + if (!ref.startsWith("#/")) return undefined; + let value = document; + for (const rawSegment of ref.slice(2).split("/")) { + if (!isRecord(value)) return undefined; + const segment = rawSegment.replaceAll("~1", "/").replaceAll("~0", "~"); + value = value[segment]; } - flushAgent(); - - return agents; + return value; } -/** @internal */ -export function parseSkillsCliOutput(stdout: string): ReadonlyArray { - const result = decodeOpenCodeSkillsCliOutputExit(stdout); - return Exit.isSuccess(result) ? result.value : []; +function schemaPropertyNames( + document: unknown, + schema: unknown, + visited = new Set(), +): ReadonlySet { + if (!isRecord(schema) || visited.has(schema)) return new Set(); + visited.add(schema); + + const properties = isRecord(schema.properties) ? Object.keys(schema.properties) : []; + const nestedSchemas: Array = [schema.allOf, schema.anyOf, schema.oneOf] + .filter(Array.isArray) + .flat(); + if (typeof schema.$ref === "string") nestedSchemas.push(localRef(document, schema.$ref)); + + const result = new Set(properties); + for (const nested of nestedSchemas) { + for (const name of schemaPropertyNames(document, nested, visited)) result.add(name); + } + return result; } -export function parseOpenCodeModelSlug( - slug: string | null | undefined, -): ParsedOpenCodeModelSlug | null { - if (typeof slug !== "string") { - return null; - } +function findOperation(value: unknown, operationId: string, visited = new Set()): unknown { + if (!isRecord(value) && !Array.isArray(value)) return undefined; + if (visited.has(value)) return undefined; + visited.add(value); - const trimmed = slug.trim(); - const separator = trimmed.indexOf("/"); - if (separator <= 0 || separator === trimmed.length - 1) { - return null; + if (isRecord(value) && value.operationId === operationId) return value; + for (const nested of Array.isArray(value) ? value : Object.values(value)) { + const found = findOperation(nested, operationId, visited); + if (found !== undefined) return found; } - - return { - providerID: trimmed.slice(0, separator), - modelID: trimmed.slice(separator + 1), - }; -} - -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}`; + return undefined; } -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: NodeURL.pathToFileURL(attachmentPath).href, - }); +function findPromptOperation(document: unknown): unknown { + for (const operationId of ["v2.session.prompt", "session.prompt"]) { + const operation = findOperation(document, operationId); + if (operation !== undefined) return operation; } - return parts; -} - -export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { - if (runtimeMode === "full-access") { - return [{ permission: "*", pattern: "*", action: "allow" }]; + if (!isRecord(document)) return undefined; + const paths = document.paths; + if (!isRecord(paths)) return undefined; + for (const [path, methods] of Object.entries(paths)) { + if (!/^\/api\/session\/(?:\{[^}]+\}|:[^/]+)\/prompt$/u.test(path)) continue; + if (isRecord(methods) && isRecord(methods.post)) return methods.post; } + return undefined; +} - 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 detectOpenCodeProtocol(document: unknown): OpenCodeProtocol | null { + const operation = findPromptOperation(document); + if (!isRecord(operation)) return null; + const requestBody = operation.requestBody; + const content = isRecord(requestBody) ? requestBody.content : undefined; + const jsonContent = isRecord(content) ? content["application/json"] : undefined; + const schema = isRecord(jsonContent) ? jsonContent.schema : undefined; + const properties = schemaPropertyNames(document, schema); + const promptShape = properties.has("text") ? "flat" : properties.has("prompt") ? "nested" : null; + if (promptShape === null) return null; + return { promptShape }; } -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"; +function requestUrl( + baseUrl: string, + path: string, + query: OpenCodeRequestInput["query"], +): string { + const url = new URL(path, baseUrl); + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); } + return url.toString(); } -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 []; - }); +function withAuthentication( + request: HttpClientRequest.HttpClientRequest, + password: string, +): HttpClientRequest.HttpClientRequest { + return request.pipe( + HttpClientRequest.basicAuth("opencode", password), + HttpClientRequest.acceptJson, + ); } -function ensureRuntimeError( - operation: OpenCodeRuntimeError["operation"], - detail: string, - cause: unknown, -): OpenCodeRuntimeError { - return OpenCodeRuntimeError.is(cause) - ? cause - : new OpenCodeRuntimeError({ operation, detail, cause }); +function makeRequest( + method: OpenCodeHttpMethod, + url: string, + body: unknown | undefined, +): HttpClientRequest.HttpClientRequest { + const request = HttpClientRequest.make(method)(url); + return body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body)); } -const makeOpenCodeRuntime = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const netService = yield* NetService.NetService; - const hostPlatform = yield* HostProcessPlatform; - const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => - resolveSpawnCommand(command, args, env ? { env } : {}); - - const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => - Effect.gen(function* () { - const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); - const child = yield* spawner.spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - shell: spawnCommand.shell, - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(input.environment ? { env: input.environment } : { extendEnv: true }), - }), - ); - const [stdout, stderr, code] = yield* Effect.all( - [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], - { concurrency: "unbounded" }, - ); - const exitCode = Number(code); - if (yield* isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: `spawn ${input.binaryPath} ENOENT`, - }); - } - return { - stdout, - stderr, - code: exitCode, - } satisfies OpenCodeCommandResult; - }).pipe( - Effect.scoped, - Effect.mapError((cause) => - ensureRuntimeError( - "runOpenCodeCommand", - `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, - cause, - ), - ), - ); +function isCommandNotFoundCause(cause: unknown): boolean { + return ( + cause instanceof PlatformError.PlatformError && + cause.reason instanceof PlatformError.SystemError && + cause.reason._tag === "NotFound" + ); +} - const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => +function makeConnection(input: { + readonly baseUrl: string; + readonly password: string; + readonly httpClient: HttpClient.HttpClient; + readonly protocol: OpenCodeProtocol; +}): OpenCodeConnection { + const request: OpenCodeConnection["request"] = (method, path, requestInput) => Effect.gen(function* () { - // Bind this server's lifetime to the caller's scope. When the caller's - // scope closes, the spawned child is killed and all associated fibers - // are interrupted automatically — no `close()` method needed. - const runtimeScope = yield* Scope.Scope; - - const hostname = input.hostname ?? DEFAULT_HOSTNAME; - const port = - input.port ?? - (yield* netService.findAvailablePort(0).pipe( - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - }), + const response = yield* input.httpClient + .execute( + withAuthentication( + makeRequest( + method, + requestUrl(input.baseUrl, path, requestInput.query), + requestInput.body, + ), + input.password, ), - )); - const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; - const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; - const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); - - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - detached: hostPlatform !== "win32", - shell: spawnCommand.shell, - env: { - ...input.environment, - // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or - // the inherited process environment, only falling back to the - // empty config when neither is set. Setting it unconditionally - // previously clobbered the user's opencode config, hiding their - // providers/models. The value is set explicitly (rather than - // relying on inheritance) because `extendEnv` is false whenever - // `input.environment` is provided. - OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), - }, - extendEnv: input.environment === undefined, - }), ) .pipe( - Effect.provideService(Scope.Scope, runtimeScope), Effect.mapError( (cause) => new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, + operation: requestInput.operation, + reason: "transport", cause, }), ), ); - - const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => - hostPlatform === "win32" - ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) - : Effect.sync(() => { - try { - process.kill(-Number(child.pid), signal); - } catch { - // The direct child may already have exited after starting the - // server; the process group kill is best-effort cleanup for - // any serve process left in that group. - } - }); - const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( - Effect.andThen(Effect.sleep("1 second")), - Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), - Effect.ignore, - ); - yield* Scope.addFinalizer(runtimeScope, terminateChild); - - const stdoutRef = yield* Ref.make(""); - const stderrRef = yield* Ref.make(""); - const readyDeferred = yield* Deferred.make(); - - const setReadyFromStdoutChunk = (chunk: string) => - Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe( - Effect.flatMap((nextStdout) => { - const parsed = parseServerUrlFromOutput(nextStdout); - return parsed - ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) - : Effect.void; - }), - ); - - const stdoutFiber = yield* child.stdout.pipe( - Stream.decodeText(), - Stream.runForEach(setReadyFromStdoutChunk), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - const stderrFiber = yield* child.stderr.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - - const exitFiber = yield* child.exitCode.pipe( - Effect.flatMap((code) => - Effect.gen(function* () { - const stdout = yield* Ref.get(stdoutRef); - const stderr = yield* Ref.get(stderrRef); - const exitCode = Number(code); - yield* Deferred.fail( - readyDeferred, - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: [ - `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, - stdout.trim() ? `stdout:\n${stdout.trim()}` : null, - stderr.trim() ? `stderr:\n${stderr.trim()}` : null, - ] - .filter(Boolean) - .join("\n\n"), - cause: { exitCode, stdout, stderr }, - }), - ).pipe(Effect.ignore); - }), - ), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - - const readyExit = yield* Effect.exit( - Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), - ); - - // Startup-time fibers are no longer needed once ready has resolved (either - // way). The exit fiber is only interrupted on failure; on success it keeps - // the caller's `exitCode` effect observable until the scope closes. - yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore); - yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore); - - if (Exit.isFailure(readyExit)) { - yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); - const squashed = Cause.squash(readyExit.cause); - return yield* ensureRuntimeError( - "startOpenCodeServerProcess", - `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, - squashed, - ); - } - - const readyOption = readyExit.value; - if (Option.isNone(readyOption)) { - yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); + if (response.status < 200 || response.status >= 300) { return yield* new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, + operation: requestInput.operation, + reason: "http-status", + status: response.status, }); } - - return { - url: readyOption.value, - exitCode: child.exitCode.pipe( - Effect.map(Number), - Effect.orElseSucceed(() => 0), + const decodeEmptyResponse = Schema.decodeUnknownEffect(requestInput.schema); + const decodeResponse = + response.status === 204 + ? decodeEmptyResponse(undefined) + : HttpClientResponse.schemaBodyJson(requestInput.schema)(response); + return yield* decodeResponse.pipe( + Effect.mapError( + (cause) => + new OpenCodeUnsupportedPreviewError({ + operation: requestInput.operation, + cause, + }), ), - } satisfies OpenCodeServerProcess; - }); - - const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { - const serverUrl = input.serverUrl?.trim(); - if (serverUrl) { - // We don't own externally-configured servers — no scope interaction. - return Effect.succeed({ - url: serverUrl, - exitCode: null, - external: true, - }); - } - - return startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - ...(input.environment !== undefined ? { environment: input.environment } : {}), - ...(input.port !== undefined ? { port: input.port } : {}), - ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), - ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), - }).pipe( - Effect.map((server) => ({ - url: server.url, - exitCode: server.exitCode, - external: false, - })), - ); - }; - - const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => - createOpencodeClient({ - baseUrl: input.baseUrl, - directory: input.directory, - ...(input.serverPassword - ? { - headers: { - Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, - }, - } - : {}), - throwOnError: true, + ); }); - const loadProviders = (client: OpencodeClient) => - runOpenCodeSdk("provider.list", () => client.provider.list()).pipe( - Effect.filterMapOrFail( - (list) => - list.data - ? Result.succeed(list.data) - : Result.fail( + const globalEvents = Stream.unwrap( + input.httpClient + .execute( + withAuthentication( + HttpClientRequest.get(requestUrl(input.baseUrl, "/api/event", undefined)).pipe( + HttpClientRequest.setHeader("accept", "text/event-stream"), + ), + input.password, + ), + ) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "event.subscribe", + reason: "transport", + cause, + }), + ), + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? Effect.succeed(response.stream) + : Effect.fail( new OpenCodeRuntimeError({ - operation: "provider.list", - detail: "OpenCode provider list was empty.", + operation: "event.subscribe", + reason: "http-status", + status: response.status, }), ), - (result) => result, + ), ), - ); - - const loadAgents = (client: OpencodeClient) => - runOpenCodeSdk("app.agents", () => client.app.agents()).pipe( - Effect.map((result) => result.data ?? []), - ); + ).pipe( + Stream.decodeText(), + Stream.pipeThroughChannel(Sse.decodeDataSchema(OpenCodeEventSchema)), + Stream.map((event) => event.data), + Stream.mapError((cause) => + isOpenCodeRuntimeError(cause) || + isOpenCodeUnsupportedPreviewError(cause) || + isOpenCodeCommandNotFoundError(cause) || + isOpenCodeTimeoutError(cause) + ? cause + : new OpenCodeUnsupportedPreviewError({ + operation: "event.subscribe", + cause, + }), + ), + ); - const loadSkills = (client: OpencodeClient) => - runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( - Effect.map((result) => (result.data ?? []) as ReadonlyArray), - Effect.orElseSucceed((): ReadonlyArray => []), - ); + return { + url: input.baseUrl, + protocol: input.protocol, + request, + globalEvents, + }; +} - const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => - Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { - concurrency: "unbounded", - }).pipe(Effect.map(([providerList, agents, skills]) => ({ providerList, agents, skills }))); +export const make = Effect.fn("OpenCodeRuntime.make")(function* () { + const httpClient = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const connections = new Map(); + const attachLock = yield* Semaphore.make(1); - const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => + const runCommand = (command: OpenCodeCommandInput) => Effect.gen(function* () { - const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); - const commandContext = { cwd: input.cwd, ...env }; - - const runModelsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["models", "--verbose"], - ...commandContext, - }).pipe(Effect.exit); - const runAgentsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["agent", "list"], - ...commandContext, - }).pipe(Effect.exit); - const runSkillsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["debug", "skill"], - ...commandContext, - }).pipe(Effect.exit); - - // First attempt — run all inventory commands in parallel. - const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( - [runModelsCli(), runAgentsCli(), runSkillsCli()], + const spawn = yield* resolveSpawnCommand(command.binaryPath, command.args, { + env: command.environment, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawn.command, spawn.args, { + env: command.environment, + shell: spawn.shell, + }), + ) + .pipe( + Effect.mapError((cause) => + isCommandNotFoundCause(cause) + ? new OpenCodeCommandNotFoundError({ + operation: command.operation, + cause, + }) + : new OpenCodeRuntimeError({ + operation: command.operation, + reason: "transport", + cause, + }), + ), + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: command.operation, + reason: "transport", + cause, + }), + ), ); - let modelsResult = initialModelsResult; - let agentsResult = initialAgentsResult; - let skillsResult = initialSkillsResult; - - // Retry once after 1s on transient failures (e.g. SQLite "database is locked") - const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; - const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; - const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; - if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { - yield* Effect.sleep("1 second"); - const [m2, a2, s2] = yield* Effect.all( - [ - needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), - needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), - needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), - ], - { concurrency: "unbounded" }, - ); - modelsResult = m2; - agentsResult = a2; - skillsResult = s2; - } + return { stdout, stderr, code }; + }).pipe( + Effect.scoped, + Effect.timeoutOption("10 seconds"), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new OpenCodeTimeoutError({ + operation: command.operation, + }), + ), + onSome: Effect.succeed, + }), + ), + ); - if (modelsResult._tag === "Failure") { - const cause = Cause.squash(modelsResult.cause); - return yield* ensureRuntimeError( - "loadInventoryFromCli", - `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - ); - } - if (modelsResult.value.code !== 0) { - return yield* new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + const attach: OpenCodeRuntime["Service"]["attach"] = (attachInput) => { + const environment = attachInput.environment ?? process.env; + const key = attachKey({ binaryPath: attachInput.binaryPath, environment }); + return attachLock.withPermit( + Effect.gen(function* () { + const existing = connections.get(key); + if (existing) { + const health = yield* Effect.exit( + existing + .request("GET", "/api/health", { + operation: "health.get", + schema: Schema.Unknown, + }) + .pipe( + Effect.timeoutOption(OPENCODE_ATTACH_REQUEST_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new OpenCodeTimeoutError({ operation: "health.get" })), + onSome: Effect.succeed, + }), + ), + ), + ); + if (Exit.isSuccess(health)) return existing; + connections.delete(key); + } + + const start = yield* runCommand({ + operation: "service.start", + binaryPath: attachInput.binaryPath, + args: ["service", "start"], + environment, }); - } + if (start.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "service.start", + reason: "command-exit", + exitCode: start.code, + }); + } + const baseUrl = parseServiceUrl(start.stdout); + if (baseUrl === null) { + return yield* new OpenCodeUnsupportedPreviewError({ + operation: "service.start", + }); + } - const parsed = parseModelsCliOutput(modelsResult.value.stdout); - const connected = [...parsed.connected]; - const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( - (provider) => ({ - id: provider.id, - name: provider.name, - source: "config" as const, - env: [], - options: {}, - models: provider.models, - }), - ); + const legacyPasswordResult = yield* runCommand({ + operation: "service.get.password", + binaryPath: attachInput.binaryPath, + args: ["service", "get", "password"], + environment, + }); + const passwordResult = + legacyPasswordResult.code === 0 + ? legacyPasswordResult + : yield* runCommand({ + operation: "service.password", + binaryPath: attachInput.binaryPath, + args: ["service", "password"], + environment, + }); + if (passwordResult.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "service.password", + reason: "command-exit", + exitCode: passwordResult.code, + }); + } + const password = passwordResult.stdout.trim(); + if (password.length === 0) { + return yield* new OpenCodeUnsupportedPreviewError({ + operation: "service.password", + }); + } - // Agent and skill metadata enrich the provider snapshot but are not required - // for an authoritative model inventory, so either may degrade to an empty list. - let agents: ReadonlyArray = []; - if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { - agents = parseAgentListCliOutput(agentsResult.value.stdout); - } - let skills: ReadonlyArray = []; - if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { - skills = parseSkillsCliOutput(skillsResult.value.stdout); - } + const provisional = makeConnection({ + baseUrl, + password, + httpClient, + protocol: { promptShape: "flat" }, + }); + const document = yield* provisional + .request("GET", "/openapi.json", { + operation: "openapi.get", + schema: Schema.Unknown, + }) + .pipe( + Effect.timeoutOption(OPENCODE_ATTACH_REQUEST_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new OpenCodeTimeoutError({ operation: "openapi.get" })), + onSome: Effect.succeed, + }), + ), + ); + const protocol = detectOpenCodeProtocol(document); + if (protocol === null) { + return yield* new OpenCodeUnsupportedPreviewError({ + operation: "openapi.detect", + }); + } - return { - providerList: { all: allProviders, default: {}, connected }, - agents, - skills, - }; - }); + const connection = makeConnection({ + baseUrl, + password, + httpClient, + protocol, + }); + connections.set(key, connection); + return connection; + }), + ); + }; - return { - startOpenCodeServerProcess, - connectToOpenCodeServer, - runOpenCodeCommand, - createOpenCodeSdkClient, - loadOpenCodeInventory, - loadInventoryFromCli, - } satisfies OpenCodeRuntimeShape; + return OpenCodeRuntime.of({ attach }); }); -export class OpenCodeRuntime extends Context.Service()( - "t3/provider/opencodeRuntime", -) {} - -export const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( - Layer.provide(NetService.layer), -); +export const layer = Layer.effect(OpenCodeRuntime, make()); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3e41b4390f82..6e5bbf1f5a22 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -392,12 +392,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Provided once at the runtime level so every consumer sees the same // logger instances. Layer.provideMerge(ProviderEventLoggers.layer), - // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old - // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but - // the rewritten registry reads snapshots off the instance registry and - // no longer transitively provides it. Exposing it at the runtime level - // keeps a single Live for all opencode consumers. - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + // OpenCode captures one shared native runtime while building its instance. + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolver.layer), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 35ef5e976223..ad1c4a9529d5 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -560,9 +560,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: " /opt/homebrew/bin/claude ", }, opencode: { - binaryPath: " /opt/homebrew/bin/opencode ", - serverUrl: " http://127.0.0.1:4096 ", - serverPassword: " secret-password ", + binaryPath: " /opt/homebrew/bin/opencode2 ", }, }, }); @@ -583,11 +581,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { launchArgs: "", }); assert.deepEqual(next.providers.opencode, { - // OpenCode is disabled by default; this update only touches paths. - enabled: false, - binaryPath: "/opt/homebrew/bin/opencode", - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", + enabled: true, + binaryPath: "/opt/homebrew/bin/opencode2", customModels: [], }); }).pipe(Effect.provide(makeServerSettingsLayer())), @@ -649,8 +644,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", }, opencode: { - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", + binaryPath: "/opt/homebrew/bin/opencode2-preview", }, }, automaticGitFetchInterval: Duration.seconds(10), @@ -671,8 +665,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", }, opencode: { - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", + binaryPath: "/opt/homebrew/bin/opencode2-preview", }, }, backgroundActivity: { diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 1fcf9bc4c73a..3717cef5c2cb 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -1,456 +1,176 @@ -import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import * as NodeAssert from "node:assert/strict"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import * as NetService from "@t3tools/shared/Net"; -import { beforeEach, expect } from "vite-plus/test"; - -import * as ServerConfig from "../config.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; -import * as TextGeneration from "./TextGeneration.ts"; +import * as Stream from "effect/Stream"; -const runtimeMock = { - state: { - startCalls: [] as string[], - promptUrls: [] as string[], - authHeaders: [] as Array, - closeCalls: [] as string[], - sessionCreateError: undefined as unknown, - sessionResult: undefined as { data?: { id: string } } | undefined, - promptRequestError: undefined as unknown, - promptResult: undefined as - | { data?: { info?: { error?: unknown }; parts?: Array } } - | undefined, - }, - reset() { - this.state.startCalls.length = 0; - this.state.promptUrls.length = 0; - this.state.authHeaders.length = 0; - this.state.closeCalls.length = 0; - this.state.sessionCreateError = undefined; - this.state.sessionResult = undefined; - this.state.promptRequestError = undefined; - this.state.promptResult = undefined; - }, -}; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; -const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => - Effect.gen(function* () { - const index = runtimeMock.state.startCalls.length + 1; - const url = `http://127.0.0.1:${4_300 + index}`; - runtimeMock.state.startCalls.push(binaryPath); - // The production runtime binds server lifetime to the caller's scope. - // Mirror that here so the closeCalls probe observes scope close. - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls.push(url); - }), - ); - return { - url, - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.succeed({ - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), - }), - runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => - ({ - session: { - create: async () => { - if (runtimeMock.state.sessionCreateError !== undefined) { - throw runtimeMock.state.sessionCreateError; - } - return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; - }, - prompt: async () => { - runtimeMock.state.promptUrls.push(baseUrl); - runtimeMock.state.authHeaders.push( - serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, - ); - if (runtimeMock.state.promptRequestError !== undefined) { - throw runtimeMock.state.promptRequestError; - } - return ( - runtimeMock.state.promptResult ?? { - data: { - parts: [ - { - type: "text", - text: JSON.stringify({ - subject: "Improve OpenCode reuse", - body: "Reuse one server for the full action.", - }), - }, - ], - }, - } - ); - }, - }, - }) as unknown as ReturnType, - loadOpenCodeInventory: () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", - cause: null, - }), - ), - loadInventoryFromCli: () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", - cause: null, - }), - ), -}; - -const DEFAULT_TEST_MODEL_SELECTION = { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", -}; -const DEFAULT_COMMIT_MESSAGE_INPUT = { - 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, -}; - -const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; - -const OpenCodeTextGenerationTestLayer = Layer.succeed( - OpenCodeRuntime.OpenCodeRuntime, - OpenCodeRuntimeTestDouble, -).pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-test-", - }), - ), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(NodeServices.layer), -); - -const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( - OpenCodeRuntime.OpenCodeRuntime, - OpenCodeRuntimeTestDouble, -).pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-existing-server-test-", - }), - ), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(NodeServices.layer), -); - -const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", -}); -const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", -}); +import { ServerConfig } from "../config.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; -function withOpenCodeTextGeneration( - settings: OpenCodeSettings, - effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, -) { - return Effect.gen(function* () { - const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); - return yield* effectFn(textGeneration); - }).pipe(Effect.scoped); +interface CapturedRequest { + readonly method: string; + readonly path: string; + readonly query: unknown; + readonly body: unknown; } -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)("OpenCodeTextGeneration", (it) => { - it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - 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", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - 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("preserves the SDK cause when session creation fails", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - const sdkCause = new Error("session endpoint unavailable"); - runtimeMock.state.sessionCreateError = sdkCause; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(TextGenerationError); - expect(error.message).toContain("OpenCode session.create request failed."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationSessionRequestError", - operation: "generateCommitMessage", - cwd: process.cwd(), - cause: sdkCause, - }); - expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); - }), - ), +type RuntimeResponse = unknown | ((request: CapturedRequest) => unknown); + +const configLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-opencode-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function runtimeLayer(response: RuntimeResponse, requests: Array) { + const connection: OpenCodeRuntime.OpenCodeConnection = { + url: "http://127.0.0.1:49374/", + protocol: { promptShape: "flat" }, + request: (( + method: string, + path: string, + input: { readonly query?: unknown; readonly body?: unknown }, + ) => { + const request = { method, path, query: input.query, body: input.body }; + requests.push(request); + return Effect.succeed(typeof response === "function" ? response(request) : response); + }) as OpenCodeRuntime.OpenCodeConnection["request"], + globalEvents: Stream.empty, + }; + return Layer.succeed( + OpenCodeRuntime.OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime.of({ attach: () => Effect.succeed(connection) }), ); +} - it.effect("reports a missing session payload without manufacturing a cause", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.sessionResult = {}; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode session.create returned no session payload."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationSessionPayloadError", - operation: "generateCommitMessage", - cwd: process.cwd(), - }); - expect(error.cause).not.toHaveProperty("cause"); - }), - ), - ); - - it.effect("preserves the SDK cause and request context when prompting fails", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - const sdkCause = new Error("prompt endpoint unavailable"); - runtimeMock.state.promptRequestError = sdkCause; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode session.prompt request failed."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationPromptRequestError", - operation: "generateCommitMessage", - cwd: process.cwd(), - sessionId: "http://127.0.0.1:4301/session", - providerId: "openai", - modelId: "gpt-5", - cause: sdkCause, - }); - expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); - }), - ), - ); - - it.effect("returns a typed empty-output error for malformed and blank response parts", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - parts: [null, { type: "tool" }, { type: "text", text: " " }], - }, - }; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); +function testLayer(response: RuntimeResponse, requests: Array) { + return Layer.merge(runtimeLayer(response, requests), configLayer); +} - expect(error.message).toContain("OpenCode returned empty output."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationEmptyOutputError", - operation: "generateCommitMessage", - cwd: process.cwd(), - sessionId: "http://127.0.0.1:4301/session", - providerId: "openai", - modelId: "gpt-5", - responsePartCount: 3, - textPartCount: 1, - }); - expect(error.cause).not.toHaveProperty("cause"); - }), +it.effect("generates structured text through the attached OpenCode 2 service", () => { + const requests: Array = []; + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCodeTextGeneration({ binaryPath: "opencode2" }); + const generated = yield* textGeneration.generateCommitMessage({ + cwd: "/work/project", + branch: "feat/opencode2", + stagedSummary: "M apps/server/src/provider/opencodeRuntime.ts", + stagedPatch: "diff --git a/runtime.ts b/runtime.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5.6", [ + { id: "variant", value: "high" }, + ]), + }); + + NodeAssert.deepEqual(generated, { + subject: "Add OpenCode 2 runtime", + body: "Attach to the shared service.", + }); + NodeAssert.equal(requests.length, 1); + const request = requests[0]; + NodeAssert.ok(request); + NodeAssert.equal(request.method, "POST"); + NodeAssert.equal(request.path, "/api/generate"); + NodeAssert.deepEqual(request.query, { + "location[directory]": "/work/project", + }); + NodeAssert.deepEqual(request.body, { + prompt: (request.body as { readonly prompt: string }).prompt, + model: { providerID: "openai", id: "gpt-5.6", variant: "high" }, + }); + NodeAssert.match((request.body as { readonly prompt: string }).prompt, /commit message/i); + }).pipe( + Effect.provide( + testLayer( + { + text: JSON.stringify({ + subject: "Add OpenCode 2 runtime", + body: "Attach to the shared service.", + }), + }, + requests, + ), ), ); +}); - it.effect("parses JSON returned as plain text output", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - parts: [ - { - type: "text", - text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', - }, - ], - }, - }; - - const result = 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(result).toEqual({ - subject: "Tighten OpenCode parsing", - body: "Handle JSON text output locally.", - }); +it.effect("rejects model selections that do not identify an upstream provider", () => { + const requests: Array = []; + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCodeTextGeneration({ binaryPath: "opencode2" }); + const error = yield* Effect.flip( + textGeneration.generateBranchName({ + cwd: "/work/project", + message: "add opencode 2", + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "gpt-5.6"), }), - ), - ); - - it.effect("surfaces the upstream OpenCode structured-output error message", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - info: { - error: { - name: "StructuredOutputError", - data: { - message: "Model did not produce structured output", - retries: 2, - }, - }, - }, - }, - }; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); + ); - expect(error.message).toContain("Model did not produce structured output"); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationPromptResponseError", - operation: "generateCommitMessage", - cwd: process.cwd(), - sessionId: "http://127.0.0.1:4301/session", - providerId: "openai", - modelId: "gpt-5", - providerErrorName: "StructuredOutputError", - providerMessage: "Model did not produce structured output", - }); - expect(error.cause).not.toHaveProperty("cause"); - }), - ), - ); + NodeAssert.equal(error._tag, "TextGenerationError"); + NodeAssert.match(error.detail, /provider\/model/); + NodeAssert.equal(requests.length, 0); + }).pipe(Effect.provide(testLayer({ text: JSON.stringify({ branch: "unused" }) }, requests))); }); -it.layer(OpenCodeTextGenerationExistingServerTestLayer)( - "OpenCodeTextGeneration with configured server URL", - (it) => { - it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => - withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - 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; +it.effect("passes image attachments through a temporary native session", () => { + const requests: Array = []; + const response = (request: CapturedRequest) => { + if (request.method === "POST" && request.path === "/api/session") { + return { data: { id: "ses_generated" } }; + } + if (request.path.endsWith("/prompt")) return { data: { id: "msg_user" } }; + if (request.path.endsWith("/wait") || request.method === "DELETE") return undefined; + if (request.path.endsWith("/message")) { + return { + data: [ + { + type: "assistant", + content: [{ type: "text", text: JSON.stringify({ branch: "native-images" }) }], + }, + ], + }; + } + NodeAssert.fail(`Unexpected request: ${request.method} ${request.path}`); + }; - expect(runtimeMock.state.closeCalls).toEqual([]); - }), - ).pipe(Effect.provide(TestClock.layer())), + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCodeTextGeneration({ binaryPath: "opencode2" }); + const generated = yield* textGeneration.generateBranchName({ + cwd: "/work/project", + message: "fix the layout shown here", + attachments: [ + { + type: "image", + id: "thread-native-images-9a30ddad-3b87-463b-a306-2aa270f089d2", + name: "layout.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ], + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5.6"), + }); + + NodeAssert.deepEqual(generated, { branch: "native-images" }); + NodeAssert.deepEqual( + requests.map((request) => `${request.method} ${request.path}`), + [ + "POST /api/session", + "POST /api/session/ses_generated/prompt", + "POST /api/session/ses_generated/wait", + "GET /api/session/ses_generated/message", + "DELETE /api/session/ses_generated", + ], ); - }, -); + const prompt = requests[1]?.body as { + readonly text: string; + readonly files: ReadonlyArray<{ readonly uri: string; readonly name: string }>; + readonly delivery: string; + }; + NodeAssert.equal(prompt.delivery, "steer"); + NodeAssert.equal(prompt.files[0]?.name, "layout.png"); + NodeAssert.match(prompt.files[0]?.uri ?? "", /thread-native-images-.*\.png$/u); + }).pipe(Effect.provide(testLayer(response, requests))); +}); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cffc..95937fabec20 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -1,525 +1,245 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeURL from "node:url"; + import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import { - NonNegativeInt, - TextGenerationError, - type ChatAttachment, - type ModelSelection, - type OpenCodeSettings, -} from "@t3tools/contracts"; +import { TextGenerationError, type ChatAttachment, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import * as ServerConfig from "../config.ts"; import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import type { OpenCodeProviderSettings } from "../provider/Layers/OpenCodeProvider.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; -import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; +const OPENCODE_GENERATE_TIMEOUT = "3 minutes"; +const isTextGenerationError = Schema.is(TextGenerationError); -const OpenCodeTextGenerationOperation = Schema.Literals([ - "generateCommitMessage", - "generatePrContent", - "generateBranchName", - "generateThreadTitle", +const OpenCodeGenerateResponseSchema = Schema.Union([ + Schema.Struct({ text: Schema.String }), + Schema.Struct({ data: Schema.Struct({ text: Schema.String }) }), ]); +const OpenCodeSessionResponseSchema = Schema.Struct({ + data: Schema.Struct({ id: Schema.String }), +}); +const OpenCodeMessageListResponseSchema = Schema.Struct({ + data: Schema.Array(Schema.Unknown), +}); +const OpenCodeAssistantMessageSchema = Schema.Struct({ + type: Schema.Literal("assistant"), + content: Schema.Array(Schema.Unknown), +}); +const OpenCodeTextPartSchema = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, +}); -type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; - -const openCodeTextGenerationErrorContext = { - operation: OpenCodeTextGenerationOperation, - cwd: Schema.String, -}; - -export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationSessionRequestError", - { - ...openCodeTextGenerationErrorContext, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; - } -} - -export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationSessionPayloadError", - openCodeTextGenerationErrorContext, -) { - override get message(): string { - return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; - } -} - -const openCodePromptErrorContext = { - ...openCodeTextGenerationErrorContext, - sessionId: Schema.String, - providerId: Schema.String, - modelId: Schema.String, -}; - -export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationPromptRequestError", - { - ...openCodePromptErrorContext, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; - } -} - -export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationPromptResponseError", - { - ...openCodePromptErrorContext, - providerErrorName: Schema.optional(Schema.String), - providerMessage: Schema.String, - }, -) { - override get message(): string { - const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; - return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; - } -} - -export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationEmptyOutputError", - { - ...openCodePromptErrorContext, - responsePartCount: NonNegativeInt, - textPartCount: NonNegativeInt, - }, -) { - override get message(): string { - return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`; - } -} - -interface OpenCodePromptFailure { - readonly name?: string; - readonly message: string; +const decodeAssistantMessage = Schema.decodeUnknownOption(OpenCodeAssistantMessageSchema); +const decodeTextPart = Schema.decodeUnknownOption(OpenCodeTextPartSchema); + +type OpenCodeTextGenerationOperation = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +function parseModelSelection(modelSelection: ModelSelection): { + readonly providerID: string; + readonly id: string; + readonly variant?: string; +} | null { + const separator = modelSelection.model.indexOf("/"); + if (separator <= 0 || separator === modelSelection.model.length - 1) return null; + const providerID = modelSelection.model.slice(0, separator).trim(); + const id = modelSelection.model.slice(separator + 1).trim(); + if (!providerID || !id) return null; + const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + return { + providerID, + id, + ...(variant ? { variant } : {}), + }; } -interface OpenCodeTextPart { - readonly type: "text"; - readonly text: string; +function generatedText(response: typeof OpenCodeGenerateResponseSchema.Type): string { + return "data" in response ? response.data.text : response.text; } -function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { - if (!error || typeof error !== "object") { - return null; - } - - const name = - "name" in error && typeof error.name === "string" && error.name.trim().length > 0 - ? error.name.trim() - : undefined; - const message = - "data" in error && - error.data && - typeof error.data === "object" && - "message" in error.data && - typeof error.data.message === "string" - ? error.data.message.trim() - : ""; - if (message.length > 0) { - return { - ...(name ? { name } : {}), - message, - }; - } - - if (name) { - return { name, message: name }; +function latestAssistantText(response: typeof OpenCodeMessageListResponseSchema.Type): string { + for (const value of response.data) { + const message = Option.getOrUndefined(decodeAssistantMessage(value)); + if (!message) continue; + const text = message.content + .flatMap((part) => Option.toArray(decodeTextPart(part))) + .map((part) => part.text) + .join("") + .trim(); + if (text.length > 0) return text; } - - return null; -} - -function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { - return ( - part !== null && - typeof part === "object" && - "type" in part && - part.type === "text" && - "text" in part && - typeof part.text === "string" - ); -} - -function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { - return (parts ?? []) - .filter(isOpenCodeTextPart) - .map((part) => part.text) - .join("") - .trim(); -} - -interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeRuntime.OpenCodeServerProcess | null; - /** - * The scope that owns the shared server's lifetime. Closing this scope - * terminates the OpenCode child process and interrupts any fibers the - * runtime forked during startup. We don't hold a `close()` function on - * the server handle anymore — the scope is the only lifecycle handle. - */ - serverScope: Scope.Closeable | null; - binaryPath: string | null; - activeRequests: number; - idleCloseFiber: Fiber.Fiber | null; + return ""; } export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( - openCodeSettings: OpenCodeSettings, + settings: Pick, environment?: NodeJS.ProcessEnv, ) { - const serverConfig = yield* ServerConfig.ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const resolvedEnvironment = environment ?? process.env; - const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const sharedServerMutex = yield* Semaphore.make(1); - const sharedServerState: SharedOpenCodeTextGenerationServerState = { - server: null, - serverScope: null, - binaryPath: null, - activeRequests: 0, - idleCloseFiber: null, - }; - - const closeSharedServer = Effect.fn("closeSharedServer")(function* () { - const scope = sharedServerState.serverScope; - sharedServerState.server = null; - sharedServerState.serverScope = null; - sharedServerState.binaryPath = null; - if (scope !== null) { - yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); - } - }); - - 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: OpenCodeRuntime.OpenCodeServerProcess, - ) { - yield* cancelIdleCloseFiber(); - const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( - Effect.andThen( - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { - return; - } - sharedServerState.idleCloseFiber = null; - yield* closeSharedServer(); - }), - ), - ), - 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 - ) { - yield* closeSharedServer(); - } 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; - } - } - - // Create a fresh scope that owns this shared server. The runtime - // will attach its child-process and fiber finalizers to this scope; - // closing it kills the server and interrupts those fibers. - // - // The `Scope.make` / spawn / record-or-close transitions run inside - // `uninterruptibleMask` so an interrupt arriving between any two - // steps can't orphan the scope (and the child process attached to - // it) before we either close it on failure or hand ownership to - // `sharedServerState`. `restore` keeps the actual spawn - // interruptible; an interrupt during the spawn is captured by - // `Effect.exit` and drives us through the failure branch that - // closes the fresh scope. - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const serverScope = yield* Scope.make(); - const startedExit = yield* Effect.exit( - restore( - openCodeRuntime - .startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - environment: resolvedEnvironment, - }) - .pipe( - Effect.provideService(Scope.Scope, serverScope), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: input.operation, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - cause, - }), - ), - ), - ), - ); - if (startedExit._tag === "Failure") { - yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); - return yield* Effect.failCause(startedExit.cause); - } - - const server = startedExit.value; - sharedServerState.server = server; - sharedServerState.serverScope = serverScope; - sharedServerState.binaryPath = input.binaryPath; - sharedServerState.activeRequests = 1; - return server; - }), - ); - }), - ); - - const releaseSharedServer = (server: OpenCodeRuntime.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); - } - }), - ); - - // Module-level finalizer: on layer shutdown, cancel the idle close fiber - // and close the shared server scope. Consumers therefore cannot leak - // the shared OpenCode server by forgetting to call anything. - yield* Effect.addFinalizer(() => - sharedServerMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - sharedServerState.activeRequests = 0; - yield* closeSharedServer(); - }), - ), - ); + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const serverConfig = yield* ServerConfig; const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { readonly operation: OpenCodeTextGenerationOperation; readonly cwd: string; readonly prompt: string; - readonly outputSchemaJson: S; + readonly outputSchema: S; readonly modelSelection: ModelSelection; readonly attachments?: ReadonlyArray | undefined; - }) { - const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); - if (!parsedModel) { + }): Effect.fn.Return { + const model = parseModelSelection(input.modelSelection); + if (model === null) { return yield* new TextGenerationError({ operation: input.operation, - detail: "OpenCode model selection must use the 'provider/model' format.", + detail: "OpenCode 2 model selection must use the `provider/model` format.", }); } - const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ - attachments: input.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }); - - const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( - function* (server: Pick) { - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }); - const session = yield* Effect.tryPromise({ - try: () => - client.session.create({ - title: `T3 Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], - }), - catch: (cause) => - new OpenCodeTextGenerationSessionRequestError({ + const response = yield* Effect.gen(function* () { + const connection = yield* runtime.attach({ + binaryPath: settings.binaryPath || "opencode2", + ...(environment ? { environment } : {}), + }); + const attachments = input.attachments ?? []; + if (attachments.length > 0) { + const files: Array<{ readonly uri: string; readonly name: string }> = []; + for (const attachment of attachments) { + const path = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!path) { + return yield* new TextGenerationError({ operation: input.operation, - cwd: input.cwd, - cause, - }), - }); - if (!session.data) { - return yield* new OpenCodeTextGenerationSessionPayloadError({ - operation: input.operation, - cwd: input.cwd, + detail: `OpenCode 2 attachment '${attachment.name}' is unavailable.`, + }); + } + files.push({ + uri: NodeURL.pathToFileURL(path).href, + name: attachment.name, }); } const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); - const promptContext = { - operation: input.operation, - cwd: input.cwd, - sessionId: session.data.id, - providerId: parsedModel.providerID, - modelId: parsedModel.modelID, - }; - - const result = yield* Effect.tryPromise({ - try: () => - client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], - }), - catch: (cause) => - new OpenCodeTextGenerationPromptRequestError({ - ...promptContext, - cause, - }), - }); - const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); - if (promptFailure) { - return yield* new OpenCodeTextGenerationPromptResponseError({ - ...promptContext, - ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), - providerMessage: promptFailure.message, - }); - } - const responseParts = result.data?.parts ?? []; - const rawText = getOpenCodeTextResponse(responseParts); - if (rawText.length === 0) { - return yield* new OpenCodeTextGenerationEmptyOutputError({ - ...promptContext, - responsePartCount: responseParts.length, - textPartCount: responseParts.filter(isOpenCodeTextPart).length, - }); - } - return rawText; - }, - Effect.catchTags({ - OpenCodeTextGenerationSessionRequestError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode session.create request failed.", - cause, - }), - ), - OpenCodeTextGenerationSessionPayloadError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode session.create returned no session payload.", - cause, - }), - ), - OpenCodeTextGenerationPromptRequestError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode session.prompt request failed.", - cause, - }), - ), - OpenCodeTextGenerationPromptResponseError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: cause.providerMessage, - cause, - }), - ), - OpenCodeTextGenerationEmptyOutputError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode returned empty output.", + return yield* Effect.acquireUseRelease( + connection + .request("POST", "/api/session", { + operation: "session.create", + schema: OpenCodeSessionResponseSchema, + body: { + title: `T3 Code ${input.operation}`, + location: { directory: input.cwd }, + model, + ...(selectedAgent ? { agent: selectedAgent } : {}), + }, + }) + .pipe(Effect.map((response) => response.data.id)), + (sessionId) => { + const prompt = { text: input.prompt, files }; + const body = + connection.protocol.promptShape === "flat" + ? { ...prompt, delivery: "steer" as const } + : { prompt, delivery: "steer" as const }; + const sessionPath = `/api/session/${encodeURIComponent(sessionId)}`; + return Effect.gen(function* () { + yield* connection.request("POST", `${sessionPath}/prompt`, { + operation: "session.prompt", + schema: Schema.Unknown, + body, + }); + yield* connection.request("POST", `${sessionPath}/wait`, { + operation: "session.wait", + schema: Schema.Void, + }); + const messages = yield* connection.request("GET", `${sessionPath}/message`, { + operation: "message.list", + schema: OpenCodeMessageListResponseSchema, + query: { order: "desc", limit: 10 }, + }); + return { text: latestAssistantText(messages) }; + }); + }, + (sessionId) => + connection + .request("DELETE", `/api/session/${encodeURIComponent(sessionId)}`, { + operation: "session.remove", + schema: Schema.Void, + }) + .pipe(Effect.ignore), + ); + } + return yield* connection.request("POST", "/api/generate", { + operation: "generate.text", + schema: OpenCodeGenerateResponseSchema, + query: { "location[directory]": input.cwd }, + body: { prompt: input.prompt, model }, + }); + }).pipe( + Effect.timeoutOption(OPENCODE_GENERATE_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 text generation timed out.", + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: OpenCodeRuntime.isOpenCodeUnsupportedPreviewError(cause) + ? "This OpenCode 2 preview is not supported for text generation." + : "OpenCode 2 text generation request failed.", cause, }), - ), - }), + ), ); - const rawOutput = - openCodeSettings.serverUrl.length > 0 - ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) - : yield* Effect.acquireUseRelease( - acquireSharedServer({ - binaryPath: openCodeSettings.binaryPath, - operation: input.operation, - }), - runAgainstServer, - releaseSharedServer, - ); + const text = generatedText(response).trim(); + if (!text) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned empty text generation output.", + }); + } - const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); - return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( - Effect.catchTags({ - SchemaError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned invalid structured output.", - cause, - }), - ), - }), + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); + return yield* decodeOutput(extractJsonObject(text)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned invalid structured text generation output.", + cause, + }), + ), ); }); @@ -536,10 +256,9 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" operation: "generateCommitMessage", cwd: input.cwd, prompt, - outputSchemaJson: outputSchema, + outputSchema, modelSelection: input.modelSelection, }); - return { subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), @@ -564,10 +283,9 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" operation: "generatePrContent", cwd: input.cwd, prompt, - outputSchemaJson: outputSchema, + outputSchema, modelSelection: input.modelSelection, }); - return { title: sanitizePrTitle(generated.title), body: generated.body.trim(), @@ -584,14 +302,11 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" operation: "generateBranchName", cwd: input.cwd, prompt, - outputSchemaJson: outputSchema, + outputSchema, modelSelection: input.modelSelection, attachments: input.attachments, }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; + return { branch: sanitizeBranchFragment(generated.branch) }; }); const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = @@ -605,14 +320,11 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" operation: "generateThreadTitle", cwd: input.cwd, prompt, - outputSchemaJson: outputSchema, + outputSchema, modelSelection: input.modelSelection, attachments: input.attachments, }); - - return { - title: sanitizeThreadTitle(generated.title), - }; + return { title: sanitizeThreadTitle(generated.title) }; }); return { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d9a9c37e023..b9971bb45e98 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -6,6 +6,7 @@ import { normalizeCompactToolLabel, resolveAssistantMessageCopyState, shouldPreserveAssistantLineBreaks, + toolGroupAction, } from "./MessagesTimeline.logic"; describe("shouldPreserveAssistantLineBreaks", () => { @@ -218,6 +219,21 @@ describe("normalizeCompactToolLabel", () => { }); }); +describe("toolGroupAction", () => { + it("groups the OpenCode 2 read title with file reads", () => { + expect( + toolGroupAction({ + id: "read-1", + createdAt: "2026-01-01T00:00:00Z", + label: "Reading file", + tone: "tool", + itemType: "dynamic_tool_call", + toolTitle: "Reading file", + }), + ).toBe("read"); + }); +}); + describe("resolveAssistantMessageCopyState", () => { it("returns enabled copy state for completed assistant messages", () => { expect( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 716a72ea8c14..af53deb4f009 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -288,7 +288,8 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || - (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File") + (entry.itemType === "dynamic_tool_call" && + (entry.toolTitle === "Read File" || entry.toolTitle === "Reading file")) ) { return "read"; } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6d89140d1105..8531b4499c1b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -59,6 +59,7 @@ import { SquarePenIcon, TerminalIcon, Undo2Icon, + WorkflowIcon, WrenchIcon, XIcon, ZapIcon, @@ -1523,6 +1524,9 @@ function toolGroupSummaryIconName( case "tone-tool": return "zap"; case "mixed": + // Several different tool kinds chained together (e.g. OpenCode 2 code + // mode fanning out shell + read + grep in one run). + return "workflow"; case null: return "hammer"; } @@ -2139,6 +2143,7 @@ type WorkEntryIconName = | "search" | "square-pen" | "terminal" + | "workflow" | "wrench" | "x" | "zap"; @@ -2167,6 +2172,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN return ; case "wrench": return ; + case "workflow": + return ; case "x": return ; case "zap": diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 9c36d32ff51a..b3144d32d2fa 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -299,7 +299,7 @@ function formatProcessName(command: string): string { function formatProcessType(process: ServerProcessDiagnosticsEntry): string { if (process.depth > 0) return "Subprocess"; - if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent"; + if (/\b(codex|claude|opencode2?|cursor)\b/i.test(process.command)) return "Agent"; return "Process"; } diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index ea8712a87eb5..60d7d095f468 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -26,29 +26,37 @@ describe("ProviderSettingsForm helpers", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); - const serverPassword = deriveProviderSettingsFields(opencode!).find( - (field) => field.key === "serverPassword", + const binaryPath = deriveProviderSettingsFields(opencode!).find( + (field) => field.key === "binaryPath", ); - expect(serverPassword).toMatchObject({ - label: "Server password", - description: "Stored in plain text on disk.", - control: "password", + expect(binaryPath).toMatchObject({ + label: "Binary path", + control: "text", }); }); - it("preserves unknown config keys while omitting empty configurable fields", () => { + it("exposes one OpenCode provider backed by OpenCode 2", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; - expect(opencode).toBeDefined(); - const serverUrl = deriveProviderSettingsFields(opencode!).find( - (field) => field.key === "serverUrl", - ); - expect(serverUrl).toBeDefined(); + expect(opencode).toMatchObject({ label: "OpenCode" }); + expect(opencode).not.toHaveProperty("badgeLabel"); + expect(DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode2")]).toBeUndefined(); + expect(deriveProviderSettingsFields(opencode!).map((field) => field.key)).toEqual([ + "binaryPath", + ]); + }); + + it("preserves unknown config keys while omitting empty configurable fields", () => { + const codex = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("codex")]; + expect(codex).toBeDefined(); + + const homePath = deriveProviderSettingsFields(codex!).find((field) => field.key === "homePath"); + expect(homePath).toBeDefined(); const next = nextProviderConfigWithFieldValue( - { forkOwned: 1, serverUrl: "http://127.0.0.1:4096" }, - serverUrl!, + { forkOwned: 1, homePath: "~/.codex" }, + homePath!, "", ); diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..b78a35bf8743 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -23,6 +23,11 @@ adapter in a child scope. Adapter implementations live beside them in [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's transport, config, and event shapes are mapped. +The OpenCode driver uses OpenCode 2 and follows the CLI's desktop lifecycle instead of owning a daemon. Its runtime runs the +idempotent `opencode2 service start`, reads the service credential with the CLI's service password +command, and attaches to that singleton over the native `/api/*` HTTP and +SSE protocol. T3 Code never starts `opencode2 serve` and never stops or deletes the shared service. + ## Registry and routing Two registries separate configuration from live processes: diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..1fd0f8bd7705 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,16 +54,17 @@ yay -S t3code-nightly-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | - -Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn -them on in **Settings** → the provider's card when you want to use them. +| Provider | CLI | Default binary | Log in with | +| ---------- | ----------------------------------------------------- | -------------- | ---------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| OpenCode | [OpenCode 2](https://opencode.ai/v2) | `opencode2` | `opencode2 auth login` | + +Codex, Claude, and OpenCode are on by default. When T3 Code detects the OpenCode 2 CLI, a one-time +notice links to its Settings card. Cursor and Grok Build are off by default; turn them on in +**Settings** → the provider's card when you want to use them. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 7563ac155681..8a8d8c075fe8 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -23,6 +23,24 @@ describe("ProviderRuntimeEvent", () => { expect(parsed.providerInstanceId).toBe("ollama_local"); }); + it("accepts raw native OpenCode events", () => { + const parsed = decodeRuntimeEvent({ + type: "session.started", + eventId: "event-opencode-session", + provider: "opencode", + providerInstanceId: "opencode", + createdAt: "2026-08-21T00:00:00.000Z", + threadId: "thread-1", + payload: { message: "started" }, + raw: { + source: "opencode.api.event", + payload: { type: "server.connected" }, + }, + }); + + expect(parsed.raw?.source).toBe("opencode.api.event"); + }); + it("decodes turn.plan.updated for plan rendering", () => { const parsed = decodeRuntimeEvent({ type: "turn.plan.updated", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index bd525e6542e2..4e927625fc9f 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -26,6 +26,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("claude.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), + Schema.Literal("opencode.api.event"), Schema.Literal("acp.jsonrpc"), Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f59da5ece14..cf318b4346cf 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -186,13 +186,15 @@ describe("provider enabled defaults", () => { expect(decoded.providers.claudeAgent.enabled).toBe(true); expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); - expect(decoded.providers.opencode.enabled).toBe(false); + expect(decoded.providers.opencode.enabled).toBe(true); + expect(decoded.providers.opencode.binaryPath).toBe("opencode2"); }); it("derives per-driver defaults from the settings schemas", () => { expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("opencode"))).toBe(true); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); @@ -303,6 +305,9 @@ describe("ServerSettingsPatch string normalization", () => { homePath: " ~/.codex ", launchArgs: " --strict-config --enable foo ", }, + opencode: { + binaryPath: " /opt/homebrew/bin/opencode2 ", + }, }, providerInstances: { codex_personal: { @@ -319,6 +324,7 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); + expect(patch.providers?.opencode?.binaryPath).toBe("/opt/homebrew/bin/opencode2"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0502d303d249..94ece0c9b974 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -469,41 +469,16 @@ export type GrokSettings = typeof GrokSettings.Type; export const OpenCodeSettings = makeProviderSettingsSchema( { - // Off by default (like Cursor and Grok): the binding is not yet stable - // enough to probe on every install. Users opt in from Settings. enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), + Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), - binaryPath: makeBinaryPathSetting("opencode").pipe( + binaryPath: makeBinaryPathSetting("opencode2").pipe( Schema.annotateKey({ title: "Binary path", - description: "Path to the OpenCode binary.", - providerSettingsForm: { - placeholder: "opencode", - clearWhenEmpty: "omit", - }, - }), - ), - serverUrl: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Server URL", - description: "Leave blank to let T3 Code spawn the server when needed.", + description: "Path to the OpenCode 2 binary.", providerSettingsForm: { - placeholder: "http://127.0.0.1:4096", - clearWhenEmpty: "omit", - }, - }), - ), - serverPassword: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Server password", - description: "Stored in plain text on disk.", - providerSettingsForm: { - control: "password", - placeholder: "Optional", + placeholder: "opencode2", clearWhenEmpty: "omit", }, }), @@ -514,7 +489,7 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ), }, { - order: ["binaryPath", "serverUrl", "serverPassword"], + order: ["binaryPath"], }, ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; @@ -803,8 +778,6 @@ const GrokSettingsPatch = Schema.Struct({ const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), - serverUrl: Schema.optionalKey(TrimmedString), - serverPassword: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f66f69b87f0..601e464bc06d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -464,9 +464,6 @@ importers: '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) - '@opencode-ai/sdk': - specifier: ^1.3.15 - version: 1.15.13 '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3285,9 +3282,6 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@opencode-ai/sdk@1.15.13': - resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} - '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -13435,10 +13429,6 @@ snapshots: '@open-draft/until@2.1.0': {} - '@opencode-ai/sdk@1.15.13': - dependencies: - cross-spawn: 7.0.6 - '@oslojs/encoding@1.1.0': {} '@oxc-project/runtime@0.138.0': {}