| 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 | */ |
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 11 | import { AbsolutePath, Location, Model, Provider } from "@opencode/client/effect"; |
| 12 | import { assert, it } from "@effect/vitest"; |
| 13 | import * as Crypto from "effect/Crypto"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Base64Url from "effect/encoding/Base64Url"; |
| 16 | import * as Fiber from "effect/Fiber"; |
| 17 | import * as FileSystem from "effect/FileSystem"; |
| 18 | import * as Filter from "effect/Filter"; |
| 19 | import * as Layer from "effect/Layer"; |
| 20 | import * as Option from "effect/Option"; |
| 21 | import * as Path from "effect/Path"; |
| 22 | import * as Stream from "effect/Stream"; |
| 23 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 24 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 25 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 26 | import { describe } from "vite-plus/test"; |
| 27 | |
| 28 | import * as OpenCode2Client from "./OpenCode2Client.ts"; |
| 29 | |
| 30 | const binaryPath = process.env.OPENCODE2_BIN; |
| 31 | |
| 32 | const 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 | |
| 81 | describe.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 | }); |