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

map.test.ts7.5 KB
// The pure mapping: catalog to options, prompts to request bodies, tool
// results to rows, forms to question cards, and the event envelope.
import { test } from "node:test";
import assert from "node:assert/strict";
import { permissionMode } from "../sdk/agent.ts";
import { parseEvent } from "./api.ts";
import { formAnswer, formQuestion, subagentResult, toolContent, toolKind, toolTitle } from "./map.ts";
import { buildOptions, chosenModel } from "./options.ts";
import type { Catalog } from "./options.ts";
import { ruleset } from "./permission.ts";
import { promptBody, slashCommand } from "./prompt.ts";
import { majorVersion, parseListenUrl, passwordFor, queryString } from "./server.ts";
import { CATALOG } from "./testing.test.ts";

const catalog: Catalog = { ...CATALOG, defaultModel: { id: "ling-free", providerID: "opencode" } };

test("options come from the catalog: models by provider, primary modes, variants by effort", () => {
  const options = buildOptions(catalog);
  const [model, mode, reasoning, permissions] = options;
  assert.equal(model?.value, "opencode/ling-free", "the server's default model");
  assert.deepEqual(
    model?.choices?.map((choice) => [choice.value, choice.group, choice.reasoningLevels?.map((level) => level.value)]),
    [
      ["opencode/fledge-free", "OpenCode Zen", ["low", "high"]],
      ["opencode/ling-free", "OpenCode Zen", undefined],
    ],
  );
  assert.deepEqual(
    mode?.choices?.map((choice) => choice.value),
    ["build"],
    "subagents and hidden agents are not modes",
  );
  assert.equal(reasoning?.id, "variant");
  assert.deepEqual(reasoning?.choices, [], "the default model has no variants");
  assert.deepEqual(
    permissions?.choices?.map((choice) => choice.value),
    ["supervised", "auto_edits", "full"],
  );
});

test("a catalog without a usable model is an error, not an invented list", () => {
  assert.throws(() => buildOptions({ ...catalog, providers: [] }), /no connected provider/);
});

test("the chosen model keeps a variant only when the model has it", () => {
  assert.deepEqual(chosenModel(catalog, { model: "opencode/fledge-free", variant: "high" }), {
    providerID: "opencode",
    id: "fledge-free",
    variant: "high",
  });
  assert.deepEqual(chosenModel(catalog, { model: "opencode/ling-free", variant: "high" }), {
    providerID: "opencode",
    id: "ling-free",
  });
  assert.equal(chosenModel(catalog, { model: "gone/model" }), null);
});

test("mentions stay in the text and name their span", () => {
  const body = promptBody([
    { type: "text", text: "look at " },
    { type: "file_ref", path: "/w/a b.ts" },
    { type: "text", text: " with " },
    { type: "skill", name: "review", input: "carefully" },
    { type: "image", mimeType: "image/png", data: "AAAA" },
  ]);
  assert.equal(body.text, "look at @/w/a b.ts with $review carefully");
  assert.deepEqual(body.files, [
    { uri: "file:///w/a%20b.ts", name: "a b.ts", mention: { start: 8, end: 18, text: "@/w/a b.ts" } },
    { uri: "data:image/png;base64,AAAA", name: "image" },
  ]);
  assert.deepEqual(body.skills, [{ id: "review", mention: { start: 24, end: 31, text: "$review" } }]);
});

test("a slash command is one the server lists", () => {
  const commands = [{ name: "review" }];
  assert.deepEqual(slashCommand({ text: "/review now please" }, commands), { name: "review", text: "now please" });
  assert.deepEqual(slashCommand({ text: "/review" }, commands), { name: "review", text: "" });
  assert.equal(slashCommand({ text: "/unknown" }, commands), null);
  assert.equal(slashCommand({ text: "not /review" }, commands), null);
});

test("tool rows: kinds and titles from the structured name and input", () => {
  assert.equal(toolKind("shell"), "execute");
  assert.equal(toolKind("subagent"), "task");
  assert.equal(toolKind("convergence_lookup"), "other");
  assert.equal(toolTitle("read", { path: "a.txt" }), "a.txt");
  assert.equal(toolTitle("subagent", { description: "Count files" }), "Count files");
  assert.equal(toolTitle("question", { questions: [] }), "question");
  assert.equal(toolTitle("convergence_lucky_number", {}), "lucky_number");
});

