From 1a873b5c74813d97e07322bb5ab4dbd6bf8b4871 Mon Sep 17 00:00:00 2001 From: Ryan Vogel <89211796+R44VC0RP@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:39:29 -0400 Subject: [PATCH] feat(server): support OpenCode 2 alongside OpenCode 1 --- apps/server/package.json | 1 + .../ProviderRuntimeIngestion.approval.test.ts | 32 + .../src/provider/Drivers/OpenCodeDriver.ts | 22 +- .../provider/Layers/OpenCodeAdapter.test.ts | 446 ++++- .../src/provider/Layers/OpenCodeAdapter.ts | 113 +- .../provider/Layers/OpenCodeProvider.test.ts | 165 +- .../src/provider/Layers/OpenCodeProvider.ts | 27 +- .../src/provider/openCodeV2Client.test.ts | 1223 ++++++++++++ apps/server/src/provider/openCodeV2Client.ts | 1657 +++++++++++++++++ .../opencodeRuntime.inventory.test.ts | 506 ++++- .../opencodeRuntime.permissions.test.ts | 17 +- apps/server/src/provider/opencodeRuntime.ts | 223 ++- .../OpenCodeTextGeneration.test.ts | 267 ++- .../textGeneration/OpenCodeTextGeneration.ts | 70 +- docs/internals/providers.md | 17 + docs/user/install.md | 37 +- docs/user/permission-modes.md | 7 +- packages/contracts/src/settings.test.ts | 17 + packages/contracts/src/settings.ts | 5 +- pnpm-lock.yaml | 37 + pnpm-workspace.yaml | 3 + scripts/build-desktop-artifact.test.ts | 1 + 22 files changed, 4806 insertions(+), 87 deletions(-) create mode 100644 apps/server/src/provider/openCodeV2Client.test.ts create mode 100644 apps/server/src/provider/openCodeV2Client.ts diff --git a/apps/server/package.json b/apps/server/package.json index eb4dc7dd35ec..32603c65eba7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", + "@opencode-ai/client": "0.0.0-beta-18155", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts index 0d262028dedf..bbf14702e07d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.approval.test.ts @@ -31,6 +31,38 @@ describe("runtimeEventToActivities approval details", () => { expect((activity?.payload as Record | undefined)?.detail).toBe(detail); }); + it("keeps OpenCode v2 project-wide approval choices available to every client", () => { + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow this project" }, + { decision: "accept", label: "Allow once" }, + ] as const; + const event = { + type: "request.opened", + eventId: EventId.make("evt-opencode-approval"), + provider: ProviderDriverKind.make("opencode"), + createdAt: "2026-08-25T00:00:00.000Z", + threadId: ThreadId.make("thread-opencode"), + requestId: RuntimeRequestId.make("approval-opencode"), + payload: { + requestType: "command_execution_approval", + detail: "git status", + options, + }, + } satisfies ProviderRuntimeEvent; + + const [activity] = runtimeEventToActivities(event); + + expect(activity).toMatchObject({ + kind: "approval.requested", + payload: { + requestId: "approval-opencode", + requestKind: "command", + options, + }, + }); + }); + it("keeps app details and approval options available to remote clients", () => { const options = [ { decision: "decline", label: "Decline" }, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a01e414f8116..783ece548f0c 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -43,6 +43,7 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, + makeManualOnlyProviderMaintenanceCapabilities, makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, @@ -64,7 +65,7 @@ function isOpenCodeNativeCommandPath(commandPath: string): boolean { ); } -const UPDATE = makePackageManagedProviderMaintenanceResolver({ +const LEGACY_UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "opencode-ai", homebrewFormula: "anomalyco/tap/opencode", @@ -76,6 +77,25 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ }, }); +const UPDATE = { + resolve(options?: Parameters[0]) { + const paths = [options?.binaryPath, options?.resolvedCommandPath, options?.realCommandPath]; + const isOpenCodeV2 = paths.some( + (commandPath) => + typeof commandPath === "string" && + /(?:^|\/)opencode2(?:\.(?:cmd|bat|exe))?$/.test(normalizeCommandPath(commandPath)), + ); + const isUnresolvedDefault = + options?.binaryPath === "opencode" && + !options.resolvedCommandPath && + !options.realCommandPath; + + return isOpenCodeV2 || isUnresolvedDefault + ? makeManualOnlyProviderMaintenanceCapabilities({ provider: DRIVER_KIND, packageName: null }) + : LEGACY_UPDATE.resolve(options); + }, +}; + export type OpenCodeDriverEnv = | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..63fa5377e3bf 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -2,6 +2,7 @@ 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 * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -16,6 +17,7 @@ import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; import { + ApprovalRequestId, OpenCodeSettings, ProviderDriverKind, ProviderInstanceId, @@ -60,6 +62,12 @@ const runtimeMock = { sessionCreateUrls: [] as string[], sessionCreateInputs: [] as Array>, authHeaders: [] as Array, + permissionReplies: [] as Array<{ requestID: string; reply: string }>, + clientApiVersions: [] as Array<"v1" | "v2" | undefined>, + connectionPasswords: [] as Array, + connectionEnvironments: [] as Array, + serverApiVersion: undefined as "v1" | "v2" | undefined, + generatedServerPassword: undefined as string | undefined, abortCalls: [] as string[], closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, @@ -68,8 +76,12 @@ const runtimeMock = { closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as unknown[], + keepEventStreamOpen: false, + eventStreamAbortCount: 0, + onEventStreamOpened: null as (() => void) | null, sessionGetIds: [] as string[], missingSessionIds: new Set(), + v2MissingSessionIds: new Set(), transientErrorSessionIds: new Set(), sessionDirectoryById: new Map(), sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, @@ -80,6 +92,12 @@ const runtimeMock = { this.state.sessionCreateUrls.length = 0; this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; + this.state.permissionReplies.length = 0; + this.state.clientApiVersions.length = 0; + this.state.connectionPasswords.length = 0; + this.state.connectionEnvironments.length = 0; + this.state.serverApiVersion = undefined; + this.state.generatedServerPassword = undefined; this.state.abortCalls.length = 0; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; @@ -88,8 +106,12 @@ const runtimeMock = { this.state.closeError = null; this.state.messages = []; this.state.subscribedEvents = []; + this.state.keepEventStreamOpen = false; + this.state.eventStreamAbortCount = 0; + this.state.onEventStreamOpened = null; this.state.sessionGetIds.length = 0; this.state.missingSessionIds.clear(); + this.state.v2MissingSessionIds.clear(); this.state.transientErrorSessionIds.clear(); this.state.sessionDirectoryById.clear(); this.state.sessionUpdateCalls.length = 0; @@ -115,9 +137,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { exitCode: Effect.never, }; }), - connectToOpenCodeServer: ({ serverUrl }) => + connectToOpenCodeServer: ({ serverUrl, serverPassword, environment }) => Effect.gen(function* () { const url = serverUrl ?? "http://127.0.0.1:4301"; + runtimeMock.state.connectionPasswords.push(serverPassword ?? null); + runtimeMock.state.connectionEnvironments.push(environment); // Always register a finalizer so the closeCalls/closeError probes fire; // production attaches none for external servers. yield* Effect.addFinalizer(() => @@ -132,11 +156,18 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { url, exitCode: null, external: Boolean(serverUrl), + ...(runtimeMock.state.serverApiVersion + ? { apiVersion: runtimeMock.state.serverApiVersion } + : {}), + ...(runtimeMock.state.generatedServerPassword + ? { serverPassword: runtimeMock.state.generatedServerPassword } + : {}), }; }), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => - ({ + createOpenCodeSdkClient: ({ baseUrl, serverPassword, apiVersion }) => { + runtimeMock.state.clientApiVersions.push(apiVersion); + return { session: { create: async (input: Record) => { runtimeMock.state.sessionCreateUrls.push(baseUrl); @@ -158,6 +189,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: { status: 404, body: { name: "NotFoundError" } }, }); } + if (runtimeMock.state.v2MissingSessionIds.has(sessionID)) { + return Promise.reject({ _tag: "SessionNotFoundError", sessionID }); + } const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; }, @@ -204,15 +238,35 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, }, event: { - subscribe: async () => ({ + subscribe: async (_parameters?: unknown, options?: { readonly signal?: AbortSignal }) => ({ stream: (async function* () { + runtimeMock.state.onEventStreamOpened?.(); for (const event of runtimeMock.state.subscribedEvents) { yield event; } + if (runtimeMock.state.keepEventStreamOpen) { + await new Promise((resolve) => { + const finish = () => { + runtimeMock.state.eventStreamAbortCount += 1; + resolve(); + }; + if (options?.signal?.aborted) { + finish(); + } else { + options?.signal?.addEventListener("abort", finish, { once: true }); + } + }); + } })(), }), }, - }) as unknown as ReturnType, + permission: { + reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { + runtimeMock.state.permissionReplies.push({ requestID, reply }); + }, + }, + } as unknown as ReturnType; + }, loadOpenCodeInventory: () => Effect.fail( new OpenCodeRuntimeError({ @@ -246,7 +300,19 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory // 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)({ +const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); +const decodeOpenCodeRuntimeConfig = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + model: Schema.String, + permission: Schema.Record(Schema.String, Schema.String), + permissions: Schema.Array( + Schema.Struct({ action: Schema.String, resource: Schema.String, effect: Schema.String }), + ), + }), + ), +); +const openCodeAdapterTestSettings = decodeOpenCodeSettings({ binaryPath: "fake-opencode", serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", @@ -301,6 +367,125 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("passes OpenCode v2 API generation and server credentials to the client", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + runtimeMock.state.serverApiVersion = "v2"; + runtimeMock.state.generatedServerPassword = "generated-v2-password"; + + const threadId = asThreadId("thread-opencode-v2"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + NodeAssert.deepEqual(runtimeMock.state.connectionPasswords, ["secret-password"]); + NodeAssert.deepEqual(runtimeMock.state.clientApiVersions, ["v2"]); + NodeAssert.deepEqual(runtimeMock.state.authHeaders, [ + `Basic ${btoa("opencode:generated-v2-password")}`, + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("authenticates locally spawned OpenCode v2 servers with generated credentials", () => { + const localSettings = decodeOpenCodeSettings({ binaryPath: "opencode2" }); + const adapterLayer = Layer.effect(OpenCodeAdapter, makeOpenCodeAdapter(localSettings)).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-opencode-v2-local"); + runtimeMock.state.serverApiVersion = "v2"; + runtimeMock.state.generatedServerPassword = "generated-local-password"; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + NodeAssert.deepEqual(runtimeMock.state.connectionPasswords, [null]); + NodeAssert.deepEqual(runtimeMock.state.connectionEnvironments, [undefined]); + NodeAssert.deepEqual(runtimeMock.state.clientApiVersions, ["v2"]); + NodeAssert.deepEqual(runtimeMock.state.authHeaders, [ + `Basic ${btoa("opencode:generated-local-password")}`, + ]); + yield* adapter.stopSession(threadId); + }).pipe(Effect.provide(adapterLayer)); + }); + + it.effect("enforces restricted permission modes on locally spawned OpenCode v2 servers", () => { + const localSettings = decodeOpenCodeSettings({ binaryPath: "opencode2" }); + const adapterLayer = Layer.effect( + OpenCodeAdapter, + makeOpenCodeAdapter(localSettings, { + environment: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + model: "openai/gpt-test", + permissions: [{ action: "read", resource: "secrets/*", effect: "deny" }], + }), + }, + }), + ).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; + runtimeMock.state.serverApiVersion = "v2"; + + for (const [runtimeMode, editEffect] of [ + ["approval-required", "ask"], + ["auto", "ask"], + ["auto-accept-edits", "allow"], + ] as const) { + const threadId = asThreadId(`thread-opencode-v2-${runtimeMode}`); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode, + }); + + const configContent = + runtimeMock.state.connectionEnvironments.at(-1)?.OPENCODE_CONFIG_CONTENT; + NodeAssert.ok(configContent); + const config = decodeOpenCodeRuntimeConfig(configContent); + NodeAssert.equal(config.model, "openai/gpt-test"); + NodeAssert.equal(config.permission["*"], "ask"); + NodeAssert.equal(config.permission.bash, "ask"); + NodeAssert.equal(config.permission.edit, editEffect); + NodeAssert.equal(config.permission.question, "allow"); + NodeAssert.deepEqual(config.permissions[0], { + action: "read", + resource: "secrets/*", + effect: "deny", + }); + NodeAssert.deepEqual(config.permissions.at(-1), { + action: "read", + resource: "secrets/*", + effect: "deny", + }); + NodeAssert.ok( + config.permissions.some( + (rule) => rule.action === "shell" && rule.resource === "*" && rule.effect === "ask", + ), + ); + yield* adapter.stopSession(threadId); + } + }).pipe(Effect.provide(adapterLayer)); + }); + it.effect("returns a durable resume cursor for a freshly created session", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -414,6 +599,28 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("starts a fresh session when OpenCode v2 reports a missing persisted session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-v2-stale"); + runtimeMock.state.v2MissingSessionIds.add("ses_v2_stale"); + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_v2_stale" }, + }); + + NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_v2_stale"]); + 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; @@ -602,6 +809,30 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("aborts an open OpenCode v2 event stream before closing the session scope", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-v2-open-stream"); + const opened = yield* Deferred.make(); + runtimeMock.state.serverApiVersion = "v2"; + runtimeMock.state.keepEventStreamOpen = true; + runtimeMock.state.onEventStreamOpened = () => { + Deferred.doneUnsafe(opened, Effect.void); + }; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* Deferred.await(opened); + yield* adapter.stopSession(threadId).pipe(Effect.timeout("1 second")); + + NodeAssert.equal(runtimeMock.state.eventStreamAbortCount, 1); + NodeAssert.equal(yield* adapter.hasSession(threadId), false); + }), + ); + it.effect("emits one session.exited event when stopping a session", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -997,6 +1228,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { NodeAssert.equal(isOpenCodeNotFound({ statusCode: 404 }), true); // OpenCode NotFoundError body name with no status. NodeAssert.equal(isOpenCodeNotFound({ body: { name: "NotFoundError" } }), true); + NodeAssert.equal(isOpenCodeNotFound({ _tag: "SessionNotFoundError" }), true); + NodeAssert.equal( + isOpenCodeNotFound({ cause: { _tag: "SessionNotFoundError", sessionID: "ses_x" } }), + true, + ); // NOT a miss: only structured signals count, never free text. A non-404 // error whose message/detail merely contains "not found" must propagate, @@ -1010,6 +1246,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { // 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({ _tag: "SessionNotFoundError", status: 500 }), 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. @@ -1150,6 +1387,203 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("keeps OpenCode v2 form options valid when descriptions are absent", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-v2-form"); + runtimeMock.state.serverApiVersion = "v2"; + runtimeMock.state.subscribedEvents = [ + { + type: "question.asked", + properties: { + id: "form-choice", + sessionID: "http://127.0.0.1:9999/session", + questions: [ + { + header: "Choose", + question: "Which option should be used?", + options: [{ label: "Keep existing", description: "" }], + }, + ], + }, + }, + ]; + const formFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "user-input.requested", + ), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + const events = Array.from(yield* Fiber.join(formFiber).pipe(Effect.timeout("1 second"))); + const event = events[0]; + + NodeAssert.ok(event && event.type === "user-input.requested"); + NodeAssert.deepEqual(event.payload.questions[0]?.options, [ + { label: "Keep existing", description: "Keep existing" }, + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("classifies OpenCode v2 shell tools and approvals as command execution", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-v2-shell"); + runtimeMock.state.serverApiVersion = "v2"; + runtimeMock.state.subscribedEvents = [ + { + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "part-shell", + sessionID: "http://127.0.0.1:9999/session", + messageID: "msg-shell", + callID: "call-shell", + type: "tool", + tool: "shell", + state: { + status: "pending", + input: { command: "git status" }, + }, + }, + }, + }, + { + type: "permission.asked", + properties: { + sessionID: "http://127.0.0.1:9999/session", + id: "permission-shell", + permission: "shell", + patterns: ["git status"], + metadata: { command: "git status" }, + }, + }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const toolEvent = events.find((event) => event.type === "item.started"); + const approvalEvent = events.find((event) => event.type === "request.opened"); + + NodeAssert.ok(toolEvent); + NodeAssert.equal(toolEvent.payload.itemType, "command_execution"); + NodeAssert.ok(approvalEvent); + NodeAssert.equal(approvalEvent.payload.requestType, "command_execution_approval"); + NodeAssert.deepEqual(approvalEvent.payload.options, [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow this project" }, + { decision: "accept", label: "Allow once" }, + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("distinguishes session-only and project-wide OpenCode v2 approvals", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-v2-session-approval"); + runtimeMock.state.serverApiVersion = "v2"; + runtimeMock.state.subscribedEvents = [ + { + type: "permission.asked", + properties: { + sessionID: "http://127.0.0.1:9999/session", + id: "permission-session-only", + permission: "shell", + patterns: ["git status"], + metadata: { command: "git status" }, + }, + }, + ]; + const approvalFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + yield* Fiber.join(approvalFiber).pipe(Effect.timeout("1 second")); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make("permission-session-only"), + "acceptForSession", + ); + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make("permission-session-only"), + "acceptAlways", + ); + + NodeAssert.deepEqual(runtimeMock.state.permissionReplies, [ + { requestID: "permission-session-only", reply: "once" }, + { requestID: "permission-session-only", reply: "always" }, + ]); + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("labels OpenCode v2 persisted approval responses as project-wide", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-v2-persisted-approval"); + runtimeMock.state.serverApiVersion = "v2"; + runtimeMock.state.subscribedEvents = [ + { + type: "permission.replied", + properties: { + sessionID: "http://127.0.0.1:9999/session", + requestID: "permission-persisted", + reply: "always", + }, + }, + ]; + const approvalFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.resolved"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + const events = Array.from(yield* Fiber.join(approvalFiber).pipe(Effect.timeout("1 second"))); + + NodeAssert.equal(events[0]?.type, "request.resolved"); + if (events[0]?.type === "request.resolved") { + NodeAssert.equal(events[0].payload.decision, "acceptAlways"); + } + yield* adapter.stopSession(threadId); + }), + ); + it.effect("lets OpenCode own session title generation and emits title metadata updates", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..ae91499f0b41 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, type ProviderRuntimeEvent, type ProviderSession, + type RuntimeMode, RuntimeItemId, RuntimeRequestId, ThreadId, @@ -45,6 +46,7 @@ import { openCodeQuestionId, openCodeRuntimeErrorDetail, parseOpenCodeModelSlug, + resolveOpenCodeConfigContent, runOpenCodeSdk, toOpenCodeFileParts, toOpenCodePermissionReply, @@ -88,8 +90,9 @@ function parseOpenCodeResume(raw: unknown): { readonly sessionId: string } | und * 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 + * text: a numeric 404, the exact `NotFoundError` name, or OpenCode v2's + * `SessionNotFoundError` tag, 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. */ @@ -119,6 +122,10 @@ export function isOpenCodeNotFound(cause: unknown): boolean { continue; } + if (record._tag === "SessionNotFoundError") { + return true; + } + const name = record.name; if (typeof name === "string" && name.toLowerCase() === "notfounderror") { return true; @@ -237,6 +244,7 @@ interface OpenCodeSessionContext { activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; + eventAbortController: AbortController | undefined; /** * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. * The session lifecycle is owned by `sessionScope`; this Ref exists only @@ -302,7 +310,11 @@ type EventBaseInput = { function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { const normalized = toolName.toLowerCase(); - if (normalized.includes("bash") || normalized.includes("command")) { + if ( + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("command") + ) { return "command_execution"; } if ( @@ -337,6 +349,7 @@ function mapPermissionToRequestType( ): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { switch (permission) { case "bash": + case "shell": return "command_execution_approval"; case "read": return "file_read_approval"; @@ -359,6 +372,57 @@ function mapPermissionDecision(reply: "once" | "always" | "reject"): string { } } +function withOpenCodeRuntimePermissions( + environment: NodeJS.ProcessEnv | undefined, + runtimeMode: RuntimeMode, +): NodeJS.ProcessEnv { + const parsedConfig: unknown = JSON.parse(resolveOpenCodeConfigContent(environment)); + if (!parsedConfig || typeof parsedConfig !== "object" || Array.isArray(parsedConfig)) { + throw new Error("OpenCode configuration content must be a JSON object."); + } + + const config = parsedConfig as Record; + const existingPermissions = + config.permission && typeof config.permission === "object" && !Array.isArray(config.permission) + ? config.permission + : {}; + const configuredPermissions = Array.isArray(config.permissions) + ? (config.permissions as unknown[]) + : undefined; + const rules = buildOpenCodePermissionRules(runtimeMode); + const permissions = rules.map((rule) => ({ + action: rule.permission === "bash" ? "shell" : rule.permission, + resource: rule.pattern, + effect: rule.action, + })); + + return { + ...(environment ?? process.env), + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...config, + permission: { + ...existingPermissions, + ...Object.fromEntries(rules.map((rule) => [rule.permission, rule.action])), + }, + ...(configuredPermissions + ? { + permissions: [ + ...configuredPermissions, + ...permissions, + ...configuredPermissions.filter( + (rule) => + rule !== null && + typeof rule === "object" && + "effect" in rule && + rule.effect === "deny", + ), + ], + } + : {}), + }), + }; +} + function resolveTurnSnapshot( context: OpenCodeSessionContext, turnId: TurnId, @@ -411,7 +475,7 @@ function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray ({ label: option.label, - description: option.description, + description: option.description?.trim() || option.label, })), ...(question.multiple ? { multiSelect: true } : {}), })); @@ -570,9 +634,8 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( 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. + // Abort the HTTP stream before scope finalizers wait for its event-pump fiber. + context.eventAbortController?.abort(); yield* Scope.close(context.sessionScope, Exit.void); return true; }); @@ -729,6 +792,7 @@ export function makeOpenCodeAdapter( yield* runOpenCodeSdk("session.abort", () => context.client.session.abort({ sessionID: context.openCodeSessionId }), ).pipe(Effect.ignore({ log: true })); + context.eventAbortController?.abort(); yield* Scope.close(context.sessionScope, Exit.void); }); @@ -971,6 +1035,15 @@ export function makeOpenCodeAdapter( event.properties.patterns.length > 0 ? event.properties.patterns.join("\n") : event.properties.permission, + ...(context.server.apiVersion === "v2" + ? { + options: [ + { decision: "decline" as const, label: "Decline" }, + { decision: "acceptAlways" as const, label: "Always allow this project" }, + { decision: "accept" as const, label: "Allow once" }, + ], + } + : {}), args: event.properties.metadata, }, }); @@ -989,7 +1062,10 @@ export function makeOpenCodeAdapter( type: "request.resolved", payload: { requestType: "unknown", - decision: mapPermissionDecision(event.properties.reply), + decision: + context.server.apiVersion === "v2" && event.properties.reply === "always" + ? "acceptAlways" + : mapPermissionDecision(event.properties.reply), }, }); break; @@ -1143,6 +1219,7 @@ export function makeOpenCodeAdapter( // shutdown) and cancels the in-flight `event.subscribe` fetch so // the async iterable unwinds cleanly. const eventsAbortController = new AbortController(); + context.eventAbortController = eventsAbortController; yield* Scope.addFinalizer( context.sessionScope, Effect.sync(() => eventsAbortController.abort()), @@ -1221,15 +1298,22 @@ export function makeOpenCodeAdapter( // 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 environment = + !serverUrl && input.runtimeMode !== "full-access" + ? withOpenCodeRuntimePermissions(options?.environment, input.runtimeMode) + : options?.environment; const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, serverUrl, - ...(options?.environment ? { environment: options.environment } : {}), + ...(serverPassword ? { serverPassword } : {}), + ...(environment ? { environment } : {}), }); + const resolvedServerPassword = server.serverPassword ?? serverPassword; const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory, - ...(server.external && serverPassword ? { serverPassword } : {}), + ...(server.apiVersion ? { apiVersion: server.apiVersion } : {}), + ...(resolvedServerPassword ? { serverPassword: resolvedServerPassword } : {}), }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); if (mcpSession && !server.external) { @@ -1402,6 +1486,7 @@ export function makeOpenCodeAdapter( activeTurnId: undefined, activeAgent: undefined, activeVariant: undefined, + eventAbortController: undefined, stopped: yield* Ref.make(false), sessionScope: started.sessionScope, }; @@ -1588,10 +1673,16 @@ export function makeOpenCodeAdapter( }); } + const reply = + context.server.apiVersion === "v2" && decision === "acceptForSession" + ? "once" + : decision === "acceptAlways" + ? "always" + : toOpenCodePermissionReply(decision); yield* runOpenCodeSdk("permission.reply", () => context.client.permission.reply({ requestID: requestId, - reply: toOpenCodePermissionReply(decision), + reply, }), ).pipe(Effect.mapError(toRequestError)); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 7c07fe5ad4b8..dac0e2e520ce 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -35,6 +35,16 @@ const runtimeMock = { versionStdout: DEFAULT_VERSION_STDOUT, inventoryError: null as Error | null, inventoryCwd: null as string | null, + inventoryApiVersion: null as "v1" | "v2" | null, + connectionApiVersion: null as "v1" | "v2" | null, + connectionServerPassword: null as string | null, + connectServerPassword: null as string | null, + sdkClientOptions: null as { + readonly baseUrl: string; + readonly directory: string; + readonly apiVersion?: "v1" | "v2"; + readonly serverPassword?: string; + } | null, closeCalls: 0, inventory: { providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, @@ -47,6 +57,11 @@ const runtimeMock = { this.state.versionStdout = DEFAULT_VERSION_STDOUT; this.state.inventoryError = null; this.state.inventoryCwd = null; + this.state.inventoryApiVersion = null; + this.state.connectionApiVersion = null; + this.state.connectionServerPassword = null; + this.state.connectServerPassword = null; + this.state.sdkClientOptions = null; this.state.closeCalls = 0; this.state.inventory = { providerList: { connected: [], all: [] as unknown[], default: {} }, @@ -62,8 +77,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { url: "http://127.0.0.1:4301", exitCode: Effect.never, }), - connectToOpenCodeServer: ({ serverUrl }) => + connectToOpenCodeServer: ({ serverUrl, serverPassword }) => Effect.gen(function* () { + runtimeMock.state.connectServerPassword = serverPassword ?? null; if (!serverUrl) { yield* Effect.addFinalizer(() => Effect.sync(() => { @@ -75,6 +91,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { url: serverUrl ?? "http://127.0.0.1:4301", exitCode: null, external: Boolean(serverUrl), + ...(runtimeMock.state.connectionApiVersion + ? { apiVersion: runtimeMock.state.connectionApiVersion } + : {}), + ...(runtimeMock.state.connectionServerPassword + ? { serverPassword: runtimeMock.state.connectionServerPassword } + : {}), }; }), runOpenCodeCommand: () => @@ -87,8 +109,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), - createOpenCodeSdkClient: () => - ({}) as unknown as ReturnType, + createOpenCodeSdkClient: (input) => { + runtimeMock.state.sdkClientOptions = input; + return {} as ReturnType; + }, loadOpenCodeInventory: () => runtimeMock.state.inventoryError ? Effect.fail( @@ -99,8 +123,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), - loadInventoryFromCli: ({ cwd }) => { + loadInventoryFromCli: ({ cwd, apiVersion }) => { runtimeMock.state.inventoryCwd = cwd; + runtimeMock.state.inventoryApiVersion = apiVersion ?? null; return runtimeMock.state.inventoryError ? Effect.fail( new OpenCodeRuntimeError({ @@ -133,7 +158,7 @@ const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSe }); it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { - it.effect("shows a codex-style missing binary message", () => + it.effect("mentions both executable names when OpenCode cannot be discovered", () => Effect.gen(function* () { runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); @@ -142,11 +167,98 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { NodeAssert.equal(snapshot.installed, false); NodeAssert.equal( snapshot.message, - "OpenCode CLI (`opencode`) is not installed or not on PATH.", + "OpenCode CLI (`opencode` or `opencode2`) is not installed or not on PATH.", ); }), ); + it.effect("accepts OpenCode 2 preview releases and routes their inventory through v2", () => + Effect.gen(function* () { + for (const [stdout, expectedVersion] of [ + ["opencode2 v0.0.0-dev-18191\n", "0.0.0-dev-18191"], + ["opencode2 v0.0.0-beta-18155\n", "0.0.0-beta-18155"], + ] as const) { + runtimeMock.state.versionStdout = stdout; + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + NodeAssert.equal(snapshot.status, "warning", snapshot.message); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, expectedVersion); + NodeAssert.equal(runtimeMock.state.inventoryApiVersion, "v2"); + } + }), + ); + + it.effect("accepts actual OpenCode 2 releases and routes their inventory through v2", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode2 2.1.0\n"; + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ binaryPath: "opencode2" }), + process.cwd(), + ); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "2.1.0"); + NodeAssert.equal(runtimeMock.state.inventoryApiVersion, "v2"); + }), + ); + + it.effect("keeps enforcing the minimum supported OpenCode 1 version", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode 1.14.18\n"; + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "1.14.18"); + NodeAssert.equal( + snapshot.message, + "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.", + ); + NodeAssert.equal(runtimeMock.state.inventoryApiVersion, null); + }), + ); + + it.effect("does not exempt unconfirmed zero versions from the OpenCode 1 minimum", () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode 0.0.0\n"; + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.version, "0.0.0"); + NodeAssert.equal(runtimeMock.state.inventoryApiVersion, null); + }), + ); + + it.effect( + "rejects malformed version output even for an explicitly configured OpenCode 2 CLI", + () => + Effect.gen(function* () { + runtimeMock.state.versionStdout = "opencode2 development build\n"; + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ binaryPath: "opencode2" }), + process.cwd(), + ); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, null); + NodeAssert.match(snapshot.message ?? "", /`opencode2 --version`/); + NodeAssert.equal(runtimeMock.state.inventoryApiVersion, null); + }), + ); + + it.effect("routes supported OpenCode 1 releases through their existing CLI inventory", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(snapshot.version, "1.14.19"); + NodeAssert.equal(runtimeMock.state.inventoryApiVersion, "v1"); + }), + ); + 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"); @@ -306,6 +418,47 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }); it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { + it.effect("routes detected OpenCode 2 servers with their connection credentials", () => + Effect.gen(function* () { + runtimeMock.state.connectionApiVersion = "v2"; + runtimeMock.state.connectionServerPassword = "connection-password"; + const snapshot = yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "configured-password", + }), + process.cwd(), + ); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.equal(runtimeMock.state.connectServerPassword, "configured-password"); + NodeAssert.deepEqual(runtimeMock.state.sdkClientOptions, { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + apiVersion: "v2", + serverPassword: "connection-password", + }); + }), + ); + + it.effect("preserves configured passwords for OpenCode 1 external servers", () => + Effect.gen(function* () { + yield* checkOpenCodeProviderStatus( + makeOpenCodeSettings({ + serverUrl: "http://127.0.0.1:9999", + serverPassword: "configured-password", + }), + process.cwd(), + ); + + NodeAssert.deepEqual(runtimeMock.state.sdkClientOptions, { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + serverPassword: "configured-password", + }); + }), + ); + it.effect("surfaces a friendly auth error for configured servers", () => Effect.gen(function* () { runtimeMock.state.inventoryError = new Error("401 Unauthorized"); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 62f29c47eb38..c2debcac7411 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -19,6 +19,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { + isOpenCodeV2VersionOutput, OpenCodeRuntime, openCodeRuntimeErrorDetail, type OpenCodeInventory, @@ -107,7 +108,7 @@ function formatOpenCodeProbeError(input: { if (lower.includes("enoent") || lower.includes("notfound")) { return { installed: false, - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", + message: "OpenCode CLI (`opencode` or `opencode2`) is not installed or not on PATH.", }; } @@ -373,6 +374,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu } let version: string | null = null; + let apiVersion: "v1" | "v2" = "v1"; if (!isExternalServer) { const versionExit = yield* Effect.exit( openCodeRuntime @@ -390,17 +392,24 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu if (versionExit._tag === "Failure") { return fallback(Cause.squash(versionExit.cause)); } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; + const versionOutput = versionExit.value.stdout.replace(/\bv(?=\d+\.\d+\.\d+)/gi, ""); + apiVersion = isOpenCodeV2VersionOutput(versionExit.value.stdout, openCodeSettings.binaryPath) + ? "v2" + : "v1"; + version = + apiVersion === "v2" + ? (versionOutput.match(/\b\d+\.\d+\.\d+(?:-[\da-z.-]+)?(?:\+[\da-z.-]+)?\b/i)?.[0] ?? null) + : parseGenericCliVersion(versionOutput); 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.`, + `Unable to determine OpenCode version from \`${openCodeSettings.binaryPath} --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer, or OpenCode 2.`, ), null, ); } - if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { + if (apiVersion === "v1" && compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, @@ -425,14 +434,17 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu binaryPath: openCodeSettings.binaryPath, serverUrl: openCodeSettings.serverUrl, environment: resolvedEnvironment, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), }); + const serverPassword = server.serverPassword ?? openCodeSettings.serverPassword; return yield* openCodeRuntime.loadOpenCodeInventory( openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: cwd, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), + ...(server.apiVersion ? { apiVersion: server.apiVersion } : {}), + ...(serverPassword ? { serverPassword } : {}), }), ); }), @@ -441,6 +453,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu binaryPath: openCodeSettings.binaryPath, cwd, environment: resolvedEnvironment, + apiVersion, }) ).pipe( Effect.mapError( diff --git a/apps/server/src/provider/openCodeV2Client.test.ts b/apps/server/src/provider/openCodeV2Client.test.ts new file mode 100644 index 000000000000..5709144712bd --- /dev/null +++ b/apps/server/src/provider/openCodeV2Client.test.ts @@ -0,0 +1,1223 @@ +import * as NodeAssert from "node:assert/strict"; + +import type { + AgentInfo, + ModelInfo, + ProviderInfo, + SessionInfo, + SessionMessageAssistant, + SessionMessageUser, +} from "@opencode-ai/client"; +import { describe, it } from "vite-plus/test"; + +import { createOpenCodeV2Client } from "./openCodeV2Client.ts"; + +interface CapturedRequest { + readonly url: URL; + readonly method: string; + readonly headers: Headers; + readonly body: unknown; + readonly signal: AbortSignal | null; +} + +interface EventFixture { + readonly id: string; + readonly type: string; + readonly created?: number; + readonly location?: { readonly directory: string }; + readonly data: Record; +} + +const DIRECTORY = "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/workspace/project"; +const LOCATION = { + directory: DIRECTORY, + project: { id: "project", directory: DIRECTORY, canonical: DIRECTORY }, +}; + +function makeFetch(handler: (request: CapturedRequest) => Response | Promise): { + readonly fetch: typeof globalThis.fetch; + readonly requests: Array; +} { + const requests: Array = []; + const fetch = Object.assign( + async ( + resource: Parameters[0], + init?: Parameters[1], + ) => { + const url = new URL( + typeof resource === "string" + ? resource + : resource instanceof URL + ? resource.href + : resource.url, + ); + const request: CapturedRequest = { + url, + method: init?.method ?? "GET", + headers: new Headers(init?.headers), + body: typeof init?.body === "string" ? (JSON.parse(init.body) as unknown) : undefined, + signal: init?.signal ?? null, + }; + requests.push(request); + return handler(request); + }, + { preconnect: (_url: string | URL): void => {} }, + ); + return { fetch, requests }; +} + +function noContent(): Response { + return new Response(null, { status: 204 }); +} + +function eventStream(events: ReadonlyArray): Response { + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); +} + +function event( + type: string, + data: Record, + created: number, + id = `event-${created}`, +): EventFixture { + return { id, type, created, location: { directory: DIRECTORY }, data }; +} + +function session( + id: string, + directory = DIRECTORY, + overrides: Partial = {}, +): SessionInfo { + return { + id, + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 100, updated: 100 }, + title: "Session", + location: { directory }, + ...overrides, + }; +} + +function model(id: string, providerID: string, overrides: Partial = {}): ModelInfo { + return { + id, + modelID: `upstream/${id}`, + providerID, + name: `Model ${id}`, + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [], + time: { released: 1_735_689_600_000 }, + cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], + status: "active", + enabled: true, + limit: { context: 128_000, output: 16_000 }, + ...overrides, + }; +} + +function provider(id: string, overrides: Partial = {}): ProviderInfo { + return { + id, + name: `${id} provider`, + activation: "enabled", + package: `@provider/${id}`, + ...overrides, + }; +} + +function agent(id: string, overrides: Partial = {}): AgentInfo { + return { + id, + name: `Display ${id}`, + request: { settings: {}, headers: {}, body: {} }, + mode: "primary", + hidden: false, + permissions: [{ action: "shell", resource: "*", effect: "ask" }], + ...overrides, + }; +} + +function assistant( + id: string, + overrides: Partial = {}, +): SessionMessageAssistant { + return { + id, + type: "assistant", + time: { created: 200, completed: 250 }, + agent: "build", + model: { id: "gpt-test", providerID: "openai" }, + content: [{ type: "text", text: "Assistant response" }], + ...overrides, + }; +} + +function user(id: string, text: string): SessionMessageUser { + return { id, type: "user", time: { created: 150 }, text }; +} + +function inbox(sessionID: string, id = "message-user") { + return { + id, + sessionID, + type: "user", + timeCreated: 150, + payload: { text: "Prompt" }, + delivery: "steer", + }; +} + +describe("createOpenCodeV2Client", () => { + it("joins location-scoped providers and models while preserving agent IDs, variants, skills, and Basic auth", async () => { + const fixtures = makeFetch((request) => { + switch (request.url.pathname) { + case "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/api/model": + return Response.json({ + location: LOCATION, + data: [ + model("gpt-test", "openai", { + variants: [ + { id: "low", settings: { effort: "low" } }, + { id: "high", body: { reasoning: "high" } }, + ], + }), + model("disabled-model", "openai", { enabled: false }), + model("hidden-provider-model", "disabled"), + ], + }); + case "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/api/provider": + return Response.json({ + location: LOCATION, + data: [ + provider("openai", { settings: { baseURL: "https://api.example.test" } }), + provider("disabled", { activation: "disabled" }), + ], + }); + case "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/api/agent": + return Response.json({ + location: LOCATION, + data: [ + agent("build", { name: "Builder", description: "Build things" }), + agent("hidden", { mode: "subagent", hidden: true }), + ], + }); + case "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/api/skill": + return Response.json({ + location: LOCATION, + data: [ + { + id: "review", + name: "review", + description: "Review changes", + location: "/skills/review/SKILL.md", + content: "Instructions", + }, + ], + }); + default: + throw new Error(`Unexpected request: ${request.method} ${request.url.pathname}`); + } + }); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + serverPassword: "secret", + fetch: fixtures.fetch, + }); + + const [providers, agents, skills] = await Promise.all([ + client.provider.list(), + client.app.agents(), + client.app.skills(), + ]); + + NodeAssert.deepEqual(providers.data?.connected, ["openai"]); + NodeAssert.deepEqual(providers.data?.default, { openai: "gpt-test" }); + const openai = providers.data?.all.find((entry) => entry.id === "openai"); + NodeAssert.ok(openai); + NodeAssert.deepEqual(Object.keys(openai.models), ["gpt-test"]); + NodeAssert.deepEqual(openai.models["gpt-test"]?.variants, { + low: { effort: "low" }, + high: { body: { reasoning: "high" } }, + }); + NodeAssert.equal(openai.models["gpt-test"]?.api.url, "https://api.example.test"); + NodeAssert.equal(openai.models["gpt-test"]?.capabilities.attachment, true); + NodeAssert.equal(agents.data?.[0]?.name, "build"); + NodeAssert.equal(agents.data?.[0]?.permission[0]?.permission, "bash"); + NodeAssert.equal(agents.data?.[1]?.hidden, true); + NodeAssert.equal(skills.data?.[0]?.location, "/skills/review/SKILL.md"); + + const expectedAuthorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`; + for (const request of fixtures.requests) { + NodeAssert.equal(request.url.searchParams.get("location[directory]"), DIRECTORY); + NodeAssert.equal(request.headers.get("authorization"), expectedAuthorization); + } + }); + + it("creates located sessions, switches model and agent before prompts, and converts attachments", async () => { + let promptCount = 0; + const fixtures = makeFetch((request) => { + switch (request.url.pathname) { + case "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/api/session": + return Response.json({ data: session("ses_prompt") }); + case "/api/session/ses_prompt/model": + case "/api/session/ses_prompt/agent": + return noContent(); + case "/api/session/ses_prompt/prompt": + promptCount += 1; + return Response.json({ data: inbox("ses_prompt", `message-${promptCount}`) }); + default: + throw new Error(`Unexpected request: ${request.method} ${request.url.pathname}`); + } + }); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + fetch: fixtures.fetch, + }); + + const created = await client.session.create({ + title: "Prompt session", + permission: [{ permission: "*", pattern: "*", action: "ask" }], + }); + NodeAssert.equal(created.data?.directory, DIRECTORY); + NodeAssert.deepEqual(created.data?.permission, [ + { permission: "*", pattern: "*", action: "ask" }, + ]); + + const parameters = { + sessionID: "ses_prompt", + model: { providerID: "openai", modelID: "gpt-test" }, + variant: "high", + agent: "plan", + parts: [ + { type: "text" as const, text: "Review this" }, + { + type: "file" as const, + mime: "image/png", + filename: "screenshot.png", + url: "file:///workspace/screenshot.png", + }, + ], + }; + await client.session.promptAsync(parameters); + await client.session.promptAsync(parameters); + + NodeAssert.deepEqual( + fixtures.requests.map((request) => `${request.method} ${request.url.pathname}`), + [ + "POST /api/session", + "POST /api/session/ses_prompt/model", + "POST /api/session/ses_prompt/agent", + "POST /api/session/ses_prompt/prompt", + "POST /api/session/ses_prompt/prompt", + ], + ); + NodeAssert.deepEqual(fixtures.requests[0]?.body, { + location: { directory: DIRECTORY }, + title: "Prompt session", + }); + NodeAssert.deepEqual(fixtures.requests[1]?.body, { + model: { id: "gpt-test", providerID: "openai", variant: "high" }, + }); + NodeAssert.deepEqual(fixtures.requests[2]?.body, { agent: "plan" }); + NodeAssert.deepEqual(fixtures.requests[3]?.body, { + text: "Review this", + files: [{ uri: "file:///workspace/screenshot.png", name: "screenshot.png" }], + delivery: "steer", + }); + }); + + it("waits for synchronous prompts and reads paginated assistant responses", async () => { + const fixtures = makeFetch((request) => { + switch (request.url.pathname) { + case "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/api/session": + return Response.json({ + data: session("ses_sync", DIRECTORY, { + agent: "build", + model: { id: "gpt-test", providerID: "openai" }, + }), + }); + case "/api/session/ses_sync/prompt": + return Response.json({ data: inbox("ses_sync", "message-prompt") }); + case "/api/session/ses_sync/wait": + return noContent(); + case "/api/session/ses_sync/message": + return request.url.searchParams.get("cursor") === "older" + ? Response.json({ + data: [ + assistant("message-assistant", { + content: [ + { type: "reasoning", text: "Thinking" }, + { type: "text", text: "Final output" }, + ], + error: { type: "provider.auth", message: "Credentials expired" }, + }), + ], + cursor: {}, + }) + : Response.json({ + data: [user("message-prompt", "Prompt")], + cursor: { next: "older" }, + }); + default: + throw new Error(`Unexpected request: ${request.method} ${request.url.pathname}`); + } + }); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + fetch: fixtures.fetch, + }); + await client.session.create({ + agent: "build", + model: { id: "gpt-test", providerID: "openai" }, + }); + + const result = await client.session.prompt({ + sessionID: "ses_sync", + agent: "build", + model: { providerID: "openai", modelID: "gpt-test" }, + parts: [{ type: "text", text: "Prompt" }], + }); + + NodeAssert.equal(result.data?.info.role, "assistant"); + NodeAssert.equal(result.data?.info.parentID, "message-prompt"); + NodeAssert.deepEqual(result.data?.info.error, { + name: "ProviderAuthError", + data: { providerID: "openai", message: "Credentials expired" }, + }); + NodeAssert.deepEqual( + result.data?.parts.map((part) => ({ + type: part.type, + text: "text" in part ? part.text : "", + })), + [ + { type: "reasoning", text: "Thinking" }, + { type: "text", text: "Final output" }, + ], + ); + NodeAssert.deepEqual( + fixtures.requests + .filter((request) => request.url.pathname.endsWith("/message")) + .map((request) => request.url.searchParams.get("order")), + ["desc", "desc"], + ); + }); + + it("moves and waits for cross-directory forks, paginates history, updates titles, interrupts, and commits reverts", async () => { + const destination = "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/workspace/worktree"; + let moved = false; + let reverted = false; + const fixtures = makeFetch((request) => { + switch (request.url.pathname) { + case "/api/session/ses_parent": + return Response.json({ data: session("ses_parent") }); + case "/api/session/ses_parent/fork": + return Response.json({ data: session("ses_child") }); + case "/api/session/ses_child/move": + moved = true; + return noContent(); + case "/api/session/ses_child/wait": + return noContent(); + case "/api/session/ses_child": + return Response.json({ + data: session("ses_child", moved ? destination : DIRECTORY, { + title: reverted ? "Reverted" : "Child", + }), + }); + case "/api/session/ses_child/message": + return request.url.searchParams.get("cursor") === "next-page" + ? Response.json({ + data: [assistant("message-assistant")], + cursor: {}, + }) + : Response.json({ + data: [ + { + id: "message-agent", + type: "agent-switched", + time: { created: 120 }, + agent: "build", + }, + user("message-user", "Hello"), + ], + cursor: { next: "next-page" }, + }); + case "/api/session/ses_child/rename": + return noContent(); + case "/api/session/ses_child/interrupt": + return Response.json({ interrupted: true }); + case "/api/session/ses_child/revert/stage": + return Response.json({ data: { messageID: "message-assistant" } }); + case "/api/session/ses_child/revert/commit": + reverted = true; + return noContent(); + default: + throw new Error(`Unexpected request: ${request.method} ${request.url.pathname}`); + } + }); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + fetch: fixtures.fetch, + }); + await client.session.get({ sessionID: "ses_parent" }); + + const forked = await client.session.fork({ + sessionID: "ses_parent", + directory: destination, + }); + NodeAssert.equal(forked.data?.directory, destination); + NodeAssert.deepEqual( + fixtures.requests.find((request) => request.url.pathname.endsWith("/fork"))?.body, + { boundary: { type: "through" } }, + ); + + const messages = await client.session.messages({ sessionID: "ses_child" }); + NodeAssert.deepEqual( + messages.data?.map((entry) => entry.info.role), + ["user", "assistant"], + ); + NodeAssert.equal(messages.data?.[1]?.info.role, "assistant"); + if (messages.data?.[1]?.info.role === "assistant") { + NodeAssert.equal(messages.data[1].info.parentID, "message-user"); + } + + const countBeforePermissionUpdate = fixtures.requests.length; + const updated = await client.session.update({ + sessionID: "ses_child", + permission: [{ permission: "edit", pattern: "*", action: "allow" }], + }); + NodeAssert.equal(fixtures.requests.length, countBeforePermissionUpdate); + NodeAssert.equal(updated.data?.permission?.[0]?.permission, "edit"); + await client.session.update({ sessionID: "ses_child", title: "Renamed" }); + NodeAssert.deepEqual( + fixtures.requests.find((request) => request.url.pathname.endsWith("/rename"))?.body, + { title: "Renamed" }, + ); + + const aborted = await client.session.abort({ sessionID: "ses_child" }); + NodeAssert.equal(aborted.data, true); + const revertedSession = await client.session.revert({ + sessionID: "ses_child", + messageID: "message-user", + }); + NodeAssert.equal(revertedSession.data?.title, "Reverted"); + NodeAssert.deepEqual( + fixtures.requests.find((request) => request.url.pathname.endsWith("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/revert/stage"))?.body, + { messageID: "message-assistant", files: false }, + ); + }); + + it("preserves the requested rollback boundary without restoring files twice", async () => { + const history = [ + user("message-user-1", "First"), + assistant("message-assistant-1"), + user("message-user-2", "Second"), + assistant("message-assistant-2"), + ]; + const fixtures = makeFetch((request) => { + if (request.url.pathname === "/api/session/ses_revert/message") { + return Response.json({ data: history, cursor: {} }); + } + if (request.url.pathname === "/api/session/ses_revert/revert/stage") { + const body = request.body as { messageID: string }; + return Response.json({ data: { messageID: body.messageID } }); + } + if (request.url.pathname === "/api/session/ses_revert/revert/commit") { + return noContent(); + } + if (request.url.pathname === "/api/session/ses_revert") { + return Response.json({ data: session("ses_revert") }); + } + throw new Error(`Unexpected request: ${request.method} ${request.url.pathname}`); + }); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + fetch: fixtures.fetch, + }); + + await client.session.revert({ + sessionID: "ses_revert", + messageID: "message-assistant-1", + }); + await client.session.revert({ sessionID: "ses_revert" }); + await client.session.revert({ + sessionID: "ses_revert", + messageID: "message-assistant-2", + }); + + NodeAssert.deepEqual( + fixtures.requests + .filter((request) => request.url.pathname.endsWith("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/revert/stage")) + .map((request) => request.body), + [ + { messageID: "message-user-2", files: false }, + { messageID: "message-user-1", files: false }, + ], + ); + await NodeAssert.rejects( + client.session.revert({ sessionID: "ses_revert", messageID: "message-missing" }), + /rollback target 'message-missing' was not found/, + ); + }); + + it("auto-approves only permissions allowed by cached session rules and preserves supervised shell prompts", async () => { + const ids = ["ses_edits", "ses_full", "ses_denied", "ses_supervised"]; + let createIndex = 0; + const events = [ + event( + "permission.asked", + { + id: "permission-edit", + sessionID: "ses_edits", + action: "edit", + resources: ["src/file.ts"], + }, + 200, + ), + event( + "permission.replied", + { sessionID: "ses_edits", requestID: "permission-edit", reply: "once" }, + 201, + ), + event( + "permission.asked", + { + id: "permission-full", + sessionID: "ses_full", + action: "shell", + resources: ["git status"], + }, + 202, + ), + event( + "permission.replied", + { sessionID: "ses_full", requestID: "permission-full", reply: "once" }, + 203, + ), + event( + "permission.asked", + { + id: "permission-denied", + sessionID: "ses_denied", + action: "edit", + resources: ["secret/token"], + metadata: { reason: "Explicit upstream deny" }, + }, + 204, + ), + event( + "permission.asked", + { + id: "permission-shell", + sessionID: "ses_supervised", + action: "shell", + resources: ["rm generated.txt"], + save: ["rm *"], + metadata: { command: "rm generated.txt" }, + source: { type: "tool", messageID: "message-assistant", id: "tool-shell" }, + }, + 205, + ), + ]; + const fixtures = makeFetch((request) => { + if (request.url.pathname === "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/api/session") { + const id = ids[createIndex]; + createIndex += 1; + NodeAssert.ok(id); + return Response.json({ data: session(id) }); + } + if (request.url.pathname === "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/api/event") { + return eventStream(events); + } + if (request.url.pathname.endsWith("/reply")) { + return noContent(); + } + throw new Error(`Unexpected request: ${request.method} ${request.url.pathname}`); + }); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + fetch: fixtures.fetch, + }); + await client.session.create({ + permission: [ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "edit", pattern: "*", action: "allow" }, + ], + }); + await client.session.create({ + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }); + await client.session.create({ + permission: [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "edit", pattern: "secret/*", action: "deny" }, + ], + }); + await client.session.create({ + permission: [{ permission: "*", pattern: "*", action: "ask" }], + }); + + const subscription = await client.event.subscribe(); + const translated = []; + for await (const received of subscription.stream) { + translated.push(received); + } + + NodeAssert.deepEqual( + translated.map((received) => received.type), + ["permission.asked", "permission.asked"], + ); + const denied = translated[0]; + NodeAssert.equal(denied?.type, "permission.asked"); + if (denied?.type === "permission.asked") { + NodeAssert.equal(denied.properties.id, "permission-denied"); + } + const shell = translated[1]; + NodeAssert.equal(shell?.type, "permission.asked"); + if (shell?.type === "permission.asked") { + NodeAssert.equal(shell.properties.permission, "bash"); + NodeAssert.deepEqual(shell.properties.patterns, ["rm generated.txt"]); + NodeAssert.deepEqual(shell.properties.always, ["rm *"]); + NodeAssert.deepEqual(shell.properties.tool, { + messageID: "message-assistant", + callID: "tool-shell", + }); + } + + await client.permission.reply({ requestID: "permission-shell", reply: "always" }); + await client.permission.reply({ requestID: "permission-denied", reply: "reject" }); + NodeAssert.deepEqual( + fixtures.requests + .filter((request) => request.url.pathname.endsWith("/reply")) + .map((request) => ({ path: request.url.pathname, body: request.body })), + [ + { + path: "/api/session/ses_edits/permission/permission-edit/reply", + body: { reply: "once" }, + }, + { + path: "/api/session/ses_full/permission/permission-full/reply", + body: { reply: "once" }, + }, + { + path: "/api/session/ses_supervised/permission/permission-shell/reply", + body: { reply: "always" }, + }, + { + path: "/api/session/ses_denied/permission/permission-denied/reply", + body: { reply: "reject" }, + }, + ], + ); + }); + + it("translates forms into questions and converts option labels and typed answers back into form replies", async () => { + const questionForm = { + id: "form-question", + sessionID: "ses_form", + title: "Questions", + metadata: { + kind: "question", + tool: { messageID: "message-assistant", id: "tool-question" }, + }, + fields: [ + { + key: "q0", + type: "string", + title: "Color", + description: "Choose a color", + options: [{ value: "blue-id", label: "Blue", description: "Ocean blue" }], + custom: true, + }, + { + key: "q1", + type: "multiselect", + title: "Features", + options: [ + { value: "alpha-id", label: "Alpha" }, + { value: "beta-id", label: "Beta" }, + ], + }, + { key: "q2", type: "boolean", title: "Enabled" }, + { key: "q3", type: "integer", title: "Count" }, + { key: "q4", type: "external", title: "Terms", url: "https://example.test/terms" }, + ], + }; + const events = [ + event("form.created", { form: questionForm }, 200), + event( + "form.replied", + { + id: "form-question", + sessionID: "ses_form", + answer: { + q0: "blue-id", + q1: ["alpha-id", "beta-id"], + q2: true, + q3: 3, + q4: true, + }, + }, + 201, + ), + event( + "form.created", + { + form: { + id: "form-cancelled", + sessionID: "ses_form", + title: "Cancelled", + fields: [{ key: "q0", type: "string", title: "Why?" }], + }, + }, + 202, + ), + event("form.cancelled", { id: "form-cancelled", sessionID: "ses_form" }, 203), + ]; + const fixtures = makeFetch((request) => { + if (request.url.pathname === "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/api/event") { + return eventStream(events); + } + if (request.url.pathname === "/api/session/ses_form/form/form-question/reply") { + return noContent(); + } + throw new Error(`Unexpected request: ${request.method} ${request.url.pathname}`); + }); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + fetch: fixtures.fetch, + }); + const iterator = (await client.event.subscribe()).stream[Symbol.asyncIterator](); + + const asked = await iterator.next(); + NodeAssert.equal(asked.done, false); + if (asked.done || asked.value.type !== "question.asked") { + throw new Error("Expected a translated question request."); + } + NodeAssert.deepEqual(asked.value.properties.questions[0], { + header: "Color", + question: "Choose a color", + options: [{ label: "Blue", description: "Ocean blue" }], + custom: true, + }); + NodeAssert.equal(asked.value.properties.questions[1]?.multiple, true); + NodeAssert.deepEqual(asked.value.properties.tool, { + messageID: "message-assistant", + callID: "tool-question", + }); + + await client.question.reply({ + requestID: "form-question", + answers: [["Blue"], ["Alpha", "Beta"], ["Yes"], ["3"], ["Acknowledge"]], + }); + NodeAssert.deepEqual(fixtures.requests[1]?.body, { + answer: { + q0: "blue-id", + q1: ["alpha-id", "beta-id"], + q2: true, + q3: 3, + q4: true, + }, + }); + + const replied = await iterator.next(); + NodeAssert.equal(replied.done, false); + if (!replied.done && replied.value.type === "question.replied") { + NodeAssert.deepEqual(replied.value.properties.answers, [ + ["Blue"], + ["Alpha", "Beta"], + ["true"], + ["3"], + ["true"], + ]); + } else { + throw new Error("Expected a translated question reply."); + } + + const secondAsked = await iterator.next(); + NodeAssert.equal(secondAsked.value?.type, "question.asked"); + const rejected = await iterator.next(); + NodeAssert.equal(rejected.value?.type, "question.rejected"); + if (rejected.value?.type === "question.rejected") { + NodeAssert.equal(rejected.value.properties.requestID, "form-cancelled"); + } + }); + + it("projects text, reasoning, tool lifecycles, session titles, retry status, errors, and abort signals", async () => { + const events = [ + event("session.renamed", { sessionID: "ses_stream", title: "Useful title" }, 200), + event("session.execution.started", { sessionID: "ses_stream" }, 201), + event( + "session.inbox.enqueued", + { + sessionID: "ses_stream", + inboxID: "message-user", + item: { type: "user", payload: { text: "Prompt" }, delivery: "steer" }, + }, + 202, + ), + event("session.inbox.delivered", { sessionID: "ses_stream", inboxID: "message-user" }, 203), + event( + "session.step.started", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + agent: "build", + model: { providerID: "openai", id: "gpt-test" }, + }, + 204, + ), + event( + "session.text.started", + { sessionID: "ses_stream", assistantMessageID: "message-assistant", ordinal: 0 }, + 205, + ), + event( + "session.text.delta", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + ordinal: 0, + delta: "Hello ", + }, + 206, + ), + event( + "session.text.delta", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + ordinal: 0, + delta: "world", + }, + 207, + ), + event( + "session.text.ended", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + ordinal: 0, + text: "Hello world", + }, + 208, + ), + event( + "session.reasoning.delta", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + ordinal: 1, + delta: "Think", + }, + 209, + ), + event( + "session.reasoning.ended", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + ordinal: 1, + text: "Thinking", + }, + 210, + ), + event( + "session.tool.input.started", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + id: "tool-shell", + name: "shell", + }, + 211, + ), + event( + "session.tool.input.delta", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + id: "tool-shell", + delta: '{"command":', + }, + 212, + ), + event( + "session.tool.input.ended", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + id: "tool-shell", + text: '{"command":"pwd"}', + }, + 213, + ), + event( + "session.tool.called", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + id: "tool-shell", + input: { command: "pwd" }, + executed: true, + }, + 214, + ), + event( + "session.tool.progress", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + id: "tool-shell", + metadata: { title: "Printing directory" }, + }, + 215, + ), + event( + "session.tool.success", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + id: "tool-shell", + content: [ + { type: "text", text: DIRECTORY }, + { + type: "file", + uri: "file:///workspace/output.txt", + mime: "text/plain", + name: "output.txt", + }, + ], + metadata: { exitCode: 0 }, + executed: true, + }, + 216, + ), + event( + "session.tool.input.started", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + id: "tool-edit", + name: "edit", + }, + 217, + ), + event( + "session.tool.failed", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + id: "tool-edit", + error: { type: "tool.failure", message: "File is read-only" }, + executed: true, + }, + 218, + ), + event( + "session.retry.scheduled", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + attempt: 2, + at: 300, + error: { type: "provider.rate-limit", message: "Try again" }, + }, + 219, + ), + event( + "session.step.ended", + { + sessionID: "ses_stream", + assistantMessageID: "message-assistant", + finish: "stop", + cost: 0.25, + tokens: { input: 5, output: 7, reasoning: 2, cache: { read: 1, write: 0 } }, + }, + 220, + ), + event("session.execution.succeeded", { sessionID: "ses_stream" }, 221), + event( + "session.execution.failed", + { + sessionID: "ses_stream", + error: { type: "provider.failure", message: "Provider failed", status: 503 }, + }, + 222, + ), + ]; + const fixtures = makeFetch((request) => { + if (request.url.pathname === "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/api/session") { + return Response.json({ data: session("ses_stream") }); + } + if (request.url.pathname === "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/api/event") { + return eventStream(events); + } + throw new Error(`Unexpected request: ${request.method} ${request.url.pathname}`); + }); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + fetch: fixtures.fetch, + }); + await client.session.create(); + const controller = new AbortController(); + const subscription = await client.event.subscribe(undefined, { signal: controller.signal }); + const translated = []; + for await (const received of subscription.stream) { + translated.push(received); + } + + const title = translated.find((received) => received.type === "session.updated"); + NodeAssert.equal(title?.type, "session.updated"); + if (title?.type === "session.updated") { + NodeAssert.equal(title.properties.info.title, "Useful title"); + } + + const messages = translated.filter((received) => received.type === "message.updated"); + NodeAssert.equal(messages[0]?.properties.info.role, "user"); + NodeAssert.equal(messages[1]?.properties.info.role, "assistant"); + + const deltas = translated.filter((received) => received.type === "message.part.delta"); + NodeAssert.deepEqual( + deltas.map((received) => ({ + partID: received.properties.partID, + delta: received.properties.delta, + })), + [ + { partID: "message-assistant:text:0", delta: "Hello " }, + { partID: "message-assistant:text:0", delta: "world" }, + { partID: "message-assistant:reasoning:1", delta: "Think" }, + ], + ); + + const textCompleted = translated.find( + (received) => + received.type === "message.part.updated" && + received.properties.part.type === "text" && + received.properties.part.time?.end !== undefined, + ); + if ( + textCompleted?.type === "message.part.updated" && + textCompleted.properties.part.type === "text" + ) { + NodeAssert.equal(textCompleted.properties.part.text, "Hello world"); + } else { + throw new Error("Expected a completed assistant text part."); + } + + const tools = translated.flatMap((received) => + received.type === "message.part.updated" && received.properties.part.type === "tool" + ? [received.properties.part] + : [], + ); + NodeAssert.deepEqual( + tools.map((tool) => ({ tool: tool.tool, status: tool.state.status })), + [ + { tool: "bash", status: "pending" }, + { tool: "bash", status: "running" }, + { tool: "bash", status: "running" }, + { tool: "bash", status: "completed" }, + { tool: "edit", status: "pending" }, + { tool: "edit", status: "error" }, + ], + ); + const completedTool = tools.find((tool) => tool.state.status === "completed"); + if (completedTool?.state.status === "completed") { + NodeAssert.equal(completedTool.state.attachments?.[0]?.filename, "output.txt"); + NodeAssert.equal(completedTool.state.metadata.exitCode, 0); + } else { + throw new Error("Expected a completed shell tool."); + } + + const statuses = translated.flatMap((received) => + received.type === "session.status" ? [received.properties.status] : [], + ); + NodeAssert.deepEqual( + statuses.map((status) => status.type), + ["busy", "retry", "idle"], + ); + const failure = translated.find((received) => received.type === "session.error"); + if (failure?.type === "session.error") { + NodeAssert.deepEqual(failure.properties.error, { + name: "APIError", + data: { message: "Provider failed", statusCode: 503, isRetryable: false }, + }); + } else { + throw new Error("Expected a translated provider failure."); + } + NodeAssert.equal( + fixtures.requests.find((request) => request.url.pathname === "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/api/event")?.signal, + controller.signal, + ); + }); + + it("registers location-scoped MCP servers using the V2 config shape", async () => { + const fixtures = makeFetch((request) => { + NodeAssert.equal(request.url.pathname, "/api/mcp/t3-code"); + NodeAssert.equal(request.method, "PUT"); + return noContent(); + }); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + fetch: fixtures.fetch, + }); + + const result = await client.mcp.add({ + name: "t3-code", + config: { + type: "remote", + url: "https://mcp.example.test", + headers: { Authorization: "Bearer token" }, + oauth: false, + timeout: 5_000, + }, + }); + + NodeAssert.deepEqual(result.data, { "t3-code": { status: "connected" } }); + NodeAssert.equal(fixtures.requests[0]?.url.searchParams.get("location[directory]"), DIRECTORY); + NodeAssert.deepEqual(fixtures.requests[0]?.body, { + config: { + type: "remote", + url: "https://mcp.example.test", + headers: { Authorization: "Bearer token" }, + oauth: false, + timeout: { startup: 5_000, catalog: 5_000, execution: 5_000 }, + }, + }); + }); + + it("preserves structured session-not-found errors for safe resume handling", async () => { + const fixtures = makeFetch(() => + Response.json( + { + _tag: "SessionNotFoundError", + sessionID: "ses_missing", + message: "Session does not exist", + }, + { status: 404 }, + ), + ); + const client = createOpenCodeV2Client({ + baseUrl: "https://opencode.example.test", + directory: DIRECTORY, + fetch: fixtures.fetch, + }); + + await NodeAssert.rejects(client.session.get({ sessionID: "ses_missing" }), { + _tag: "SessionNotFoundError", + sessionID: "ses_missing", + }); + }); +}); diff --git a/apps/server/src/provider/openCodeV2Client.ts b/apps/server/src/provider/openCodeV2Client.ts new file mode 100644 index 000000000000..f2ed3d287aa4 --- /dev/null +++ b/apps/server/src/provider/openCodeV2Client.ts @@ -0,0 +1,1657 @@ +import { + OpenCode, + type AgentInfo, + type FormAnswer, + type FormField1, + type FormInfo1, + type ModelInfo, + type ModelRef, + type OpenCodeEvent, + type ProviderInfo, + type SessionInfo, + type SessionMessageAssistant, + type SessionMessageAssistantTool, + type SessionMessageInfo, + type SessionStructuredError, + type ToolContent, + type ToolContent1, +} from "@opencode-ai/client"; +import * as DateTime from "effect/DateTime"; +import type { + Agent, + AssistantMessage, + Event, + FilePart, + Model, + OpencodeClient, + Part, + PermissionRequest, + PermissionRuleset, + Provider, + ProviderListResponse, + QuestionAnswer, + QuestionInfo, + Session, + SessionMessagesResponse, + SessionPromptResponse, + ToolPart, + UserMessage, +} from "@opencode-ai/sdk/v2"; + +type LegacySessionCreateInput = NonNullable[0]>; +type LegacySessionGetInput = Parameters[0]; +type LegacySessionUpdateInput = Parameters[0]; +type LegacySessionForkInput = Parameters[0]; +type LegacySessionPromptInput = Parameters[0]; +type LegacySessionMessagesInput = Parameters[0]; +type LegacySessionRevertInput = Parameters[0]; +type LegacyPermissionReplyInput = Parameters[0]; +type LegacyQuestionReplyInput = Parameters[0]; +type LegacyMcpAddInput = NonNullable[0]>; +type LegacySubscribeOptions = Parameters[1]; +type LegacyMessageEntry = SessionMessagesResponse[number]; +type V2Client = ReturnType; + +interface OpenCodeV2ClientInput { + readonly baseUrl: string; + readonly directory: string; + readonly serverPassword?: string; + readonly fetch?: typeof globalThis.fetch; +} + +interface StreamState { + readonly assistants: Map; + readonly parts: Map; + readonly tools: Map; + readonly pendingUsers: Map>; + readonly latestUsers: Map; +} + +const EMPTY_TOKENS = { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, +} as const; + +export function createOpenCodeV2Client(input: OpenCodeV2ClientInput): OpencodeClient { + const location = { directory: input.directory }; + const client = OpenCode.make({ + baseUrl: input.baseUrl, + ...(input.fetch ? { fetch: input.fetch } : {}), + ...(input.serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + }); + const sessions = new Map(); + const permissionsBySession = new Map(); + const permissionSessions = new Map(); + const approvedPermissions = new Set(); + const forms = new Map(); + + const rememberSession = (session: SessionInfo): Session => { + const converted = toLegacySession(session, permissionsBySession.get(session.id)); + sessions.set(session.id, converted); + return converted; + }; + + const getSession = async (parameters: LegacySessionGetInput) => ({ + data: rememberSession(await client.session.get({ sessionID: parameters.sessionID })), + }); + + const listMessages = async ( + parameters: LegacySessionMessagesInput, + ): Promise<{ readonly data: SessionMessagesResponse }> => { + const messages: SessionMessagesResponse = []; + const limit = parameters.limit; + const pageSize = Math.min(limit ?? 200, 200); + let cursor: string | undefined; + let latestUser: string | undefined; + + do { + const page = await client.message.list({ + sessionID: parameters.sessionID, + limit: pageSize, + order: "asc", + ...(cursor ? { cursor } : {}), + }); + + for (const message of page.data) { + if (message.type !== "user" && message.type !== "assistant") { + continue; + } + if (message.type === "user") { + latestUser = message.id; + } + messages.push( + toLegacyMessage( + message, + parameters.sessionID, + sessions.get(parameters.sessionID), + latestUser, + ), + ); + if (limit !== undefined && messages.length >= limit) { + return { data: messages }; + } + } + + const next = page.cursor.next ?? undefined; + if (!next || next === cursor) { + break; + } + cursor = next; + } while (cursor !== undefined); + + return { data: messages }; + }; + + const submitPrompt = async (parameters: LegacySessionPromptInput) => { + const session = sessions.get(parameters.sessionID); + + if (parameters.model) { + const model: ModelRef = { + id: parameters.model.modelID, + providerID: parameters.model.providerID, + ...(parameters.variant ? { variant: parameters.variant } : {}), + }; + if ( + session?.model?.id !== model.id || + session.model.providerID !== model.providerID || + session.model.variant !== model.variant + ) { + await client.session.switchModel({ sessionID: parameters.sessionID, model }); + if (session) { + sessions.set(parameters.sessionID, { ...session, model }); + } + } + } + + if (parameters.agent && session?.agent !== parameters.agent) { + await client.session.switchAgent({ + sessionID: parameters.sessionID, + agent: parameters.agent, + }); + const current = sessions.get(parameters.sessionID); + if (current) { + sessions.set(parameters.sessionID, { ...current, agent: parameters.agent }); + } + } + + const parts = parameters.parts ?? []; + const files = parts.flatMap((part) => + part.type === "file" + ? [{ uri: part.url, ...(part.filename ? { name: part.filename } : {}) }] + : [], + ); + const agents = parts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + ...(part.source + ? { + mention: { + start: part.source.start, + end: part.source.end, + text: part.source.value, + }, + } + : {}), + }, + ] + : [], + ); + + return client.session.prompt({ + sessionID: parameters.sessionID, + ...(parameters.messageID ? { id: parameters.messageID } : {}), + text: parts + .flatMap((part) => (part.type === "text" && !part.ignored ? [part.text] : [])) + .join("\n"), + ...(files.length > 0 ? { files } : {}), + ...(agents.length > 0 ? { agents } : {}), + delivery: "steer", + ...(parameters.noReply ? { resume: false } : {}), + }); + }; + + async function* translateEvents(source: AsyncIterable): AsyncGenerator { + const state: StreamState = { + assistants: new Map(), + parts: new Map(), + tools: new Map(), + pendingUsers: new Map(), + latestUsers: new Map(), + }; + + const ensureAssistant = ( + event: OpenCodeEvent, + sessionID: string, + messageID: string, + model?: ModelRef, + agent?: string, + ): { readonly info: AssistantMessage; readonly created: boolean } => { + const previous = state.assistants.get(messageID); + if (previous) { + return { info: previous, created: false }; + } + const session = sessions.get(sessionID); + const selectedModel = model ?? session?.model ?? { id: "", providerID: "" }; + const selectedAgent = agent ?? session?.agent ?? ""; + const directory = session?.directory ?? event.location?.directory ?? input.directory; + const info: AssistantMessage = { + id: messageID, + sessionID, + role: "assistant", + time: { created: eventTimestamp(event) }, + parentID: state.latestUsers.get(sessionID) ?? "", + modelID: selectedModel.id, + providerID: selectedModel.providerID, + mode: selectedAgent, + agent: selectedAgent, + path: { cwd: directory, root: directory }, + cost: 0, + tokens: { ...EMPTY_TOKENS, cache: { ...EMPTY_TOKENS.cache } }, + ...(selectedModel.variant ? { variant: selectedModel.variant } : {}), + }; + state.assistants.set(messageID, info); + return { info, created: true }; + }; + + for await (const event of source) { + switch (event.type) { + case "server.connected": + yield { id: event.id, type: "server.connected", properties: {} }; + break; + + case "session.created": { + const permission = permissionsBySession.get(event.data.sessionID); + const info: Session = { + id: event.data.sessionID, + slug: event.data.slug, + projectID: event.data.projectID, + directory: event.data.location.directory, + title: event.data.title ?? "", + version: event.data.version, + time: { created: event.created, updated: event.created }, + ...(event.data.parentID ? { parentID: event.data.parentID } : {}), + ...(event.data.location.workspaceID + ? { workspaceID: event.data.location.workspaceID } + : {}), + ...(event.data.agent ? { agent: event.data.agent } : {}), + ...(event.data.model ? { model: event.data.model } : {}), + ...(permission ? { permission } : {}), + }; + sessions.set(info.id, info); + yield { + id: event.id, + type: "session.created", + properties: { sessionID: info.id, info }, + }; + break; + } + + case "session.renamed": { + const previous = sessions.get(event.data.sessionID); + const info: Session = previous + ? { + ...previous, + title: event.data.title, + time: { ...previous.time, updated: event.created }, + } + : { + id: event.data.sessionID, + slug: event.data.sessionID, + projectID: "", + directory: event.location?.directory ?? input.directory, + title: event.data.title, + version: "2", + time: { created: event.created, updated: event.created }, + }; + sessions.set(info.id, info); + yield { + id: event.id, + type: "session.updated", + properties: { sessionID: info.id, info }, + }; + break; + } + + case "session.moved": { + const previous = sessions.get(event.data.sessionID); + if (previous) { + sessions.set(event.data.sessionID, { + ...previous, + directory: event.data.location.directory, + projectID: event.data.projectID, + ...(event.data.location.workspaceID + ? { workspaceID: event.data.location.workspaceID } + : {}), + }); + } + break; + } + + case "session.agent.selected": { + const previous = sessions.get(event.data.sessionID); + if (previous) { + sessions.set(event.data.sessionID, { ...previous, agent: event.data.agent }); + } + break; + } + + case "session.model.selected": { + const previous = sessions.get(event.data.sessionID); + if (previous) { + sessions.set(event.data.sessionID, { ...previous, model: event.data.model }); + } + break; + } + + case "session.deleted": { + const info = sessions.get(event.data.sessionID); + sessions.delete(event.data.sessionID); + permissionsBySession.delete(event.data.sessionID); + if (info) { + yield { + id: event.id, + type: "session.deleted", + properties: { sessionID: event.data.sessionID, info }, + }; + } + break; + } + + case "session.inbox.enqueued": + if (event.data.item.type === "user") { + state.pendingUsers.set(event.data.inboxID, event); + } + break; + + case "session.inbox.delivered": { + const pending = state.pendingUsers.get(event.data.inboxID); + state.pendingUsers.delete(event.data.inboxID); + state.latestUsers.set(event.data.sessionID, event.data.inboxID); + if (pending && pending.data.item.type === "user") { + const info = toLegacyUserInfo( + { + id: event.data.inboxID, + time: { created: pending.created }, + }, + event.data.sessionID, + sessions.get(event.data.sessionID), + ); + yield { + id: event.id, + type: "message.updated", + properties: { sessionID: event.data.sessionID, info }, + }; + } + break; + } + + case "session.execution.started": + yield legacySessionStatus(event.id, event.data.sessionID, { type: "busy" }); + break; + + case "session.execution.succeeded": + case "session.execution.interrupted": + case "session.idle": + yield legacySessionStatus(event.id, event.data.sessionID, { type: "idle" }); + break; + + case "session.status": + yield legacySessionStatus(event.id, event.data.sessionID, event.data.status); + break; + + case "session.retry.scheduled": + yield legacySessionStatus(event.id, event.data.sessionID, { + type: "retry", + attempt: event.data.attempt, + message: event.data.error.message, + next: event.data.at, + }); + break; + + case "session.execution.failed": + yield { + id: event.id, + type: "session.error", + properties: { + sessionID: event.data.sessionID, + error: toLegacyError(event.data.error, sessions.get(event.data.sessionID)?.model), + }, + }; + break; + + case "session.step.started": { + const assistant = ensureAssistant( + event, + event.data.sessionID, + event.data.assistantMessageID, + event.data.model, + event.data.agent, + ); + yield { + id: event.id, + type: "message.updated", + properties: { sessionID: event.data.sessionID, info: assistant.info }, + }; + break; + } + + case "session.step.ended": { + const assistant = ensureAssistant( + event, + event.data.sessionID, + event.data.assistantMessageID, + ); + const info: AssistantMessage = { + ...assistant.info, + time: { ...assistant.info.time, completed: event.created }, + finish: event.data.finish, + cost: event.data.cost, + tokens: event.data.tokens, + }; + state.assistants.set(info.id, info); + yield { + id: event.id, + type: "message.updated", + properties: { sessionID: event.data.sessionID, info }, + }; + break; + } + + case "session.step.failed": { + const assistant = ensureAssistant( + event, + event.data.sessionID, + event.data.assistantMessageID, + ); + const info: AssistantMessage = { + ...assistant.info, + error: toLegacyError(event.data.error, sessions.get(event.data.sessionID)?.model), + ...(event.data.cost !== undefined ? { cost: event.data.cost } : {}), + ...(event.data.tokens ? { tokens: event.data.tokens } : {}), + }; + state.assistants.set(info.id, info); + yield { + id: event.id, + type: "message.updated", + properties: { sessionID: event.data.sessionID, info }, + }; + break; + } + + case "session.text.started": + case "session.reasoning.started": { + const assistant = ensureAssistant( + event, + event.data.sessionID, + event.data.assistantMessageID, + ); + if (assistant.created) { + yield { + id: event.id, + type: "message.updated", + properties: { sessionID: event.data.sessionID, info: assistant.info }, + }; + } + const type = event.type === "session.text.started" ? "text" : "reasoning"; + const part: Part = { + id: contentPartId(event.data.assistantMessageID, type, event.data.ordinal), + sessionID: event.data.sessionID, + messageID: event.data.assistantMessageID, + type, + text: "", + time: { start: event.created }, + }; + state.parts.set(part.id, part); + yield legacyPartUpdated(event.id, event.data.sessionID, part, event.created); + break; + } + + case "session.text.delta": + case "session.reasoning.delta": { + const assistant = ensureAssistant( + event, + event.data.sessionID, + event.data.assistantMessageID, + ); + if (assistant.created) { + yield { + id: event.id, + type: "message.updated", + properties: { sessionID: event.data.sessionID, info: assistant.info }, + }; + } + const type = event.type === "session.text.delta" ? "text" : "reasoning"; + const id = contentPartId(event.data.assistantMessageID, type, event.data.ordinal); + const previous = state.parts.get(id); + const part: Extract = + previous && (previous.type === "text" || previous.type === "reasoning") + ? previous + : { + id, + sessionID: event.data.sessionID, + messageID: event.data.assistantMessageID, + type, + text: "", + time: { start: event.created }, + }; + if (part !== previous) { + state.parts.set(id, part); + yield legacyPartUpdated(event.id, event.data.sessionID, part, event.created); + } + state.parts.set(id, { ...part, text: part.text + event.data.delta }); + if (event.data.delta.length > 0) { + yield { + id: event.id, + type: "message.part.delta", + properties: { + sessionID: event.data.sessionID, + messageID: event.data.assistantMessageID, + partID: id, + field: "text", + delta: event.data.delta, + }, + }; + } + break; + } + + case "session.text.ended": + case "session.reasoning.ended": { + const assistant = ensureAssistant( + event, + event.data.sessionID, + event.data.assistantMessageID, + ); + if (assistant.created) { + yield { + id: event.id, + type: "message.updated", + properties: { sessionID: event.data.sessionID, info: assistant.info }, + }; + } + const type = event.type === "session.text.ended" ? "text" : "reasoning"; + const id = contentPartId(event.data.assistantMessageID, type, event.data.ordinal); + const previous = state.parts.get(id); + const start = + previous && (previous.type === "text" || previous.type === "reasoning") + ? (previous.time?.start ?? event.created) + : event.created; + const part: Part = { + id, + sessionID: event.data.sessionID, + messageID: event.data.assistantMessageID, + type, + text: event.data.text, + time: { start, end: event.created }, + }; + state.parts.set(id, part); + yield legacyPartUpdated(event.id, event.data.sessionID, part, event.created); + break; + } + + case "session.tool.input.started": { + const assistant = ensureAssistant( + event, + event.data.sessionID, + event.data.assistantMessageID, + ); + if (assistant.created) { + yield { + id: event.id, + type: "message.updated", + properties: { sessionID: event.data.sessionID, info: assistant.info }, + }; + } + const part: ToolPart = { + id: event.data.id, + sessionID: event.data.sessionID, + messageID: event.data.assistantMessageID, + type: "tool", + callID: event.data.id, + tool: legacyPermissionName(event.data.name), + state: { status: "pending", input: {}, raw: "" }, + }; + state.tools.set(toolKey(event.data.sessionID, event.data.id), part); + state.parts.set(part.id, part); + yield legacyPartUpdated(event.id, event.data.sessionID, part, event.created); + break; + } + + case "session.tool.input.delta": { + const key = toolKey(event.data.sessionID, event.data.id); + const previous = state.tools.get(key); + if (previous?.state.status === "pending") { + const part: ToolPart = { + ...previous, + state: { ...previous.state, raw: previous.state.raw + event.data.delta }, + }; + state.tools.set(key, part); + state.parts.set(part.id, part); + } + break; + } + + case "session.tool.input.ended": { + const key = toolKey(event.data.sessionID, event.data.id); + const previous = state.tools.get(key); + if (previous?.state.status === "pending") { + const part: ToolPart = { + ...previous, + state: { ...previous.state, raw: event.data.text }, + }; + state.tools.set(key, part); + state.parts.set(part.id, part); + } + break; + } + + case "session.tool.called": + case "session.tool.progress": + case "session.tool.success": + case "session.tool.failed": { + const assistant = ensureAssistant( + event, + event.data.sessionID, + event.data.assistantMessageID, + ); + if (assistant.created) { + yield { + id: event.id, + type: "message.updated", + properties: { sessionID: event.data.sessionID, info: assistant.info }, + }; + } + const key = toolKey(event.data.sessionID, event.data.id); + const previous = state.tools.get(key); + const name = previous?.tool ?? "tool"; + const previousInput = previous?.state.input ?? {}; + const started = + previous?.state.status === "running" || + previous?.state.status === "completed" || + previous?.state.status === "error" + ? previous.state.time.start + : event.created; + const base = { + id: event.data.id, + sessionID: event.data.sessionID, + messageID: event.data.assistantMessageID, + type: "tool" as const, + callID: event.data.id, + tool: name, + }; + let part: ToolPart; + if (event.type === "session.tool.called") { + part = { + ...base, + state: { + status: "running", + input: event.data.input, + title: name, + time: { start: event.created }, + }, + }; + } else if (event.type === "session.tool.progress") { + part = { + ...base, + state: { + status: "running", + input: previousInput, + title: + typeof event.data.metadata.title === "string" ? event.data.metadata.title : name, + metadata: event.data.metadata, + time: { start: started }, + }, + }; + } else if (event.type === "session.tool.success") { + const attachments = toolAttachments( + event.data.content, + event.data.sessionID, + event.data.assistantMessageID, + event.data.id, + ); + part = { + ...base, + state: { + status: "completed", + input: previousInput, + output: toolOutput(event.data.content), + title: name, + metadata: event.data.metadata ?? {}, + time: { start: started, end: event.created }, + ...(attachments.length > 0 ? { attachments } : {}), + }, + }; + } else { + part = { + ...base, + state: { + status: "error", + input: previousInput, + error: event.data.error.message, + ...(event.data.metadata ? { metadata: event.data.metadata } : {}), + time: { start: started, end: event.created }, + }, + }; + } + state.tools.set(key, part); + state.parts.set(part.id, part); + yield legacyPartUpdated(event.id, event.data.sessionID, part, event.created); + break; + } + + case "permission.asked": { + permissionSessions.set(event.data.id, event.data.sessionID); + const rules = permissionsBySession.get(event.data.sessionID); + if (rules && shouldApprovePermission(rules, event.data.action, event.data.resources)) { + approvedPermissions.add(event.data.id); + const approved = await client.permission + .reply({ + sessionID: event.data.sessionID, + requestID: event.data.id, + reply: "once", + }) + .then( + () => true, + () => false, + ); + if (approved) { + break; + } + approvedPermissions.delete(event.data.id); + } + const properties: PermissionRequest = { + id: event.data.id, + sessionID: event.data.sessionID, + permission: legacyPermissionName(event.data.action), + patterns: event.data.resources, + metadata: event.data.metadata ?? {}, + always: event.data.save ?? [], + ...(event.data.source + ? { + tool: { + messageID: event.data.source.messageID, + callID: event.data.source.id, + }, + } + : {}), + }; + yield { id: event.id, type: "permission.asked", properties }; + break; + } + + case "permission.replied": + permissionSessions.delete(event.data.requestID); + if (approvedPermissions.delete(event.data.requestID)) { + break; + } + yield { + id: event.id, + type: "permission.replied", + properties: { + sessionID: event.data.sessionID, + requestID: event.data.requestID, + reply: event.data.reply, + }, + }; + break; + + case "form.created": { + const form = event.data.form; + forms.set(form.id, form); + const metadataTool = form.metadata?.tool; + const tool = + metadataTool && + typeof metadataTool === "object" && + "messageID" in metadataTool && + typeof metadataTool.messageID === "string" && + "id" in metadataTool && + typeof metadataTool.id === "string" + ? { messageID: metadataTool.messageID, callID: metadataTool.id } + : undefined; + yield { + id: event.id, + type: "question.asked", + properties: { + id: form.id, + sessionID: form.sessionID, + questions: form.fields.map((field) => toLegacyQuestion(field, form.title)), + ...(tool ? { tool } : {}), + }, + }; + break; + } + + case "form.replied": { + const form = forms.get(event.data.id); + const answers = form + ? form.fields.map((field) => toLegacyAnswer(field, event.data.answer[field.key])) + : Object.values(event.data.answer).map((value) => + Array.isArray(value) ? value : [String(value)], + ); + forms.delete(event.data.id); + yield { + id: event.id, + type: "question.replied", + properties: { + sessionID: event.data.sessionID, + requestID: event.data.id, + answers, + }, + }; + break; + } + + case "form.cancelled": + forms.delete(event.data.id); + yield { + id: event.id, + type: "question.rejected", + properties: { sessionID: event.data.sessionID, requestID: event.data.id }, + }; + break; + + default: + break; + } + } + } + + const bridge = { + provider: { + list: async (): Promise<{ readonly data: ProviderListResponse }> => { + const [models, providers] = await Promise.all([ + client.model.list({ location }), + client.provider.list({ location }), + ]); + const available = models.data.filter((model) => model.enabled); + const providerModels = new Map>(); + for (const model of available) { + const current = providerModels.get(model.providerID) ?? []; + current.push(model); + providerModels.set(model.providerID, current); + } + + const definitions = new Map(providers.data.map((provider) => [provider.id, provider])); + for (const providerID of providerModels.keys()) { + if (!definitions.has(providerID)) { + definitions.set(providerID, { + id: providerID, + name: providerID, + activation: "auto", + package: "", + }); + } + } + + const all = [...definitions.values()].map( + (provider): Provider => ({ + id: provider.id, + name: provider.name, + source: "config", + env: [], + options: provider.settings ?? {}, + models: Object.fromEntries( + (providerModels.get(provider.id) ?? []).map((model) => [ + model.id, + toLegacyModel(model, provider), + ]), + ), + }), + ); + const connected = [...definitions.values()] + .filter( + (provider) => + provider.activation !== "disabled" && + (providerModels.get(provider.id)?.length ?? 0) > 0, + ) + .map((provider) => provider.id); + const defaults = Object.fromEntries( + connected.flatMap((providerID) => { + const model = providerModels.get(providerID)?.[0]; + return model ? [[providerID, model.id]] : []; + }), + ); + + return { data: { all, connected, default: defaults } }; + }, + }, + + app: { + agents: async (): Promise<{ readonly data: Array }> => ({ + data: (await client.agent.list({ location })).data.map(toLegacyAgent), + }), + skills: async () => ({ data: (await client.skill.list({ location })).data }), + }, + + event: { + subscribe: async (_parameters?: unknown, options?: LegacySubscribeOptions) => ({ + stream: translateEvents( + client.event.subscribe(options?.signal ? { signal: options.signal } : undefined), + ), + }), + }, + + session: { + create: async (parameters?: LegacySessionCreateInput) => { + const session = await client.session.create({ + location: { + directory: parameters?.directory ?? input.directory, + ...(parameters?.workspaceID ? { workspaceID: parameters.workspaceID } : {}), + }, + ...(parameters?.title ? { title: parameters.title } : {}), + ...(parameters?.agent ? { agent: parameters.agent } : {}), + ...(parameters?.model ? { model: parameters.model } : {}), + }); + if (parameters?.permission) { + permissionsBySession.set(session.id, parameters.permission); + } + return { data: rememberSession(session) }; + }, + + get: getSession, + + update: async (parameters: LegacySessionUpdateInput) => { + if (parameters.permission) { + permissionsBySession.set(parameters.sessionID, parameters.permission); + } + if (parameters.title !== undefined) { + await client.session.rename({ + sessionID: parameters.sessionID, + title: parameters.title, + }); + } + if (parameters.title === undefined) { + const cached = sessions.get(parameters.sessionID); + if (cached) { + const next: Session = { + ...cached, + ...(parameters.permission ? { permission: parameters.permission } : {}), + }; + sessions.set(parameters.sessionID, next); + return { data: next }; + } + } + return getSession({ sessionID: parameters.sessionID }); + }, + + fork: async (parameters: LegacySessionForkInput) => { + let session = await client.session.fork({ + sessionID: parameters.sessionID, + boundary: parameters.messageID + ? { type: "before", messageID: parameters.messageID } + : { type: "through" }, + }); + const inherited = permissionsBySession.get(parameters.sessionID); + if (inherited) { + permissionsBySession.set(session.id, inherited); + } + if (parameters.directory && parameters.directory !== session.location.directory) { + await client.session.move({ sessionID: session.id, directory: parameters.directory }); + await client.session.wait({ sessionID: session.id }); + session = await client.session.get({ sessionID: session.id }); + } + return { data: rememberSession(session) }; + }, + + promptAsync: async (parameters: LegacySessionPromptInput) => { + await submitPrompt(parameters); + return { data: undefined }; + }, + + prompt: async ( + parameters: LegacySessionPromptInput, + ): Promise<{ readonly data: SessionPromptResponse }> => { + const submitted = await submitPrompt(parameters); + await client.session.wait({ sessionID: parameters.sessionID }); + let cursor: string | undefined; + do { + const page = await client.message.list({ + sessionID: parameters.sessionID, + limit: 100, + order: "desc", + ...(cursor ? { cursor } : {}), + }); + const assistant = page.data.find( + (message): message is SessionMessageAssistant => message.type === "assistant", + ); + if (assistant) { + const entry = toLegacyMessage( + assistant, + parameters.sessionID, + sessions.get(parameters.sessionID), + submitted.id, + ); + if (entry.info.role === "assistant") { + return { data: { info: entry.info, parts: entry.parts } }; + } + } + const next = page.cursor.next ?? undefined; + if (!next || next === cursor) { + break; + } + cursor = next; + } while (cursor !== undefined); + throw new Error( + `OpenCode session '${parameters.sessionID}' completed without an assistant response.`, + ); + }, + + abort: async (parameters: LegacySessionGetInput) => { + const result = await client.session.interrupt({ sessionID: parameters.sessionID }); + return { data: result.interrupted }; + }, + + messages: listMessages, + + revert: async (parameters: LegacySessionRevertInput) => { + const messages = await listMessages({ sessionID: parameters.sessionID }); + const targetIndex = parameters.messageID + ? messages.data.findIndex((message) => message.info.id === parameters.messageID) + : -1; + if (parameters.messageID && targetIndex < 0) { + throw new Error(`OpenCode rollback target '${parameters.messageID}' was not found.`); + } + const messageID = messages.data[targetIndex + 1]?.info.id; + if (!messageID) { + return getSession({ sessionID: parameters.sessionID }); + } + + await client.session.revert.stage({ + sessionID: parameters.sessionID, + messageID, + files: false, + }); + await client.session.revert.commit({ sessionID: parameters.sessionID }); + return getSession({ sessionID: parameters.sessionID }); + }, + }, + + permission: { + reply: async (parameters: LegacyPermissionReplyInput) => { + const sessionID = permissionSessions.get(parameters.requestID); + if (!sessionID) { + throw new Error(`Unknown OpenCode permission request: ${parameters.requestID}`); + } + await client.permission.reply({ + sessionID, + requestID: parameters.requestID, + reply: parameters.reply ?? "reject", + ...(parameters.message ? { message: parameters.message } : {}), + }); + return { data: true }; + }, + }, + + question: { + reply: async (parameters: LegacyQuestionReplyInput) => { + const form = forms.get(parameters.requestID); + if (!form) { + throw new Error(`Unknown OpenCode question request: ${parameters.requestID}`); + } + const answer: FormAnswer = {}; + for (const [index, field] of form.fields.entries()) { + const values = parameters.answers?.[index] ?? []; + const converted = toFormValue(field, values); + if (converted !== undefined) { + answer[field.key] = converted; + } + } + await client.form.reply({ + sessionID: form.sessionID, + formID: form.id, + answer, + }); + return { data: true }; + }, + }, + + mcp: { + add: async (parameters: LegacyMcpAddInput) => { + if (!parameters.name || !parameters.config) { + throw new Error("OpenCode MCP registration requires a server name and configuration."); + } + await client.mcp.add({ + server: parameters.name, + location: { directory: parameters.directory ?? input.directory }, + config: toV2McpConfig(parameters.config), + }); + return { data: { [parameters.name]: { status: "connected" as const } } }; + }, + }, + }; + + return bridge as unknown as OpencodeClient; +} + +function toLegacySession(session: SessionInfo, permission?: PermissionRuleset): Session { + return { + id: session.id, + slug: session.id, + projectID: session.projectID, + directory: session.location.directory, + title: session.title ?? "", + version: "2", + time: { + created: session.time.created, + updated: session.time.updated, + ...(session.time.archived !== undefined ? { archived: session.time.archived } : {}), + }, + cost: session.cost, + tokens: session.tokens, + ...(session.parentID ? { parentID: session.parentID } : {}), + ...(session.location.workspaceID ? { workspaceID: session.location.workspaceID } : {}), + ...(session.agent ? { agent: session.agent } : {}), + ...(session.model ? { model: session.model } : {}), + ...(permission ? { permission } : {}), + ...(session.revert + ? { + revert: { + messageID: session.revert.messageID, + ...(session.revert.partID ? { partID: session.revert.partID } : {}), + ...(session.revert.snapshot ? { snapshot: session.revert.snapshot } : {}), + }, + } + : {}), + }; +} + +function toLegacyModel(model: ModelInfo, provider: ProviderInfo): Model { + const cost = model.cost.find((entry) => !entry.tier) ?? model.cost[0]; + const tiers = model.cost.flatMap((entry) => + entry.tier + ? [ + { + input: entry.input, + output: entry.output, + cache: entry.cache, + tier: entry.tier, + }, + ] + : [], + ); + const settings: Record = provider.settings ?? {}; + const baseUrl = + typeof settings.baseURL === "string" + ? settings.baseURL + : typeof settings.baseUrl === "string" + ? settings.baseUrl + : ""; + const supports = (direction: "input" | "output", kind: string) => + model.capabilities[direction].includes(kind); + const reasoningField = model.compatibility?.reasoningField; + + return { + id: model.id, + providerID: model.providerID, + api: { id: model.modelID, url: baseUrl, npm: model.package ?? provider.package }, + name: model.name, + ...(model.family ? { family: model.family } : {}), + capabilities: { + temperature: true, + reasoning: + reasoningField !== undefined || + supports("output", "reasoning") || + model.variants.length > 0, + attachment: model.capabilities.input.some((kind) => kind !== "text"), + toolcall: model.capabilities.tools, + input: { + text: supports("input", "text"), + audio: supports("input", "audio"), + image: supports("input", "image"), + video: supports("input", "video"), + pdf: supports("input", "pdf"), + }, + output: { + text: supports("output", "text"), + audio: supports("output", "audio"), + image: supports("output", "image"), + video: supports("output", "video"), + pdf: supports("output", "pdf"), + }, + interleaved: + reasoningField === "reasoning_content" + ? { field: "reasoning_content" } + : reasoningField === "reasoning_details" + ? { field: "reasoning_details" } + : false, + }, + cost: { + input: cost?.input ?? 0, + output: cost?.output ?? 0, + cache: cost?.cache ?? { read: 0, write: 0 }, + ...(tiers.length > 0 ? { tiers } : {}), + }, + limit: model.limit, + status: model.status, + options: model.settings ?? {}, + headers: model.headers ?? {}, + release_date: DateTime.formatIso(DateTime.makeUnsafe(model.time.released)).slice(0, 10), + variants: Object.fromEntries( + model.variants.map((variant) => [ + variant.id, + { + ...variant.settings, + ...(variant.headers ? { headers: variant.headers } : {}), + ...(variant.body ? { body: variant.body } : {}), + }, + ]), + ), + }; +} + +function toLegacyAgent(agent: AgentInfo): Agent { + return { + name: agent.id, + mode: agent.mode, + hidden: agent.hidden, + permission: agent.permissions.map((rule) => ({ + permission: legacyPermissionName(rule.action), + pattern: rule.resource, + action: rule.effect, + })), + options: agent.request.settings, + ...(agent.description ? { description: agent.description } : {}), + ...(agent.color ? { color: agent.color } : {}), + ...(agent.steps !== undefined ? { steps: agent.steps } : {}), + ...(agent.model + ? { + model: { providerID: agent.model.providerID, modelID: agent.model.id }, + ...(agent.model.variant ? { variant: agent.model.variant } : {}), + } + : {}), + }; +} + +function toLegacyMessage( + message: Extract, + sessionID: string, + session: Session | undefined, + parentID?: string, +): LegacyMessageEntry { + if (message.type === "user") { + const parts: Array = [ + { + id: contentPartId(message.id, "text", 0), + sessionID, + messageID: message.id, + type: "text", + text: message.text, + time: { start: message.time.created }, + }, + ...(message.files ?? []).map( + (file, index): FilePart => ({ + id: `${message.id}:file:${index}`, + sessionID, + messageID: message.id, + type: "file", + mime: file.mime, + url: + file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, + ...(file.name ? { filename: file.name } : {}), + }), + ), + ]; + return { info: toLegacyUserInfo(message, sessionID, session), parts }; + } + + const directory = session?.directory ?? ""; + const info: AssistantMessage = { + id: message.id, + sessionID, + role: "assistant", + time: message.time, + parentID: parentID ?? "", + modelID: message.model.id, + providerID: message.model.providerID, + mode: message.agent, + agent: message.agent, + path: { cwd: directory, root: directory }, + cost: message.cost ?? 0, + tokens: message.tokens ?? { ...EMPTY_TOKENS, cache: { ...EMPTY_TOKENS.cache } }, + ...(message.model.variant ? { variant: message.model.variant } : {}), + ...(message.finish ? { finish: message.finish } : {}), + ...(message.error ? { error: toLegacyError(message.error, message.model) } : {}), + }; + const parts = message.content.map((content, ordinal): Part => { + if (content.type === "tool") { + return toLegacyTool(content, message, sessionID); + } + const start = + content.type === "reasoning" + ? (content.time?.created ?? message.time.created) + : message.time.created; + const end = + content.type === "reasoning" + ? (content.time?.completed ?? message.time.completed) + : message.time.completed; + return { + id: contentPartId(message.id, content.type, ordinal), + sessionID, + messageID: message.id, + type: content.type, + text: content.text, + time: { start, ...(end !== undefined ? { end } : {}) }, + }; + }); + return { info, parts }; +} + +function toLegacyUserInfo( + message: { readonly id: string; readonly time: { readonly created: number } }, + sessionID: string, + session: Session | undefined, +): UserMessage { + return { + id: message.id, + sessionID, + role: "user", + time: { created: message.time.created }, + agent: session?.agent ?? "", + model: { + providerID: session?.model?.providerID ?? "", + modelID: session?.model?.id ?? "", + ...(session?.model?.variant ? { variant: session.model.variant } : {}), + }, + }; +} + +function toLegacyTool( + tool: SessionMessageAssistantTool, + message: SessionMessageAssistant, + sessionID: string, +): ToolPart { + const name = legacyPermissionName(tool.name); + const base = { + id: tool.id, + sessionID, + messageID: message.id, + type: "tool" as const, + callID: tool.id, + tool: name, + }; + const start = tool.time.ran ?? tool.time.created; + switch (tool.state.status) { + case "streaming": + return { + ...base, + state: { status: "pending", input: {}, raw: tool.state.input }, + }; + case "running": + return { + ...base, + state: { + status: "running", + input: tool.state.input, + title: name, + metadata: tool.state.metadata, + time: { start }, + }, + }; + case "completed": { + const attachments = toolAttachments(tool.state.content, sessionID, message.id, tool.id); + return { + ...base, + state: { + status: "completed", + input: tool.state.input, + output: toolOutput(tool.state.content), + title: name, + metadata: tool.state.metadata ?? {}, + time: { + start, + end: tool.time.completed ?? message.time.completed ?? start, + }, + ...(attachments.length > 0 ? { attachments } : {}), + }, + }; + } + case "error": + return { + ...base, + state: { + status: "error", + input: tool.state.input, + error: tool.state.error.message, + ...(tool.state.metadata ? { metadata: tool.state.metadata } : {}), + time: { + start, + end: tool.time.completed ?? message.time.completed ?? start, + }, + }, + }; + } +} + +function toLegacyError( + error: SessionStructuredError, + model?: ModelRef, +): NonNullable { + if (error.type === "provider.auth") { + return { + name: "ProviderAuthError", + data: { providerID: model?.providerID ?? "", message: error.message }, + }; + } + if (error.type === "aborted") { + return { name: "MessageAbortedError", data: { message: error.message } }; + } + if (error.status !== undefined) { + return { + name: "APIError", + data: { message: error.message, statusCode: error.status, isRetryable: false }, + }; + } + return { name: "UnknownError", data: { message: error.message } }; +} + +function legacySessionStatus( + id: string, + sessionID: string, + status: Extract["properties"]["status"], +): Extract { + return { id, type: "session.status", properties: { sessionID, status } }; +} + +function legacyPartUpdated( + id: string, + sessionID: string, + part: Part, + time: number, +): Extract { + return { id, type: "message.part.updated", properties: { sessionID, part, time } }; +} + +function contentPartId(messageID: string, type: "text" | "reasoning", ordinal: number): string { + return `${messageID}:${type}:${ordinal}`; +} + +function toolKey(sessionID: string, id: string): string { + return `${sessionID}:${id}`; +} + +function eventTimestamp(event: OpenCodeEvent): number { + return "created" in event ? event.created : 0; +} + +function toolOutput(content: ReadonlyArray): string { + return content.map((entry) => (entry.type === "text" ? entry.text : entry.uri)).join("\n"); +} + +function toolAttachments( + content: ReadonlyArray, + sessionID: string, + messageID: string, + toolID: string, +): Array { + return content.flatMap( + (entry, index): Array => + entry.type === "file" + ? [ + { + id: `${toolID}:file:${index}`, + sessionID, + messageID, + type: "file", + mime: entry.mime, + url: entry.uri, + ...(entry.name ? { filename: entry.name } : {}), + }, + ] + : [], + ); +} + +function legacyPermissionName(action: string): string { + return action === "shell" ? "bash" : action; +} + +function shouldApprovePermission( + rules: PermissionRuleset, + action: string, + resources: ReadonlyArray, +): boolean { + if (resources.length === 0) { + return false; + } + const permission = legacyPermissionName(action); + return resources.every((resource) => { + const matching = rules.findLast( + (rule) => + matchesWildcard(permission, rule.permission) && matchesWildcard(resource, rule.pattern), + ); + return matching?.action === "allow"; + }); +} + +function matchesWildcard(value: string, pattern: string): boolean { + if (pattern === "*") { + return true; + } + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replaceAll("*", ".*") + .replaceAll("?", "."); + return new RegExp(`^${escaped}$`, "u").test(value); +} + +function toLegacyQuestion(field: FormField1, title: string): QuestionInfo { + const options = + field.type === "boolean" + ? [ + { label: "Yes", description: "Yes" }, + { label: "No", description: "No" }, + ] + : field.type === "external" + ? [{ label: "Acknowledge", description: field.url }] + : "options" in field + ? (field.options ?? []).map((option) => ({ + label: option.label, + description: option.description ?? "", + })) + : []; + return { + header: field.title ?? field.key, + question: field.description ?? field.title ?? title, + options, + ...(field.type === "multiselect" ? { multiple: true } : {}), + ...("custom" in field && field.custom !== undefined ? { custom: field.custom } : {}), + }; +} + +function toLegacyAnswer(field: FormField1, value: FormAnswer[string] | undefined): QuestionAnswer { + if (value === undefined) { + return []; + } + const values = Array.isArray(value) ? value : [String(value)]; + if (!("options" in field)) { + return values; + } + return values.map( + (entry) => field.options?.find((option) => option.value === entry)?.label ?? entry, + ); +} + +function toFormValue(field: FormField1, values: QuestionAnswer): FormAnswer[string] | undefined { + if (field.type === "external") { + return true; + } + if (values.length === 0) { + return field.type === "multiselect" ? [] : undefined; + } + const first = values[0]; + if (first === undefined) { + return undefined; + } + if (field.type === "boolean") { + return first.toLowerCase() === "yes" || first.toLowerCase() === "true"; + } + if (field.type === "number" || field.type === "integer") { + const number = Number(first); + return Number.isFinite(number) ? number : undefined; + } + const converted = values.map( + (value) => field.options?.find((option) => option.label === value)?.value ?? value, + ); + return field.type === "multiselect" ? converted : converted[0]; +} + +function toV2McpConfig( + config: NonNullable, +): Parameters[0]["config"] { + const timeout = + config.timeout === undefined + ? {} + : { + timeout: { + startup: config.timeout, + catalog: config.timeout, + execution: config.timeout, + }, + }; + const enabled = config.enabled === undefined ? {} : { disabled: !config.enabled }; + + if (config.type === "local") { + return { + type: "local", + command: config.command, + ...(config.environment ? { environment: config.environment } : {}), + ...enabled, + ...timeout, + }; + } + + const oauth = + config.oauth === false + ? { oauth: false as const } + : config.oauth + ? { + oauth: { + ...(config.oauth.clientId ? { client_id: config.oauth.clientId } : {}), + ...(config.oauth.clientSecret ? { client_secret: config.oauth.clientSecret } : {}), + ...(config.oauth.scope ? { scope: config.oauth.scope } : {}), + ...(config.oauth.callbackPort !== undefined + ? { callback_port: config.oauth.callbackPort } + : {}), + ...(config.oauth.redirectUri ? { redirect_uri: config.oauth.redirectUri } : {}), + }, + } + : {}; + return { + type: "remote", + url: config.url, + ...(config.headers ? { headers: config.headers } : {}), + ...oauth, + ...enabled, + ...timeout, + }; +} diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 7db63745eafe..c210263d5ad5 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -1,4 +1,6 @@ import * as NodeAssert from "node:assert/strict"; +// @effect-diagnostics-next-line nodeBuiltinImport:off +import * as NodeHttp from "node:http"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { OpencodeClient } from "@opencode-ai/sdk/v2"; @@ -7,17 +9,375 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { HostProcessEnvironment, HostProcessExecutablePath, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; -import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; +import { + isOpenCodeV2VersionOutput, + OpenCodeRuntime, + OpenCodeRuntimeLive, +} from "./opencodeRuntime.ts"; const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); +const encodeServiceRegistration = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.String, + version: Schema.String, + url: Schema.String, + pid: Schema.Number, + password: Schema.String, + }), + ), +); + +const withOpenCodeHttpServer = ( + handler: (request: NodeHttp.IncomingMessage, response: NodeHttp.ServerResponse) => void, + run: (origin: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer(handler); + server.once("error", (error) => resume(Effect.die(error))); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => { + const address = server.address(); + return address && typeof address !== "string" + ? run(`http://127.0.0.1:${address.port}`) + : Effect.die(new Error("Expected a TCP address")); + }, + (server) => Effect.sync(() => server.close()), + ); + +const createOpenCodeBinary = (name: string, source: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostEnvironment = yield* HostProcessEnvironment; + const executablePath = yield* HostProcessExecutablePath; + const hostPlatform = yield* HostProcessPlatform; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-v2-" }); + const isWindows = hostPlatform === "win32"; + const binaryPath = path.join(tempDir, isWindows ? `${name}.cmd` : name); + const scriptPath = path.join(tempDir, `${name}.mjs`); + + yield* fs.writeFileString(scriptPath, source); + yield* fs.writeFileString( + binaryPath, + [ + ...(isWindows ? ["@echo off"] : ["#!/bin/sh"]), + isWindows + ? '"%T3_TEST_NODE_BINARY%" "%T3_TEST_OPENCODE_SCRIPT%" %*' + : 'exec "$T3_TEST_NODE_BINARY" "$T3_TEST_OPENCODE_SCRIPT" "$@"', + "", + ].join("\n"), + ); + if (!isWindows) yield* fs.chmod(binaryPath, 0o755); + + return { + binaryPath, + tempDir, + environment: { + ...hostEnvironment, + T3_TEST_NODE_BINARY: executablePath, + T3_TEST_OPENCODE_SCRIPT: scriptPath, + }, + }; + }); + +const openCodeV2FixtureScript = ` +import http from "node:http"; + +if (process.argv[2] === "--version") { + console.log("opencode2 v0.0.0-beta-18155"); +} else if (process.argv[2] === "serve") { + const password = process.env.OPENCODE_SERVER_PASSWORD; + if (!password || process.env.OPENCODE_PASSWORD !== password) { + throw new Error("Expected a generated OpenCode server password"); + } + if ( + process.env.T3_TEST_EXPECTED_CONFIG !== undefined && + process.env.OPENCODE_CONFIG_CONTENT !== process.env.T3_TEST_EXPECTED_CONFIG + ) { + throw new Error("Expected the inherited OpenCode config to be preserved"); + } + const hostname = process.argv.find((arg) => arg.startsWith("--hostname="))?.split("=")[1]; + const port = Number(process.argv.find((arg) => arg.startsWith("--port="))?.split("=")[1]); + const directory = process.env.T3_TEST_DIRECTORY ?? process.cwd(); + const location = { + directory, + project: { id: "project-test", directory, canonical: directory }, + }; + const model = { + id: "gpt-test", + modelID: "gpt-test", + providerID: "openai", + name: "GPT Test", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 200000, output: 32000 }, + }; + const server = http.createServer((request, response) => { + const expected = "Basic " + Buffer.from("opencode:" + password).toString("base64"); + if (request.headers.authorization !== expected) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "Unauthorized" })); + return; + } + const pathname = new URL(request.url, "http://localhost").pathname; + const data = pathname === "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/api/provider" + ? [{ id: "openai", name: "OpenAI", activation: "enabled", package: "openai" }] + : pathname === "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/api/model" + ? [model] + : pathname === "/api/model/default" + ? model + : pathname === "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/api/agent" + ? [{ + id: "build", + name: "build", + request: { settings: {}, headers: {}, body: {} }, + mode: "primary", + hidden: false, + permissions: [], + }] + : pathname === "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/api/skill" + ? [{ + id: "review", + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + content: "x".repeat(220 * 1024), + }] + : undefined; + if (pathname === "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/api/health") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + healthy: true, + version: "0.0.0-beta-18155", + pid: process.pid, + config: process.env.OPENCODE_CONFIG_CONTENT, + })); + return; + } + if (data === undefined) { + response.writeHead(404); + response.end(); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ location, data })); + }); + server.listen(port, hostname, () => console.log("server listening on http://" + hostname + ":" + port)); +} else { + throw new Error("Unsupported OpenCode 2 command: " + process.argv.slice(2).join(" ")); +} +`; + +it.layer(testLayer, { excludeTestServices: true })("OpenCodeRuntime inventory", (it) => { + it.effect("recognizes OpenCode 2 version output even through an opencode symlink", () => + Effect.sync(() => { + NodeAssert.equal(isOpenCodeV2VersionOutput("opencode2 v0.0.0-dev-18192\n", "opencode"), true); + NodeAssert.equal(isOpenCodeV2VersionOutput("0.0.0-beta-18155\n", "opencode"), true); + NodeAssert.equal(isOpenCodeV2VersionOutput("2.1.0\n", "/usr/local/bin/opencode2"), true); + NodeAssert.equal(isOpenCodeV2VersionOutput("opencode 1.14.19\n", "opencode"), false); + }), + ); + + it.effect("falls back to opencode2 only for the default opencode binary", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const fixture = yield* createOpenCodeBinary( + "opencode2", + 'console.log("opencode2 v0.0.0-beta-18155");\n', + ); + const environment = { ...fixture.environment, PATH: fixture.tempDir }; + const result = yield* runtime.runOpenCodeCommand({ + binaryPath: "opencode", + args: ["--version"], + environment, + }); + + NodeAssert.match(result.stdout, /opencode2 v0\.0\.0-beta-18155/); + + const missingCustomBinary = yield* runtime + .runOpenCodeCommand({ + binaryPath: "custom-opencode", + args: ["--version"], + environment, + }) + .pipe(Effect.flip); + NodeAssert.match(missingCustomBinary.detail, /custom-opencode/); + }), + ); + + it.effect("starts v2 symlink-style binaries with generated Basic auth and inherited config", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const fixture = yield* createOpenCodeBinary("opencode", openCodeV2FixtureScript); + + yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* runtime.startOpenCodeServerProcess({ + binaryPath: fixture.binaryPath, + environment: { + ...fixture.environment, + OPENCODE_PASSWORD: "inherited-password", + OPENCODE_CONFIG_CONTENT: '{"provider":{"openai":{}}}', + T3_TEST_EXPECTED_CONFIG: '{"provider":{"openai":{}}}', + }, + }); + NodeAssert.equal(server.apiVersion, "v2"); + NodeAssert.ok(server.serverPassword); + NodeAssert.ok(server.serverPassword.length >= 40); + NodeAssert.notEqual(server.serverPassword, "inherited-password"); + + const environment = { + ...fixture.environment, + OPENCODE_PASSWORD: undefined, + OPENCODE_SERVER_PASSWORD: undefined, + }; + const unauthorized = yield* runtime + .connectToOpenCodeServer({ + binaryPath: fixture.binaryPath, + serverUrl: server.url, + environment, + }) + .pipe(Effect.flip); + NodeAssert.match(unauthorized.detail, /requires authentication.*401/); + + const authorized = yield* runtime.connectToOpenCodeServer({ + binaryPath: fixture.binaryPath, + serverUrl: server.url, + serverPassword: server.serverPassword, + environment, + }); + NodeAssert.equal(authorized.apiVersion, "v2"); + }), + ); + }), + ); + + it.effect("continues to recognize the original OpenCode 1 startup banner", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const fixture = yield* createOpenCodeBinary( + "legacy-opencode", + [ + 'if (process.argv[2] === "--version") {', + ' console.log("opencode 1.14.19");', + "} else {", + ' const hostname = process.argv.find((arg) => arg.startsWith("--hostname=")).split("=")[1];', + ' const port = process.argv.find((arg) => arg.startsWith("--port=")).split("=")[1];', + ' console.log("opencode server listening on http://" + hostname + ":" + port);', + " setInterval(() => {}, 1000);", + "}", + "", + ].join("\n"), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* runtime.startOpenCodeServerProcess({ + binaryPath: fixture.binaryPath, + environment: fixture.environment, + }); + NodeAssert.equal(server.apiVersion, "v1"); + NodeAssert.equal(server.serverPassword, undefined); + NodeAssert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/); + }), + ); + }), + ); + + it.effect("detects external v2 servers with an authenticated health request", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const password = "external-password"; + let authorization: string | undefined; + + return yield* withOpenCodeHttpServer( + (request, response) => { + authorization = request.headers.authorization; + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ healthy: true, version: "0.0.0-beta-18155", pid: 42 })); + }, + (origin) => + Effect.gen(function* () { + const connection = yield* runtime.connectToOpenCodeServer({ + binaryPath: "opencode", + serverUrl: origin, + serverPassword: password, + }); + + NodeAssert.equal(connection.external, true); + NodeAssert.equal(connection.apiVersion, "v2"); + NodeAssert.equal(connection.serverPassword, password); + NodeAssert.equal( + authorization, + `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`, + ); + }), + ); + }), + ); + + it.effect("retains friendly authentication failures from external v2 probes", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + + return yield* withOpenCodeHttpServer( + (_request, response) => { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "Unauthorized" })); + }, + (origin) => + Effect.gen(function* () { + const error = yield* runtime + .connectToOpenCodeServer({ + binaryPath: "opencode", + serverUrl: origin, + serverPassword: "incorrect-password", + }) + .pipe(Effect.flip); + + NodeAssert.match(error.detail, /rejected authentication.*401/); + }), + ); + }), + ); + + it.effect("falls back to v1 when an external health endpoint is not JSON", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + + return yield* withOpenCodeHttpServer( + (_request, response) => { + response.writeHead(200, { "content-type": "text/html" }); + response.end("OpenCode"); + }, + (origin) => + Effect.gen(function* () { + const connection = yield* runtime.connectToOpenCodeServer({ + binaryPath: "opencode", + serverUrl: origin, + }); + + NodeAssert.equal(connection.apiVersion, "v1"); + NodeAssert.equal(connection.external, true); + }), + ); + }), + ); -it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { it.effect("keeps provider inventory when skill discovery fails", () => Effect.gen(function* () { const runtime = yield* OpenCodeRuntime; @@ -88,6 +448,148 @@ it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { }), ); + it.effect("loads untruncated v2 inventory from an existing authenticated shared service", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const hostEnvironment = yield* HostProcessEnvironment; + const stateHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-v2-service-" }); + const password = "shared-service-password"; + const requestedPaths: string[] = []; + const directory = stateHome; + const location = { + directory, + project: { id: "project-test", directory, canonical: directory }, + }; + + return yield* withOpenCodeHttpServer( + (request, response) => { + const expected = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`; + if (request.headers.authorization !== expected) { + response.writeHead(401); + response.end(); + return; + } + + const pathname = new URL(request.url ?? "/", "http://localhost").pathname; + requestedPaths.push(pathname); + response.writeHead(200, { "content-type": "application/json" }); + if (pathname === "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/api/health") { + response.end( + JSON.stringify({ + healthy: true, + version: "0.0.0-beta-18155", + pid: process.pid, + }), + ); + return; + } + + const model = { + id: "gpt-test", + modelID: "gpt-test", + providerID: "openai", + name: "GPT Test", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 200_000, output: 32_000 }, + }; + const data = + pathname === "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/api/provider" + ? [{ id: "openai", name: "OpenAI", activation: "enabled", package: "openai" }] + : pathname === "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/api/model" + ? [model] + : pathname === "/api/model/default" + ? model + : pathname === "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/api/agent" + ? [ + { + id: "build", + name: "build", + request: { settings: {}, headers: {}, body: {} }, + mode: "primary", + hidden: false, + permissions: [], + }, + ] + : pathname === "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/api/skill" + ? [ + { + id: "review", + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + content: "x".repeat(220 * 1024), + }, + ] + : []; + response.end(JSON.stringify({ location, data })); + }, + (origin) => + Effect.gen(function* () { + const serviceDirectory = path.join(stateHome, "opencode"); + yield* fs.makeDirectory(serviceDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(serviceDirectory, "service.json"), + encodeServiceRegistration({ + id: "test-service", + version: "0.0.0-beta-18155", + url: origin, + pid: process.pid, + password, + }), + ); + + const inventory = yield* runtime.loadInventoryFromCli({ + binaryPath: "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/missing/opencode2", + cwd: directory, + environment: { ...hostEnvironment, XDG_STATE_HOME: stateHome }, + apiVersion: "v2", + }); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.equal(inventory.agents[0]?.name, "build"); + NodeAssert.deepEqual(inventory.skills, [ + { + name: "review", + description: "Review code changes", + location: "/skills/review/SKILL.md", + }, + ]); + NodeAssert.ok(requestedPaths.includes("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/api/provider")); + NodeAssert.ok(requestedPaths.includes("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/api/model")); + NodeAssert.ok(requestedPaths.includes("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/api/skill")); + }), + ); + }), + ); + + it.effect("uses a scoped authenticated v2 server when no shared service is registered", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const fixture = yield* createOpenCodeBinary("opencode2", openCodeV2FixtureScript); + const inventory = yield* runtime.loadInventoryFromCli({ + binaryPath: fixture.binaryPath, + cwd: fixture.tempDir, + environment: { + ...fixture.environment, + XDG_STATE_HOME: fixture.tempDir, + T3_TEST_DIRECTORY: fixture.tempDir, + }, + apiVersion: "v2", + }); + + NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); + NodeAssert.equal(inventory.agents[0]?.name, "build"); + NodeAssert.equal(inventory.skills[0]?.name, "review"); + }), + ); + it.effect("drops oversized CLI skill output without losing the model inventory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/opencodeRuntime.permissions.test.ts b/apps/server/src/provider/opencodeRuntime.permissions.test.ts index ad95e38d1495..1b5adcf0f0eb 100644 --- a/apps/server/src/provider/opencodeRuntime.permissions.test.ts +++ b/apps/server/src/provider/opencodeRuntime.permissions.test.ts @@ -2,7 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import { describe, it } from "vite-plus/test"; -import { buildOpenCodePermissionRules } from "./opencodeRuntime.ts"; +import { buildOpenCodePermissionRules, toOpenCodePermissionReply } from "./opencodeRuntime.ts"; function actionFor( runtimeMode: Parameters[0], @@ -42,3 +42,18 @@ describe("buildOpenCodePermissionRules", () => { ]); }); }); + +describe("toOpenCodePermissionReply", () => { + it("maps one-time approvals without saving them", () => { + NodeAssert.equal(toOpenCodePermissionReply("accept"), "once"); + }); + + it("maps legacy session approvals to the upstream always option", () => { + NodeAssert.equal(toOpenCodePermissionReply("acceptForSession"), "always"); + }); + + it("rejects declined and cancelled requests", () => { + NodeAssert.equal(toOpenCodePermissionReply("decline"), "reject"); + NodeAssert.equal(toOpenCodePermissionReply("cancel"), "reject"); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 80329a6794d5..9c6240bb38ab 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -1,6 +1,8 @@ +import * as NodeCrypto from "node:crypto"; import * as NodeURL from "node:url"; import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; +import { Service } from "@opencode-ai/client/service"; import { createOpencodeClient, type Agent, @@ -21,6 +23,7 @@ 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 Path from "effect/Path"; import * as P from "effect/Predicate"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; @@ -30,11 +33,13 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isWindowsCommandNotFound } from "../processRunner.ts"; +import { createOpenCodeV2Client } from "./openCodeV2Client.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"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { resolveSpawnCommand, SpawnExecutableResolution } from "@t3tools/shared/shell"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); +const nodeFetch = globalThis.fetch; const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; export function resolveOpenCodeConfigContent( @@ -48,19 +53,25 @@ export function resolveOpenCodeConfigContent( ); } -const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; + +export type OpenCodeApiVersion = "v1" | "v2"; + export interface OpenCodeServerProcess { readonly url: string; readonly exitCode: Effect.Effect; + readonly apiVersion?: OpenCodeApiVersion; + readonly serverPassword?: string; } export interface OpenCodeServerConnection { readonly url: string; readonly exitCode: Effect.Effect | null; readonly external: boolean; + readonly apiVersion?: OpenCodeApiVersion; + readonly serverPassword?: string; } const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; @@ -149,6 +160,7 @@ export interface OpenCodeRuntimeShape { readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; + readonly apiVersion?: OpenCodeApiVersion; }) => Effect.Effect; /** * Returns a handle to either an externally-managed OpenCode server (when @@ -158,10 +170,12 @@ export interface OpenCodeRuntimeShape { readonly connectToOpenCodeServer: (input: { readonly binaryPath: string; readonly serverUrl?: string | null; + readonly serverPassword?: string; readonly environment?: NodeJS.ProcessEnv; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; + readonly apiVersion?: OpenCodeApiVersion; }) => Effect.Effect; readonly runOpenCodeCommand: (input: { readonly binaryPath: string; @@ -173,6 +187,7 @@ export interface OpenCodeRuntimeShape { readonly createOpenCodeSdkClient: (input: { readonly baseUrl: string; readonly directory: string; + readonly apiVersion?: OpenCodeApiVersion; readonly serverPassword?: string; }) => OpencodeClient; readonly loadOpenCodeInventory: ( @@ -182,16 +197,30 @@ export interface OpenCodeRuntimeShape { readonly binaryPath: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; + readonly apiVersion?: OpenCodeApiVersion; }) => Effect.Effect; } -function parseServerUrlFromOutput(output: string): string | null { +export function isOpenCodeV2VersionOutput(stdout: string, binaryPath?: string): boolean { + return ( + /(?:^|\s)opencode2(?:\s|$)/i.test(stdout) || + /\b0\.0\.0-(?:dev|beta|next|prod|local)-\d+(?:\.\d+)?\b/i.test(stdout) || + /(?:^|[/\\])opencode2(?:\.(?:cmd|bat|exe))?$/i.test(binaryPath ?? "") + ); +} + +function parseServerUrlFromOutput(output: string): { + readonly url: string; + readonly apiVersion: OpenCodeApiVersion; +} | null { for (const line of output.split("\n")) { - if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { - continue; + const match = /^(opencode )?server listening on\s+(https?:\/\/[^\s]+)/.exec(line.trim()); + if (match) { + return { + url: match[2]!, + apiVersion: match[1] ? "v1" : "v2", + }; } - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - return match?.[1] ?? null; } return null; } @@ -439,9 +468,21 @@ function ensureRuntimeError( const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService.NetService; + const path = yield* Path.Path; const hostPlatform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; + const resolveExecutable = yield* SpawnExecutableResolution; + const resolveBinaryPath = (binaryPath: string, environment?: NodeJS.ProcessEnv) => { + if (binaryPath !== "opencode") return binaryPath; + const resolvedEnvironment = environment ?? hostEnvironment; + return ( + resolveExecutable(binaryPath, hostPlatform, resolvedEnvironment) ?? + resolveExecutable("opencode2", hostPlatform, resolvedEnvironment) ?? + binaryPath + ); + }; const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => - resolveSpawnCommand(command, args, env ? { env } : {}); + resolveSpawnCommand(resolveBinaryPath(command, env), args, env ? { env } : {}); const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => Effect.gen(function* () { @@ -507,6 +548,26 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ), )); const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const resolvedBinaryPath = resolveBinaryPath(input.binaryPath, input.environment); + const apiVersion = + input.apiVersion ?? + (isOpenCodeV2VersionOutput("", resolvedBinaryPath) + ? "v2" + : yield* runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["--version"], + ...(input.environment ? { environment: input.environment } : {}), + }).pipe( + Effect.map( + (result): OpenCodeApiVersion => + result.code === 0 && isOpenCodeV2VersionOutput(result.stdout, resolvedBinaryPath) + ? "v2" + : "v1", + ), + Effect.orElseSucceed((): OpenCodeApiVersion => "v1"), + )); + const serverPassword = + apiVersion === "v2" ? NodeCrypto.randomBytes(32).toString("base64url") : undefined; const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); @@ -525,6 +586,12 @@ const makeOpenCodeRuntime = Effect.gen(function* () { // relying on inheritance) because `extendEnv` is false whenever // `input.environment` is provided. OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), + ...(serverPassword + ? { + OPENCODE_PASSWORD: serverPassword, + OPENCODE_SERVER_PASSWORD: serverPassword, + } + : {}), }, extendEnv: input.environment === undefined, }), @@ -562,7 +629,10 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const stdoutRef = yield* Ref.make(""); const stderrRef = yield* Ref.make(""); - const readyDeferred = yield* Deferred.make(); + const readyDeferred = yield* Deferred.make< + { readonly url: string; readonly apiVersion: OpenCodeApiVersion }, + OpenCodeRuntimeError + >(); const setReadyFromStdoutChunk = (chunk: string) => Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe( @@ -643,7 +713,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { } return { - url: readyOption.value, + url: readyOption.value.url, + apiVersion: readyOption.value.apiVersion, + ...(readyOption.value.apiVersion === "v2" && serverPassword ? { serverPassword } : {}), exitCode: child.exitCode.pipe( Effect.map(Number), Effect.orElseSucceed(() => 0), @@ -654,12 +726,54 @@ const makeOpenCodeRuntime = Effect.gen(function* () { 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, - }); + const environment = input.environment ?? hostEnvironment; + const serverPassword = + input.serverPassword ?? + environment.OPENCODE_PASSWORD ?? + environment.OPENCODE_SERVER_PASSWORD; + + return runOpenCodeSdk("health.get", async () => { + // @effect-diagnostics-next-line globalFetch:off + const response = await nodeFetch( + new URL("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/api/health", serverUrl), + serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}, + ); + + if (response.status === 401 || response.status === 403) { + throw new Error( + serverPassword + ? `OpenCode server rejected authentication (HTTP ${response.status}).` + : `OpenCode server requires authentication (HTTP ${response.status}).`, + ); + } + if (!response.ok) return "v1" as const; + + const health: unknown = await response.json().catch(() => undefined); + return typeof health === "object" && + health !== null && + "healthy" in health && + health.healthy === true && + "version" in health && + typeof health.version === "string" && + "pid" in health && + typeof health.pid === "number" + ? "v2" + : "v1"; + }).pipe( + Effect.map((apiVersion) => ({ + url: serverUrl, + exitCode: null, + external: true, + apiVersion, + ...(serverPassword ? { serverPassword } : {}), + })), + ); } return startOpenCodeServerProcess({ @@ -668,28 +782,37 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ...(input.port !== undefined ? { port: input.port } : {}), ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.apiVersion !== undefined ? { apiVersion: input.apiVersion } : {}), }).pipe( Effect.map((server) => ({ url: server.url, exitCode: server.exitCode, external: false, + ...(server.apiVersion ? { apiVersion: server.apiVersion } : {}), + ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), })), ); }; 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, - }); + input.apiVersion === "v2" + ? createOpenCodeV2Client({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword ? { serverPassword: input.serverPassword } : {}), + }) + : 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( @@ -731,6 +854,48 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => Effect.gen(function* () { + if (input.apiVersion === "v2") { + const stateHome = + input.environment?.XDG_STATE_HOME ?? + (input.environment?.HOME + ? path.join(input.environment.HOME, ".local", "state") + : undefined); + const service = yield* runOpenCodeSdk("service.discover", () => + Service.discover( + stateHome ? { file: path.join(stateHome, "opencode", "service.json") } : {}, + ), + ).pipe(Effect.orElseSucceed(() => undefined)); + + if (service) { + return yield* loadOpenCodeInventory( + createOpenCodeSdkClient({ + baseUrl: service.url, + directory: input.cwd, + apiVersion: "v2", + ...(service.auth?.password ? { serverPassword: service.auth.password } : {}), + }), + ); + } + + return yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + apiVersion: "v2", + ...(input.environment ? { environment: input.environment } : {}), + }); + return yield* loadOpenCodeInventory( + createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + apiVersion: "v2", + ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), + }), + ); + }), + ); + } + const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); const commandContext = { cwd: input.cwd, ...env }; diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 1fcf9bc4c73a..c2924b68e79a 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -14,12 +14,32 @@ import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; import * as TextGeneration from "./TextGeneration.ts"; +type OpenCodeServerMetadata = Pick< + OpenCodeRuntime.OpenCodeServerConnection, + "apiVersion" | "serverPassword" +>; + const runtimeMock = { state: { startCalls: [] as string[], + startEnvironments: [] as Array, + connectCalls: [] as Array<{ + readonly binaryPath: string; + readonly serverUrl?: string | null; + readonly serverPassword?: string; + }>, + clientCalls: [] as Array< + OpenCodeServerMetadata & { + readonly baseUrl: string; + readonly directory: string; + } + >, promptUrls: [] as string[], authHeaders: [] as Array, closeCalls: [] as string[], + localServerMetadata: {} as OpenCodeServerMetadata, + externalServerMetadata: {} as OpenCodeServerMetadata, + connectionError: undefined as OpenCodeRuntime.OpenCodeRuntimeError | undefined, sessionCreateError: undefined as unknown, sessionResult: undefined as { data?: { id: string } } | undefined, promptRequestError: undefined as unknown, @@ -29,9 +49,15 @@ const runtimeMock = { }, reset() { this.state.startCalls.length = 0; + this.state.startEnvironments.length = 0; + this.state.connectCalls.length = 0; + this.state.clientCalls.length = 0; this.state.promptUrls.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; + this.state.localServerMetadata = {}; + this.state.externalServerMetadata = {}; + this.state.connectionError = undefined; this.state.sessionCreateError = undefined; this.state.sessionResult = undefined; this.state.promptRequestError = undefined; @@ -40,11 +66,12 @@ const runtimeMock = { }; const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => + startOpenCodeServerProcess: ({ binaryPath, environment }) => 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); + runtimeMock.state.startEnvironments.push(environment); // The production runtime binds server lifetime to the caller's scope. // Mirror that here so the closeCalls probe observes scope close. yield* Effect.addFinalizer(() => @@ -55,17 +82,36 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { return { url, exitCode: Effect.never, + ...runtimeMock.state.localServerMetadata, }; }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.succeed({ + connectToOpenCodeServer: (input) => { + const { binaryPath, serverUrl, serverPassword } = input; + runtimeMock.state.connectCalls.push({ + binaryPath, + ...(serverUrl ? { serverUrl } : {}), + ...(serverPassword ? { serverPassword } : {}), + }); + if (runtimeMock.state.connectionError) { + return Effect.fail(runtimeMock.state.connectionError); + } + return Effect.succeed({ url: serverUrl ?? "http://127.0.0.1:4301", exitCode: null, external: Boolean(serverUrl), - }), + ...runtimeMock.state.externalServerMetadata, + }); + }, runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => - ({ + createOpenCodeSdkClient: (input) => { + const { baseUrl, directory, apiVersion, serverPassword } = input; + runtimeMock.state.clientCalls.push({ + baseUrl, + directory, + ...(apiVersion ? { apiVersion } : {}), + ...(serverPassword ? { serverPassword } : {}), + }); + return { session: { create: async () => { if (runtimeMock.state.sessionCreateError !== undefined) { @@ -98,7 +144,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { ); }, }, - }) as unknown as ReturnType, + } as unknown as ReturnType; + }, loadOpenCodeInventory: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ @@ -130,6 +177,19 @@ const DEFAULT_COMMIT_MESSAGE_INPUT = { }; const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; +const decodeOpenCodeTextGenerationConfig = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + model: Schema.optional(Schema.String), + permission: Schema.String, + permissions: Schema.optional( + Schema.Array( + Schema.Struct({ action: Schema.String, resource: Schema.String, effect: Schema.String }), + ), + ), + }), + ), +); const OpenCodeTextGenerationTestLayer = Layer.succeed( OpenCodeRuntime.OpenCodeRuntime, @@ -169,9 +229,13 @@ const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ function withOpenCodeTextGeneration( settings: OpenCodeSettings, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, + environment?: NodeJS.ProcessEnv, ) { return Effect.gen(function* () { - const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); + const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration( + settings, + environment, + ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -219,6 +283,91 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ).pipe(Effect.provide(TestClock.layer())), ); + it.effect("forwards local OpenCode V2 metadata and generated credentials across warm reuse", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.localServerMetadata = { + apiVersion: "v2", + serverPassword: "generated-local-password", + }; + + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.connectCalls).toEqual([]); + expect( + decodeOpenCodeTextGenerationConfig( + runtimeMock.state.startEnvironments[0]?.OPENCODE_CONFIG_CONTENT ?? "{}", + ), + ).toMatchObject({ permission: "deny" }); + expect(runtimeMock.state.clientCalls).toEqual([ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + apiVersion: "v2", + serverPassword: "generated-local-password", + }, + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + apiVersion: "v2", + serverPassword: "generated-local-password", + }, + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:generated-local-password")}`, + `Basic ${btoa("opencode:generated-local-password")}`, + ]); + }), + ), + ); + + it.effect("keeps background OpenCode generation tool-free with native v2 configuration", () => + withOpenCodeTextGeneration( + DEFAULT_OPENCODE_SETTINGS, + (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + const config = decodeOpenCodeTextGenerationConfig( + runtimeMock.state.startEnvironments[0]?.OPENCODE_CONFIG_CONTENT ?? "{}", + ); + expect(config.model).toBe("test/preview"); + expect(config.permission).toBe("deny"); + expect(config.permissions).toEqual([ + { action: "edit", resource: "*", effect: "allow" }, + { action: "*", resource: "*", effect: "deny" }, + ]); + }), + { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + model: "test/preview", + permissions: [{ action: "edit", resource: "*", effect: "allow" }], + }), + }, + ), + ); + + it.effect("preserves unauthenticated local OpenCode V1 clients", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.localServerMetadata = { apiVersion: "v1" }; + + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.clientCalls).toEqual([ + { + baseUrl: "http://127.0.0.1:4301", + directory: process.cwd(), + apiVersion: "v1", + }, + ]); + expect(runtimeMock.state.authHeaders).toEqual([null]); + }), + ), + ); + it.effect("starts a new server after the warm server idles out", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { @@ -437,6 +586,30 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( }); expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.connectCalls).toEqual([ + { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + ]); + expect(runtimeMock.state.clientCalls).toEqual([ + { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + serverPassword: "secret-password", + }, + { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + serverPassword: "secret-password", + }, + ]); expect(runtimeMock.state.promptUrls).toEqual([ "http://127.0.0.1:9999", "http://127.0.0.1:9999", @@ -452,5 +625,83 @@ it.layer(OpenCodeTextGenerationExistingServerTestLayer)( }), ).pipe(Effect.provide(TestClock.layer())), ); + + it.effect("detects external OpenCode V2 and forwards its configured credentials", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.externalServerMetadata = { + apiVersion: "v2", + serverPassword: "secret-password", + }; + + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.connectCalls).toEqual([ + { + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", + }, + ]); + expect(runtimeMock.state.clientCalls).toEqual([ + { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + apiVersion: "v2", + serverPassword: "secret-password", + }, + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + ), + ); + + it.effect("preserves authenticated external OpenCode V1 clients", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.externalServerMetadata = { apiVersion: "v1" }; + + yield* textGeneration.generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.clientCalls).toEqual([ + { + baseUrl: "http://127.0.0.1:9999", + directory: process.cwd(), + apiVersion: "v1", + serverPassword: "secret-password", + }, + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + ]); + }), + ), + ); + + it.effect("preserves configured-server connection failures as text-generation errors", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const connectionError = new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "connectToOpenCodeServer", + detail: "Unable to detect the OpenCode server API version.", + }); + runtimeMock.state.connectionError = connectionError; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("Unable to detect the OpenCode server API version."); + expect(error.cause).toBe(connectionError); + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.clientCalls).toEqual([]); + }), + ), + ); }, ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cffc..bd7e19cb5046 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -33,6 +33,9 @@ import { import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; +const OpenCodeConfigContent = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); +const decodeOpenCodeConfigContent = Schema.decodeUnknownExit(OpenCodeConfigContent); +const encodeOpenCodeConfigContent = Schema.encodeSync(OpenCodeConfigContent); const OpenCodeTextGenerationOperation = Schema.Literals([ "generateCommitMessage", @@ -295,13 +298,38 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" // closes the fresh scope. return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { + const decodedConfig = decodeOpenCodeConfigContent( + OpenCodeRuntime.resolveOpenCodeConfigContent(resolvedEnvironment), + ); + if (Exit.isFailure(decodedConfig)) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode configuration content must be a JSON object.", + }); + } + const config = decodedConfig.value; + const serverEnvironment = { + ...resolvedEnvironment, + OPENCODE_CONFIG_CONTENT: encodeOpenCodeConfigContent({ + ...config, + permission: "deny", + ...(Array.isArray(config.permissions) + ? { + permissions: [ + ...config.permissions, + { action: "*", resource: "*", effect: "deny" }, + ], + } + : {}), + }), + }; const serverScope = yield* Scope.make(); const startedExit = yield* Effect.exit( restore( openCodeRuntime .startOpenCodeServerProcess({ binaryPath: input.binaryPath, - environment: resolvedEnvironment, + environment: serverEnvironment, }) .pipe( Effect.provideService(Scope.Scope, serverScope), @@ -381,13 +409,17 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( - function* (server: Pick) { + function* ( + server: Pick< + OpenCodeRuntime.OpenCodeServerConnection, + "url" | "apiVersion" | "serverPassword" + >, + ) { const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), + ...(server.apiVersion ? { apiVersion: server.apiVersion } : {}), + ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), }); const session = yield* Effect.tryPromise({ try: () => @@ -498,7 +530,33 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const rawOutput = openCodeSettings.serverUrl.length > 0 - ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) + ? yield* openCodeRuntime + .connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + serverUrl: openCodeSettings.serverUrl, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }) + .pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), + cause, + }), + ), + Effect.flatMap((server) => + runAgainstServer({ + ...server, + ...(openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }), + ), + Effect.scoped, + ) : yield* Effect.acquireUseRelease( acquireSharedServer({ binaryPath: openCodeSettings.binaryPath, diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..6fd2161b2d58 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -23,6 +23,21 @@ 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. +### OpenCode protocol generations + +The `opencode` driver supports both OpenCode 1 (`opencode`) and OpenCode 2 (`opencode2`). OpenCode 1 +uses the legacy `@opencode-ai/sdk/v2` export; despite its name, that export is not the OpenCode 2 +API. OpenCode 2 uses `@opencode-ai/client`, authenticated `/api/*` endpoints, and a different event +format. + +[`opencodeRuntime.ts`][opencode-runtime] discovers the available executable, detects the upstream +protocol, and carries server credentials into each connection. The [OpenCode 2 compatibility +client][opencode-v2] normalizes models, sessions, permissions, forms, and streaming events at the +existing adapter boundary. Restricted permission modes are applied to locally spawned OpenCode +processes through compatible configuration; externally managed OpenCode servers retain their own +permission policies. Orchestration and the web, desktop, and mobile clients continue to use the +same provider-neutral contracts for either generation. + ## Registry and routing Two registries separate configuration from live processes: @@ -81,6 +96,8 @@ when a request opens (approval) or user input is requested, via [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts +[opencode-runtime]: ../../apps/server/src/provider/opencodeRuntime.ts +[opencode-v2]: ../../apps/server/src/provider/openCodeV2Client.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..151d7408d86f 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,13 +54,13 @@ 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` | +| 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` or `opencode2` | `opencode auth login` or `opencode2 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. @@ -71,6 +71,29 @@ T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login` Run the login command on the machine running the T3 Code server, not on the device you browse from. +### OpenCode 1 and OpenCode 2 + +OpenCode 1 uses the `opencode` executable, while OpenCode 2 uses `opencode2`. Both versions can be +installed side by side: + +```bash +npm install -g opencode-ai +npm install -g @opencode-ai/cli@beta +``` + +Authenticate the version you want to use: + +```bash +opencode auth login +opencode2 auth login +``` + +T3 Code defaults to `opencode` and automatically tries `opencode2` when `opencode` is not available. +If both versions are installed and you want OpenCode 2, set **Settings** → **OpenCode** → **Binary +path** to `opencode2` or the full path to that executable. An explicitly configured path is used +instead of automatic discovery. OpenCode 1 requires version `1.14.19` or newer; OpenCode 2 beta and +preview versions are also supported. + ### Binary Discovery Each provider CLI must be on the server's `PATH`, or have an explicit binary path set in diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 0648bafc8b77..2082c72d4afd 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -24,7 +24,8 @@ Supervised. unattended until it finishes or asks a question of its own. Approvals appear inline in the conversation. Approve or reject one and the agent continues from -there. +there. For OpenCode 2, **Always allow this project** also applies to later sessions in the same +project; choose **Allow once** when you do not want to save the approval. ## Choosing a Mode @@ -44,4 +45,8 @@ with prompting enabled and a restricted workspace while **Full access** disables labels above describe what you get; the exact per-provider translation is internal and may change. +When you connect to an externally managed OpenCode 2 server, that server controls its permission +rules. Configure the OpenCode server to ask before sensitive actions if you need supervised +behavior; T3 Code cannot make a separately managed server stricter for an individual thread. + Mobile offers the same four modes with the same labels and descriptions. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index d75b0b4ae798..34d2672b7d72 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -8,6 +8,7 @@ import { ClaudeSettings, DEFAULT_SERVER_SETTINGS, defaultEnabledForDriver, + OpenCodeSettings, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -19,6 +20,7 @@ const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); +const decodeOpenCodeSettings = Schema.decodeUnknownSync(OpenCodeSettings); describe("ClaudeSettings auto-compaction", () => { it("uses Claude's default threshold when no override is configured", () => { @@ -49,6 +51,21 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("OpenCodeSettings binary discovery", () => { + it("keeps opencode as the default binary", () => { + expect(decodeOpenCodeSettings({}).binaryPath).toBe("opencode"); + expect(decodeOpenCodeSettings({ binaryPath: "" }).binaryPath).toBe("opencode"); + }); + + it("accepts an explicit OpenCode 2 executable", () => { + expect(decodeOpenCodeSettings({ binaryPath: " opencode2 " }).binaryPath).toBe("opencode2"); + expect( + decodeServerSettingsPatch({ providers: { opencode: { binaryPath: " opencode2 " } } }) + .providers?.opencode?.binaryPath, + ).toBe("opencode2"); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 76bb032c866e..63ae86288802 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -509,9 +509,10 @@ export const OpenCodeSettings = makeProviderSettingsSchema( binaryPath: makeBinaryPathSetting("opencode").pipe( Schema.annotateKey({ title: "Binary path", - description: "Path to the OpenCode binary.", + description: + "Path to opencode or opencode2. The default automatically discovers opencode2 when opencode is unavailable.", providerSettingsForm: { - placeholder: "opencode", + placeholder: "opencode or opencode2", clearWhenEmpty: "omit", }, }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7995dec1a2cf..98daefee1ed3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -475,6 +475,9 @@ importers: '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) + '@opencode-ai/client': + specifier: 0.0.0-beta-18155 + version: 0.0.0-beta-18155(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -3263,6 +3266,23 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opencode-ai/client@0.0.0-beta-18155': + resolution: {integrity: sha512-LWEOrUuluxWjPAgpEb0ejNhjlf+OtKbNN/5RqPGXOkIYJwGj+GTtYAePd06jlJvdFhanemAZCDG1ds3U/jbkUA==} + peerDependencies: + effect: 4.0.0-beta.103 + solid-js: '>=1.9.0' + peerDependenciesMeta: + effect: + optional: true + solid-js: + optional: true + + '@opencode-ai/protocol@0.0.0-beta-18155': + resolution: {integrity: sha512-sKdp+6tTrmnKNSe0VBOGu6WY21gQ2/j4suZvzpImY0N0N0XNDytyw+OUO58SpT27yr0LhC5XMumadjF8dadcPA==} + + '@opencode-ai/schema@0.0.0-beta-18155': + resolution: {integrity: sha512-vH77QNMZvJqlITkB0qoRpWIPVkZlQ6f9KE7IfwAdstmwc3OUQ+fiCK7kA8AawUJ7NG57KMvOn17lNeNmMsiIiA==} + '@opencode-ai/sdk@1.15.13': resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} @@ -13403,6 +13423,23 @@ snapshots: '@open-draft/until@2.1.0': {} + '@opencode-ai/client@0.0.0-beta-18155(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': + dependencies: + '@opencode-ai/protocol': 0.0.0-beta-18155 + '@opencode-ai/schema': 0.0.0-beta-18155 + optionalDependencies: + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + + '@opencode-ai/protocol@0.0.0-beta-18155': + dependencies: + '@opencode-ai/schema': 0.0.0-beta-18155 + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + + '@opencode-ai/schema@0.0.0-beta-18155': + dependencies: + '@standard-schema/spec': 1.1.0 + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + '@opencode-ai/sdk@1.15.13': dependencies: cross-spawn: 7.0.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0a4d3cc7cb50..e76ea2d05160 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -76,6 +76,9 @@ minimumReleaseAgeExclude: - alchemy@2.0.0-beta.65 - effect@4.0.0-beta.103 - "@legendapp/list@3.3.5" + - "@opencode-ai/client@0.0.0-beta-18155" + - "@opencode-ai/protocol@0.0.0-beta-18155" + - "@opencode-ai/schema@0.0.0-beta-18155" overrides: # The SDK always receives the user's Claude executable, so its bundled binaries are unused. diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index a7bb1b9b7c4c..177e97e34531 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -547,6 +547,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { serverDependencies: { "@anthropic-ai/claude-agent-sdk": "^0.3.170", "@ff-labs/fff-node": "0.9.4", + "@opencode-ai/client": "0.0.0-beta-18155", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "1.3.0", "msgpackr-extract": "3.0.4",