| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | |
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { assert, it } from "@effect/vitest"; |
| 8 | import { ProviderInstanceId } from "@t3tools/contracts"; |
| 9 | import type { OpenCodeSettings } from "../settings.ts"; |
| 10 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Fiber from "effect/Fiber"; |
| 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as TestClock from "effect/testing/TestClock"; |
| 15 | import * as HttpClient from "effect/http/HttpClient"; |
| 16 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 17 | |
| 18 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 19 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 20 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 21 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 22 | import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; |
| 23 | import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; |
| 24 | import { |
| 25 | OPENCODE_2_RESPONSES, |
| 26 | OPENCODE_2_WORKSPACE_RESPONSES, |
| 27 | replayOpenCodeServer, |
| 28 | } from "./probeResponses.fixture.ts"; |
| 29 | import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts"; |
| 30 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 31 | |
| 32 | const serverStarts: Array<string> = []; |
| 33 | const reachedServer = (operation: string) => |
| 34 | Effect.sync(() => serverStarts.push(operation)).pipe( |
| 35 | Effect.andThen( |
| 36 | Effect.fail( |
| 37 | new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 38 | operation, |
| 39 | detail: "reached a 1.x server path", |
| 40 | }), |
| 41 | ), |
| 42 | ), |
| 43 | ); |
| 44 | // Reports OpenCode 2 from `--version`; any attempt to reach a server is recorded and refused. |
| 45 | const openCode2Runtime = { |
| 46 | runOpenCodeCommand: () => Effect.succeed({ stdout: "opencode v2.0.18\n", stderr: "", code: 0 }), |
| 47 | startOpenCodeServerProcess: () => reachedServer("start"), |
| 48 | connectToOpenCodeServer: () => reachedServer("connect"), |
| 49 | } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]; |
| 50 | |
| 51 | const layer = Layer.mergeAll( |
| 52 | IdAllocator.layer, |
| 53 | McpProviderSessions.layer, |
| 54 | ProviderLatestVersions.layer, |
| 55 | TestProviderHost.layer(), |
| 56 | Layer.succeed( |
| 57 | ProviderEventLoggers.ProviderEventLoggers, |
| 58 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 59 | ), |
| 60 | Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), |
| 61 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 62 | |
| 63 | const create = (config: Partial<OpenCodeSettings>, http: HttpClient.HttpClient) => |
| 64 | OpenCodeDriver.create({ |
| 65 | instanceId: ProviderInstanceId.make("opencode-test"), |
| 66 | displayName: undefined, |
| 67 | environment: [], |
| 68 | enabled: true, |
| 69 | config: { ...OpenCodeDriver.defaultConfig(), ...config }, |
| 70 | }).pipe(Effect.provideService(HttpClient.HttpClient, http)); |
| 71 | |
| 72 | const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")); |
| 73 | |
| 74 | it.layer(layer)("OpenCodeDriver runtime selection", (it) => { |
| 75 | it.effect("never starts a 1.x server for an OpenCode 2 instance", () => |
| 76 | Effect.gen(function* () { |
| 77 | serverStarts.length = 0; |
| 78 | const instance = yield* create({}, noHttp); |
| 79 | // Text generation goes to the instance's 2.x server, which this test refuses to start. |
| 80 | yield* Effect.flip( |
| 81 | instance.textGeneration.generateThreadTitle({ |
| 82 | cwd: process.cwd(), |
| 83 | message: "hello", |
| 84 | modelSelection: { instanceId: instance.instanceId, model: "opencode/big-pickle" }, |
| 85 | }), |
| 86 | ); |
| 87 | assert.deepStrictEqual(serverStarts, ["start"]); |
| 88 | }).pipe(Effect.scoped), |
| 89 | ); |
| 90 | |
| 91 | it.effect("lists an OpenCode 2 workspace's own skills and commands from its server", () => |
| 92 | Effect.gen(function* () { |
| 93 | serverStarts.length = 0; |
| 94 | const requested: Array<string> = []; |
| 95 | const server = replayOpenCodeServer( |
| 96 | { ...OPENCODE_2_RESPONSES, ...OPENCODE_2_WORKSPACE_RESPONSES }, |
| 97 | "secret", |
| 98 | requested, |
| 99 | ); |
| 100 | const instance = yield* create( |
| 101 | { serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" }, |
| 102 | server, |
| 103 | ); |
| 104 | |
| 105 | const workspace = yield* instance.snapshotForCwd!("/work"); |
| 106 | assert.includeMembers( |
| 107 | workspace.skills.map((skill) => skill.name), |
| 108 | ["plum", "opencode"], |
| 109 | ); |
| 110 | assert.deepStrictEqual( |
| 111 | workspace.slashCommands.map((command) => command.name), |
| 112 | ["compact", "init", "review", "bee"], |
| 113 | ); |
| 114 | assert.include(requested, "/api/skill"); |
| 115 | assert.deepStrictEqual(serverStarts, []); |
| 116 | }).pipe(Effect.scoped), |
| 117 | ); |
| 118 | |
| 119 | it.effect("answers capability reads without waiting on an unreachable server", () => |
| 120 | Effect.gen(function* () { |
| 121 | const hang = HttpClient.make(() => Effect.never); |
| 122 | const instance = yield* create({ serverUrl: "http://127.0.0.1:9" }, hang); |
| 123 | |
| 124 | // No probe has succeeded yet, and the server never answers: the 1.x default applies. |
| 125 | const capabilities = yield* instance.orchestrationAdapter |
| 126 | .getCapabilities() |
| 127 | .pipe(Effect.forkChild); |
| 128 | yield* Effect.yieldNow; |
| 129 | assert.isDefined(capabilities.pollUnsafe()); |
| 130 | yield* Fiber.join(capabilities); |
| 131 | }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), |
| 132 | ); |
| 133 | }); |
| 134 | |
| 135 | // npm links `<prefix>/bin/opencode` into the package that installed it: |
| 136 | // `opencode-ai` for 1.x and `@opencode/cli` (whose bin is `bin/opencode.exe`) for 2.x. |
| 137 | const npmGlobalInstall = (prefix: string, packagePath: ReadonlyArray<string>, bin: string) => { |
| 138 | const target = NodePath.join(prefix, "lib", "node_modules", ...packagePath, "bin", bin); |
| 139 | NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); |
| 140 | NodeFS.writeFileSync(target, "#!/bin/sh\n"); |
| 141 | NodeFS.chmodSync(target, 0o755); |
| 142 | const link = NodePath.join(prefix, "bin", "opencode"); |
| 143 | NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); |
| 144 | NodeFS.symlinkSync(target, link); |
| 145 | return link; |
| 146 | }; |
| 147 | const resolveUpdate = (generation: "v1" | "v2", binaryPath: string) => |
| 148 | ProviderMaintenance.resolveProviderMaintenanceCapabilitiesEffect(openCodeUpdateFor(generation), { |
| 149 | binaryPath, |
| 150 | env: { PATH: "" }, |
| 151 | }).pipe( |
| 152 | Effect.provideService( |
| 153 | ChildProcessSpawner.ChildProcessSpawner, |
| 154 | ChildProcessSpawner.make(() => Effect.die("resolving an update must not spawn")), |
| 155 | ), |
| 156 | Effect.provide(NodeServices.layer), |
| 157 | ); |
| 158 | |
| 159 | it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( |
| 160 | "updates an npm-global OpenCode 2 install as @opencode/cli and a 1.x one as opencode-ai", |
| 161 | () => |
| 162 | Effect.gen(function* () { |
| 163 | const root = NodeFS.realpathSync( |
| 164 | NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-update-")), |
| 165 | ); |
| 166 | const v2Prefix = NodePath.join(root, "v2"); |
| 167 | const v1Prefix = NodePath.join(root, "v1"); |
| 168 | const v2 = yield* resolveUpdate( |
| 169 | "v2", |
| 170 | npmGlobalInstall(v2Prefix, ["@opencode", "cli"], "opencode.exe"), |
| 171 | ); |
| 172 | assert.deepStrictEqual(v2.update?.args, [ |
| 173 | "install", |
| 174 | "-g", |
| 175 | "--prefix", |
| 176 | v2Prefix, |
| 177 | "--allow-scripts=@opencode/cli", |
| 178 | "@opencode/cli@latest", |
| 179 | ]); |
| 180 | const v1 = yield* resolveUpdate( |
| 181 | "v1", |
| 182 | npmGlobalInstall(v1Prefix, ["opencode-ai"], "opencode"), |
| 183 | ); |
| 184 | assert.strictEqual(v1.update?.args.at(-1), "opencode-ai@latest"); |
| 185 | // A 1.x install is never offered 2.x's package, or the reverse. |
| 186 | assert.isNull( |
| 187 | (yield* resolveUpdate("v2", NodePath.join(v1Prefix, "bin", "opencode"))).update, |
| 188 | ); |
| 189 | assert.isNull( |
| 190 | (yield* resolveUpdate("v1", NodePath.join(v2Prefix, "bin", "opencode"))).update, |
| 191 | ); |
| 192 | }), |
| 193 | ); |
| 194 | |
| 195 | // The update the driver offers follows the version the binary reports now. |
| 196 | const versionOutput: { current: string | undefined } = { current: undefined }; |
| 197 | const versionProbes: Array<string> = []; |
| 198 | const changingRuntime = { |
| 199 | runOpenCodeCommand: ({ binaryPath }: { readonly binaryPath: string }) => |
| 200 | Effect.sync(() => versionProbes.push(binaryPath)).pipe( |
| 201 | Effect.andThen( |
| 202 | versionOutput.current === undefined |
| 203 | ? Effect.fail( |
| 204 | new OpenCodeRuntime.OpenCodeRuntimeError({ |
| 205 | operation: "version", |
| 206 | detail: "no version", |
| 207 | }), |
| 208 | ) |
| 209 | : Effect.succeed({ stdout: versionOutput.current, stderr: "", code: 0 }), |
| 210 | ), |
| 211 | ), |
| 212 | startOpenCodeServerProcess: () => reachedServer("start"), |
| 213 | connectToOpenCodeServer: () => reachedServer("connect"), |
| 214 | } as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]; |
| 215 | const layerUpdate = Layer.mergeAll( |
| 216 | IdAllocator.layer, |
| 217 | McpProviderSessions.layer, |
| 218 | ProviderLatestVersions.layer, |
| 219 | TestProviderHost.layer(), |
| 220 | Layer.succeed( |
| 221 | ProviderEventLoggers.ProviderEventLoggers, |
| 222 | ProviderEventLoggers.NoOpProviderEventLoggers, |
| 223 | ), |
| 224 | Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, changingRuntime), |
| 225 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 226 | |
| 227 | it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { |
| 228 | it.effect("never runs the binary for a disabled instance's update check", () => |
| 229 | Effect.gen(function* () { |
| 230 | versionProbes.length = 0; |
| 231 | versionOutput.current = "opencode v2.0.18\n"; |
| 232 | const instance = yield* OpenCodeDriver.create({ |
| 233 | instanceId: ProviderInstanceId.make("opencode-disabled"), |
| 234 | displayName: undefined, |
| 235 | environment: [], |
| 236 | enabled: false, |
| 237 | config: { ...OpenCodeDriver.defaultConfig(), binaryPath: "opencode" }, |
| 238 | }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); |
| 239 | const maintenance = yield* instance.snapshot.resolveMaintenance(); |
| 240 | yield* instance.snapshot.resolveMaintenance({ fresh: true }); |
| 241 | assert.isNull(maintenance.update); |
| 242 | assert.deepStrictEqual(versionProbes, []); |
| 243 | }).pipe(Effect.scoped), |
| 244 | ); |
| 245 | |
| 246 | it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( |
| 247 | "offers no package update for an unknown version and follows a changed one on a fresh read", |
| 248 | () => |
| 249 | Effect.gen(function* () { |
| 250 | const root = NodeFS.realpathSync( |
| 251 | NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-opencode-driver-update-")), |
| 252 | ); |
| 253 | const binaryPath = npmGlobalInstall(NodePath.join(root, "v1"), ["opencode-ai"], "opencode"); |
| 254 | const instance = yield* OpenCodeDriver.create({ |
| 255 | instanceId: ProviderInstanceId.make("opencode-update"), |
| 256 | displayName: undefined, |
| 257 | environment: [], |
| 258 | enabled: true, |
| 259 | config: { ...OpenCodeDriver.defaultConfig(), binaryPath }, |
| 260 | }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); |
| 261 | |
| 262 | // The version could not be read: which package owns the binary is unknown. |
| 263 | versionOutput.current = undefined; |
| 264 | const unknown = yield* instance.snapshot.resolveMaintenance({ fresh: true }); |
| 265 | assert.isNull(unknown.update); |
| 266 | assert.isNull(unknown.packageName); |
| 267 | |
| 268 | versionOutput.current = "1.18.32\n"; |
| 269 | const v1 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); |
| 270 | assert.strictEqual(v1.packageName, "opencode-ai"); |
| 271 | |
| 272 | // The same path now reports 2.x (reinstalled in place): a fresh read re-probes |
| 273 | // and never offers 1.x's package for it. |
| 274 | versionOutput.current = "opencode v2.0.18\n"; |
| 275 | const v2 = yield* instance.snapshot.resolveMaintenance({ fresh: true }); |
| 276 | assert.strictEqual(v2.packageName, "@opencode/cli"); |
| 277 | assert.isNull(v2.update); |
| 278 | }).pipe(Effect.scoped), |
| 279 | ); |
| 280 | }); |