1import * as NodeAssert from "node:assert/strict";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Schema from "effect/Schema";
11import * as TestClock from "effect/testing/TestClock";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientResponse from "effect/http/HttpClientResponse";
14import { beforeEach } from "vite-plus/test";
15
16import { OpenCodeSettings } from "../settings.ts";
17import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
18import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
19import {
20 checkOpenCodeProviderStatus,
21 loadOpenCode2Workspace,
22 makeOpenCode2ModelLoader,
23 type OpenCode2Model,
24 type OpenCode2Workspace,
25 openCode2CommandsToServerProviderSlashCommands,
26 openCode2SkillsToServerProviderSkills,
27 openCodeCommandsToServerProviderSlashCommands,
28} from "./status.ts";
29import { readOpenCodeGoUsageLimits } from "./usageLimits.ts";
30import { probeOpenCodeRuntime } from "./versionProbe.ts";
31import {
32 OPENCODE_1_RESPONSES,
33 OPENCODE_2_RESPONSES,
34 replayOpenCodeServer,
35} from "./probeResponses.fixture.ts";
36const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
37
38const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
39
40it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () =>
41 Effect.gen(function* () {
42 const resetsAt = "2026-09-17T12:00:00.000Z";
43 const limits = yield* readOpenCodeGoUsageLimits({
44 enabled: true,
45 serverUrl: "",
46 environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" },
47 }).pipe(
48 Effect.provideService(
49 FileSystem.FileSystem,
50 FileSystem.makeNoop({
51 readFileString: (path) => {
52 NodeAssert.equal(path, "/instance/data/opencode/auth.json");
53 return Effect.succeed(
54 JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }),
55 );
56 },
57 }),
58 ),
59 Effect.provideService(
60 HttpClient.HttpClient,
61 HttpClient.make((request) => {
62 NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage");
63 NodeAssert.equal(request.headers.authorization, "Bearer instance-key");
64 return Effect.succeed(
65 HttpClientResponse.fromWeb(
66 request,
67 Response.json({
68 usage: {
69 rolling: { percent: 0, resetsAt },
70 weekly: { percent: 10, resetsAt },
71 monthly: { percent: 125, resetsAt },
72 },
73 }),
74 ),
75 );
76 }),
77 ),
78 Effect.provide(NodeServices.layer),
79 );
80 NodeAssert.equal(limits.unavailable, undefined);
81 NodeAssert.equal(
82 limits.credentialFingerprint,
83 // SHA-256 of "opencode-go\0instance-key".
84 "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37",
85 );
86 NodeAssert.deepEqual(
87 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({
88 kind,
89 usedPercent,
90 reset,
91 })),
92 [
93 { kind: "session", usedPercent: 0, reset: resetsAt },
94 { kind: "weekly", usedPercent: 10, reset: resetsAt },
95 { kind: "monthly", usedPercent: 100, reset: resetsAt },
96 ],
97 );
98 }),
99);
100
101it.effect("does not read local credentials for external or disabled OpenCode instances", () =>
102 Effect.gen(function* () {
103 for (const settings of [
104 { enabled: true, serverUrl: "https://remote.example" },
105 { enabled: false, serverUrl: "" },
106 ]) {
107 const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe(
108 Effect.provideService(
109 FileSystem.FileSystem,
110 FileSystem.makeNoop({
111 readFileString: () => Effect.die("unexpected credential read"),
112 }),
113 ),
114 Effect.provideService(
115 HttpClient.HttpClient,
116 HttpClient.make(() => Effect.die("unexpected usage request")),
117 ),
118 Effect.provide(NodeServices.layer),
119 );
120 NodeAssert.equal(limits.unavailable?.reason, "unsupported");
121 }
122 }),
123);
124
125it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () =>
126 Effect.gen(function* () {
127 for (const [status, reason] of [
128 [403, "unsupported"],
129 [401, "probeFailed"],
130 [200, "probeFailed"],
131 ] as const) {
132 const limits = yield* readOpenCodeGoUsageLimits({
133 enabled: true,
134 serverUrl: "",
135 environment: {
136 OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}',
137 },
138 }).pipe(
139 Effect.provideService(
140 FileSystem.FileSystem,
141 FileSystem.makeNoop({
142 readFileString: () => Effect.die("inline credentials must bypass disk"),
143 }),
144 ),
145 Effect.provideService(
146 HttpClient.HttpClient,
147 HttpClient.make((request) =>
148 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))),
149 ),
150 ),
151 Effect.provide(NodeServices.layer),
152 );
153 NodeAssert.equal(limits.unavailable?.reason, reason);
154 NodeAssert.equal(limits.credentialFingerprint, undefined);
155 NodeAssert.deepEqual(limits.windows, []);
156 }
157 }),
158);
159
160/**
161 * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag
162 * are deleted. The snapshot-producing logic they wrapped now lives in the
163 * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which
164 * drivers call directly when building their per-instance snapshot
165 * `ManagedServerProvider`. Tests mirror that shape: build a settings payload,
166 * invoke the check, assert on the returned snapshot.
167 */
168
169const runtimeMock = {
170 state: {
171 runVersionError: null as Error | null,
172 runVersionPending: false,
173 versionStdout: DEFAULT_VERSION_STDOUT,
174 inventoryError: null as Error | null,
175 connectionError: null as Error | null,
176 inventoryCwd: null as string | null,
177 closeCalls: 0,
178 sdkClientInputs: [] as Array<{
179 baseUrl: string;
180 directory: string;
181 serverPassword?: string;
182 }>,
183 inventory: {
184 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
185 agents: [] as unknown[],
186 skills: [] as unknown[],
187 } as unknown,
188 },
189 reset() {
190 this.state.runVersionError = null;
191 this.state.runVersionPending = false;
192 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
193 this.state.inventoryError = null;
194 this.state.connectionError = null;
195 this.state.inventoryCwd = null;
196 this.state.closeCalls = 0;
197 this.state.sdkClientInputs.length = 0;
198 this.state.inventory = {
199 providerList: { connected: [], all: [] as unknown[], default: {} },
200 agents: [] as unknown[],
201 skills: [] as unknown[],
202 };
203 },
204};
205
206const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
207 startOpenCodeServerProcess: ({ serverPassword, environment }) =>
208 Effect.gen(function* () {
209 yield* Effect.addFinalizer(() =>
210 Effect.sync(() => {
211 runtimeMock.state.closeCalls += 1;
212 }),
213 );
214 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
215 external: false,
216 ...(serverPassword !== undefined ? { serverPassword } : {}),
217 ...(environment !== undefined ? { environment } : {}),
218 });
219 return {
220 url: "http://127.0.0.1:4301",
221 ...(effectiveServerPassword !== undefined
222 ? { serverPassword: effectiveServerPassword }
223 : {}),
224 version: "1.14.19",
225 isRunning: Effect.succeed(true),
226 exitCode: Effect.never,
227 };
228 }),
229 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
230 Effect.gen(function* () {
231 if (runtimeMock.state.connectionError) {
232 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
233 operation: "global.health",
234 detail: runtimeMock.state.connectionError.message,
235 cause: runtimeMock.state.connectionError,
236 });
237 }
238 if (!serverUrl) {
239 yield* Effect.addFinalizer(() =>
240 Effect.sync(() => {
241 runtimeMock.state.closeCalls += 1;
242 }),
243 );
244 }
245 return {
246 url: serverUrl ?? "http://127.0.0.1:4301",
247 ...(serverPassword ? { serverPassword } : {}),
248 version: "1.14.19",
249 exitCode: null,
250 external: Boolean(serverUrl),
251 };
252 }),
253 runOpenCodeCommand: () =>
254 runtimeMock.state.runVersionPending
255 ? Effect.never
256 : runtimeMock.state.runVersionError
257 ? Effect.fail(
258 new OpenCodeRuntime.OpenCodeRuntimeError({
259 operation: "runOpenCodeCommand",
260 detail: runtimeMock.state.runVersionError.message,
261 cause: runtimeMock.state.runVersionError,
262 }),
263 )
264 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
265 createOpenCodeSdkClient: (input) => {
266 runtimeMock.state.sdkClientInputs.push(input);
267 return {} as unknown as ReturnType<
268 OpenCodeRuntime.OpenCodeRuntime["Service"]["createOpenCodeSdkClient"]
269 >;
270 },
271 loadOpenCodeInventory: () =>
272 runtimeMock.state.inventoryError
273 ? Effect.fail(
274 new OpenCodeRuntime.OpenCodeRuntimeError({
275 operation: "loadOpenCodeInventory",
276 detail: runtimeMock.state.inventoryError.message,
277 cause: runtimeMock.state.inventoryError,
278 }),
279 )
280 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory),
281 loadInventoryFromCli: ({ cwd }) => {
282 runtimeMock.state.inventoryCwd = cwd;
283 return runtimeMock.state.inventoryError
284 ? Effect.fail(
285 new OpenCodeRuntime.OpenCodeRuntimeError({
286 operation: "loadInventoryFromCli",
287 detail: runtimeMock.state.inventoryError.message,
288 cause: runtimeMock.state.inventoryError,
289 }),
290 )
291 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory);
292 },
293 loadOpenCodeSkills: () => Effect.succeed([]),
294 loadSkillsFromCli: () => Effect.succeed([]),
295};
296
297beforeEach(() => {
298 runtimeMock.reset();
299});
300
301it("keeps native and MCP commands while preserving compaction and separate skills", () => {
302 NodeAssert.deepEqual(
303 openCodeCommandsToServerProviderSlashCommands([
304 { name: "review", description: "Review changes", source: "command", hints: ["$ARGUMENTS"] },
305 { name: "review", source: "command", hints: [] },
306 { name: "compact", source: "command", hints: [] },
307 { name: "skill", source: "skill", hints: [] },
308 { name: "mcp:search", source: "mcp", hints: ["query"] },
309 ]).slice(1),
310 [
311 { name: "review", description: "Review changes", input: { hint: "$ARGUMENTS" } },
312 { name: "mcp:search", input: { hint: "query" } },
313 ],
314 );
315});
316
317const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe(
318 Layer.provideMerge(NodeServices.layer),
319);
320
321const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>): OpenCodeSettings =>
322 decodeOpenCodeSettings({
323 enabled: true,
324 binaryPath: "opencode",
325 serverUrl: "",
326 serverPassword: "",
327 customModels: [],
328 ...overrides,
329 });
330
331const checkProvider = Effect.fn("checkProvider")(function* (
332 settings: OpenCodeSettings,
333 cwd = process.cwd(),
334 environment?: NodeJS.ProcessEnv,
335 server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword),
336 openCode2Models: Effect.Effect<
337 ReadonlyArray<OpenCode2Model>,
338 OpenCodeRuntime.OpenCodeRuntimeError
339 > = Effect.succeed([]),
340) {
341 return yield* Effect.scoped(
342 Effect.gen(function* () {
343 const serverOwner = yield* OpenCodeServerOwner.make({
344 binaryPath: settings.binaryPath,
345 directory: cwd,
346 ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
347 ...(environment ? { environment } : {}),
348 });
349 const probe = probeOpenCodeRuntime(settings, environment).pipe(
350 Effect.provideService(HttpClient.HttpClient, server),
351 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble),
352 );
353 return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe(
354 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
355 );
356 }),
357 );
358});
359
360it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
361 it.effect("shows a codex-style missing binary message", () =>
362 Effect.gen(function* () {
363 runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT");
364 const snapshot = yield* checkProvider(makeOpenCodeSettings());
365
366 NodeAssert.equal(snapshot.status, "error");
367 NodeAssert.equal(snapshot.installed, false);
368 NodeAssert.equal(
369 snapshot.message,
370 "OpenCode CLI (`opencode`) is not installed or not on PATH.",
371 );
372 }),
373 );
374
375 it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () =>
376 Effect.gen(function* () {
377 runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise");
378 const snapshot = yield* checkProvider(makeOpenCodeSettings());
379
380 NodeAssert.equal(snapshot.status, "error");
381 NodeAssert.equal(snapshot.installed, true);
382 NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check.");
383 }),
384 );
385
386 it.effect("times out a hanging local CLI version probe", () =>
387 Effect.gen(function* () {
388 runtimeMock.state.runVersionPending = true;
389 const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild);
390
391 yield* Effect.yieldNow;
392 yield* TestClock.adjust("4 seconds");
393 const snapshot = yield* Fiber.join(probeFiber);
394
395 NodeAssert.equal(snapshot.status, "error");
396 NodeAssert.equal(snapshot.installed, true);
397 NodeAssert.equal(
398 snapshot.message,
399 "Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
400 );
401 }).pipe(Effect.provide(TestClock.layer())),
402 );
403
404 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
405 Effect.gen(function* () {
406 runtimeMock.state.inventory = {
407 providerList: {
408 connected: ["openai"],
409 all: [
410 {
411 id: "openai",
412 name: "OpenAI",
413 models: {
414 "gpt-5.4": {
415 id: "gpt-5.4",
416 name: "GPT-5.4",
417 variants: {
418 none: {},
419 low: {},
420 medium: {},
421 high: {},
422 xhigh: {},
423 },
424 },
425 },
426 },
427 ],
428 default: {},
429 },
430 agents: [
431 { name: "build", hidden: false, mode: "primary" },
432 { name: "plan", hidden: false, mode: "primary" },
433 ],
434 };
435
436 const snapshot = yield* checkProvider(makeOpenCodeSettings());
437 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
438
439 NodeAssert.ok(model);
440 const variantDescriptor = model.capabilities?.optionDescriptors?.find(
441 (descriptor) => descriptor.id === "variant" && descriptor.type === "select",
442 );
443 NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select");
444 NodeAssert.equal(
445 variantDescriptor.options.find((option) => option.isDefault === true)?.id,
446 "medium",
447 );
448 const agentDescriptor = model.capabilities?.optionDescriptors?.find(
449 (descriptor) => descriptor.id === "agent" && descriptor.type === "select",
450 );
451 NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select");
452 NodeAssert.equal(
453 agentDescriptor.options.find((option) => option.isDefault === true)?.id,
454 "build",
455 );
456 }),
457 );
458
459 it.effect("includes OpenCode skills in the provider snapshot", () =>
460 Effect.gen(function* () {
461 runtimeMock.state.inventory = {
462 providerList: {
463 connected: ["openai"],
464 all: [
465 {
466 id: "openai",
467 name: "OpenAI",
468 models: {
469 "gpt-5.4": {
470 id: "gpt-5.4",
471 name: "GPT-5.4",
472 variants: {},
473 },
474 },
475 },
476 ],
477 default: {},
478 },
479 agents: [],
480 skills: [
481 {
482 name: "openclaw-review",
483 description: "Review OpenClaw workflow changes.",
484 location: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
485 },
486 {
487 name: "openclaw-triage",
488 description: "Triage OpenClaw routing issues.",
489 location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
490 },
491 {
492 name: "missing-location",
493 description: "This incomplete SDK row should be skipped.",
494 location: "",
495 },
496 ],
497 };
498
499 const snapshot = yield* checkProvider(makeOpenCodeSettings());
500
501 NodeAssert.deepEqual(
502 snapshot.skills.map((skill) => ({
503 name: skill.name,
504 path: skill.path,
505 enabled: skill.enabled,
506 shortDescription: skill.shortDescription,
507 })),
508 [
509 {
510 name: "openclaw-review",
511 path: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
512 enabled: true,
513 shortDescription: "Review OpenClaw workflow changes.",
514 },
515 {
516 name: "openclaw-triage",
517 path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
518 enabled: true,
519 shortDescription: "Triage OpenClaw routing issues.",
520 },
521 ],
522 );
523 }),
524 );
525
526 it.effect("loads local inventory from a scoped OpenCode server", () =>
527 Effect.gen(function* () {
528 yield* checkProvider(makeOpenCodeSettings({ serverPassword: "secret-password" }));
529
530 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
531 {
532 baseUrl: "http://127.0.0.1:4301",
533 directory: process.cwd(),
534 serverPassword: "secret-password",
535 },
536 ]);
537 NodeAssert.equal(runtimeMock.state.closeCalls, 1);
538 NodeAssert.equal(runtimeMock.state.inventoryCwd, null);
539 }),
540 );
541
542 it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () =>
543 Effect.gen(function* () {
544 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
545 const snapshot = yield* checkProvider(
546 makeOpenCodeSettings(),
547 process.cwd(),
548 undefined,
549 undefined,
550 Effect.succeed([
551 { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] },
552 {
553 providerID: "opencode",
554 id: "space-bunny-free",
555 name: "Space Bunny Free",
556 variants: [{ id: "low" }, { id: "medium" }, { id: "high" }],
557 },
558 ]),
559 );
560
561 NodeAssert.equal(snapshot.status, "ready");
562 NodeAssert.equal(snapshot.version, "2.0.18");
563 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
564 "approval-required",
565 "auto-accept-edits",
566 "auto",
567 "full-access",
568 ]);
569 // Plan mode runs as OpenCode's plan agent, and every session can compact.
570 NodeAssert.equal(snapshot.showInteractionModeToggle, true);
571 NodeAssert.deepEqual(
572 snapshot.slashCommands.map((command) => command.name),
573 ["compact"],
574 );
575 NodeAssert.deepEqual(
576 snapshot.models.map((model) => model.slug),
577 ["opencode/big-pickle", "opencode/space-bunny-free"],
578 );
579 const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free")
580 ?.capabilities?.optionDescriptors?.[0];
581 NodeAssert.deepEqual(
582 variant?.type === "select" ? variant.options.map((option) => option.id) : [],
583 ["low", "medium", "high"],
584 );
585 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
586 }),
587 );
588
589 it.effect("reports a failed OpenCode 2 model list without the server's response", () =>
590 Effect.gen(function* () {
591 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
592 const snapshot = yield* checkProvider(
593 makeOpenCodeSettings(),
594 process.cwd(),
595 undefined,
596 undefined,
597 Effect.fail(
598 new OpenCodeRuntime.OpenCodeRuntimeError({
599 operation: "model.list",
600 detail: 'status=500 body={"token":"leaked-response-body"}',
601 }),
602 ),
603 );
604
605 NodeAssert.equal(snapshot.status, "error");
606 NodeAssert.equal(snapshot.message, "OpenCode could not load its model list.");
607 // A failed catalog read still leaves the session able to compact.
608 NodeAssert.deepEqual(
609 snapshot.slashCommands.map((command) => command.name),
610 ["compact"],
611 );
612 }),
613 );
614
615 it.effect("uses an environment-only password for local inventory", () =>
616 Effect.gen(function* () {
617 yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {
618 OPENCODE_SERVER_PASSWORD: "environment-password",
619 });
620
621 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
622 {
623 baseUrl: "http://127.0.0.1:4301",
624 directory: process.cwd(),
625 serverPassword: "environment-password",
626 },
627 ]);
628 }),
629 );
630
631 it.effect("uses the settings password when local environment auth differs", () =>
632 Effect.gen(function* () {
633 yield* checkProvider(
634 makeOpenCodeSettings({ serverPassword: "settings-password" }),
635 process.cwd(),
636 { OPENCODE_SERVER_PASSWORD: "environment-password" },
637 );
638
639 NodeAssert.equal(runtimeMock.state.sdkClientInputs[0]?.serverPassword, "settings-password");
640 }),
641 );
642
643 it.effect("reports local model inventory failures without treating them as empty", () =>
644 Effect.gen(function* () {
645 runtimeMock.state.inventoryError = new Error("opencode models failed");
646 const snapshot = yield* checkProvider(makeOpenCodeSettings());
647
648 NodeAssert.equal(snapshot.status, "error");
649 NodeAssert.equal(snapshot.installed, true);
650 NodeAssert.equal(snapshot.models.length, 0);
651 NodeAssert.equal(
652 snapshot.message,
653 "Failed to load OpenCode provider inventory: opencode models failed",
654 );
655 }),
656 );
657});
658
659it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => {
660 it.effect("does not send a local environment password to a configured server", () =>
661 Effect.gen(function* () {
662 const snapshot = yield* checkProvider(
663 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
664 process.cwd(),
665 { OPENCODE_SERVER_PASSWORD: "local-secret" },
666 );
667
668 NodeAssert.equal(snapshot.version, "1.14.19");
669 NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
670 {
671 baseUrl: "http://127.0.0.1:9999",
672 directory: process.cwd(),
673 },
674 ]);
675 }),
676 );
677
678 it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () =>
679 Effect.gen(function* () {
680 const settings = makeOpenCodeSettings({
681 serverUrl: "http://127.0.0.1:9999",
682 serverPassword: "secret-password",
683 });
684 const snapshot = yield* checkProvider(
685 settings,
686 process.cwd(),
687 undefined,
688 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
689 );
690
691 NodeAssert.equal(snapshot.status, "warning");
692 NodeAssert.equal(snapshot.version, "2.0.18");
693 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
694 "approval-required",
695 "auto-accept-edits",
696 "auto",
697 "full-access",
698 ]);
699 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
700 }),
701 );
702
703 it.effect("labels each model with the sub-provider that listed it", () =>
704 Effect.gen(function* () {
705 // Names come from the server's provider list; see openCode2Catalog.test.ts
706 // for reading that list itself.
707 const snapshot = yield* checkProvider(
708 makeOpenCodeSettings({
709 serverUrl: "http://127.0.0.1:9999",
710 serverPassword: "secret-password",
711 }),
712 process.cwd(),
713 undefined,
714 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
715 Effect.succeed([
716 {
717 providerID: "openai",
718 id: "gpt-5.4",
719 name: "GPT-5.4",
720 providerName: "OpenAI",
721 variants: [],
722 },
723 {
724 providerID: "custom",
725 id: "m1",
726 name: "Custom 1",
727 providerName: "OpenCode",
728 variants: [],
729 },
730 { providerID: "anthropic", id: "claude", name: "Claude", variants: [] },
731 ]),
732 );
733
734 const subProviderFor = (slug: string) =>
735 snapshot.models.find((model) => model.slug === slug)?.subProvider;
736 NodeAssert.equal(subProviderFor("openai/gpt-5.4"), "OpenAI");
737 // A provider called plain "OpenCode" would repeat the instance name.
738 NodeAssert.equal(subProviderFor("custom/m1"), undefined);
739 // The id stands in until the server names its providers.
740 NodeAssert.equal(subProviderFor("anthropic/claude"), "anthropic");
741 }),
742 );
743
744 it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () =>
745 Effect.gen(function* () {
746 const snapshot = yield* checkProvider(
747 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }),
748 process.cwd(),
749 undefined,
750 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
751 );
752
753 NodeAssert.equal(snapshot.status, "error");
754 NodeAssert.equal(
755 snapshot.message,
756 "OpenCode server rejected authentication. Check the server URL and password.",
757 );
758 }),
759 );
760
761 it.effect("rejects an unsupported server before loading inventory", () =>
762 Effect.gen(function* () {
763 runtimeMock.state.connectionError = new Error(
764 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
765 );
766 const snapshot = yield* checkProvider(
767 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
768 );
769
770 NodeAssert.equal(snapshot.status, "error");
771 NodeAssert.equal(snapshot.models.length, 0);
772 NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/);
773 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
774 }),
775 );
776
777 it.effect("surfaces a friendly auth error for configured servers", () =>
778 Effect.gen(function* () {
779 runtimeMock.state.connectionError = new Error("401 Unauthorized");
780 const snapshot = yield* checkProvider(
781 makeOpenCodeSettings({
782 serverUrl: "http://127.0.0.1:9999",
783 serverPassword: "secret-password",
784 }),
785 );
786
787 NodeAssert.equal(snapshot.status, "error");
788 NodeAssert.equal(snapshot.installed, true);
789 NodeAssert.equal(
790 snapshot.message,
791 "OpenCode server rejected authentication. Check the server URL and password.",
792 );
793 }),
794 );
795
796 it.effect("surfaces a friendly connection error for configured servers", () =>
797 Effect.gen(function* () {
798 runtimeMock.state.connectionError = new Error(
799 "fetch failed: connect ECONNREFUSED 127.0.0.1:9999",
800 );
801 const snapshot = yield* checkProvider(
802 makeOpenCodeSettings({
803 serverUrl: "http://127.0.0.1:9999",
804 serverPassword: "secret-password",
805 }),
806 );
807
808 NodeAssert.equal(snapshot.status, "error");
809 NodeAssert.equal(snapshot.installed, true);
810 NodeAssert.equal(
811 snapshot.message,
812 "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.",
813 );
814 }),
815 );
816});
817
818const bigPickle: OpenCode2Model = {
819 providerID: "opencode",
820 id: "big-pickle",
821 name: "Big Pickle",
822 variants: [],
823};
824
825it.effect("waits for a fresh OpenCode 2 server to list its models", () =>
826 Effect.gen(function* () {
827 // A fresh server lists nothing until its catalog loads.
828 const replies: Array<ReadonlyArray<OpenCode2Model>> = [[], [], [bigPickle]];
829 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => replies.shift() ?? [bigPickle]));
830 const fiber = yield* load.pipe(Effect.forkChild);
831 yield* TestClock.adjust("1 second");
832 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
833 }).pipe(Effect.provide(TestClock.layer())),
834);
835
836it.effect("keeps the last OpenCode 2 model list while a fresh server's stays empty", () =>
837 Effect.gen(function* () {
838 let listed: ReadonlyArray<OpenCode2Model> = [bigPickle];
839 const load = yield* makeOpenCode2ModelLoader(Effect.sync(() => listed));
840 NodeAssert.deepEqual(yield* load, [bigPickle]);
841 listed = [];
842 const fiber = yield* load.pipe(Effect.forkChild);
843 yield* TestClock.adjust("6 seconds");
844 NodeAssert.deepEqual(yield* Fiber.join(fiber), [bigPickle]);
845 }).pipe(Effect.provide(TestClock.layer())),
846);
847
848// What 2.0.18 lists for a directory once it has scanned it (live, 2026-09-29).
849const builtinCommands = [
850 { name: "init", description: "guided AGENTS.md setup" },
851 { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" },
852];
853const builtinSkills = [
854 { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" },
855 { id: "report", name: "Report", path: "/builtin/report.md" },
856];
857const scanned: OpenCode2Workspace = {
858 commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }],
859 skills: [
860 ...builtinSkills,
861 {
862 id: "greet",
863 name: "greet",
864 path: "/work/.opencode/skills/greet/SKILL.md",
865 description: "Greets the user with the secret word MANGO.",
866 },
867 ],
868};
869
870it.effect("reads a fresh OpenCode 2 directory again once its scan has ended", () =>
871 Effect.gen(function* () {
872 // A directory the server has not served yet lists no commands until its scan ends.
873 const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned];
874 const scanEnded = yield* Deferred.make<void>();
875 const load = yield* loadOpenCode2Workspace(
876 Effect.sync(() => reads.shift()!),
877 Deferred.await(scanEnded),
878 ).pipe(Effect.forkChild);
879 yield* Effect.yieldNow;
880 NodeAssert.equal(reads.length, 1);
881 yield* Deferred.succeed(scanEnded, undefined);
882 NodeAssert.deepEqual(yield* Fiber.join(load), scanned);
883 }),
884);
885
886it.effect("takes a served OpenCode 2 directory's first listing as is", () =>
887 Effect.gen(function* () {
888 const workspace = yield* loadOpenCode2Workspace(Effect.succeed(scanned), Effect.never);
889 NodeAssert.deepEqual(workspace, scanned);
890 }),
891);
892
893it("lists an OpenCode 2 directory's skills by id and its commands after /compact", () => {
894 NodeAssert.deepEqual(
895 openCode2SkillsToServerProviderSkills(scanned.skills).map((skill) => [
896 skill.name,
897 skill.displayName,
898 skill.path,
899 ]),
900 [
901 ["greet", undefined, "/work/.opencode/skills/greet/SKILL.md"],
902 ["opencode", "OpenCode", "/builtin/opencode.md"],
903 ["report", "Report", "/builtin/report.md"],
904 ],
905 );
906 NodeAssert.deepEqual(
907 openCode2CommandsToServerProviderSlashCommands([
908 ...scanned.commands,
909 { name: "compact", description: "a workspace command named like T3's own" },
910 ]).map((command) => command.name),
911 ["compact", "init", "review", "hello"],
912 );
913});