From c2b9b83a092e3b879e297e837a22afff11d60075 Mon Sep 17 00:00:00 2001 From: Narayan201120 Date: Fri, 25 Sep 2026 14:02:35 +0530 Subject: [PATCH] fix(server): support OpenCode 2.x provider inventory OpenCode 2.x broke provider status with "Timed out waiting for OpenCode server start after 30000ms". The 2.x server prints `server listening on` instead of `opencode server listening on`, prints a generated server password, moved the API under /api with new envelopes, and answers empty catalogs for a few seconds after its banner. Detect the new banner, pick up the printed password, detect v1 vs v2 per server, and load inventory through @opencode/client on v2 with a bounded warm-up retry. Live sessions still use the v1 SDK and need their own migration. Built with Muse Spark 1.3 Free via OpenCode. --- apps/server/package.json | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 12 + .../provider/Layers/OpenCodeProvider.test.ts | 62 +++- .../src/provider/Layers/OpenCodeProvider.ts | 128 ++++++-- .../src/provider/OpenCodeServerOwner.test.ts | 12 + .../opencodeRuntime.cliParsers.test.ts | 40 +++ .../opencodeRuntime.environment.test.ts | 134 ++++++++ .../opencodeRuntime.inventory.test.ts | 105 +++++++ apps/server/src/provider/opencodeRuntime.ts | 296 ++++++++++++++++-- .../OpenCodeTextGeneration.test.ts | 12 + docs/user/providers-opencode.md | 6 +- pnpm-lock.yaml | 37 +++ 12 files changed, 795 insertions(+), 50 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index b7f1bce88768..b83be02bfe6e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", + "@opencode/client": "2.0.16", "diff": "8.0.3", "effect": "catalog:", "node-pty": "^1.1.0", diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index baded8094ba6..9bd909e9b0b3 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -213,6 +213,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { return { url, version: "1.15.13", + apiVersion: "v1" as const, ...(serverPassword ? { serverPassword } : {}), exitCode: Effect.never, isRunning: Effect.succeed(true), @@ -234,6 +235,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { return { url, version: "1.15.13", + apiVersion: "v1" as const, ...(serverPassword ? { serverPassword } : {}), exitCode: null, external: Boolean(serverUrl), @@ -561,6 +563,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + createOpenCodeV2Client: () => + ({}) as unknown as ReturnType, + loadOpenCodeInventoryV2: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventoryV2", + detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventoryV2 not used in this test", + cause: null, + }), + ), loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index b50a51f6cb31..37e141621022 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -24,7 +24,11 @@ import { checkOpenCodeProviderStatus, openCodeCommandsToServerProviderSlashCommands, } from "./OpenCodeProvider.ts"; -import type { OpenCodeInventory } from "../opencodeRuntime.ts"; +import type { + OpenCodeApiVersion, + OpenCodeInventory, + OpenCodeV2Inventory, +} from "../opencodeRuntime.ts"; import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); @@ -167,6 +171,9 @@ const runtimeMock = { directory: string; serverPassword?: string; }>, + v2ClientInputs: [] as Array<{ baseUrl: string; serverPassword?: string }>, + apiVersion: "v1" as OpenCodeApiVersion, + inventoryV2: null as OpenCodeV2Inventory | null, inventory: { providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -182,6 +189,9 @@ const runtimeMock = { this.state.inventoryCwd = null; this.state.closeCalls = 0; this.state.sdkClientInputs.length = 0; + this.state.v2ClientInputs.length = 0; + this.state.apiVersion = "v1"; + this.state.inventoryV2 = null; this.state.inventory = { providerList: { connected: [], all: [] as unknown[], default: {} }, agents: [] as unknown[], @@ -209,6 +219,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ? { serverPassword: effectiveServerPassword } : {}), version: "1.14.19", + apiVersion: runtimeMock.state.apiVersion, isRunning: Effect.succeed(true), exitCode: Effect.never, }; @@ -233,6 +244,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { url: serverUrl ?? "http://127.0.0.1:4301", ...(serverPassword ? { serverPassword } : {}), version: "1.14.19", + apiVersion: runtimeMock.state.apiVersion, exitCode: null, external: Boolean(serverUrl), }; @@ -253,6 +265,19 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.sdkClientInputs.push(input); return {} as unknown as ReturnType; }, + createOpenCodeV2Client: (input) => { + runtimeMock.state.v2ClientInputs.push(input); + return {} as unknown as ReturnType; + }, + loadOpenCodeInventoryV2: () => + runtimeMock.state.inventoryV2 + ? Effect.succeed(runtimeMock.state.inventoryV2) + : Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventoryV2", + detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventoryV2 not used in this test", + }), + ), loadOpenCodeInventory: () => runtimeMock.state.inventoryError ? Effect.fail( @@ -433,6 +458,41 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("loads model inventory through the v2 API when the server reports v2", () => + Effect.gen(function* () { + runtimeMock.state.apiVersion = "v2"; + runtimeMock.state.inventoryV2 = { + providers: [{ id: "openai", name: "OpenAI" }], + connectedProviders: ["openai"], + models: [ + { providerID: "openai", modelID: "gpt-5", name: "GPT-5", variantIDs: ["high"] }, + { providerID: "other", modelID: "other-1", name: "Other 1", variantIDs: [] }, + ], + agents: [{ name: "build", mode: "primary", hidden: false }], + skills: [{ name: "review", description: "Reviews", location: "/skills/review/SKILL.md" }], + commands: [{ name: "init", description: "Init" }], + }; + + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.deepEqual( + snapshot.models.map((entry) => entry.slug), + ["openai/gpt-5"], + ); + NodeAssert.deepEqual( + snapshot.skills.map((skill) => skill.name), + ["review"], + ); + NodeAssert.deepEqual( + snapshot.slashCommands.map((command) => command.name), + ["compact", "init"], + ); + NodeAssert.equal(runtimeMock.state.v2ClientInputs.length, 1); + NodeAssert.equal(runtimeMock.state.v2ClientInputs[0]?.baseUrl, "http://127.0.0.1:4301"); + }), + ); + it.effect("includes OpenCode skills in the provider snapshot", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 0495f8b30737..f93b2727b794 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -24,9 +24,10 @@ import { MINIMUM_OPENCODE_VERSION, OpenCodeRuntime, openCodeRuntimeErrorDetail, + type OpenCodeApiVersion, type OpenCodeInventory, + type OpenCodeV2Inventory, } from "../opencodeRuntime.ts"; -import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; const OPENCODE_PRESENTATION = { @@ -168,7 +169,7 @@ function inferDefaultVariant( return undefined; } -function inferDefaultAgent(agents: ReadonlyArray): string | undefined { +function inferDefaultAgent(agents: ReadonlyArray<{ readonly name: string }>): string | undefined { return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; } @@ -201,10 +202,14 @@ const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabi function openCodeCapabilitiesForModel(input: { readonly providerID: string; - readonly model: ProviderListResponse["all"][number]["models"][string]; - readonly agents: ReadonlyArray; + readonly variantIDs: ReadonlyArray; + readonly agents: ReadonlyArray<{ + readonly name: string; + readonly mode: string; + readonly hidden?: boolean; + }>; }): ModelCapabilities { - const rawVariantValues = Object.keys(input.model.variants ?? {}); + const rawVariantValues = input.variantIDs; // When a model advertises no variants, synthesize the standard reasoning // levels so the composer still offers a Reasoning selector (mirrors the // Codex/Grok experience where reasoning is always configurable). The set @@ -278,7 +283,7 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray left.name.localeCompare(right.name)); } +function flattenOpenCodeV2Models(input: OpenCodeV2Inventory): ReadonlyArray { + const connected = new Set(input.connectedProviders); + const displayNames = new Map(input.providers.map((provider) => [provider.id, provider.name])); + const models: Array = []; + + for (const model of input.models) { + if (!connected.has(model.providerID)) { + continue; + } + const name = nonEmptyTrimmed(model.name); + if (!name) { + continue; + } + + const subProvider = nonEmptyTrimmed(displayNames.get(model.providerID) ?? ""); + models.push({ + slug: `${model.providerID}/${model.modelID}`, + name, + ...(subProvider ? { subProvider } : {}), + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: model.providerID, + variantIDs: model.variantIDs, + agents: input.agents, + }), + }); + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} + function trimOptional(value: string | null | undefined): string | undefined { const trimmed = value?.trim(); return trimmed && trimmed.length > 0 ? trimmed : undefined; } export function openCodeSkillsToServerProviderSkills( - input: OpenCodeInventory["skills"] | undefined, + input: + | ReadonlyArray<{ + readonly name?: string | null; + readonly description?: string | null; + readonly location?: string | null; + }> + | undefined, ): ReadonlyArray { const skills: ServerProviderSkill[] = []; for (const skill of input ?? []) { @@ -317,7 +359,14 @@ export function openCodeSkillsToServerProviderSkills( } export function openCodeCommandsToServerProviderSlashCommands( - input: OpenCodeInventory["commands"], + input: + | ReadonlyArray<{ + readonly name?: string | null; + readonly description?: string | null; + readonly source?: string | null; + readonly hints?: ReadonlyArray; + }> + | undefined, ): ReadonlyArray { const commands: ServerProviderSlashCommand[] = [COMPACT_SLASH_COMMAND]; const names = new Set([COMPACT_SLASH_COMMAND.name]); @@ -326,7 +375,7 @@ export function openCodeCommandsToServerProviderSlashCommands( if (!name || names.has(name) || command.source === "skill") continue; names.add(name); const description = trimOptional(command.description); - const hint = trimOptional(command.hints.join(" ")); + const hint = trimOptional((command.hints ?? []).join(" ")); commands.push({ name, ...(description ? { description } : {}), @@ -499,16 +548,55 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu readonly url: string; readonly serverPassword?: string; readonly version: string; - }) => - openCodeRuntime + readonly apiVersion: OpenCodeApiVersion; + }) => { + const passwordOption = + server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}; + if (server.apiVersion === "v2") { + return openCodeRuntime + .loadOpenCodeInventoryV2( + openCodeRuntime.createOpenCodeV2Client({ + baseUrl: server.url, + ...passwordOption, + }), + cwd, + ) + .pipe( + Effect.map((inventory) => ({ + version: server.version, + models: providerModelsFromSettings( + flattenOpenCodeV2Models(inventory), + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + skills: openCodeSkillsToServerProviderSkills(inventory.skills), + slashCommands: openCodeCommandsToServerProviderSlashCommands(inventory.commands), + connectedCount: inventory.connectedProviders.length, + })), + ); + } + return openCodeRuntime .loadOpenCodeInventory( openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: cwd, - ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), + ...passwordOption, }), ) - .pipe(Effect.map((inventory) => ({ inventory, version: server.version }))); + .pipe( + Effect.map((inventory) => ({ + version: server.version, + models: providerModelsFromSettings( + flattenOpenCodeModels(inventory), + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + skills: openCodeSkillsToServerProviderSkills(inventory.skills), + slashCommands: openCodeCommandsToServerProviderSlashCommands(inventory.commands), + connectedCount: inventory.providerList.connected.length, + })), + ); + }; const inventoryEffect = isExternalServer ? openCodeRuntime .connectToOpenCodeServer({ @@ -534,22 +622,16 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu version = inventoryExit.value.version; - const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value.inventory), - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.inventory.skills); - const connectedCount = inventoryExit.value.inventory.providerList.connected.length; + const models = inventoryExit.value.models; + const skills = inventoryExit.value.skills; + const connectedCount = inventoryExit.value.connectedCount; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, skills, - slashCommands: openCodeCommandsToServerProviderSlashCommands( - inventoryExit.value.inventory.commands, - ), + slashCommands: inventoryExit.value.slashCommands, probe: { installed: true, version, diff --git a/apps/server/src/provider/OpenCodeServerOwner.test.ts b/apps/server/src/provider/OpenCodeServerOwner.test.ts index 5e66f0a56356..83d261533b4e 100644 --- a/apps/server/src/provider/OpenCodeServerOwner.test.ts +++ b/apps/server/src/provider/OpenCodeServerOwner.test.ts @@ -48,6 +48,7 @@ const makeRuntime = Effect.gen(function* () { return { url: `http://127.0.0.1:${index}`, version: "1.14.19", + apiVersion: "v1" as const, isRunning: Effect.succeed(true), exitCode: Effect.never, }; @@ -55,7 +56,9 @@ const makeRuntime = Effect.gen(function* () { connectToOpenCodeServer: unusedRuntimeMethod, runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, + createOpenCodeV2Client: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeInventoryV2: unusedRuntimeMethod, loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, loadSkillsFromCli: unusedRuntimeMethod, @@ -131,6 +134,7 @@ it.effect("invalidates an exited process so the next borrower starts a new one", return { url: `http://127.0.0.1:${index}`, version: "1.14.19", + apiVersion: "v1" as const, isRunning: Effect.succeed(true), exitCode: Deferred.await(exitCode), }; @@ -138,7 +142,9 @@ it.effect("invalidates an exited process so the next borrower starts a new one", connectToOpenCodeServer: unusedRuntimeMethod, runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, + createOpenCodeV2Client: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeInventoryV2: unusedRuntimeMethod, loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, loadSkillsFromCli: unusedRuntimeMethod, @@ -179,6 +185,7 @@ it.effect("replaces a dead cached process before its exit watcher runs", () => return { url: `http://127.0.0.1:${index}`, version: "1.14.19", + apiVersion: "v1" as const, isRunning: Ref.get(isRunning), exitCode: Effect.never, }; @@ -186,7 +193,9 @@ it.effect("replaces a dead cached process before its exit watcher runs", () => connectToOpenCodeServer: unusedRuntimeMethod, runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, + createOpenCodeV2Client: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeInventoryV2: unusedRuntimeMethod, loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, loadSkillsFromCli: unusedRuntimeMethod, @@ -234,6 +243,7 @@ it.effect("cleans up an interrupted startup and allows a retry", () => return { url: `http://127.0.0.1:${index}`, version: "1.14.19", + apiVersion: "v1" as const, isRunning: Effect.succeed(true), exitCode: Effect.never, }; @@ -241,7 +251,9 @@ it.effect("cleans up an interrupted startup and allows a retry", () => connectToOpenCodeServer: unusedRuntimeMethod, runOpenCodeCommand: unusedRuntimeMethod, createOpenCodeSdkClient: () => ({}) as never, + createOpenCodeV2Client: () => ({}) as never, loadOpenCodeInventory: unusedRuntimeMethod, + loadOpenCodeInventoryV2: unusedRuntimeMethod, loadOpenCodeSkills: unusedRuntimeMethod, loadInventoryFromCli: unusedRuntimeMethod, loadSkillsFromCli: unusedRuntimeMethod, diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index c66a2f36a207..56b47bb71995 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -5,6 +5,8 @@ import { describe, it } from "vite-plus/test"; import { parseAgentListCliOutput, parseModelsCliOutput, + parseServerPasswordFromOutput, + parseServerUrlFromOutput, parseSkillsCliOutput, toOpenCodeFileParts, } from "./opencodeRuntime.ts"; @@ -333,3 +335,41 @@ describe("toOpenCodeFileParts", () => { NodeAssert.deepEqual(parts, []); }); }); + +describe("parseServerUrlFromOutput", () => { + it("parses the v1 ready banner", () => { + NodeAssert.equal( + parseServerUrlFromOutput("opencode server listening on http://127.0.0.1:4096\n"), + "http://127.0.0.1:4096", + ); + }); + + it("parses the v2 ready banner without the opencode prefix", () => { + NodeAssert.equal( + parseServerUrlFromOutput("server listening on http://127.0.0.1:4099\n"), + "http://127.0.0.1:4099", + ); + }); + + it("ignores unrelated startup lines", () => { + NodeAssert.equal(parseServerUrlFromOutput("starting up\nloading config\n"), null); + }); +}); + +describe("parseServerPasswordFromOutput", () => { + it("extracts the printed v2 server password", () => { + NodeAssert.equal( + parseServerPasswordFromOutput( + "server listening on http://127.0.0.1:4099\nserver password secret-123\n", + ), + "secret-123", + ); + }); + + it("returns undefined when no password line is present", () => { + NodeAssert.equal( + parseServerPasswordFromOutput("opencode server listening on http://127.0.0.1:4096\n"), + undefined, + ); + }); +}); diff --git a/apps/server/src/provider/opencodeRuntime.environment.test.ts b/apps/server/src/provider/opencodeRuntime.environment.test.ts index 680032de06b4..dcde6e7aabc4 100644 --- a/apps/server/src/provider/opencodeRuntime.environment.test.ts +++ b/apps/server/src/provider/opencodeRuntime.environment.test.ts @@ -236,4 +236,138 @@ server.listen(0, "127.0.0.1", () => { ), 10_000, ); + + effectIt.live( + "starts a v2-style server from the new banner and uses its printed password", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const executablePath = yield* HostProcessExecutablePath; + const platform = yield* HostProcessPlatform; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-v2-" }); + const isWindows = platform === "win32"; + const binaryPath = path.join(tempDir, isWindows ? "opencode.cmd" : "opencode"); + const scriptPath = path.join(tempDir, "opencode.mjs"); + + yield* fs.writeFileString( + scriptPath, + `import { createServer } from "node:http"; +const json = (response, value) => { + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(value)); +}; +const expectedAuth = "Basic " + Buffer.from("opencode:test-password-123", "utf8").toString("base64"); +let modelHits = 0; +const server = createServer((request, response) => { + if (request.url.startsWith("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/global/health")) { + response.statusCode = 200; + response.setHeader("Content-Type", "text/html"); + response.end("opencode"); + return; + } + if (request.headers.authorization !== expectedAuth) { + response.statusCode = 401; + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ message: "Unauthorized", got: request.headers.authorization ?? null })); + return; + } + if (request.url.startsWith("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/api/info")) { + json(response, { version: "2.0.16", pid: 1, urls: [], paths: { tmp: "/tmp" } }); + return; + } + if (request.url.startsWith("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/api/provider")) { + json(response, { location: {}, data: [{ id: "openai", name: "OpenAI" }] }); + return; + } + if (request.url.startsWith("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/api/model")) { + modelHits += 1; + if (modelHits <= 2) { + json(response, { location: {}, data: [] }); + return; + } + json(response, { location: {}, data: [ + { providerID: "openai", modelID: "gpt-5", name: "GPT-5", variants: [{ id: "high" }] }, + { providerID: "other", modelID: "other-1", name: "Other 1", variants: [] }, + ] }); + return; + } + if (request.url.startsWith("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/api/agent")) { + json(response, { location: {}, data: [{ name: "build", mode: "primary", hidden: false }] }); + return; + } + if (request.url.startsWith("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/api/skill")) { + json(response, { location: {}, data: [{ name: "review", description: "Reviews", path: "/skills/review/SKILL.md" }] }); + return; + } + if (request.url.startsWith("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/api/command")) { + json(response, { location: {}, data: [{ name: "init", description: "Init" }] }); + return; + } + response.statusCode = 404; + response.end("nope"); +}); +server.listen(0, "127.0.0.1", () => { + process.stdout.write("server listening on http://127.0.0.1:" + server.address().port + "\\n"); + process.stdout.write("server password test-password-123\\n"); +}); +`, + ); + 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); + } + + const runtime = yield* OpenCodeRuntime; + const server = yield* runtime.startOpenCodeServerProcess({ + binaryPath, + directory: tempDir, + port: 0, + environment: { + ...environment, + T3_TEST_NODE_BINARY: executablePath, + T3_TEST_OPENCODE_SCRIPT: scriptPath, + }, + }); + + expect(server.apiVersion).toBe("v2"); + expect(server.version).toBe("2.0.16"); + expect(server.serverPassword).toBe("test-password-123"); + + const inventory = yield* runtime.loadOpenCodeInventoryV2( + runtime.createOpenCodeV2Client({ + baseUrl: server.url, + ...(server.serverPassword !== undefined + ? { serverPassword: server.serverPassword } + : {}), + }), + tempDir, + ); + expect(inventory.connectedProviders).toEqual(["openai"]); + expect(inventory.models.map((model) => `${model.providerID}/${model.modelID}`)).toEqual([ + "openai/gpt-5", + "other/other-1", + ]); + expect(inventory.models[0]?.variantIDs).toEqual(["high"]); + expect(inventory.agents).toEqual([{ name: "build", mode: "primary", hidden: false }]); + expect(inventory.skills).toEqual([ + { name: "review", description: "Reviews", location: "/skills/review/SKILL.md" }, + ]); + expect(inventory.commands).toEqual([{ name: "init", description: "Init" }]); + }).pipe( + Effect.scoped, + Effect.provide([OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer))]), + ), + 15_000, + ); }); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 4613a8bd6082..84406ef3e7ce 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -2,6 +2,7 @@ import * as NodeAssert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"; +import { OpenCode } from "@opencode/client"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -59,6 +60,110 @@ it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { }), ); + it.effect("aborts the v2 models gate when inventory loading is interrupted", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const started = yield* Queue.make(); + const aborted = yield* Queue.make(); + const client = OpenCode.make({ + baseUrl: "http://opencode.test", + fetch: Object.assign( + (input: string | Request | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input.toString()); + const pathname = new URL(request.url).pathname; + // The v2 client calls fetch(url, init); the abort signal lives + // on init, so a stub that only watches request.signal would never + // observe interruption. + const signal = init?.signal ?? request.signal; + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + Queue.offerUnsafe(aborted, pathname); + reject(signal.reason); + }, + { once: true }, + ); + Queue.offerUnsafe(started, pathname); + }); + }, + { preconnect: () => undefined }, + ), + }); + + const inventoryFiber = yield* runtime + .loadOpenCodeInventoryV2(client, "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/workspace/project") + .pipe(Effect.forkChild); + // The models call gates the rest: nothing else can be in flight yet, + // so the first offer is deterministically the models request. + yield* Queue.takeN(started, 1); + yield* Fiber.interrupt(inventoryFiber); + + NodeAssert.deepEqual(yield* Queue.takeAll(aborted), ["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/api/model"]); + }), + ); + + it.effect("aborts trailing v2 requests when inventory loading is interrupted", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const started = yield* Queue.make(); + const aborted = yield* Queue.make(); + // Models resolve only once the test releases them, so the trailing + // calls cannot start early and takeN observes a deterministic order. + let releaseModels: () => void = () => undefined; + const modelsGate = new Promise((resolve) => { + releaseModels = () => resolve(undefined); + }); + const client = OpenCode.make({ + baseUrl: "http://opencode.test", + fetch: Object.assign( + (input: string | Request | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input.toString()); + const pathname = new URL(request.url).pathname; + const signal = init?.signal ?? request.signal; + if (pathname === "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/api/model") { + Queue.offerUnsafe(started, pathname); + return modelsGate.then(() => + Response.json({ + location: {}, + data: [{ providerID: "openai", modelID: "gpt-5", name: "GPT-5", variants: [] }], + }), + ); + } + return new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + Queue.offerUnsafe(aborted, pathname); + reject(signal.reason); + }, + { once: true }, + ); + Queue.offerUnsafe(started, pathname); + }); + }, + { preconnect: () => undefined }, + ), + }); + + const inventoryFiber = yield* runtime + .loadOpenCodeInventoryV2(client, "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/workspace/project") + .pipe(Effect.forkChild); + // Only the models gate can be in flight before the release. + yield* Queue.takeN(started, 1); + releaseModels(); + // The release lets the loader fire the remaining four calls together, + // the same burst the v1 abort test relies on. + yield* Queue.takeN(started, 4); + yield* Fiber.interrupt(inventoryFiber); + + const aborts = yield* Queue.takeAll(aborted); + for (const pathname of ["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/api/agent", "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/api/command", "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/api/provider", "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/api/skill"]) { + NodeAssert.ok(aborts.includes(pathname), `missing abort for ${pathname}`); + } + }), + ); + it.effect("discovers directory-scoped commands without retaining prompt templates", () => Effect.gen(function* () { const runtime = yield* OpenCodeRuntime; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index e87f758e0e3d..a05d643e38a6 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -13,6 +13,7 @@ import { type QuestionAnswer, type QuestionRequest, } from "@opencode-ai/sdk/v2"; +import { OpenCode as OpenCodeV2, type OpenCodeClient as OpenCodeV2Client } from "@opencode/client"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -78,15 +79,19 @@ export function resolveOpenCodeServerPassword( : input.environment.OPENCODE_SERVER_PASSWORD; } -const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; +const OPENCODE_SERVER_READY_PREFIXES = ["opencode server listening", "server listening"] as const; +const OPENCODE_SERVER_PASSWORD_PREFIX = "server password"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024; const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +export type OpenCodeApiVersion = "v1" | "v2"; + export interface OpenCodeServerProcess { readonly url: string; readonly serverPassword?: string; readonly version: string; + readonly apiVersion: OpenCodeApiVersion; readonly isRunning: Effect.Effect; readonly exitCode: Effect.Effect; } @@ -95,6 +100,7 @@ export interface OpenCodeServerConnection { readonly url: string; readonly serverPassword?: string; readonly version: string; + readonly apiVersion: OpenCodeApiVersion; readonly exitCode: Effect.Effect | null; readonly external: boolean; } @@ -178,6 +184,80 @@ export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersio return health.version; }); +export const verifyOpenCodeServerVersionV2 = Effect.fn("verifyOpenCodeServerVersionV2")(function* ( + client: OpenCodeV2Client, +) { + const infoOption = yield* runOpenCodeSdk("server.info", (signal) => + client.server.info({ signal }), + ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT)); + if (Option.isNone(infoOption)) { + return yield* new OpenCodeRuntimeError({ + operation: "server.info", + detail: "Timed out while checking the OpenCode server version.", + }); + } + + const version = infoOption.value.version; + if (typeof version !== "string" || parseSemver(version) === null) { + return yield* new OpenCodeRuntimeError({ + operation: "server.info", + detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); + } + if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { + return yield* new OpenCodeRuntimeError({ + operation: "server.info", + detail: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, + }); + } + return version; +}); + +// A v1 client pointed at a v2 server fails with "not supported" (the old +// routes serve the web UI as text/html). Only that incompatibility signal +// falls through to the v2 API; every other v1 failure (auth, refused, +// timeout) propagates unchanged so real problems keep their diagnosis. +const OPENCODE_V1_INCOMPATIBLE_RE = /not supported|text\/html/i; + +export const detectOpenCodeServerApi = Effect.fn("detectOpenCodeServerApi")(function* ( + v1client: OpencodeClient, + v2client: OpenCodeV2Client, +) { + const v1Exit = yield* Effect.exit(verifyOpenCodeServerVersion(v1client)); + if (v1Exit._tag === "Success") { + return { version: v1Exit.value, apiVersion: "v1" as const }; + } + if (!OPENCODE_V1_INCOMPATIBLE_RE.test(openCodeRuntimeErrorDetail(Cause.squash(v1Exit.cause)))) { + return yield* Effect.failCause(v1Exit.cause); + } + const version = yield* verifyOpenCodeServerVersionV2(v2client); + return { version, apiVersion: "v2" as const }; +}); + +export interface OpenCodeV2InventoryModel { + readonly providerID: string; + readonly modelID: string; + readonly name: string; + readonly variantIDs: ReadonlyArray; +} + +export interface OpenCodeV2Inventory { + readonly providers: ReadonlyArray<{ readonly id: string; readonly name: string }>; + readonly connectedProviders: ReadonlyArray; + readonly models: ReadonlyArray; + readonly agents: ReadonlyArray<{ + readonly name: string; + readonly mode: string; + readonly hidden: boolean; + }>; + readonly skills: ReadonlyArray<{ + readonly name: string; + readonly description?: string; + readonly location: string; + }>; + readonly commands: ReadonlyArray<{ readonly name: string; readonly description?: string }>; +} + export interface OpenCodeCommandResult { readonly stdout: string; readonly stderr: string; @@ -269,9 +349,17 @@ export interface OpenCodeRuntimeShape { readonly directory: string; readonly serverPassword?: string; }) => OpencodeClient; + readonly createOpenCodeV2Client: (input: { + readonly baseUrl: string; + readonly serverPassword?: string; + }) => OpenCodeV2Client; readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; + readonly loadOpenCodeInventoryV2: ( + client: OpenCodeV2Client, + directory: string, + ) => Effect.Effect; readonly loadOpenCodeSkills: ( client: OpencodeClient, ) => Effect.Effect, OpenCodeRuntimeError>; @@ -287,9 +375,11 @@ export interface OpenCodeRuntimeShape { }) => Effect.Effect, OpenCodeRuntimeError>; } -function parseServerUrlFromOutput(output: string): string | null { +/** @internal */ +export function parseServerUrlFromOutput(output: string): string | null { for (const line of output.split("\n")) { - if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { + const trimmed = line.trimStart(); + if (!OPENCODE_SERVER_READY_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) { continue; } const match = line.match(/on\s+(https?:\/\/[^\s]+)/); @@ -298,6 +388,23 @@ function parseServerUrlFromOutput(output: string): string | null { return null; } +/** @internal */ +export function parseServerPasswordFromOutput(output: string): string | undefined { + for (const line of output.split("\n")) { + const trimmed = line.trimStart(); + if (!trimmed.startsWith(OPENCODE_SERVER_PASSWORD_PREFIX)) { + continue; + } + const password = trimmed + .slice(OPENCODE_SERVER_PASSWORD_PREFIX.length) + .trim() + .replace(/^[:=]\s*/, "") + .trim(); + return password.length > 0 ? password : undefined; + } + return undefined; +} + const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; @@ -747,22 +854,41 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const stdoutRef = yield* Ref.make(""); const stderrRef = yield* Ref.make(""); const readyDeferred = yield* Deferred.make(); + const printedPasswordRef = yield* Ref.make(undefined); + const passwordDeferred = yield* Deferred.make(); + // Separate rolling window for the password line. It must survive the + // post-ready drain (when stdoutRef is nulled) because 2.x prints + // `server password ...` after `server listening on ...`, often in a + // later chunk, and split lines must still match. + const passwordScanRef = yield* Ref.make(""); const setReadyFromStdoutChunk = (chunk: string) => - Ref.modify(stdoutRef, (stdout) => { - if (stdout === null) { - return [null, null] as const; + Effect.gen(function* () { + const chunkPassword = yield* Ref.modify(passwordScanRef, (previous) => { + const next = `${previous}${chunk}`.slice(-512); + return [parseServerPasswordFromOutput(next), next] as const; + }); + if (chunkPassword !== undefined) { + const already = yield* Ref.get(printedPasswordRef); + if (already === undefined) { + yield* Ref.set(printedPasswordRef, chunkPassword); + yield* Deferred.succeed(passwordDeferred, chunkPassword).pipe(Effect.ignore); + } } - const nextStdout = `${stdout}${chunk}`; - return [ - parseServerUrlFromOutput(nextStdout), - nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), - ] as const; - }).pipe( - Effect.flatMap((parsed) => - parsed ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) : Effect.void, - ), - ); + const parsedUrl = yield* Ref.modify(stdoutRef, (stdout) => { + if (stdout === null) { + return [null, null] as const; + } + const nextStdout = `${stdout}${chunk}`; + return [ + parseServerUrlFromOutput(nextStdout), + nextStdout.slice(-OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS), + ] as const; + }); + if (parsedUrl !== null) { + yield* Deferred.succeed(readyDeferred, parsedUrl).pipe(Effect.ignore); + } + }); const stdoutFiber = yield* child.stdout.pipe( Stream.decodeText(), @@ -828,9 +954,17 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const readyOption = readyExit.value; if (Option.isNone(readyOption)) { + const stdout = ((yield* Ref.get(stdoutRef)) ?? "").trim().slice(-2000); + const stderr = ((yield* Ref.get(stderrRef)) ?? "").trim().slice(-2000); return yield* new OpenCodeRuntimeError({ operation: "startOpenCodeServerProcess", - detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, + detail: [ + `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, + stdout ? `stdout:\n${stdout}` : null, + stderr ? `stderr:\n${stderr}` : null, + ] + .filter(Boolean) + .join("\n\n"), }); } @@ -841,18 +975,33 @@ const makeOpenCodeRuntime = Effect.gen(function* () { yield* Ref.set(stderrRef, null); const url = readyOption.value; - const version = yield* verifyOpenCodeServerVersion( - createOpenCodeSdkClient({ + // OpenCode 2.x prints `server listening on ...` followed by + // `server password ...` when no password was configured. The password + // can arrive in a later chunk than the URL, so allow a brief window + // for it before falling back to the configured password. + if (serverPassword === undefined) { + yield* Deferred.await(passwordDeferred).pipe(Effect.timeoutOption(500)); + } + const printedPassword = yield* Ref.get(printedPasswordRef); + const effectivePassword = printedPassword ?? serverPassword; + const v1client = createOpenCodeSdkClient({ + baseUrl: url, + directory: input.directory, + ...(effectivePassword !== undefined ? { serverPassword: effectivePassword } : {}), + }); + const { version, apiVersion } = yield* detectOpenCodeServerApi( + v1client, + createOpenCodeV2Client({ baseUrl: url, - directory: input.directory, - ...(serverPassword !== undefined ? { serverPassword } : {}), + ...(effectivePassword !== undefined ? { serverPassword: effectivePassword } : {}), }), ); return { url, - ...(serverPassword !== undefined ? { serverPassword } : {}), + ...(effectivePassword !== undefined ? { serverPassword: effectivePassword } : {}), version, + apiVersion, isRunning: child.isRunning.pipe(Effect.orElseSucceed(() => false)), exitCode: child.exitCode.pipe( Effect.map(Number), @@ -868,17 +1017,22 @@ const makeOpenCodeRuntime = Effect.gen(function* () { external: true, ...(input.serverPassword !== undefined ? { serverPassword: input.serverPassword } : {}), }); - return verifyOpenCodeServerVersion( + return detectOpenCodeServerApi( createOpenCodeSdkClient({ baseUrl: serverUrl, directory: input.directory, ...(serverPassword !== undefined ? { serverPassword } : {}), }), + createOpenCodeV2Client({ + baseUrl: serverUrl, + ...(serverPassword !== undefined ? { serverPassword } : {}), + }), ).pipe( - Effect.map((version) => ({ + Effect.map(({ version, apiVersion }) => ({ url: serverUrl, ...(serverPassword !== undefined ? { serverPassword } : {}), version, + apiVersion, exitCode: null, external: true, })), @@ -898,12 +1052,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () { url: server.url, ...(server.serverPassword !== undefined ? { serverPassword: server.serverPassword } : {}), version: server.version, + apiVersion: server.apiVersion, exitCode: server.exitCode, external: false, })), ); }; + const createOpenCodeV2Client: OpenCodeRuntimeShape["createOpenCodeV2Client"] = (input) => + OpenCodeV2.make({ + baseUrl: input.baseUrl, + ...(input.serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + }); + const loadProviders = (client: OpencodeClient) => runOpenCodeSdk("provider.list", (signal) => client.provider.list(undefined, { signal })).pipe( Effect.filterMapOrFail( @@ -959,6 +1126,85 @@ const makeOpenCodeRuntime = Effect.gen(function* () { })), ); + // OpenCode 2.x moved the API under /api with new envelopes. Only the + // provider-status path speaks v2 so far; live sessions still use the v1 + // SDK and need their own migration before they work against 2.x servers. + const loadOpenCodeInventoryV2: OpenCodeRuntimeShape["loadOpenCodeInventoryV2"] = ( + client, + directory, + ) => + Effect.gen(function* () { + const location = { location: { directory } }; + // A freshly spawned 2.x server answers list calls with empty catalogs + // for a few seconds after printing its ready banner (measured 4-8s + // locally: 0 models, then the full registry). An empty model catalog + // therefore means "still warming up", never "no models exist", so wait + // for models first, then load the remaining lists together. + const models = yield* Effect.gen(function* () { + for (let attempt = 0; ; attempt += 1) { + const fetched = yield* runOpenCodeSdk("model.list", (signal) => + client.model.list(location, { signal }), + ).pipe(Effect.map((result) => result.data)); + if (fetched.length > 0 || attempt >= 4) { + return fetched; + } + yield* Effect.sleep("2 seconds"); + } + }); + const [providers, agents, skills, commands] = yield* Effect.all( + [ + runOpenCodeSdk("provider.list", (signal) => + client.provider.list(location, { signal }), + ).pipe(Effect.map((result) => result.data)), + runOpenCodeSdk("agent.list", (signal) => client.agent.list(location, { signal })).pipe( + Effect.map((result) => + result.data.map((agent) => ({ + name: agent.name, + mode: agent.mode, + hidden: agent.hidden, + })), + ), + Effect.orElseSucceed((): OpenCodeV2Inventory["agents"] => []), + ), + runOpenCodeSdk("skill.list", (signal) => client.skill.list(location, { signal })).pipe( + Effect.map((result) => + result.data.map((skill) => ({ + name: skill.name, + ...(skill.description === undefined ? {} : { description: skill.description }), + location: skill.path, + })), + ), + Effect.orElseSucceed((): OpenCodeV2Inventory["skills"] => []), + ), + runOpenCodeSdk("command.list", (signal) => + client.command.list(location, { signal }), + ).pipe( + Effect.map((result) => + result.data.map((command) => ({ + name: command.name, + ...(command.description === undefined ? {} : { description: command.description }), + })), + ), + Effect.orElseSucceed((): OpenCodeV2Inventory["commands"] => []), + ), + ], + { concurrency: "unbounded" }, + ); + return { + providers: providers.map((provider) => ({ id: provider.id, name: provider.name })), + connectedProviders: providers.map((provider) => provider.id), + models: models.map((model) => ({ + providerID: model.providerID, + modelID: model.modelID, + name: model.name, + variantIDs: (model.variants ?? []).map((variant) => variant.id), + })), + agents, + skills, + commands, + }; + }); + const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => Effect.gen(function* () { const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); @@ -1084,7 +1330,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { connectToOpenCodeServer, runOpenCodeCommand, createOpenCodeSdkClient, + createOpenCodeV2Client, loadOpenCodeInventory, + loadOpenCodeInventoryV2, loadOpenCodeSkills, loadInventoryFromCli, loadSkillsFromCli, diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 688bf4bca352..1429db477e60 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -70,6 +70,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { ? { serverPassword: effectiveServerPassword } : {}), version: "1.14.19", + apiVersion: "v1" as const, isRunning: Effect.succeed(true), exitCode: Effect.never, }; @@ -87,6 +88,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { url: serverUrl ?? "http://127.0.0.1:4301", ...(serverPassword ? { serverPassword } : {}), version: "1.14.19", + apiVersion: "v1" as const, exitCode: null, external: Boolean(serverUrl), }), @@ -136,6 +138,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + createOpenCodeV2Client: () => + ({}) as unknown as ReturnType, + loadOpenCodeInventoryV2: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadOpenCodeInventoryV2", + detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventoryV2 not used in this test", + cause: null, + }), + ), loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 23de99798956..64ffb24e795e 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -3,14 +3,16 @@ Install and authenticate OpenCode on the machine running your environment, then enable it in **Settings > Providers**. See [provider setup](./install.md#providers). T3 Code requires OpenCode 1.14.19 or newer, including when you connect an existing -OpenCode server. +OpenCode server. OpenCode 2.x is supported for provider status, models, skills, +and commands; live agentic sessions against a 2.x server are not yet supported. ## Local or external server Leave **Server URL** empty to let T3 Code start OpenCode locally. A password in provider settings applies to both that server and T3 Code's connection. With no password setting, the local server uses `OPENCODE_SERVER_PASSWORD` from its -environment. +environment. OpenCode 2.x prints a generated `server password` on startup when +none is configured; T3 Code picks that up automatically. To use an existing OpenCode server, set **Server URL** and its password in provider settings. T3 Code uses only that configured password for an external server; it diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1090902dab8b..b55b68871d0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -519,6 +519,9 @@ importers: '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 + '@opencode/client': + specifier: 2.0.16 + version: 2.0.16(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5)) diff: specifier: 8.0.3 version: 8.0.3 @@ -3460,6 +3463,23 @@ packages: '@opencode-ai/sdk@1.15.13': resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} + '@opencode/client@2.0.16': + resolution: {integrity: sha512-lEZM5NY8cKVwnlIJVHiERIaIMJfWfKTlQvKKkIz7NcNEwUXlrTXOA79kS5n17HtjYer1u1XiQIIggYtEYJXHRw==} + peerDependencies: + effect: 4.0.0-rc.115 + solid-js: '>=1.9.0' + peerDependenciesMeta: + effect: + optional: true + solid-js: + optional: true + + '@opencode/protocol@2.0.16': + resolution: {integrity: sha512-TQQOXLngSH4D9Z/B6DkyioFsHyApaczjU9EdD5BJ0UtwihKs3iT5mLQlicctKzcWomNoCqIM45XEjBv+WrSynA==} + + '@opencode/schema@2.0.16': + resolution: {integrity: sha512-MXENqPxsEzv77AAQDZsyYM4kSqUSApGEKD3xQ6kDDDvm5f5SHMffcNQ+uJXbh7rFo7Fm7nzdvoDHeZwiXm2UvQ==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -14021,6 +14041,23 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@opencode/client@2.0.16(effect@4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5))': + dependencies: + '@opencode/protocol': 2.0.16 + '@opencode/schema': 2.0.16 + optionalDependencies: + effect: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) + + '@opencode/protocol@2.0.16': + dependencies: + '@opencode/schema': 2.0.16 + effect: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) + + '@opencode/schema@2.0.16': + dependencies: + '@standard-schema/spec': 1.1.0 + effect: 4.0.0-rc.115(patch_hash=0dfc4bb8ebd80fb3e06b91ef61346f5259517ab0f2437644fe95ae531084b1f5) + '@oslojs/encoding@1.1.0': {} '@oxc-parser/binding-android-arm-eabi@0.147.0':