Official
opencode
OpenCode agent provider: runs opencode serve and talks to it over HTTP and server-sent events.
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/opencode@0.2.0
Permissions in 0.2.0
Files
testing.test.ts8.1 KB
// A scripted stand-in for `opencode serve` behind `api.net.fetch`, for the
// agent tests: the event stream, the session lookups and the pending
// request lists, with every other call recorded.
//
// Plus the scoped adapter runtime for the provider's scripted tests: a
// local Promise facade keeps the scenarios and assertions close to the
// JavaScript originals.
import { afterEach } from "node:test";
import * as Effect from "effect/Effect";
import * as ManagedRuntime from "effect/ManagedRuntime";
import * as Scope from "effect/Scope";
import * as Exit from "effect/Exit";
import { liveLayer } from "convergence/effect";
import type { PluginServices } from "convergence/effect";
import type { Api, RequestInit } from "convergence";
import { FakeStream, fakeApi, fakeResponse } from "../sdk/testing.ts";
import type { FakeOptions } from "../sdk/testing.ts";
import { OpenCodeAgent } from "./agent.ts";
import { defaultInstance } from "./instances.ts";
import type { Instance } from "./instances.ts";
export const BASE = "http://127.0.0.1:4096";
export interface FakeCall {
method: string;
path: string;
query: Record<string, string>;
body: unknown;
headers: Record<string, string>;
}
export type FakeRoutes = (
call: FakeCall,
) => { status?: number; body?: unknown; headers?: Record<string, string> } | undefined;
/// `events`: the SSE payload each `GET /event` connection sends; entry `n`
/// serves connection `n`, and the last one is held open. `routes(call)`
/// answers any other call (`{ status, body }`), or `undefined` for the
/// defaults.
export class FakeOpenCode {
events: string[];
routes: FakeRoutes;
status: unknown;
connections = 0;
streams: FakeStream[] = [];
calls: FakeCall[] = [];
created = 0;
/// `status` answers `GET /session/status` (the sessions at work); none
/// answers 404, as a server that cannot say.
constructor({
events = [""],
routes = () => undefined,
status = null,
}: { events?: string[]; routes?: FakeRoutes; status?: unknown } = {}) {
this.events = events;
this.routes = routes;
this.status = status;
}
handle(url: string, init: RequestInit = {}) {
const parsed = new URL(url);
const method = String(init.method ?? "GET").toUpperCase();
const query = Object.fromEntries(parsed.searchParams.entries());
const raw = init.body;
const call: FakeCall = {
method,
path: parsed.pathname,
query,
body: raw ? JSON.parse(String(raw)) : null,
headers: headerRecord(init),
};
if (parsed.pathname === "/event") {
const index = this.connections++;
const stream = new FakeStream();
this.streams.push(stream);
const payload = this.events[index];
if (payload) stream.push(payload);
// The last entry is the steady state: held open.
if (index + 1 < this.events.length) stream.end();
return fakeResponse({ status: 200, body: stream, headers: { "content-type": "text/event-stream" } });
}
this.calls.push(call);
const answer = this.routes(call);
if (answer !== undefined) return fakeResponse(answer);
const path = parsed.pathname;
if (path === "/session/status")
return this.status ? fakeResponse({ body: this.status }) : fakeResponse({ status: 404, body: "" });
if (path === "/permission" || path === "/question" || path.endsWith("/children")) return fakeResponse({ body: [] });
if (method === "GET" && /^\/session\/[^/]+$/.test(path)) return fakeResponse({ body: { id: path.split("/")[2] } });
if (method === "POST" && path === "/mcp") return fakeResponse({ body: { convergence: { status: "connected" } } });
if (method === "POST" && path === "/session") {
this.created += 1;
const body = (call.body ?? {}) as { permission?: unknown };
return fakeResponse({ body: { id: `ses_new${this.created}`, permission: body.permission ?? [] } });
}
if (path.startsWith("/session/")) return fakeResponse({ body: { id: "ses_1" } });
return fakeResponse({ status: 204, body: "" });
}
/// Pushes more events on the open stream.
push(...events: unknown[]) {
const stream = this.streams.at(-1);
if (!stream) throw new Error("no event stream is open");
for (const event of events) stream.push(sse(event));
}
/// Waits until a call `test` accepts has arrived.
async waitFor(test: (call: FakeCall) => unknown, timeout = 3000): Promise<FakeCall> {
const deadline = Date.now() + timeout;
for (;;) {
const found = this.calls.find(test);
if (found) return found;
if (Date.now() > deadline)
throw new Error(`the call never came: ${JSON.stringify(this.calls.map((c) => `${c.method} ${c.path}`))}`);
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
}
/// The request headers as a record, for the calls the tests inspect.
function headerRecord(init: RequestInit): Record<string, string> {
const headers = init.headers;
if (headers && typeof headers === "object" && !Array.isArray(headers) && !(headers instanceof Headers)) {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) out[key] = String(value);
return out;
}
return {};
}
/// One SSE event carrying `value` as JSON.
export function sse(value: unknown): string {
return `data: ${JSON.stringify(value)}\n\n`;
}
/// A fake `api` whose network is `server`.
export function apiFor(server: FakeOpenCode, options: FakeOptions = {}) {
return fakeApi({ ...options, onFetch: (url, init) => server.handle(url, init) });
}
/// Collects the agent's events until `stop(event)` is true or `timeout`
/// passes.
export async function collect<E>(emitted: E[], stop: (event: E) => unknown, timeout = 3000): Promise<E[]> {
const deadline = Date.now() + timeout;
for (;;) {
const found = emitted.findIndex(stop);
if (found >= 0) return emitted.slice(0, found + 1);
if (Date.now() > deadline) return [...emitted];
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
type PromiseMethods<A> = {
[K in keyof A]: A[K] extends (...args: infer P) => Effect.Effect<infer R, infer _E, infer _S>
? (...args: P) => Promise<R>
: A[K];
};
const cleanups = new Set<() => Promise<void>>();
afterEach(async () => {
for (const close of cleanups) await close();
cleanups.clear();
});
/// An agent on the plugin scope with Effect methods as Promises, for the
/// scripted scenarios. `instance` defaults to a server the user runs (so
/// nothing is started).
export function testAgent(
api: Api,
env: Record<string, string> = {},
instance: Instance = { ...defaultInstance(), serverUrl: BASE },
) {
const scope = Scope.makeUnsafe();
const builtins = liveLayer(
api,
(effect) => runtime.runPromise(effect as Effect.Effect<never, unknown, PluginServices>),
() => {},
);
const runtime = ManagedRuntime.make(builtins);
const agent = new OpenCodeAgent({ scope, instance, env });
cleanups.add(async () => {
await runtime.runPromise(Scope.close(scope, Exit.void));
await runtime.dispose();
});
// This locally built proxy changes only Effect methods into Promise methods.
// Their environment is the same PluginServices provided by this runtime.
return new Proxy(agent, {
get(target, key, receiver) {
const value: unknown = Reflect.get(target, key, receiver);
if (typeof value !== "function") return value;
return (...args: unknown[]) => {
const result: unknown = Reflect.apply(value, target, args);
return Effect.isEffect(result)
? runtime.runPromise(result as Effect.Effect<unknown, unknown, PluginServices>)
: result;
};
},
}) as unknown as PromiseMethods<OpenCodeAgent>;
}
/// Runs one Effect with the plugin services of `api`, for the client, CLI
/// and maintenance tests.
export function testRuntime(api: Api) {
const builtins = liveLayer(
api,
(effect) => runtime.runPromise(effect as Effect.Effect<never, unknown, PluginServices>),
() => {},
);
const runtime = ManagedRuntime.make(builtins);
cleanups.add(async () => {
await runtime.dispose();
});
return {
run: <A, E>(effect: Effect.Effect<A, E, PluginServices>): Promise<A> => runtime.runPromise(effect),
runtime,
};
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 94.8 KB | 5 permissions | Listed |
No comments yet.