Official

acp

ACP agents from the ACP registry (Gemini, Cursor, Droid, Kilo, pi, ...), Oh My Pi, and your own entries (custom.json in the plugin's data folder). Agents are discovered at runtime.

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/acp@0.2.0

Permissions in 0.2.0

Take care. This plugin asks for permissions that can do anything your account can. The app asks you to hold Enable for two seconds or to type the plugin's name before it turns on.
  • Run any command process.anyDangerousStarts any program or shell command. This is as strong as your own account.Start the ACP agents, which the registry launches by path or through package runners such as npx and uvx, and run the terminal commands an agent asks for
  • Provide agents agents.provideMediumAdds agents to the app.Provide the agents of the ACP registry, and serve plugin tools to them through the host's loopback MCP server
  • Network access netMediumConnects to the listed hosts.Download the ACP registry and the agents' marks once a dayHosts: cdn.agentclientprotocol.com
  • Read files fs.readMediumReads files in the listed places.Read the files an agent asks for (ACP fs/read_text_file), only inside the chat's workspace, and your own agents from custom.json in this plugin's data folderPlaces: the open workspaceits own data folder
  • Write files fs.writeMediumCreates, changes and deletes files in the listed places.Write the files an agent asks to write (ACP fs/write_text_file), only inside the chat's workspace, and move the custom agents of the old ACP plugin into custom.json oncePlaces: the open workspaceits own data folder
  • Environment variables envMediumReads the listed environment variables.Sign Grok in with your xAI API key when you set one, and tell which Windows build of an agent to runVariables: XAI_API_KEYPROCESSOR_ARCHITECTURE

Files

map.test.ts22.7 KB
// Ported from the Rust plugin's map.rs tests.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { ContentBlock } from "convergence/protocol";
import * as map from "./map.ts";

test("tool kinds come from the ACP kind", () => {
  assert.equal(map.toolKind("execute"), "execute");
  assert.equal(map.toolKind("switch_mode"), "switch_mode");
  assert.equal(map.toolKind(undefined), "other");
  assert.equal(map.toolKind("teleport"), "other");
});

test("stop reasons map to outcomes", () => {
  assert.deepEqual(map.stopReason("end_turn"), { status: "completed" });
  assert.deepEqual(map.stopReason("max_tokens"), { status: "completed" });
  assert.deepEqual(map.stopReason("cancelled"), { status: "cancelled" });
  assert.equal(map.stopReason("refusal").status, "failed");
});

test("a diff keeps both snapshots", () => {
  assert.deepEqual(map.toolContent([{ type: "diff", path: "/w/a.rs", oldText: "a\n", newText: "b\n" }]), [
    { type: "diff", path: "/w/a.rs", oldText: "a\n", newText: "b\n" },
  ]);
});

test("a terminal content item carries the command and its output", () => {
  const terminals = new Map([["t1", { command: "ls -a", cwd: "/w", output: ".\n..\n", exitCode: 0 }]]);
  assert.deepEqual(map.toolContent([{ type: "terminal", terminalId: "t1" }], terminals), [
    { type: "terminal", command: "ls -a", cwd: "/w", output: ".\n..\n", exitCode: 0 },
  ]);
  // One not known yet is an empty terminal, not a missing row.
  assert.deepEqual(map.toolContent([{ type: "terminal", terminalId: "t2" }], terminals), [
    { type: "terminal", command: "", output: "" },
  ]);
});

test("modes and config options become one snapshot", () => {
  const modes = {
    currentModeId: "ask",
    availableModes: [
      { id: "ask", name: "Ask" },
      { id: "code", name: "Code" },
    ],
  };
  const config = [
    {
      id: "model",
      name: "Model",
      category: "model",
      type: "select",
      currentValue: "composer-2.5",
      options: [{ group: "Cursor", name: "Cursor", options: [{ value: "composer-2.5", name: "Composer 2.5" }] }],
    },
    {
      id: "thought_level",
      name: "Thinking",
      category: "thought_level",
      type: "select",
      currentValue: "high",
      options: [{ value: "high", name: "High" }],
    },
    { id: "fast", name: "Fast", category: "model_config", type: "boolean", currentValue: true },
  ];
  const options = map.options(modes, null, config);
  assert.ok(options[1]?.choices?.[0]);
  assert.equal(options[0].id, map.MODE_OPTION);
  assert.equal(options[0].category, "mode");
  assert.equal(options[0].value, "ask");
  assert.equal(options[1].category, "model");
  assert.equal(options[1].choices[0].group, "Cursor");
  assert.equal(options[2].category, "reasoning");
  assert.equal(options[3].category, "model_config");
  assert.equal(options[3].kind, "toggle");
  assert.equal(options[3].value, true);
});

test("an option of a type this client does not know is left out, not the snapshot", () => {
  const options = map.options(null, null, [
    { id: "odd", name: "Odd", type: "slider", currentValue: 3 },
    { id: "fast", name: "Fast", type: "boolean", currentValue: false },
  ]);
  assert.deepEqual(
    options.map((option) => option.id),
    ["fast"],
  );
  // An option without a category files under its own id.
  assert.equal(options[0].category, "fast");
});

// Agents on the older schema publish models here and nowhere else, so
// without this the chat has no model picker at all.
test("models become a picker with the chosen model's reasoning levels", () => {
  const models = {
    currentModelId: "grok-4.6",
    availableModels: [
      {
        modelId: "grok-4.6",
        name: "Grok 4.6",
        description: "The big one",
        _meta: { reasoningEffort: "high", reasoningEfforts: ["low", "high", "xhigh"] },
      },
      { modelId: "grok-code", name: "Grok Code" },
    ],
  };
  const options = map.options(null, models, []);
  assert.ok(options[0]?.choices?.[0]?.reasoningLevels);
  assert.ok(options[0].choices[1]);
  assert.equal(options[0].id, map.MODEL_OPTION);
  assert.equal(options[0].category, "model");
  assert.equal(options[0].value, "grok-4.6");
  assert.equal(options[0].choices[0].description, "The big one");
  assert.equal(options[0].choices[0].reasoningLevels.length, 3);
  assert.equal(options[0].choices[1].reasoningLevels, undefined, "levels belong to the model that has them");
  assert.equal(options[1].id, map.EFFORT_OPTION);
  assert.equal(options[1].category, "reasoning");
  assert.equal(options[1].value, "high", "the agent's own current effort");
});

test("levels may be objects with a value and a name", () => {
  assert.deepEqual(
    map.effortLevels({ reasoningEffort: [{ id: "low", name: "Low", description: "Fast" }, { effort: "max" }, 7] }),
    [
      { value: "low", name: "Low", description: "Fast" },
      { value: "max", name: "max" },
    ],
  );
});

// An agent on the current schema publishes the same setting as a config
// option; describing it twice would give the chat two pickers.
test("models are dropped when a config option already owns the model", () => {
  const models = { currentModelId: "a", availableModels: [{ modelId: "a", name: "A" }] };
  const config = [
    {
      id: "chosen_model",
      name: "Model",
      category: "model",
      type: "select",
      currentValue: "a",
      options: [{ value: "a", name: "A" }],
    },
  ];
  const options = map.options(null, models, config);
  assert.equal(options.length, 1);
  assert.equal(options[0].id, "chosen_model");
});

// A model with one known effort is not a picker: the agent told us what it
// is set to, not what it accepts.
test("a single reported effort gives no reasoning picker", () => {
  const models = {
    currentModelId: "a",
    availableModels: [{ modelId: "a", name: "A", _meta: { reasoningEffort: "high" } }],
  };
  const options = map.options(null, models, []);
  assert.equal(options.length, 1);
  assert.equal(options[0].id, map.MODEL_OPTION);
  assert.equal(map.currentEffort(models), "high");
});

// pi publishes its thinking levels as legacy modes and again as a
// `thought_level` config option; only the config option is kept.
test("legacy modes that duplicate a config option are dropped", () => {
  const modes = {
    currentModeId: "off",
    availableModes: [
      { id: "off", name: "Thinking: off" },
      { id: "high", name: "Thinking: high" },
    ],
  };
  const config = [
    {
      id: "thinking",
      name: "Thinking",
      category: "thought_level",
      type: "select",
      currentValue: "off",
      options: [
        { value: "high", name: "Thinking: high" },
        { value: "off", name: "Thinking: off" },
      ],
    },
  ];
  const options = map.options(modes, null, config);
  assert.equal(options.length, 1);
  assert.equal(options[0].category, "reasoning");
});

test("config values carry their type", () => {
  assert.deepEqual(map.configValue("high"), { value: "high" });
  assert.deepEqual(map.configValue(true), { type: "boolean", value: true });
});

test("elicitation schemas become question fields, in the schema's order", () => {
  const schema = {
    type: "object",
    properties: {
      branch: { type: "string", title: "Branch", description: "Where to push" },
      force: { type: "boolean", title: "Force" },
      target: {
        type: "string",
        oneOf: [
          { const: "main", title: "Main" },
          { const: "dev", title: "Dev" },
        ],
      },
      tags: { type: "array", items: { enum: ["a", "b"], enumNames: ["A"] } },
    },
    required: ["branch"],
  };
  const fields = map.questionFields(schema);
  assert.deepEqual(
    fields.map((field) => field.id),
    ["branch", "force", "target", "tags"],
  );
  const [branch, force, target, tags] = fields;
  assert.ok(target.options?.[0]);
  assert.equal(branch.kind, "text");
  assert.equal(branch.required, true);
  assert.equal(branch.description, "Where to push");
  assert.equal(force.kind, "boolean");
  assert.equal(target.kind, "select");
  assert.equal(target.options[0].label, "Main");
  assert.equal(tags.kind, "multi_select");
  assert.deepEqual(tags.options, [
    { value: "a", label: "A" },
    { value: "b", label: "b" },
  ]);
});

test("numbers go back as numbers where the schema asks for them", () => {
  const schema = { properties: { count: { type: "integer" }, ratio: { type: "number" }, name: { type: "string" } } };
  assert.deepEqual(map.elicitationContent(schema, { count: "3", ratio: "0.5", name: "7", other: true }), {
    count: 3,
    ratio: 0.5,
    name: "7",
    other: true,
  });
});

test("prompt blocks use resource links for mentions", () => {
  const everything = { image: true, audio: true, embeddedContext: true };
  const blocks = map.promptBlocks(
    [
      { type: "text", text: "look at " },
      { type: "file_ref", path: "/w/src/main.rs" },
      { type: "image", mimeType: "image/png", data: "AAA" },
      { type: "skill", name: "review", input: "" },
      { type: "resource", path: "/w/a.rs", text: "fn main() {}" },
    ],
    everything,
    "Test",
  );
  assert.deepEqual(blocks[0], { type: "text", text: "look at " });
  assert.deepEqual(blocks[1], { type: "resource_link", uri: "file:///w/src/main.rs", name: "main.rs" });
  assert.deepEqual(blocks[2], { type: "image", mimeType: "image/png", data: "AAA" });
  assert.deepEqual(blocks[3], { type: "text", text: "/review" });
  assert.deepEqual(blocks[4], {
    type: "resource",
    resource: { uri: "file:///w/a.rs", text: "fn main() {}", mimeType: "text/plain" },
  });
});

// Sending a block the agent did not declare fails the whole prompt with the
// agent's own wording, which says nothing the user can act on.
test("a prompt block the agent cannot read is refused here", () => {
  const textOnly = {};
  const image: ContentBlock = { type: "image", mimeType: "image/png", data: "AAA" };
  assert.throws(() => map.promptBlocks([image], textOnly, "Test"), /Test cannot read images/);
  assert.throws(() => map.promptBlocks([{ type: "audio", mimeType: "audio/wav", data: "AAA" }], textOnly, "Test"));
  assert.throws(() =>
    map.promptBlocks([{ type: "resource", path: "/w/a.rs", text: "fn main() {}" }], textOnly, "Test"),
  );
  // A mention is a link, not content, so it needs no capability.
  assert.doesNotThrow(() => map.promptBlocks([{ type: "file_ref", path: "/w/a.rs" }], textOnly, "Test"));
  // The declared type still has to match the bytes, and the blob has to be
  // small enough to be worth sending.
  const images = { image: true };
  assert.throws(
    () => map.promptBlocks([{ type: "image", mimeType: "application/pdf", data: "AAA" }], images, "Test"),
    /not image/,
  );
  assert.throws(
    () =>
      map.promptBlocks([{ type: "image", mimeType: "image/png", data: "A".repeat(map.MAX_BLOB * 2) }], images, "Test"),
    /larger than/,
  );
  assert.doesNotThrow(() => map.promptBlocks([image], images, "Test"));
});

test("a turn that hit a limit says so", () => {
  assert.match(map.stopNotice("max_tokens"), /output limit/);
  assert.match(map.stopNotice("max_turn_requests"), /requests per turn/);
  assert.equal(map.stopNotice("end_turn"), null);
  assert.equal(map.stopNotice("cancelled"), null);
});

test("token counts carry the whole breakdown", () => {
  const reported = {
    totalTokens: 1200,
    inputTokens: 900,
    outputTokens: 300,
    thoughtTokens: 120,
    cachedReadTokens: 400,
    cachedWriteTokens: 50,
  };
  assert.deepEqual(map.usage(reported, null), {
    usedTokens: 1200,
    inputTokens: 900,
    outputTokens: 300,
    reasoningTokens: 120,
    cachedInputTokens: 450,
  });
  // An agent that reports no cache says nothing about it, rather than
  // claiming zero cached tokens.
  const bare = map.usage({ totalTokens: 5, inputTokens: 5, outputTokens: 0 }, null);
  assert.equal(bare.cachedInputTokens, undefined);
  assert.equal(bare.outputTokens, undefined);
});

test("session totals keep the context the agent reported", () => {
  const merged = map.usage(
    { totalTokens: 480_000, inputTokens: 470_000, outputTokens: 10_000 },
    { usedTokens: 32_000, contextWindow: 200_000, costUsd: 0.5 },
  );
  assert.deepEqual([merged.usedTokens, merged.contextWindow, merged.costUsd], [32_000, 200_000, 0.5]);
  assert.equal(merged.inputTokens, 470_000);
});

test("the context meter comes from usage_update, in dollars only", () => {
  assert.deepEqual(map.contextUsage({ used: 40, size: 1000 }), { usedTokens: 40, contextWindow: 1000 });
  assert.deepEqual(map.contextUsage({ used: 1, size: 0, cost: { amount: 0.25, currency: "USD" } }), {
    usedTokens: 1,
    costUsd: 0.25,
  });
  assert.deepEqual(map.contextUsage({ used: 1, size: 10, cost: { amount: 3, currency: "EUR" } }), {
    usedTokens: 1,
    contextWindow: 10,
  });
});

test("a tool result image is kept as an image", () => {
  const content = [
    { type: "content", content: { type: "image", data: "AAA", mimeType: "image/png", uri: "file:///w/shot.png" } },
  ];
  assert.deepEqual(map.toolContent(content), [
    { type: "image", mimeType: "image/png", data: "AAA", path: "/w/shot.png" },
  ]);
});

// Several agents put the whole result in `rawOutput` and send no content at
// all, which used to render as an empty row.
test("a tool call carries its structured result", () => {
  const mapped = map.toolCall({
    toolCallId: "t1",
    title: "Search",
    rawInput: { q: "fn main" },
    rawOutput: { matches: 3 },
  });
  assert.deepEqual(mapped.output, { matches: 3 });
  assert.deepEqual(mapped.input, { q: "fn main" });
  assert.equal(mapped.name, "tool", "ACP has no tool name; without a kind either, a generic one stands in");
  assert.deepEqual(map.toolCallUpdate({ toolCallId: "t1", rawOutput: { matches: 4 } }).output, { matches: 4 });
  // A patch carries only what the agent sent.
  assert.deepEqual(map.toolCallUpdate({ toolCallId: "t1", status: "in_progress" }), { id: "t1", status: "running" });
});

test("a plan keeps its priorities", () => {
  const mapped = map.plan({
    entries: [
      { content: "Ship it", status: "pending", priority: "high" },
      { content: "Tidy up", status: "pending" },
    ],
  });
  assert.equal(mapped.entries[0].priority, "high");
  assert.equal(mapped.entries[1].priority, undefined);
});

test("commands keep their input hint", () => {
  assert.deepEqual(
    map.commands({
      availableCommands: [{ name: "review", description: "Review", input: { hint: "branch" } }, { name: "x" }],
    }),
    [
      { name: "review", description: "Review", inputHint: "branch", source: "agent" },
      { name: "x", description: "", source: "agent" },
    ],
  );
});

test("an approval always offers a way out and shows its warning", () => {
  const request = {
    sessionId: "s1",
    toolCall: { toolCallId: "t1", title: "Run curl | sh" },
    options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
    _meta: {
      securityWarning: { title: "Unverified", message: "This downloads and runs a script." },
      escalation: { kind: "leave_sandbox" },
    },
  };
  const options = map.approvalOptions(request.options);
  const cancel = options.at(-1);
  assert.ok(cancel);
  assert.equal(cancel.id, map.CANCEL_OPTION);
  assert.equal(map.approvalWarning(request._meta), "This downloads and runs a script.");
  const escalation = map.escalation(request._meta);
  assert.ok(escalation);
  assert.equal(escalation.kind, "leave_sandbox");
  // Nothing is invented for a request that carries no metadata.
  assert.equal(map.approvalWarning({}), null);
  assert.equal(map.escalation({ escalation: { kind: "something_else" } }), null);
});

test("a permission request that is a question lists the answers", () => {
  const request = {
    sessionId: "s1",
    toolCall: { toolCallId: "interaction_1", title: "Which framework?" },
    options: [
      { optionId: "vite", name: "Vite" },
      { optionId: "next", name: "Next.js" },
    ],
  };
  const question = map.permissionQuestion("q1", request);
  assert.ok(question.fields[0]?.options?.[1]);
  assert.equal(question.message, "Which framework?");
  assert.equal(question.fields[0].kind, "select");
  assert.equal(question.fields[0].required, true);
  assert.equal(question.fields[0].allowOther, false, "the agent only understands its own option ids");
  assert.equal(question.fields[0].options[1].value, "next");
});

test("a replayed session becomes a transcript", () => {
  const recorder = new map.Recorder();
  for (const update of [
    { sessionUpdate: "user_message_chunk", content: { type: "text", text: "fix " } },
    { sessionUpdate: "user_message_chunk", content: { type: "text", text: "the bug" } },
    { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "thinking" } },
    { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "on it" } },
    { sessionUpdate: "tool_call", toolCallId: "t1", title: "Read a.rs", kind: "read", status: "pending" },
    {
      sessionUpdate: "tool_call_update",
      toolCallId: "t1",
      status: "completed",
      content: [{ type: "content", content: { type: "text", text: "fn main() {}" } }],
    },
    { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "done" } },
  ]) {
    recorder.push(update);
  }
  const items = recorder.finish();
  assert.equal(items.length, 5);
  assert.ok(items[0]?.role === "user");
  assert.ok(items[1]?.role === "reasoning");
  assert.ok(items[2]?.role === "assistant");
  assert.ok(items[3]?.role === "tool");
  assert.ok(items[4]?.role === "assistant");
  assert.equal(items[0].role, "user");
  assert.deepEqual(items[0].blocks, [{ type: "text", text: "fix the bug" }]);
  assert.deepEqual([items[1].role, items[1].text], ["reasoning", "thinking"]);
  assert.deepEqual([items[2].role, items[2].text], ["assistant", "on it"]);
  assert.equal(items[3].role, "tool");
  assert.equal(items[3].call.status, "completed");
  assert.equal(items[3].call.kind, "read");
  assert.deepEqual(items[3].call.content, [{ type: "text", text: "fn main() {}" }]);
  // A tool call splits the assistant message, so the last chunk is its own
  // item rather than an extension of "on it".
  assert.deepEqual([items[4].role, items[4].text], ["assistant", "done"]);
});

