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

  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode 2 agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode 2 server (`opencode serve`, or the binary you choose in Settings) and read its versionPrograms: opencode${settings.binaryPath}
  • Network access netMediumConnects to the listed hosts.Talk to the OpenCode server it starts on this computer, at the port it picks for each launch, and to the external server you choose in SettingsHosts: localhost:*${settings.serverUrl}
  • Environment variables envMediumReads the listed environment variables.Read the user name and password of the OpenCode serverVariables: OPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

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

VersionPublishedPlugin APISizePermissionsStatus
0.1.0latestOct 5, 2026>=2 <345.1 KB4 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.