1/**
2 * Live contract check for `@opencode/client` against a real OpenCode 2 server.
3 * It re-proves that OpenCode's schemas decode under the workspace Effect
4 * version, which the pnpm overrides force. Run it after bumping either side:
5 *
6 * OPENCODE2_BIN=/path/to/opencode vp test run src/provider/opencode2/OpenCode2Client.live.test.ts
7 *
8 * The turn uses the free `opencode/big-pickle` model.
9 */
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect";
12import { assert, it } from "@effect/vitest";
13import * as Crypto from "effect/Crypto";
14import * as Effect from "effect/Effect";
15import * as Base64Url from "effect/encoding/Base64Url";
16import * as Fiber from "effect/Fiber";
17import * as FileSystem from "effect/FileSystem";
18import * as Filter from "effect/Filter";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import * as Stream from "effect/Stream";
23import * as FetchHttpClient from "effect/http/FetchHttpClient";
24import * as ChildProcess from "effect/process/ChildProcess";
25import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
26import { describe } from "vite-plus/test";
27
28import * as OpenCode2Client from "./OpenCode2Client.ts";
29
30const binaryPath = process.env.OPENCODE2_BIN;
31
32const startServer = Effect.fn("OpenCode2ClientLive.startServer")(function* (binary: string) {
33 const fs = yield* FileSystem.FileSystem;
34 const path = yield* Path.Path;
35 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
36 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode2-live-" });
37 const directory = path.join(root, "work");
38 yield* fs.makeDirectory(directory);
39 // Non-ASCII on purpose: OpenCode decodes Basic credentials as UTF-8.
40 const crypto = yield* Crypto.Crypto;
41 const password = `${Base64Url.encode(yield* crypto.randomBytes(32))}-pässwörd€`;
42 const child = yield* spawner.spawn(
43 ChildProcess.make(binary, ["serve", "--hostname=127.0.0.1", "--port=0"], {
44 cwd: directory,
45 env: {
46 PATH: process.env.PATH,
47 HOME: root,
48 XDG_CONFIG_HOME: path.join(root, "config"),
49 XDG_DATA_HOME: path.join(root, "data"),
50 XDG_STATE_HOME: path.join(root, "state"),
51 XDG_CACHE_HOME: path.join(root, "cache"),
52 OPENCODE_PASSWORD: password,
53 },
54 }),
55 );
56 // Stop by the PID captured at spawn, before the temp directory is removed.
57 yield* Effect.addFinalizer(() =>
58 Effect.sync(() => {
59 try {
60 process.kill(Number(child.pid), "SIGTERM");
61 } catch {
62 // Already exited.
63 }
64 }).pipe(Effect.andThen(child.exitCode), Effect.timeout("10 seconds"), Effect.ignore),
65 );
66 const baseUrl = yield* child.stdout.pipe(
67 Stream.decodeText(),
68 Stream.splitLines,
69 Stream.filterMap(
70 Filter.fromPredicateOption((line: string) =>
71 Option.fromUndefinedOr(/server listening on\s+(https?:\/\/\S+)/i.exec(line)?.[1]),
72 ),
73 ),
74 Stream.runHead,
75 Effect.flatMap(Effect.fromOption),
76 Effect.timeout("30 seconds"),
77 );
78 return { baseUrl, password, directory, pid: Number(child.pid) };
79});
80
81describe.runIf(binaryPath !== undefined)("OpenCode2Client live", () => {
82 it.live(
83 "reads server info, runs one turn, and pages the history",
84 () =>
85 Effect.gen(function* () {
86 const server = yield* startServer(binaryPath!);
87 const opencode = yield* OpenCode2Client.OpenCode2Client;
88 const { client, events } = yield* opencode.connect({
89 baseUrl: server.baseUrl,
90 password: server.password,
91 });
92
93 const info = yield* client.server.info();
94 assert.strictEqual(info.version, "2.0.28");
95 assert.strictEqual(info.pid, server.pid);
96
97 const session = yield* client.session.create({
98 title: "t3 client live check",
99 location: Location.PublicRef.make({ directory: AbsolutePath.make(server.directory) }),
100 model: Model.Ref.make({
101 providerID: Provider.ID.make("opencode"),
102 id: Model.ID.make("big-pickle"),
103 }),
104 });
105 const finished = yield* (yield* events).pipe(
106 Stream.filter(
107 (event) =>
108 event.type !== "unreadable.execution.ended" &&
109 event.type !== "unreadable.execution.started" &&
110 (event.data as { readonly sessionID?: string }).sessionID === session.id &&
111 event.type.startsWith("session.execution.") &&
112 event.type !== "session.execution.started",
113 ),
114 Stream.runHead,
115 Effect.forkChild,
116 );
117 yield* client.session.prompt({
118 sessionID: session.id,
119 text: "Reply with exactly: OPENCODE2_CLIENT_OK",
120 });
121 const terminal = yield* Fiber.join(finished).pipe(
122 Effect.flatMap(Effect.fromOption),
123 Effect.timeout("120 seconds"),
124 );
125 assert.strictEqual(terminal.type, "session.execution.succeeded");
126
127 // One message per page makes every request after the first send only a cursor.
128 const history = yield* OpenCode2Client.paginate(
129 { sessionID: session.id, order: "asc", limit: 1 },
130 client.message.list,
131 ).pipe(Stream.runCollect);
132 assert.deepStrictEqual(
133 history.map((message) => message.type),
134 ["user", "assistant", "idle"],
135 );
136 const assistant = history[1];
137 assert(assistant?.type === "assistant");
138 const reply = assistant.content.flatMap((part) =>
139 part.type === "text" ? [part.text] : [],
140 );
141 assert.include(reply.join(""), "OPENCODE2_CLIENT_OK");
142 }).pipe(
143 Effect.scoped,
144 Effect.provide(
145 OpenCode2Client.layer.pipe(
146 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)),
147 ),
148 ),
149 ),
150 180_000,
151 );
152});