test("a replay leaves out the instructions this plugin sent", () => {
  const recorder = new map.Recorder();
  const block = map.instructionsBlock("Follow the rules.");
  // An agent may split the text anywhere.
  recorder.push({ sessionUpdate: "user_message_chunk", content: { type: "text", text: block.text.slice(0, 10) } });
  recorder.push({ sessionUpdate: "user_message_chunk", content: { type: "text", text: block.text.slice(10) } });
  recorder.push({ sessionUpdate: "user_message_chunk", content: { type: "text", text: "hello" } });
  recorder.push({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hi" } });
  const items = recorder.finish();
  assert.ok(items[0]?.role === "user");
  assert.deepEqual(items[0].blocks, [{ type: "text", text: "hello" }]);
  // A message that was only instructions leaves nothing behind.
  const only = new map.Recorder();
  only.push({ sessionUpdate: "user_message_chunk", content: block });
  assert.deepEqual(only.finish(), []);
});

test("a tool update without its start still makes a row, and later patches land on it", () => {
  const recorder = new map.Recorder();
  recorder.push({
    sessionUpdate: "tool_call_update",
    toolCallId: "t9",
    title: "Edit",
    kind: "edit",
    status: "in_progress",
  });
  recorder.push({
    sessionUpdate: "tool_call_update",
    toolCallId: "t9",
    status: "completed",
    content: [{ type: "diff", path: "/w/a", newText: "x" }],
  });
  const [item] = recorder.finish();
  assert.ok(item?.role === "tool");
  assert.equal(item.call.title, "Edit");
  assert.equal(item.call.status, "completed");
  assert.deepEqual(item.call.content, [{ type: "diff", path: "/w/a", newText: "x" }]);
});

test("an update's empty title keeps the title the call started with", () => {
  // OpenCode-based agents (Kilo) finish an MCP call with `title: ""`.
  assert.equal(map.toolCallUpdate({ toolCallId: "t1", status: "completed", title: "" }).title, undefined);
  assert.equal(map.toolCallUpdate({ toolCallId: "t1", title: "Read a.rs" }).title, "Read a.rs");
  const recorder = new map.Recorder();
  recorder.push({ sessionUpdate: "tool_call", toolCallId: "t1", title: "convergence_lucky_number", status: "pending" });
  recorder.push({ sessionUpdate: "tool_call_update", toolCallId: "t1", title: "", status: "completed" });
  const [item] = recorder.finish();
  assert.ok(item?.role === "tool");
  assert.equal(item.call.title, "convergence_lucky_number");
});

test("replayed user content keeps its structure", () => {
  assert.deepEqual(map.userBlock({ type: "resource_link", uri: "file:///w/a.rs", name: "a.rs" }), {
    type: "file_ref",
    path: "/w/a.rs",
  });
  assert.deepEqual(map.userBlock({ type: "image", data: "AAA", mimeType: "image/png" }), {
    type: "image",
    mimeType: "image/png",
    data: "AAA",
  });
  assert.deepEqual(map.userBlock({ type: "resource", resource: { uri: "file:///w/a", text: "body" } }), {
    type: "text",
    text: "body",
  });
});

// pi-acp's bash calls, as it sends them: the agent runs the command and
// reports the terminal in `_meta` (terminal_info, then terminal_output and
// terminal_exit). The row once showed an empty terminal named by its call id.
const piBash = [
  {
    sessionUpdate: "tool_call",
    toolCallId: "call_1",
    title: "ls -la",
    kind: "execute",
    status: "pending",
    content: [{ type: "terminal", terminalId: "call_1" }],
    _meta: { terminal_info: { terminal_id: "call_1", cwd: "/w" } },
  },
  {
    sessionUpdate: "tool_call_update",
    toolCallId: "call_1",
    status: "in_progress",
    _meta: { terminal_output: { terminal_id: "call_1", data: "a.txt\n" } },
  },
  {
    sessionUpdate: "tool_call_update",
    toolCallId: "call_1",
    status: "completed",
    _meta: {
      terminal_output: { terminal_id: "call_1", data: "b.txt\n" },
      terminal_exit: { terminal_id: "call_1", exit_code: 0, signal: null },
    },
  },
];

test("a terminal the agent reports in _meta fills the row, live and replayed", () => {
  const views = new Map<unknown, map.TerminalView>();
  const [first, ...updates] = piBash;
  assert.ok(first);
  map.applyTerminalMeta(first, views);
  const started = map.toolCall(first, views);
  assert.equal(started.name, "execute", "the kind, not the call id");
  assert.deepEqual(started.content, [{ type: "terminal", command: "ls -la", cwd: "/w", output: "" }]);
  for (const update of updates) {
    assert.equal(map.applyTerminalMeta(update, views), "call_1");
  }
  assert.deepEqual(views.get("call_1"), { command: "ls -la", cwd: "/w", output: "a.txt\nb.txt\n", exitCode: 0 });

  const recorder = new map.Recorder();
  for (const update of piBash) recorder.push(update);
  const [row] = recorder.finish();
  assert.ok(row?.role === "tool");
  assert.equal(row.call.status, "completed");
  assert.deepEqual(row.call.content, [
    { type: "terminal", command: "ls -la", cwd: "/w", output: "a.txt\nb.txt\n", exitCode: 0 },
  ]);
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.9 KB6 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.