test("tool content: diffs from the changed files, command output as a terminal", () => {
  const patch = "@@ -1 +1 @@\n-a\n+b\n";
  assert.deepEqual(
    toolContent("edit", { path: "a" }, [{ type: "text", text: "Edited" }], { files: [{ file: "a", patch }] }),
    [{ type: "diff", path: "a", diff: patch }],
  );
  assert.deepEqual(toolContent("shell", { command: "false" }, [{ type: "text", text: "" }], { exit: 1 }), [
    { type: "terminal", command: "false", output: "", exitCode: 1 },
  ]);
  assert.deepEqual(toolContent("read", {}, [{ type: "text", text: "1: hello" }]), [{ type: "text", text: "1: hello" }]);
});

test("a subagent's result loses its wrapper", () => {
  assert.equal(subagentResult('<subagent sessionID="s" state="completed">\nThe answer\n</subagent>'), "The answer");
  assert.equal(subagentResult("plain"), "plain");
});

test("forms: fields become question fields, answers take each field's type", () => {
  const form = {
    id: "frm_1",
    sessionID: "ses_1",
    title: "Setup",
    fields: [
      { key: "name", type: "string", title: "Name" },
      { key: "tags", type: "multiselect", title: "Tags", options: [{ value: "a", label: "A" }] },
      { key: "count", type: "integer", title: "Count" },
      { key: "ok", type: "boolean", title: "OK?" },
      { key: "secret", type: "string", hidden: true },
    ],
  };
  assert.deepEqual(
    formQuestion(form).fields.map((field) => [field.id, field.kind]),
    [
      ["name", "text"],
      ["tags", "multi_select"],
      ["count", "text"],
      ["ok", "boolean"],
    ],
  );
  assert.deepEqual(formAnswer(form, { name: "x", tags: ["a"], count: "3.7", ok: true, secret: "" }), {
    name: "x",
    tags: ["a"],
    count: 3,
    ok: true,
  });
});

test("events: known types are parsed, others and malformed payloads are skipped", () => {
  assert.deepEqual(parseEvent({ type: "session.execution.started", data: { sessionID: "ses_1" } }), {
    type: "session.execution.started",
    data: { sessionID: "ses_1" },
  });
  assert.equal(parseEvent({ type: "project.updated", data: {} }), null);
  assert.equal(parseEvent({ type: "session.text.delta", data: { sessionID: 1 } }), null);
  assert.equal(parseEvent("nonsense"), null);
});

test("permission rules: full access allows everything, the others ask before acting", () => {
  assert.deepEqual(ruleset(permissionMode.FULL), [
    { action: "*", resource: "*", effect: "allow" },
    { action: "external_directory", resource: "*", effect: "allow" },
  ]);
  const supervised = ruleset(permissionMode.SUPERVISED);
  assert.deepEqual(supervised[0], { action: "*", resource: "*", effect: "ask" });
  assert.equal(supervised.find((rule) => rule.action === "edit")?.effect, "ask");
  assert.equal(ruleset(permissionMode.AUTO_EDITS).find((rule) => rule.action === "edit")?.effect, "allow");
});

test("server helpers: versions, listen lines, passwords and queries", () => {
  assert.equal(majorVersion("opencode v2.0.16"), 2);
  assert.equal(majorVersion("1.18.29\n"), 1);
  assert.equal(majorVersion("nothing"), null);
  assert.equal(parseListenUrl("server listening on http://127.0.0.1:56931"), "http://127.0.0.1:56931");
  assert.equal(parseListenUrl("see http://example.com for help"), null);
  assert.equal(passwordFor(true, null, "ambient"), null, "a local secret never goes to a remote server");
  assert.equal(passwordFor(false, null, "ambient"), "ambient");
  assert.equal(queryString({ "location[directory]": "/a b", cursor: undefined }), "?location%5Bdirectory%5D=%2Fa%20b");
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.