1import { assert, it } from "@effect/vitest";
2import { Session } from "@opencode/client/effect";
3import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Exit from "effect/Exit";
8import * as Scope from "effect/Scope";
9import * as Layer from "effect/Layer";
10import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";
12import * as TestClock from "effect/testing/TestClock";
13import * as UrlParams from "effect/http/UrlParams";
14import * as HttpClient from "effect/http/HttpClient";
15import * as HttpClientResponse from "effect/http/HttpClientResponse";
16import { describe } from "vite-plus/test";
17
18import * as OpenCode2Client from "./OpenCode2Client.ts";
19
20const INFO_BODY = '{"version":"2.0.18","pid":4242,"urls":[],"paths":{"tmp":"/tmp"}}';
21
22const authorizationFor = (password: string | Redacted.Redacted) => {
23 const seen: Array<string | undefined> = [];
24 const layerCapturing = Layer.succeed(
25 HttpClient.HttpClient,
26 HttpClient.make((request) =>
27 Effect.sync(() => {
28 seen.push(request.headers.authorization);
29 return HttpClientResponse.fromWeb(
30 request,
31 new Response(INFO_BODY, { headers: { "content-type": "application/json" } }),
32 );
33 }),
34 ),
35 );
36 return Effect.gen(function* () {
37 const opencode = yield* OpenCode2Client.OpenCode2Client;
38 const { client } = yield* opencode.connect({ baseUrl: "http://127.0.0.1:4096", password });
39 yield* client.server.info();
40 return seen[0];
41 }).pipe(Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(layerCapturing))));
42};
43
44const utf8Basic = (password: string) =>
45 `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;
46
47describe("OpenCode2Client authorization", () => {
48 // OpenCode requires UTF-8 Basic auth; btoa rejects or misencodes non-ASCII.
49 it.effect("encodes non-ASCII passwords as UTF-8", () =>
50 Effect.gen(function* () {
51 assert.strictEqual(yield* authorizationFor("pässwörd"), utf8Basic("pässwörd"));
52 assert.strictEqual(yield* authorizationFor("pass€word"), utf8Basic("pass€word"));
53 assert.strictEqual(yield* authorizationFor(Redacted.make("pässwörd")), utf8Basic("pässwörd"));
54 }),
55 );
56});
57
58describe("OpenCode2Client event subscription", () => {
59 it.effect("times out and cancels a subscription with no response headers", () =>
60 Effect.gen(function* () {
61 const cancelled = yield* Deferred.make<void>();
62 const transport = Layer.succeed(
63 HttpClient.HttpClient,
64 HttpClient.make((request) => {
65 assert.strictEqual(request.url, "http://127.0.0.1:4096/api/event");
66 return Effect.never.pipe(Effect.ensuring(Deferred.succeed(cancelled, undefined)));
67 }),
68 );
69 const opencode = yield* OpenCode2Client.OpenCode2Client.pipe(
70 Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(transport))),
71 );
72 const { events } = yield* opencode.connect({
73 baseUrl: "http://127.0.0.1:4096",
74 password: "test-password",
75 });
76 const opening = yield* events.pipe(Effect.flip, Effect.forkScoped);
77
78 yield* TestClock.adjust("10 seconds");
79 assert.isTrue(yield* Deferred.isDone(cancelled));
80 const error = yield* Fiber.join(opening);
81 assert.strictEqual(error._tag, "HttpClientError");
82 assert.strictEqual(error.reason._tag, "TransportError");
83 assert.isTrue(Cause.isTimeoutError(error.cause));
84 }),
85 );
86});
87
88const previous = {
89 id: "msg_repro088",
90 type: "synthetic",
91 text: "Fixture message 88",
92 time: { created: 1088 },
93};
94
95const compaction = (fields: Record<string, unknown>) => ({
96 type: "compaction",
97 id: "msg_repro089",
98 time: { created: 1089 },
99 reason: "manual",
100 summary: "Fixture compaction",
101 ...fields,
102});
103
104const listHistory = (messages: ReadonlyArray<unknown>) =>
105 Effect.gen(function* () {
106 const transport = Layer.succeed(
107 HttpClient.HttpClient,
108 HttpClient.make((request) =>
109 Effect.sync(() => {
110 assert.include(request.url, "/api/session/ses_repro/message");
111 return HttpClientResponse.fromWeb(
112 request,
113 new Response(JSON.stringify({ data: messages, cursor: {} }), {
114 headers: { "content-type": "application/json" },
115 }),
116 );
117 }),
118 ),
119 );
120 const opencode = yield* OpenCode2Client.OpenCode2Client.pipe(
121 Effect.provide(OpenCode2Client.layer.pipe(Layer.provide(transport))),
122 );
123 const { client } = yield* opencode.connect({
124 baseUrl: "http://127.0.0.1:4096",
125 password: "test-password",
126 });
127 return yield* client.message.list({ sessionID: Session.ID.make("ses_repro"), order: "asc" });
128 });
129
130describe("OpenCode2Client message history", () => {
131 it.effect.each([
132 { status: "running", shape: "native keepID", fields: { metadata: { keepID: previous.id } } },
133 { status: "completed", shape: "native keepID", fields: { metadata: { keepID: previous.id } } },
134 { status: "running", shape: "native without metadata", fields: {} },
135 { status: "completed", shape: "native without metadata", fields: {} },
136 {
137 status: "completed",
138 shape: "legacy metadata",
139 fields: { metadata: { recent: previous.id } },
140 },
141 { status: "running", shape: "top-level", fields: { recent: previous.id } },
142 ] as const)("decodes a $status compaction in the $shape format", ({ status, fields }) =>
143 Effect.gen(function* () {
144 const history = yield* listHistory([previous, compaction({ status, ...fields })]);
145
146 assert.deepStrictEqual(
147 history.data.map((message) => message.id),
148 [previous.id, "msg_repro089"],
149 );
150 assert.deepInclude(history.data[1], {
151 type: "compaction",
152 status,
153 summary: "Fixture compaction",
154 recent:
155 "metadata" in fields && "recent" in fields.metadata ? previous.id : (fields.recent ?? ""),
156 });
157 }),
158 );
159
160 it.effect.each([
161 { field: "recent", fields: { status: "completed", recent: 42 } },
162 { field: "reason", fields: { status: "completed", reason: "scheduled", recent: previous.id } },
163 ] as const)("keeps a compaction with an invalid $field an error", ({ field, fields }) =>
164 Effect.gen(function* () {
165 const error = yield* listHistory([compaction(fields)]).pipe(Effect.flip);
166
167 assert.strictEqual(error._tag, "ClientError");
168 assert.isTrue(Schema.isSchemaError(error.cause));
169 assert.include(String(error.cause), field);
170 }),
171 );
172});
173it.effect("closes an event subscription that recovery has not started reading", () =>
174 Effect.gen(function* () {
175 const signals: Array<AbortSignal> = [];
176 const http = HttpClient.make((request, _url, signal) =>
177 Effect.sync(() => {
178 signals.push(signal);
179 return HttpClientResponse.fromWeb(
180 request,
181 new Response(": connected\n\n", {
182 headers: { "content-type": "text/event-stream" },
183 }),
184 );
185 }),
186 );
187 const opencode = yield* OpenCode2Client.make.pipe(
188 Effect.provideService(HttpClient.HttpClient, http),
189 );
190 const connection = yield* opencode.connect({
191 baseUrl: "http://127.0.0.1:4096",
192 password: "test",
193 });
194 const scope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
195 Scope.close(scope, Exit.void),
196 );
197 yield* connection.events.pipe(Effect.provideService(Scope.Scope, scope), Effect.asVoid);
198 assert.isFalse(signals[0]!.aborted);
199 yield* Scope.close(scope, Exit.void);
200 assert.isTrue(signals[0]!.aborted);
201 }),
202);
203
204describe("OpenCode2Client native image jobs", () => {
205 it.effect.each([
206 { path: "/work/image.png", location: "/work" },
207 { path: "/tmp/images/session/image.png", location: "/tmp/images/session" },
208 ])("reads remote image $path through its native filesystem location", ({ path, location }) =>
209 Effect.gen(function* () {
210 const transport = HttpClient.make((request) =>
211 Effect.sync(() => {
212 assert.strictEqual(request.url, "http://remote.test/api/fs/read/image.png");
213 assert.deepEqual(UrlParams.toRecord(request.urlParams), {
214 "location[directory]": location,
215 });
216 return HttpClientResponse.fromWeb(
217 request,
218 new Response(Buffer.from("iVBORw0KGgo=", "base64")),
219 );
220 }),
221 );
222 const service = yield* OpenCode2Client.make.pipe(
223 Effect.provideService(HttpClient.HttpClient, transport),
224 );
225 const api = yield* service.connect({ baseUrl: "http://remote.test", password: "secret" });
226 assert.deepEqual(yield* api.readImage(path, "/work"), {
227 uri: "data:image/png;base64,iVBORw0KGgo=",
228 mime: "image/png",
229 });
230 }),
231 );
232
233 it.effect("rejects remote previews above the shared image limit", () =>
234 Effect.gen(function* () {
235 const transport = HttpClient.make((request) =>
236 Effect.succeed(
237 HttpClientResponse.fromWeb(request, new Response(new Uint8Array(11 * 1024 * 1024))),
238 ),
239 );
240 const service = yield* OpenCode2Client.make.pipe(
241 Effect.provideService(HttpClient.HttpClient, transport),
242 );
243 const api = yield* service.connect({ baseUrl: "http://remote.test", password: "secret" });
244 const result = yield* api.readImage("/work/image.png", "/work").pipe(Effect.result);
245 assert.equal(result._tag, "Failure");
246 }),
247 );
248
249 it.effect("reads scoped image jobs without accepting other sessions or job kinds", () =>
250 Effect.gen(function* () {
251 const job = {
252 kind: "image",
253 id: "image_test",
254 sessionID: "ses_owner",
255 status: "running",
256 path: "/work/image",
257 model: "test/image",
258 } as const;
259 const transport = HttpClient.make((request) =>
260 Effect.sync(() => {
261 assert.strictEqual(request.headers.authorization, utf8Basic("secret"));
262 return HttpClientResponse.fromWeb(
263 request,
264 Response.json({
265 data: [
266 job,
267 { ...job, sessionID: "ses_other" },
268 { ...job, kind: "shell" },
269 { ...job, status: "future" },
270 ],
271 }),
272 );
273 }),
274 );
275 const service = yield* OpenCode2Client.make.pipe(
276 Effect.provideService(HttpClient.HttpClient, transport),
277 );
278 const api = yield* service.connect({ baseUrl: "http://remote.test", password: "secret" });
279 assert.deepEqual(yield* api.imageJobs("ses_owner"), [job]);
280 }),
281 );
282});
283
284describe("OpenCode2Client usage limits", () => {
285 it.effect("reads the connected workspace and maps Go and Console budgets", () =>
286 Effect.gen(function* () {
287 let workspace = "wrk_remote";
288 let usageURL: string | undefined;
289 const transport = HttpClient.make((request) => {
290 assert.strictEqual(request.headers.authorization, utf8Basic("remote-password"));
291 assert.deepStrictEqual(Array.from(request.urlParams), [
292 ["location[directory]", "/remote/project"],
293 ]);
294 if (request.url === "https://remote.example/prefix/api/provider")
295 return Effect.succeed(
296 HttpClientResponse.fromWeb(
297 request,
298 Response.json({
299 data: [
300 { id: "opencode", integrationID: "opencode" },
301 { id: "console-custom", integrationID: "opencode" },
302 { id: "opencode-go", integrationID: "opencode" },
303 { id: "anthropic", integrationID: "anthropic" },
304 { id: "byok" },
305 ],
306 }),
307 ),
308 );
309 assert.strictEqual(
310 request.url,
311 "https://remote.example/prefix/api/experimental/usage/limits",
312 );
313 return Effect.succeed(
314 HttpClientResponse.fromWeb(
315 request,
316 Response.json({
317 location: { directory: "/remote/project" },
318 data: {
319 workspace: {
320 id: workspace,
321 name: workspace,
322 usageURL: usageURL ?? "https://opencode.ai/console/" + workspace,
323 },
324 go: {
325 rolling: { percent: 23, limited: false, resetsAt: 1793491200000 },
326 weekly: { percent: 99, limited: true, resetsAt: 1793491200000 },
327 monthly: { percent: 12, limited: false, resetsAt: 1793491200000 },
328 },
329 budgets: [
330 {
331 id: "all",
332 limit: 20,
333 spent: 15,
334 scope: "combined",
335 per: "set",
336 coverage: "all",
337 models: [],
338 vendors: [],
339 picked: false,
340 shared: true,
341 resetsAt: 1793491200000,
342 },
343 {
344 id: "models",
345 limit: 10,
346 spent: 12,
347 scope: "each",
348 per: "model",
349 coverage: "models",
350 models: [
351 { providerID: "opencode", modelID: "a", name: "Model A", spent: 12 },
352 { providerID: "opencode", modelID: "b", name: "Model B", spent: 2 },
353 ],
354 vendors: [],
355 picked: true,
356 shared: false,
357 resetsAt: 1793491200000,
358 },
359 {
360 id: "zz_missing",
361 limit: 10,
362 spent: 10,
363 scope: "each",
364 per: "model",
365 coverage: "models",
366 models: [{ providerID: "opencode", modelID: "unknown", name: "Unknown spend" }],
367 shared: false,
368 resetsAt: 1793491200000,
369 },
370 {
371 id: "zero",
372 limit: 0,
373 spent: 0,
374 scope: "each",
375 per: "set",
376 coverage: "all",
377 models: [],
378 vendors: [],
379 picked: false,
380 shared: false,
381 resetsAt: 1793491200000,
382 },
383 ],
384 },
385 }),
386 ),
387 );
388 });
389 const opencode = yield* OpenCode2Client.make.pipe(
390 Effect.provideService(HttpClient.HttpClient, transport),
391 );
392 const api = yield* opencode.connect({
393 baseUrl: "https://remote.example/prefix",
394 password: "remote-password",
395 });
396 const first = yield* api.usageLimits("/remote/project");
397 assert.isDefined(first);
398 assert.deepStrictEqual(
399 first?.windows.map((window) => window.usedPercent),
400 [23, 100, 12, 75, 100, 20, 100, 100],
401 );
402 assert.isTrue(
403 first?.windows.every((window) => window.resetsAt === "2026-11-01T00:00:00.000Z"),
404 );
405 assert.include(first?.windows[3]?.label ?? "", "Shared");
406 assert.include(first?.windows[4]?.label ?? "", "Model A");
407 assert.deepStrictEqual(first?.windows[4]?.modelIds, ["opencode/a"]);
408 assert.strictEqual(first?.windows[3]?.modelIds, undefined);
409 assert.deepStrictEqual(first?.windows[3]?.providerIds, ["opencode", "console-custom"]);
410 assert.deepStrictEqual(first?.windows[7]?.modelIds, []);
411 assert.include(first?.windows[7]?.label ?? "", "Most used model");
412 assert.strictEqual(first?.credentialFingerprint, undefined);
413 workspace = "wrk_switched";
414 const next = yield* api.usageLimits("/remote/project");
415 assert.strictEqual(next?.externalUsage?.label, "wrk_switched");
416 assert.strictEqual(next?.externalUsage?.url, "https://opencode.ai/console/wrk_switched");
417 for (const url of [
418 "not a URL",
419 "javascript:alert(1)",
420 "custom-app://usage",
421 "file:///tmp/usage",
422 ]) {
423 usageURL = url;
424 const invalid = yield* api.usageLimits("/remote/project");
425 assert.strictEqual(invalid?.externalUsage, undefined);
426 assert.deepStrictEqual(invalid?.windows, next?.windows);
427 }
428 usageURL = "http://console.example/workspace/usage";
429 assert.strictEqual((yield* api.usageLimits("/remote/project"))?.externalUsage?.url, usageURL);
430 }),
431 );
432
433 it.effect("recognizes an older server's SPA fallback", () =>
434 Effect.gen(function* () {
435 const opencode = yield* OpenCode2Client.make.pipe(
436 Effect.provideService(
437 HttpClient.HttpClient,
438 HttpClient.make((request) =>
439 Effect.succeed(
440 HttpClientResponse.fromWeb(
441 request,
442 new Response("<!doctype html><title>OpenCode</title>", {
443 headers: { "content-type": "text/html; charset=utf-8" },
444 }),
445 ),
446 ),
447 ),
448 ),
449 );
450 const api = yield* opencode.connect({ baseUrl: "https://old.example", password: "secret" });
451 assert.strictEqual(yield* api.usageLimits("/remote"), undefined);
452 }),
453 );
454
455 it.effect("distinguishes missing endpoints, signed out, and failed remote probes", () =>
456 Effect.gen(function* () {
457 for (const [status, body, reason] of [
458 [404, {}, undefined],
459 [405, {}, undefined],
460 [200, { location: { directory: "/remote" }, data: null }, "unsupported"],
461 [503, {}, "probeFailed"],
462 [401, {}, "probeFailed"],
463 [200, { data: { workspace: {} } }, "probeFailed"],
464 ] as const) {
465 const opencode = yield* OpenCode2Client.make.pipe(
466 Effect.provideService(
467 HttpClient.HttpClient,
468 HttpClient.make((request) =>
469 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))),
470 ),
471 ),
472 );
473 const api = yield* opencode.connect({
474 baseUrl: "https://remote.example",
475 password: "secret",
476 });
477 const result = yield* api.usageLimits("/remote");
478 if (reason === undefined) assert.strictEqual(result, undefined);
479 else assert.strictEqual(result?.unavailable?.reason, reason);
480 if (status === 200 && reason === "unsupported")
481 assert.include(result?.unavailable?.message ?? "", "signed in");
482 }
483 }),
484 );
485});