1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { assert, it } from "@effect/vitest";
8import { ProviderInstanceId } from "@t3tools/contracts";
9import type { OpenCodeSettings } from "../settings.ts";
10import * as HostProcess from "@t3tools/shared/HostProcess";
11import * as Effect from "effect/Effect";
12import * as Fiber from "effect/Fiber";
13import * as Layer from "effect/Layer";
14import * as TestClock from "effect/testing/TestClock";
15import * as HttpClient from "effect/http/HttpClient";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17
18import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
19import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
20import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
21import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
22import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver";
23import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
24import {
25 OPENCODE_2_RESPONSES,
26 OPENCODE_2_WORKSPACE_RESPONSES,
27 replayOpenCodeServer,
28} from "./probeResponses.fixture.ts";
29import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts";
30import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
31
32const serverStarts: Array<string> = [];
33const 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.
45const 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
51const 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
63const 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
72const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP"));
73
74it.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.
137const 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};
147const 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
159it.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.
196const versionOutput: { current: string | undefined } = { current: undefined };
197const versionProbes: Array<string> = [];
198const 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"];
215const 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
227it.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});