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

  • Read files fs.readMediumReads files in the listed places.Read, once, the servers the previous OpenCode provider keptPlaces: its own data folder
  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode server (`opencode serve`, or the binary you choose in Settings), read its catalog from the command line when the server cannot answer, upgrade it (`opencode upgrade`), and ask or tell the npm installation that owns it about a newer versionPrograms: opencodenpm${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.Expand ~ in a configured binary, and read the OpenCode settings you set in the environment: the binary to run, an external server's address, and the server's user name and passwordVariables: HOMEOPENCODE_PATHOPENCODE_SERVER_URLOPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.8 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.