Official
opencode-v2
OpenCode 2 agent provider: runs opencode serve (2.x) and talks to its /api 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-v2@0.1.0
Permissions in 0.1.0
Files
testing.test.ts6.4 KB
// A scripted stand-in for an OpenCode 2 server behind `api.net.fetch`, and
// the runtime the agent tests run on: the event stream the test feeds,
// default answers for the session calls, and every call recorded.
import { afterEach } from "node:test";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as ManagedRuntime from "effect/ManagedRuntime";
import * as Scope from "effect/Scope";
import { liveLayer } from "convergence/effect";
import type { PluginServices } from "convergence/effect";
import type { AgentEvent } from "convergence/protocol";
import type { RequestInit } from "convergence";
import { FakeStream, fakeApi, fakeResponse } from "../sdk/testing.ts";
import type { HostScript } from "../sdk/testing.ts";
import { OpenCodeV2Agent } from "./agent.ts";
export const BASE = "http://127.0.0.1:4096";
export interface FakeCall {
method: string;
path: string;
query: Record<string, string>;
body: unknown;
}
export type Routes = (call: FakeCall) => { status?: number; body?: unknown } | undefined;
/// The catalog a fresh server answers with.
export const CATALOG = {
providers: [{ id: "opencode", name: "OpenCode Zen" }],
models: [
{
id: "fledge-free",
providerID: "opencode",
name: "Fledge Free",
enabled: true,
limit: { context: 200_000 },
variants: [{ id: "high" }, { id: "low" }],
},
{ id: "ling-free", providerID: "opencode", name: "Ling Free", enabled: true },
],
agents: [
{ id: "build", name: "Build", mode: "primary", hidden: false },
{ id: "explore", name: "Explore", mode: "subagent", hidden: false },
{ id: "title", name: "Title", mode: "primary", hidden: true },
],
};
export class FakeServer {
routes: Routes;
calls: FakeCall[] = [];
streams: FakeStream[] = [];
created = 0;
constructor(routes: Routes = () => undefined) {
this.routes = routes;
}
handle(url: string, init: RequestInit = {}) {
const parsed = new URL(url);
const call: FakeCall = {
method: String(init.method ?? "GET").toUpperCase(),
path: parsed.pathname,
query: Object.fromEntries(parsed.searchParams.entries()),
body: init.body ? JSON.parse(String(init.body)) : null,
};
if (call.path === "/api/event") {
const stream = new FakeStream();
this.streams.push(stream);
stream.push(sse({ type: "server.connected", data: {} }));
return fakeResponse({ status: 200, body: stream, headers: { "content-type": "text/event-stream" } });
}
this.calls.push(call);
const answer = this.routes(call);
if (answer) return fakeResponse(answer);
return fakeResponse(this.answer(call));
}
answer(call: FakeCall): { status?: number; body?: unknown } {
const { method, path } = call;
if (path === "/api/info") return { body: { version: "2.0.16" } };
if (path === "/api/provider") return { body: { data: CATALOG.providers } };
if (path === "/api/model") return { body: { data: CATALOG.models } };
if (path === "/api/model/default") return { body: { data: { id: "fledge-free", providerID: "opencode" } } };
if (path === "/api/agent") return { body: { data: CATALOG.agents } };
if (path === "/api/command") return { body: { data: [{ name: "review", description: "review changes" }] } };
if (path === "/api/session/active") return { body: { data: {} } };
if (path === "/api/permission/request" || path === "/api/form") return { body: { data: [] } };
if (path === "/api/mcp") return { body: { data: [{ name: "convergence", status: { status: "connected" } }] } };
if (method === "POST" && path === "/api/session") {
this.created += 1;
return { body: { data: session(`ses_new${this.created}`) } };
}
if (method === "GET" && /^\/api\/session\/[^/]+$/.test(path)) {
return { body: { data: session(path.split("/")[3] ?? "") } };
}
if (method === "POST" && path.endsWith("/prompt")) {
const body = call.body as { id?: string; delivery?: string; text: string };
return { body: { data: { id: body.id ?? "msg_1", sessionID: path.split("/")[3], type: "user", time: { created: 1 }, payload: { text: body.text }, delivery: body.delivery ?? "steer" } } };
}
return { status: 204, body: "" };
}
/// Pushes events on the open stream.
push(...events: { type: string; data: unknown; location?: { directory: string } }[]) {
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));
}
find(method: string, path: string): FakeCall | undefined {
return this.calls.find((call) => call.method === method && call.path === path);
}
}
export function session(id: string, extra: Record<string, unknown> = {}) {
return { id, time: { created: 1, updated: 2 }, ...extra };
}
export function sse(value: unknown): string {
return `data: ${JSON.stringify(value)}\n\n`;
}
const cleanups = new Set<() => Promise<void>>();
afterEach(async () => {
for (const close of cleanups) await close();
cleanups.clear();
});
/// The agent on a fake server the user runs (so nothing is started), its
/// events in `emitted`, and `run` for its Effect methods.
export function harness({ routes, onHost }: { routes?: Routes; onHost?: HostScript } = {}) {
const server = new FakeServer(routes);
const api = fakeApi({ onFetch: (url, init) => server.handle(url, init), ...(onHost ? { onHost } : {}) });
const runtime: ManagedRuntime.ManagedRuntime<PluginServices, never> = ManagedRuntime.make(
liveLayer(
api,
(effect) => runtime.runPromise(effect as Effect.Effect<never, unknown, PluginServices>),
() => {},
),
);
const scope = Scope.makeUnsafe();
const agent = new OpenCodeV2Agent(scope, { binaryPath: null, serverUrl: BASE });
const emitted: AgentEvent[] = [];
agent.emit = (event) => emitted.push(event);
const run = <A, E>(effect: Effect.Effect<A, E, PluginServices>): Promise<A> => runtime.runPromise(effect);
cleanups.add(async () => {
await runtime.runPromise(agent.shutdown());
await runtime.runPromise(Scope.close(scope, Exit.void));
await runtime.dispose();
});
return { agent, api, server, emitted, run };
}
/// Waits until `test` holds, polling.
export async function until(test: () => unknown, timeout = 3000): Promise<void> {
const deadline = Date.now() + timeout;
while (!test()) {
if (Date.now() > deadline) throw new Error("the condition never held");
await new Promise((resolve) => setTimeout(resolve, 5));
}
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.1.0latest | Oct 5, 2026 | >=2 <3 | 45.1 KB | 4 permissions | Listed |
No comments yet.