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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.1.0latest | Oct 5, 2026 | >=2 <3 | 45.1 KB | 4 permissions | Listed |
No comments yet.