Open History to see the last commit
1import * as NodeAssert from "node:assert/strict";
2import * as NodeSqlite from "node:sqlite";
3
4import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Logger from "effect/Logger";
13import * as Schema from "effect/Schema";
14import * as Tracer from "effect/Tracer";
15import * as TestClock from "effect/testing/TestClock";
16import * as HttpClient from "effect/http/HttpClient";
17import * as HttpClientResponse from "effect/http/HttpClientResponse";
18import { beforeEach } from "vite-plus/test";
19import { buildProviderOptionSelectionsFromDescriptors } from "@t3tools/shared/model";
20import { settingsSecretLiteral } from "@t3tools/contracts";
21
22import { OpenCodeSettings } from "../settings.ts";
23import * as OpenCodeRuntime from "./OpenCodeRuntime.ts";
24import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts";
25import {
26 checkOpenCodeProviderStatus,
27 loadOpenCode2Workspace,
28 makeOpenCode2ModelLoader,
29 type OpenCode2Model,
30 type OpenCode2Workspace,
31} from "./status.ts";
32import { readOpenCodeGoUsageLimits } from "./usageLimits.ts";
33import { probeOpenCodeRuntime } from "./versionProbe.ts";
34import {
35 OPENCODE_1_RESPONSES,
36 OPENCODE_2_RESPONSES,
37 replayOpenCodeServer,
38} from "./probeResponses.fixture.ts";
39const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
40
41const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
42
43it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () =>
44 Effect.gen(function* () {
45 const resetsAt = "2026-09-17T12:00:00.000Z";
46 const limits = yield* readOpenCodeGoUsageLimits({
47 enabled: true,
48 serverUrl: "",
49 environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" },
50 }).pipe(
51 Effect.provideService(
52 FileSystem.FileSystem,
53 FileSystem.makeNoop({
54 readFileString: (path) => {
55 NodeAssert.equal(path, "/instance/data/opencode/auth.json");
56 return Effect.succeed(
57 JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }),
58 );
59 },
60 }),
61 ),
62 Effect.provideService(
63 HttpClient.HttpClient,
64 HttpClient.make((request) => {
65 NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage");
66 NodeAssert.equal(request.headers.authorization, "Bearer instance-key");
67 return Effect.succeed(
68 HttpClientResponse.fromWeb(
69 request,
70 Response.json({
71 usage: {
72 rolling: { percent: 0, resetsAt },
73 weekly: { percent: 10, resetsAt },
74 monthly: { percent: 125, resetsAt },
75 },
76 }),
77 ),
78 );
79 }),
80 ),
81 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
82 );
83 NodeAssert.equal(limits.unavailable, undefined);
84 NodeAssert.equal(
85 limits.credentialFingerprint,
86 "aba48e85c981a8edc1c9fb4575121accc7235fa55d0f8689f66f153de5566a37",
87 );
88 NodeAssert.deepEqual(
89 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({
90 kind,
91 usedPercent,
92 reset,
93 })),
94 [
95 { kind: "session", usedPercent: 0, reset: resetsAt },
96 { kind: "weekly", usedPercent: 10, reset: resetsAt },
97 { kind: "monthly", usedPercent: 100, reset: resetsAt },
98 ],
99 );
100 }),
101);
102
103it.effect(
104 "uses the selected public Console key for Go limits, falling back only on auth rejection",
105 () =>
106 Effect.gen(function* () {
107 const fs = yield* FileSystem.FileSystem;
108 const dataHome = yield* fs.makeTempDirectoryScoped();
109 yield* fs.makeDirectory(`${dataHome}/opencode`);
110 yield* fs.writeFileString(
111 `${dataHome}/opencode/auth.json`,
112 JSON.stringify({ "opencode-go": { type: "api", key: "go-key" } }),
113 );
114 const database = yield* Effect.acquireRelease(
115 Effect.sync(() => new NodeSqlite.DatabaseSync(`${dataHome}/opencode/opencode.db`)),
116 (database) => Effect.sync(() => database.close()),
117 );
118 database.exec(
119 "CREATE TABLE credential (id text PRIMARY KEY, integration_id text, value text, active integer, time_created integer, time_updated integer)",
120 );
121 const insert = database.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?)");
122 const window = { percent: 1, resetsAt: "2026-09-17T12:00:00.000Z" };
123 const consoleKey = { type: "key", key: "console-key" };
124 const fingerprints = new Map([
125 ["console-key", "3e45cfde9158185930d4cc7020a4e390895574fd54bd8f9631d23a336bbf94f0"],
126 ["go-key", "d0e590e93fc9bd9353f2b993c0c287391cae505da2a78cb698b75418d2f5367e"],
127 ["inline-key", "85649de18e63305548b6407e185f708483727ee6189cc4bc9512ddb17e23e776"],
128 ]);
129 for (const {
130 consoleRow = consoleKey,
131 active = 1,
132 consoleStatus = 200,
133 inline = false,
134 databaseFile,
135 requested,
136 } of [
137 {
138 consoleRow: { ...consoleKey, metadata: { server: "https://opencode.ai/console" } },
139 requested: ["console-key"],
140 },
141 { consoleStatus: 401, requested: ["console-key", "go-key"] },
142 { consoleStatus: 403, requested: ["console-key", "go-key"] },
143 { consoleStatus: 500, requested: ["console-key"] },
144 { active: 0, requested: ["go-key"] },
145 {
146 consoleRow: {
147 type: "oauth",
148 methodID: "device",
149 access: "token",
150 refresh: "refresh",
151 expires: 0,
152 },
153 requested: ["go-key"],
154 },
155 {
156 consoleRow: { ...consoleKey, metadata: { server: "https://console.example" } },
157 requested: ["go-key"],
158 },
159 { inline: true, requested: ["inline-key"] },
160 { databaseFile: "selected.db", requested: ["console-key"] },
161 { databaseFile: ":memory:", requested: ["go-key"] },
162 { active: null, requested: ["console-key"] },
163 ]) {
164 database.exec("DELETE FROM credential");
165 insert.run("cred_selected", "opencode", JSON.stringify(consoleRow), active, 2, 2);
166 // Legacy selections follow creation order, even when an older key was updated later.
167 insert.run(
168 "cred_older",
169 "opencode",
170 JSON.stringify({ type: "key", key: "old-key" }),
171 active === null ? null : 0,
172 1,
173 9,
174 );
175 insert.run("cred_other", "other", JSON.stringify(consoleKey), 1, 10, 10);
176 if (databaseFile === "selected.db") {
177 yield* fs.writeFile(
178 `${dataHome}/opencode/selected.db`,
179 yield* fs.readFile(`${dataHome}/opencode/opencode.db`),
180 );
181 database
182 .prepare("UPDATE credential SET value = ? WHERE id = 'cred_selected'")
183 .run(JSON.stringify({ type: "key", key: "old-key" }));
184 }
185 const before = yield* fs.readFile(`${dataHome}/opencode/opencode.db`);
186 const authorizations: string[] = [];
187 const limits = yield* readOpenCodeGoUsageLimits({
188 enabled: true,
189 serverUrl: "",
190 environment: {
191 XDG_DATA_HOME: dataHome,
192 ...(databaseFile !== undefined ? { OPENCODE_DB: databaseFile } : {}),
193 ...(inline
194 ? { OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}' }
195 : {}),
196 },
197 }).pipe(
198 Effect.provideService(
199 HttpClient.HttpClient,
200 HttpClient.make((request) => {
201 NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage");
202 const key = (request.headers.authorization ?? "").replace("Bearer ", "");
203 authorizations.push(key);
204 // The legacy key is expired in the regression; it only works in fallback scenarios.
205 const status =
206 key === "console-key" ? consoleStatus : requested.includes(key) ? 200 : 401;
207 return Effect.succeed(
208 HttpClientResponse.fromWeb(
209 request,
210 Response.json(
211 { usage: { rolling: window, weekly: window, monthly: window } },
212 { status },
213 ),
214 ),
215 );
216 }),
217 ),
218 );
219 NodeAssert.deepEqual(authorizations, requested);
220 NodeAssert.deepEqual(yield* fs.readFile(`${dataHome}/opencode/opencode.db`), before);
221 NodeAssert.equal(
222 limits.unavailable?.reason,
223 consoleStatus === 500 ? "probeFailed" : undefined,
224 );
225 NodeAssert.equal(limits.windows.length, consoleStatus === 500 ? 0 : 3);
226 NodeAssert.equal(
227 limits.credentialFingerprint,
228 consoleStatus === 500 ? undefined : fingerprints.get(requested.at(-1)!),
229 );
230 }
231 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
232);
233
234it.effect("prefers the selected stored Go key over the auth.json key OpenCode 2 imported", () =>
235 Effect.gen(function* () {
236 const fs = yield* FileSystem.FileSystem;
237 const dataHome = yield* fs.makeTempDirectoryScoped();
238 yield* fs.makeDirectory(`${dataHome}/opencode`);
239 yield* fs.writeFileString(
240 `${dataHome}/opencode/auth.json`,
241 JSON.stringify({ "opencode-go": { type: "api", key: "imported-key" } }),
242 );
243 const database = yield* Effect.acquireRelease(
244 Effect.sync(() => new NodeSqlite.DatabaseSync(`${dataHome}/opencode/opencode.db`)),
245 (database) => Effect.sync(() => database.close()),
246 );
247 database.exec(
248 "CREATE TABLE credential (id text PRIMARY KEY, integration_id text, value text, active integer, time_created integer, time_updated integer)",
249 );
250 const insert = database.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?)");
251 insert.run(
252 "cred_imported",
253 "opencode-go",
254 JSON.stringify({ type: "key", key: "imported-key" }),
255 0,
256 1,
257 2,
258 );
259 insert.run(
260 "cred_replaced",
261 "opencode-go",
262 JSON.stringify({ type: "key", key: "replaced-key" }),
263 1,
264 2,
265 2,
266 );
267 const window = { percent: 1, resetsAt: "2026-09-17T12:00:00.000Z" };
268 for (const { replacedStatus, requested } of [
269 { replacedStatus: 200, requested: ["replaced-key"] },
270 { replacedStatus: 401, requested: ["replaced-key", "imported-key"] },
271 ]) {
272 const authorizations: string[] = [];
273 const limits = yield* readOpenCodeGoUsageLimits({
274 enabled: true,
275 serverUrl: "",
276 environment: { XDG_DATA_HOME: dataHome },
277 }).pipe(
278 Effect.provideService(
279 HttpClient.HttpClient,
280 HttpClient.make((request) => {
281 const key = (request.headers.authorization ?? "").replace("Bearer ", "");
282 authorizations.push(key);
283 const status = key === "replaced-key" ? replacedStatus : 200;
284 return Effect.succeed(
285 HttpClientResponse.fromWeb(
286 request,
287 Response.json(
288 { usage: { rolling: window, weekly: window, monthly: window } },
289 { status },
290 ),
291 ),
292 );
293 }),
294 ),
295 );
296 NodeAssert.deepEqual(authorizations, requested);
297 NodeAssert.equal(limits.unavailable, undefined);
298 NodeAssert.equal(limits.windows.length, 3);
299 }
300 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
301);
302
303it.effect("does not read local credentials for external or disabled OpenCode instances", () =>
304 Effect.gen(function* () {
305 for (const settings of [
306 { enabled: true, serverUrl: "https://remote.example" },
307 { enabled: false, serverUrl: "" },
308 ]) {
309 const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe(
310 Effect.provideService(
311 FileSystem.FileSystem,
312 FileSystem.makeNoop({
313 readFileString: () => Effect.die("unexpected credential read"),
314 }),
315 ),
316 Effect.provideService(
317 HttpClient.HttpClient,
318 HttpClient.make(() => Effect.die("unexpected usage request")),
319 ),
320 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
321 );
322 NodeAssert.equal(limits.unavailable?.reason, "unsupported");
323 }
324 }),
325);
326
327it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () =>
328 Effect.gen(function* () {
329 for (const [status, reason] of [
330 [403, "unsupported"],
331 [401, "probeFailed"],
332 [200, "probeFailed"],
333 ] as const) {
334 const limits = yield* readOpenCodeGoUsageLimits({
335 enabled: true,
336 serverUrl: "",
337 environment: {
338 OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}',
339 },
340 }).pipe(
341 Effect.provideService(
342 FileSystem.FileSystem,
343 FileSystem.makeNoop({
344 readFileString: () => Effect.die("inline credentials must bypass disk"),
345 }),
346 ),
347 Effect.provideService(
348 HttpClient.HttpClient,
349 HttpClient.make((request) =>
350 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))),
351 ),
352 ),
353 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
354 );
355 NodeAssert.equal(limits.unavailable?.reason, reason);
356 NodeAssert.equal(limits.credentialFingerprint, undefined);
357 NodeAssert.deepEqual(limits.windows, []);
358 }
359 }),
360);
361
362const runtimeMock = {
363 state: {
364 runVersionError: null as Error | null,
365 runVersionPending: false,
366 versionStdout: DEFAULT_VERSION_STDOUT,
367 inventoryError: null as Error | null,
368 connectionError: null as Error | null,
369 inventoryCwd: null as string | null,
370 startCalls: 0,
371 closeCalls: 0,
372 sdkClientInputs: [] as Array<{
373 baseUrl: string;
374 directory: string;
375 serverPassword?: string;
376 }>,
377 inventory: {
378 providerList: { connected: [] as string[], all: [] as unknown[], default: {} },
379 agents: [] as unknown[],
380 skills: [] as unknown[],
381 } as unknown,
382 },
383 reset() {
384 this.state.runVersionError = null;
385 this.state.runVersionPending = false;
386 this.state.versionStdout = DEFAULT_VERSION_STDOUT;
387 this.state.inventoryError = null;
388 this.state.connectionError = null;
389 this.state.inventoryCwd = null;
390 this.state.startCalls = 0;
391 this.state.closeCalls = 0;
392 this.state.sdkClientInputs.length = 0;
393 this.state.inventory = {
394 providerList: { connected: [], all: [] as unknown[], default: {} },
395 agents: [] as unknown[],
396 skills: [] as unknown[],
397 };
398 },
399};
400
401const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
402 startOpenCodeServerProcess: ({ serverPassword, environment }) =>
403 Effect.gen(function* () {
404 runtimeMock.state.startCalls += 1;
405 yield* Effect.addFinalizer(() =>
406 Effect.sync(() => {
407 runtimeMock.state.closeCalls += 1;
408 }),
409 );
410 const effectiveServerPassword = OpenCodeRuntime.resolveOpenCodeServerPassword({
411 external: false,
412 ...(serverPassword !== undefined ? { serverPassword } : {}),
413 ...(environment !== undefined ? { environment } : {}),
414 });
415 return {
416 url: "http://127.0.0.1:4301",
417 ...(effectiveServerPassword !== undefined
418 ? { serverPassword: effectiveServerPassword }
419 : {}),
420 version: "1.14.19",
421 isRunning: Effect.succeed(true),
422 exitCode: Effect.never,
423 };
424 }),
425 connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
426 Effect.gen(function* () {
427 if (runtimeMock.state.connectionError) {
428 return yield* new OpenCodeRuntime.OpenCodeRuntimeError({
429 operation: "global.health",
430 detail: runtimeMock.state.connectionError.message,
431 cause: runtimeMock.state.connectionError,
432 });
433 }
434 if (!serverUrl) {
435 yield* Effect.addFinalizer(() =>
436 Effect.sync(() => {
437 runtimeMock.state.closeCalls += 1;
438 }),
439 );
440 }
441 return {
442 url: serverUrl ?? "http://127.0.0.1:4301",
443 ...(serverPassword ? { serverPassword } : {}),
444 version: "1.14.19",
445 exitCode: null,
446 external: Boolean(serverUrl),
447 };
448 }),
449 runOpenCodeCommand: () =>
450 runtimeMock.state.runVersionPending
451 ? Effect.never
452 : runtimeMock.state.runVersionError
453 ? Effect.fail(
454 new OpenCodeRuntime.OpenCodeRuntimeError({
455 operation: "runOpenCodeCommand",
456 detail: runtimeMock.state.runVersionError.message,
457 cause: runtimeMock.state.runVersionError,
458 }),
459 )
460 : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
461 createOpenCodeSdkClient: (input) => {
462 runtimeMock.state.sdkClientInputs.push(input);
463 return {} as unknown as ReturnType<
464 OpenCodeRuntime.OpenCodeRuntime["Service"]["createOpenCodeSdkClient"]
465 >;
466 },
467 loadOpenCodeInventory: () =>
468 runtimeMock.state.inventoryError
469 ? Effect.fail(
470 new OpenCodeRuntime.OpenCodeRuntimeError({
471 operation: "loadOpenCodeInventory",
472 detail: runtimeMock.state.inventoryError.message,
473 cause: runtimeMock.state.inventoryError,
474 }),
475 )
476 : Effect.succeed(runtimeMock.state.inventory as OpenCodeRuntime.OpenCodeInventory),
477 loadOpenCodeSkills: () => Effect.succeed([]),
478};
479
480beforeEach(() => {
481 runtimeMock.reset();
482});
483
484const layerTest = Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe(
485 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
486);
487
488// As the instance registry hands them to the driver: a referenced password is already read.
489const makeOpenCodeSettings = (overrides?: Partial<OpenCodeSettings>) => {
490 const settings = decodeOpenCodeSettings({
491 enabled: true,
492 binaryPath: "opencode",
493 serverUrl: "",
494 serverPassword: "",
495 customModels: [],
496 ...overrides,
497 });
498 return { ...settings, serverPassword: settingsSecretLiteral(settings.serverPassword) };
499};
500
501const checkProvider = Effect.fn("checkProvider")(function* (
502 settings: ReturnType<typeof makeOpenCodeSettings>,
503 cwd = process.cwd(),
504 environment?: NodeJS.ProcessEnv,
505 server = replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword),
506 openCode2Models: Effect.Effect<
507 ReadonlyArray<OpenCode2Model>,
508 OpenCodeRuntime.OpenCodeRuntimeError
509 > = Effect.succeed([]),
510) {
511 return yield* Effect.scoped(
512 Effect.gen(function* () {
513 const serverOwner = yield* OpenCodeServerOwner.make({
514 binaryPath: settings.binaryPath,
515 directory: cwd,
516 ...(settings.serverPassword ? { serverPassword: settings.serverPassword } : {}),
517 ...(environment ? { environment } : {}),
518 });
519 const probe = probeOpenCodeRuntime(settings, environment).pipe(
520 Effect.provideService(HttpClient.HttpClient, server),
521 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble),
522 );
523 return yield* checkOpenCodeProviderStatus(settings, cwd, probe, openCode2Models).pipe(
524 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
525 );
526 }),
527 );
528});
529
530it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
531 it.effect.each(["version", "inventory", "models", "empty-v1", "empty-v2"] as const)(
532 "records %s discovery failures in the existing trace and logger",
533 (failure) =>
534 Effect.gen(function* () {
535 const cause = new Error("discovery connection reset");
536 const v2 = failure === "models" || failure === "empty-v2";
537 runtimeMock.state.versionStdout = v2 ? "opencode v2.0.18\n" : DEFAULT_VERSION_STDOUT;
538 if (failure === "version") runtimeMock.state.runVersionError = cause;
539 if (failure === "inventory") runtimeMock.state.inventoryError = cause;
540 if (failure === "empty-v1") {
541 runtimeMock.state.inventory = {
542 providerList: {
543 connected: ["openai"],
544 all: [{ id: "openai", name: "OpenAI", models: {} }],
545 default: {},
546 },
547 agents: [],
548 skills: [],
549 };
550 }
551 const spans: Array<Tracer.NativeSpan> = [];
552 yield* checkProvider(
553 makeOpenCodeSettings({ serverPassword: "private-password" }),
554 process.cwd(),
555 undefined,
556 undefined,
557 failure === "models"
558 ? Effect.fail(
559 new OpenCodeRuntime.OpenCodeRuntimeError({
560 operation: "model.list",
561 detail: "model list failed",
562 cause,
563 }),
564 )
565 : Effect.succeed([]),
566 ).pipe(
567 Effect.provideService(
568 Tracer.Tracer,
569 Tracer.make({
570 span: (options) => {
571 const span = new Tracer.NativeSpan(options);
572 spans.push(span);
573 return span;
574 },
575 }),
576 ),
577 Effect.provide(Logger.layer([Logger.tracerLogger], { mergeWithExisting: false })),
578 );
579
580 const diagnostics = spans.flatMap((span) =>
581 span.events
582 .filter(([name]) => name === "OpenCode discovery failed")
583 .map((event) => ({ span, event })),
584 );
585 NodeAssert.equal(diagnostics.length, 1);
586 const {
587 span,
588 event: [, timestamp, attributes],
589 } = diagnostics[0]!;
590 NodeAssert.equal(attributes.provider, "opencode");
591 NodeAssert.equal(
592 attributes.phase,
593 failure.startsWith("empty") ? (v2 ? "models" : "inventory") : failure,
594 );
595 NodeAssert.equal(attributes.outcome, failure.startsWith("empty") ? "empty" : "failed");
596 NodeAssert.equal(attributes.serverMode, "managed");
597 NodeAssert.equal(attributes["effect.logLevel"], "WARN");
598 NodeAssert.ok(timestamp >= 0n);
599 NodeAssert.ok(span.traceId.length > 0);
600 NodeAssert.equal(span.attributes.get("discovery.outcome"), attributes.outcome);
601 if (!failure.startsWith("empty")) {
602 NodeAssert.match(String(attributes["effect.cause"]), /discovery connection reset/);
603 }
604 NodeAssert.ok(!JSON.stringify(attributes).includes("private-password"));
605 }),
606 );
607
608 it.effect("keeps an empty legacy catalog retryable until connected providers list models", () =>
609 Effect.gen(function* () {
610 const upstream = { id: "openai", name: "OpenAI", models: {} };
611 runtimeMock.state.inventory = {
612 providerList: { connected: ["openai"], all: [upstream], default: {} },
613 agents: [],
614 skills: [],
615 };
616 const settings = makeOpenCodeSettings();
617 const empty = yield* checkProvider(settings);
618 NodeAssert.equal(empty.status, "warning");
619 NodeAssert.equal(empty.auth.status, "unknown");
620 NodeAssert.deepEqual(empty.models, []);
621
622 upstream.models = {
623 "gpt-5.4": { id: "gpt-5.4", name: "GPT-5.4", variants: {} },
624 };
625 const ready = yield* checkProvider(settings);
626 NodeAssert.equal(ready.status, "ready");
627 NodeAssert.equal(ready.auth.status, "authenticated");
628 NodeAssert.deepEqual(
629 ready.models.map((model) => model.slug),
630 ["openai/gpt-5.4"],
631 );
632 }),
633 );
634
635 it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
636 Effect.gen(function* () {
637 runtimeMock.state.inventory = {
638 providerList: {
639 connected: ["openai"],
640 all: [
641 {
642 id: "openai",
643 name: "OpenAI",
644 models: {
645 "gpt-5.4": {
646 id: "gpt-5.4",
647 name: "GPT-5.4",
648 variants: {
649 none: {},
650 low: {},
651 medium: {},
652 high: {},
653 xhigh: {},
654 },
655 },
656 },
657 },
658 ],
659 default: {},
660 },
661 agents: [
662 { name: "build", hidden: false, mode: "primary" },
663 { name: "plan", hidden: false, mode: "primary" },
664 ],
665 };
666
667 const snapshot = yield* checkProvider(makeOpenCodeSettings());
668 const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4");
669
670 NodeAssert.ok(model);
671 const variantDescriptor = model.capabilities?.optionDescriptors?.find(
672 (descriptor) => descriptor.id === "variant" && descriptor.type === "select",
673 );
674 NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select");
675 NodeAssert.equal(
676 variantDescriptor.options.find((option) => option.isDefault === true)?.id,
677 "medium",
678 );
679 const agentDescriptor = model.capabilities?.optionDescriptors?.find(
680 (descriptor) => descriptor.id === "agent" && descriptor.type === "select",
681 );
682 NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select");
683 NodeAssert.equal(
684 agentDescriptor.options.find((option) => option.isDefault === true)?.id,
685 "build",
686 );
687 }),
688 );
689
690 it.effect("keeps Agent without inventing Reasoning for models with no variants", () =>
691 Effect.gen(function* () {
692 runtimeMock.state.inventory = {
693 providerList: {
694 connected: ["opencode"],
695 all: [
696 {
697 id: "opencode",
698 name: "OpenCode Zen",
699 models: {
700 "big-pickle": { id: "big-pickle", name: "Big Pickle", variants: {} },
701 "glm-5": { id: "glm-5", name: "GLM-5" },
702 },
703 },
704 ],
705 default: {},
706 },
707 agents: [
708 { name: "build", hidden: false, mode: "primary" },
709 { name: "plan", hidden: false, mode: "primary" },
710 ],
711 };
712 const snapshot = yield* checkProvider(makeOpenCodeSettings());
713 for (const slug of ["opencode/big-pickle", "opencode/glm-5"]) {
714 const model = snapshot.models.find((entry) => entry.slug === slug);
715 NodeAssert.ok(model);
716 const descriptors = model.capabilities?.optionDescriptors;
717 NodeAssert.deepEqual(descriptors, [
718 {
719 id: "agent",
720 label: "Agent",
721 type: "select",
722 options: [
723 { id: "build", label: "Build", isDefault: true },
724 { id: "plan", label: "Plan" },
725 ],
726 currentValue: "build",
727 },
728 ]);
729 NodeAssert.deepEqual(buildProviderOptionSelectionsFromDescriptors(descriptors), [
730 { id: "agent", value: "build" },
731 ]);
732 }
733 }),
734 );
735
736 it.effect("includes OpenCode skills in the provider snapshot", () =>
737 Effect.gen(function* () {
738 runtimeMock.state.inventory = {
739 providerList: {
740 connected: ["openai"],
741 all: [
742 {
743 id: "openai",
744 name: "OpenAI",
745 models: {
746 "gpt-5.4": {
747 id: "gpt-5.4",
748 name: "GPT-5.4",
749 variants: {},
750 },
751 },
752 },
753 ],
754 default: {},
755 },
756 agents: [],
757 skills: [
758 {
759 name: "openclaw-review",
760 description: "Review OpenClaw workflow changes.",
761 location: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
762 },
763 {
764 name: "openclaw-triage",
765 description: "Triage OpenClaw routing issues.",
766 location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
767 },
768 {
769 name: "missing-location",
770 description: "This incomplete SDK row should be skipped.",
771 location: "",
772 },
773 ],
774 };
775
776 const snapshot = yield* checkProvider(makeOpenCodeSettings());
777
778 NodeAssert.deepEqual(
779 snapshot.skills.map((skill) => ({
780 name: skill.name,
781 path: skill.path,
782 enabled: skill.enabled,
783 shortDescription: skill.shortDescription,
784 })),
785 [
786 {
787 name: "openclaw-review",
788 path: "/Users/test/.agents/skills/openclaw-review/SKILL.md",
789 enabled: true,
790 shortDescription: "Review OpenClaw workflow changes.",
791 },
792 {
793 name: "openclaw-triage",
794 path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md",
795 enabled: true,
796 shortDescription: "Triage OpenClaw routing issues.",
797 },
798 ],
799 );
800 }),
801 );
802
803 it.effect("reports the upgraded CLI without restarting a borrowed OpenCode server", () =>
804 Effect.scoped(
805 Effect.gen(function* () {
806 const settings = makeOpenCodeSettings();
807 const owner = yield* OpenCodeServerOwner.make({
808 binaryPath: settings.binaryPath,
809 directory: process.cwd(),
810 });
811 yield* owner.withServer(() =>
812 Effect.gen(function* () {
813 runtimeMock.state.versionStdout = "opencode 1.14.20\n";
814 const snapshot = yield* checkOpenCodeProviderStatus(
815 settings,
816 process.cwd(),
817 probeOpenCodeRuntime(settings).pipe(
818 Effect.provideService(
819 HttpClient.HttpClient,
820 replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword),
821 ),
822 Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble),
823 ),
824 Effect.succeed([]),
825 ).pipe(Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, owner));
826
827 NodeAssert.equal(snapshot.version, "1.14.19");
828 NodeAssert.equal(snapshot.installedVersion, "1.14.20");
829 NodeAssert.equal(runtimeMock.state.startCalls, 1);
830 NodeAssert.equal(runtimeMock.state.closeCalls, 0);
831 }),
832 );
833 }),
834 ),
835 );
836
837 it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () =>
838 Effect.gen(function* () {
839 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
840 const snapshot = yield* checkProvider(
841 makeOpenCodeSettings(),
842 process.cwd(),
843 undefined,
844 undefined,
845 Effect.succeed([
846 { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] },
847 {
848 providerID: "opencode",
849 id: "space-bunny-free",
850 name: "Space Bunny Free",
851 variants: [{ id: "low" }, { id: "medium" }, { id: "high" }],
852 },
853 {
854 providerID: "opencode-go",
855 id: "deepseek-v4.1-flash",
856 name: "DeepSeek V4.1 Flash",
857 variants: [{ id: "low" }, { id: "high" }, { id: "max" }],
858 },
859 ]),
860 );
861
862 NodeAssert.equal(snapshot.status, "ready");
863 NodeAssert.equal(snapshot.version, "2.0.18");
864 NodeAssert.equal("installedVersion" in snapshot, false);
865 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
866 "approval-required",
867 "auto-accept-edits",
868 "auto",
869 "full-access",
870 ]);
871 NodeAssert.equal(snapshot.showInteractionModeToggle, true);
872 NodeAssert.deepEqual(
873 snapshot.slashCommands.map((command) => command.name),
874 ["compact"],
875 );
876 NodeAssert.deepEqual(
877 snapshot.models.map((model) => model.slug),
878 ["opencode/big-pickle", "opencode-go/deepseek-v4.1-flash", "opencode/space-bunny-free"],
879 );
880 const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free")
881 ?.capabilities?.optionDescriptors?.[0];
882 NodeAssert.deepEqual(
883 variant?.type === "select" ? variant.options.map((option) => option.id) : [],
884 ["low", "medium", "high"],
885 );
886 const goVariant = snapshot.models.find(
887 (model) => model.slug === "opencode-go/deepseek-v4.1-flash",
888 )?.capabilities?.optionDescriptors?.[0];
889 const goVariantIds =
890 goVariant?.type === "select" ? goVariant.options.map((option) => option.id) : [];
891 NodeAssert.deepEqual(goVariantIds, ["low", "high", "max"]);
892 NodeAssert.ok(
893 goVariant?.type === "select" &&
894 goVariant.currentValue !== undefined &&
895 goVariantIds.includes(goVariant.currentValue),
896 "an OpenCode Go model gets a default reasoning variant it lists",
897 );
898 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
899 }),
900 );
901
902 it.effect("marks older releases of a family and deprecated models as legacy", () =>
903 Effect.gen(function* () {
904 const releases = [
905 ["opencode", "opus-4-5", "claude-opus", "2025-11-24", "active"],
906 ["opencode", "opus-5-5", "claude-opus", "2026-09-22", "active"],
907 ["opencode", "opus-preview", "claude-opus", "2026-10-01", "deprecated"],
908 ["opencode", "flash", "flash", "2026-09-10", "active"],
909 ["opencode", "flash-free", "flash", "2026-09-10", "active"],
910 ["opencode", "big-pickle", undefined, "2025-10-17", "active"],
911 ["openrouter", "opus-4-5", "claude-opus", "2025-11-24", "active"],
912 ] as const;
913 const legacySlugs = (
914 models: ReadonlyArray<{ readonly slug: string; readonly isLegacy?: boolean | undefined }>,
915 ) =>
916 models
917 .filter((model) => model.isLegacy)
918 .map((model) => model.slug)
919 .toSorted();
920 const expected = ["opencode/opus-4-5", "opencode/opus-preview"];
921
922 runtimeMock.state.inventory = {
923 providerList: {
924 connected: ["opencode", "openrouter"],
925 all: ["opencode", "openrouter"].map((providerID) => ({
926 id: providerID,
927 name: providerID,
928 models: Object.fromEntries(
929 releases
930 .filter(([provider]) => provider === providerID)
931 .map(([, id, family, releaseDate, status]) => [
932 id,
933 { id, name: id, family, release_date: releaseDate, status, variants: {} },
934 ]),
935 ),
936 })),
937 default: {},
938 },
939 agents: [],
940 };
941 NodeAssert.deepEqual(
942 legacySlugs((yield* checkProvider(makeOpenCodeSettings())).models),
943 expected,
944 );
945
946 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
947 const openCode2 = yield* checkProvider(
948 makeOpenCodeSettings(),
949 process.cwd(),
950 undefined,
951 undefined,
952 Effect.succeed(
953 releases.map(([providerID, id, family, releaseDate, status]) => ({
954 providerID,
955 id,
956 name: id,
957 variants: [],
958 family,
959 released: Date.parse(releaseDate),
960 deprecated: status === "deprecated",
961 })),
962 ),
963 );
964 NodeAssert.deepEqual(legacySlugs(openCode2.models), expected);
965 }),
966 );
967
968 it.effect("reports a failed OpenCode 2 model list without the server's response", () =>
969 Effect.gen(function* () {
970 runtimeMock.state.versionStdout = "opencode v2.0.18\n";
971 const snapshot = yield* checkProvider(
972 makeOpenCodeSettings(),
973 process.cwd(),
974 undefined,
975 undefined,
976 Effect.fail(
977 new OpenCodeRuntime.OpenCodeRuntimeError({
978 operation: "model.list",
979 detail: 'status=500 body={"token":"leaked-response-body"}',
980 }),
981 ),
982 );
983
984 NodeAssert.equal(snapshot.status, "error");
985 NodeAssert.equal(snapshot.message, "OpenCode could not load its model list.");
986 NodeAssert.deepEqual(
987 snapshot.slashCommands.map((command) => command.name),
988 ["compact"],
989 );
990 }),
991 );
992
993 it.effect("reports local model inventory failures without treating them as empty", () =>
994 Effect.gen(function* () {
995 runtimeMock.state.versionStdout = "opencode 1.14.20\n";
996 runtimeMock.state.inventoryError = new Error("opencode models failed");
997 const snapshot = yield* checkProvider(makeOpenCodeSettings());
998
999 NodeAssert.equal(snapshot.status, "error");
1000 NodeAssert.equal(snapshot.installed, true);
1001 NodeAssert.equal(snapshot.version, "1.14.19");
1002 NodeAssert.equal(snapshot.installedVersion, "1.14.20");
1003 NodeAssert.equal(snapshot.models.length, 0);
1004 NodeAssert.equal(
1005 snapshot.message,
1006 "Failed to load OpenCode provider inventory: opencode models failed",
1007 );
1008 }),
1009 );
1010});
1011
1012it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => {
1013 it.effect.each([
1014 "401 Unauthorized",
1015 "403 Forbidden",
1016 "status=401 body={}",
1017 "status=403 body={}",
1018 "HTTP 401",
1019 "HTTP/1.1 403",
1020 "Response status code: 401",
1021 "Response status code: 403",
1022 "Request failed with status code 401",
1023 "Request failed with status code 403",
1024 ])("keeps explicit authentication rejection actionable: %s", (detail) =>
1025 Effect.gen(function* () {
1026 runtimeMock.state.connectionError = new Error(detail);
1027 const snapshot = yield* checkProvider(
1028 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
1029 );
1030
1031 NodeAssert.equal(snapshot.status, "error");
1032 NodeAssert.equal(snapshot.auth.status, "unauthenticated");
1033 NodeAssert.equal(
1034 snapshot.message,
1035 "OpenCode server rejected authentication. Check the server URL and password.",
1036 );
1037 }),
1038 );
1039
1040 it.effect.each([401, 403, 4010, 4030])("keeps connection failures on port %s retryable", (port) =>
1041 Effect.gen(function* () {
1042 const serverUrl = `http://127.0.0.1:${port}`;
1043 runtimeMock.state.connectionError = new Error(`connect ECONNREFUSED 127.0.0.1:${port}`);
1044 const snapshot = yield* checkProvider(makeOpenCodeSettings({ serverUrl }));
1045
1046 NodeAssert.equal(snapshot.status, "error");
1047 NodeAssert.equal(snapshot.auth.status, "unknown");
1048 NodeAssert.equal(
1049 snapshot.message,
1050 `Couldn't reach the configured OpenCode server at ${serverUrl}. Check that the server is running and the URL is correct.`,
1051 );
1052 }),
1053 );
1054
1055 it.effect("routes a configured OpenCode 2 server to the 2.x check, never via 1.x", () =>
1056 Effect.gen(function* () {
1057 const settings = makeOpenCodeSettings({
1058 serverUrl: "http://127.0.0.1:9999",
1059 serverPassword: "secret-password",
1060 });
1061 const snapshot = yield* checkProvider(
1062 settings,
1063 process.cwd(),
1064 undefined,
1065 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
1066 );
1067
1068 NodeAssert.equal(snapshot.status, "warning");
1069 NodeAssert.equal(snapshot.version, "2.0.18");
1070 NodeAssert.equal("installedVersion" in snapshot, false);
1071 NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
1072 "approval-required",
1073 "auto-accept-edits",
1074 "auto",
1075 "full-access",
1076 ]);
1077 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
1078 }),
1079 );
1080
1081 it.effect("reports a rejected OpenCode 2 password as an auth error, not a version", () =>
1082 Effect.gen(function* () {
1083 const snapshot = yield* checkProvider(
1084 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999", serverPassword: "wrong" }),
1085 process.cwd(),
1086 undefined,
1087 replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret-password"),
1088 );
1089
1090 NodeAssert.equal(snapshot.status, "error");
1091 NodeAssert.equal(
1092 snapshot.message,
1093 "OpenCode server rejected authentication. Check the server URL and password.",
1094 );
1095 }),
1096 );
1097
1098 it.effect("rejects an unsupported server before loading inventory", () =>
1099 Effect.gen(function* () {
1100 runtimeMock.state.connectionError = new Error(
1101 "OpenCode v1.14.18 is too old. Upgrade to v1.14.19 or newer.",
1102 );
1103 const snapshot = yield* checkProvider(
1104 makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
1105 );
1106
1107 NodeAssert.equal(snapshot.status, "error");
1108 NodeAssert.equal(snapshot.models.length, 0);
1109 NodeAssert.match(snapshot.message ?? "", /v1\.14\.18 is too old/);
1110 NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
1111 }),
1112 );
1113});
1114
1115const builtinCommands = [
1116 { name: "init", description: "guided AGENTS.md setup" },
1117 { name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted" },
1118];
1119const builtinSkills = [
1120 { id: "opencode", name: "OpenCode", path: "/builtin/opencode.md" },
1121 { id: "report", name: "Report", path: "/builtin/report.md" },
1122];
1123const scanned: OpenCode2Workspace = {
1124 commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }],
1125 skills: [
1126 ...builtinSkills,
1127 {
1128 id: "greet",
1129 name: "greet",
1130 path: "/work/.opencode/skills/greet/SKILL.md",
1131 description: "Greets the user with the secret word MANGO.",
1132 },
1133 ],
1134};
1135
1136it.effect(
1137 "reads a loaded OpenCode 2 directory listing no commands again once its scan has ended",
1138 () =>
1139 Effect.gen(function* () {
1140 const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned];
1141 const scanEnded = yield* Deferred.make<void>();
1142 const load = yield* loadOpenCode2Workspace(
1143 Effect.sync(() => reads.shift()!),
1144 Deferred.await(scanEnded),
1145 Effect.succeed(true),
1146 ).pipe(Effect.forkChild);
1147 yield* Effect.yieldNow;
1148 NodeAssert.equal(reads.length, 1);
1149 yield* Deferred.succeed(scanEnded, undefined);
1150 NodeAssert.deepEqual(yield* Fiber.join(load), scanned);
1151 }),
1152);
1153
1154it.effect("waits for the scan of a cold OpenCode 2 directory that first lists only built-ins", () =>
1155 Effect.gen(function* () {
1156 const calls: Array<string> = [];
1157 const reads: Array<OpenCode2Workspace> = [
1158 { commands: builtinCommands, skills: builtinSkills },
1159 scanned,
1160 ];
1161 const workspace = yield* loadOpenCode2Workspace(
1162 Effect.sync(() => (calls.push("read"), reads.shift()!)),
1163 Effect.sync(() => void calls.push("scanned")),
1164 Effect.sync(() => (calls.push("loaded"), false)),
1165 );
1166 NodeAssert.deepEqual(workspace, scanned);
1167 NodeAssert.deepEqual(calls, ["loaded", "read", "scanned", "read"]);
1168 }),
1169);
1170
1171it.effect("reads an already loaded OpenCode 2 directory once without waiting for a scan", () =>
1172 Effect.gen(function* () {
1173 const calls: Array<string> = [];
1174 const workspace = yield* loadOpenCode2Workspace(
1175 Effect.sync(() => (calls.push("read"), scanned)),
1176 Effect.sync(() => void calls.push("scanned")),
1177 Effect.succeed(true),
1178 );
1179 NodeAssert.deepEqual(workspace, scanned);
1180 NodeAssert.deepEqual(calls, ["read"]);
1181 }),
1182);
1183
1184it.effect("lists a catalog whose cold server start outlasts the settle budget", () =>
1185 Effect.gen(function* () {
1186 const catalog: ReadonlyArray<OpenCode2Model> = [
1187 { providerID: "opencode", id: "big-pickle", name: "Big Pickle", variants: [] },
1188 ];
1189 let reads = 0;
1190 // The loader's read includes spawning the server; a loaded host can take 6s to start it.
1191 const read = Effect.suspend(() =>
1192 reads++ === 0 ? Effect.sleep("6 seconds").pipe(Effect.as(catalog)) : Effect.succeed(catalog),
1193 );
1194 const load = yield* makeOpenCode2ModelLoader(read);
1195 const fiber = yield* Effect.forkChild(load);
1196 yield* TestClock.adjust("10 seconds");
1197 NodeAssert.deepEqual(yield* Fiber.join(fiber), catalog);
1198 }),
1199);