Official

chat

Built-in chat UI (JavaScript UI plugin).

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

Permissions in 0.2.0

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Send prompts, answer agent questions, and create, change, fork, revert, stop and group chats
  • Run other plugins' commands commands.runHighRuns commands of other plugins.Run the sidebar's New chat command from the title bar
  • Read chats chats.readMediumReads your transcripts and chat lists.Show workspaces, tasks, chats and transcripts, and follow them as agents work
  • Read files fs.readMediumReads files in the listed places.Suggest workspace files for @ mentionsPlaces: the open workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the chat grid and the title barSlots: centertitle

Files

approvals.test.ts9.6 KB
// Regression coverage for approvals using the real chat modules.
// Fixtures exercise the same behavior and assertions as the JavaScript tests.
import { item as fixtureItem, security } from "./ui-for-tests.test.ts";
import assert from "node:assert/strict";
import test from "node:test";
import { harness, tree, nodes, chat as makeChat, transcript as initial } from "./ui-for-tests.test.ts";
import type { TestNode } from "./ui-for-tests.test.ts";
import { applyEvent } from "./transcript.ts";
import { renderTranscript as transcriptView } from "./views/rows.ts";
import { renderSubagentPane as subagentView, renderWorkflowPane as workflowView } from "./views/tasks.ts";
import { foldItems, groupTitle } from "./grouping.ts";
import type { Chat } from "convergence";
import * as controllerModule from "./controller.ts";
function setup() {
  const h = harness();
  const state = h.controller.state,
    transcripts = state.transcripts;
  const markerRows = (chat: Chat, node: TestNode) => {
    const entries = foldItems(transcripts[chat.id].items);
    return node.items.map((wrapper) => {
      const row = wrapper.children[0];
      if (nodes(row).some((node) => node.type === "security_card")) return row;
      const markdown = nodes(row).find((node) => node.type === "markdown" && node.id?.startsWith("md-"));
      if (markdown) return { type: "row", id: markdown.id.slice(3) };
      const text = nodes(row).find((node) => node.type === "text" && node.text === "Working…");
      if (text) return { type: "activity", label: text.text };
      const fold = entries.find(
        (entry) => entry.kind === "tools" && nodes(row).some((node) => node.text === groupTitle(entry.items)),
      );
      return fold && "id" in fold ? { type: "row", id: fold.id } : row;
    });
  };
  return {
    state,
    transcripts,
    applyEvent,
    renderTranscript: (fields: Partial<Chat>) => {
      const chat = makeChat(fields);
      const node = tree(transcriptView(h.v, chat));
      return { ...node, items: markerRows(chat, node) };
    },
    renderSubagentPane: (chatId: string, taskId: string) => tree(subagentView(h.v, chatId, taskId)),
    renderWorkflowPane: (chatId: string, taskId: string) => tree(workflowView(h.v, chatId, taskId)),
  };
}
// A card's row, its card, and what the card carries besides its type.
type Marker = { type: string; id?: string; label?: string; children?: TestNode[] };
const cardOf = (row: Marker) => (row?.type === "div" ? (row.children?.[0] ?? null) : null);
const cards = (rows: Marker[]) =>
  rows
    .map(cardOf)
    .filter((c): c is TestNode => c?.type === "security_card")
    .map(({ type, ...card }) => card);
// The rows of a scroller.
const rowsOf = (scroller: { items: Marker[] }) => scroller.items;
const tool = (id: string) => fixtureItem({ id, role: "tool", call: { id, kind: "read", content: [], locations: [] } });
const chatTranscript = initial;

test("an approval is the kernel's card with nothing but its place, and no plugin buttons", () => {
  const { state, transcripts, applyEvent, renderTranscript } = setup();
  state.chats.c = makeChat();
  const t = (transcripts.c = chatTranscript([
    fixtureItem({ id: "u1", role: "user", blocks: [{ type: "text", text: "hi" }] }),
  ]));
  applyEvent(t, {
    event: "approval",
    id: "a1",
    title: "Run rm -rf",
    options: [{ id: "yes", name: "Allow", kind: "allow_once" }],
    toolCall: fixtureItem({ id: "call", role: "tool" }).call,
  });
  const rows = rowsOf(renderTranscript({ id: "c" }));
  assert.deepEqual(cards(rows), [{ kind: "approval", chatId: "c", id: "a1" }]);
  // What the plugin supplied (the title, the options) is not in the tree.
  assert.doesNotMatch(JSON.stringify(rows), /Run rm -rf|Allow|button/);
  // The chat waits on the card, so it is not "Working…".
  assert.equal(
    rows.some((r) => r.type === "activity"),
    false,
  );
});

test("the plugin can no longer answer an approval itself", () => {
  assert.doesNotMatch(
    Object.getOwnPropertyNames(controllerModule.Controller.prototype).join(" "),
    /respondApproval|respond_approval/,
  );
});

test("an approval answered on the native card leaves the chat working again", () => {
  const { state, transcripts, applyEvent, renderTranscript } = setup();
  state.chats.c = makeChat();
  const t = (transcripts.c = chatTranscript());
  applyEvent(t, { event: "approval", title: "Approval", id: "a1", options: [] });
  assert.equal(applyEvent(t, { event: "approval_resolved", id: "a1" }), true);
  const rows = rowsOf(renderTranscript({ id: "c" }));
  assert.deepEqual(cards(rows), []);
  assert.deepEqual(
    rows.filter((r) => r.type === "activity").map((r) => r.label),
    ["Working…"],
  );
});

test("a subagent's approval shows in the chat and in that subagent's pane, not in another's", () => {
  const { state, transcripts, applyEvent, renderTranscript, renderSubagentPane } = setup();
  state.chats.c = makeChat();
  const task = (id: string) =>
    fixtureItem({ id: `task-${id}`, role: "task", task: { id, title: id, status: "running" }, items: [] });
  const t = (transcripts.c = chatTranscript([task("one"), task("two")]));
  applyEvent(t, { event: "approval", title: "Approval", id: "a1", options: [] }, "one");
  assert.deepEqual(cards(rowsOf(renderTranscript({ id: "c" }))), [{ kind: "approval", chatId: "c", id: "a1" }]);
  const pane = (id: string) =>
    rowsOf(renderSubagentPane("c", id))
      .map((row) => row.children?.[0])
      .filter((row): row is TestNode => !!row);
  assert.deepEqual(cards(pane("one")), [{ kind: "approval", chatId: "c", id: "a1" }]);
  assert.deepEqual(cards(pane("two")), []);
});

test("a workflow's pane shows the approvals of its agents", () => {
  const { state, transcripts, applyEvent, renderWorkflowPane } = setup();
  state.chats.c = makeChat();
  const agent = fixtureItem({ id: "task-agent", role: "task", task: { id: "agent", status: "running" }, items: [] });
  const flow = fixtureItem({
    id: "task-flow",
    role: "task",
    task: { id: "flow", workflow: { phases: [], logs: [] }, status: "running" },
    items: [agent],
  });
  const t = (transcripts.c = chatTranscript([flow]));
  applyEvent(t, { event: "approval", title: "Approval", id: "a1", options: [] }, "agent");
  applyEvent(t, { event: "approval", title: "Approval", id: "a2", options: [] });
  const rows = rowsOf(renderWorkflowPane("c", "flow"))
    .map((row) => row.children?.[0])
    .filter((row): row is TestNode => !!row);
  assert.deepEqual(cards(rows), [{ kind: "approval", chatId: "c", id: "a1" }]);
});

test("a security request follows the item that was last when it was made, or goes at the end", () => {
  const { state, transcripts, renderTranscript } = setup();
  state.chats.c = makeChat();
  const t = (transcripts.c = chatTranscript([
    fixtureItem({ id: "u1", role: "user", blocks: [{ type: "text", text: "enable it" }] }),
    tool("r1"),
    tool("r2"),
    fixtureItem({ id: "a1", role: "assistant", text: "done" }),
  ]));
  t.running = false;
  t.requests = [
    security({ id: "after-user", afterItemId: "u1" }),
    security({ id: "in-fold", afterItemId: "r1" }),
    security({ id: "gone", afterItemId: "deleted" }),
    security({ id: "none" }),
  ];
  const order = rowsOf(renderTranscript({ id: "c" })).map((row) => (row.type === "row" ? row.id : cardOf(row)?.id));
  assert.deepEqual(order, ["u1", "after-user", "tools-r1", "in-fold", "a1", "gone", "none"]);
  // A request card carries its id only: the kernel knows the rest.
  assert.deepEqual(cards(rowsOf(renderTranscript({ id: "c" })))[0], { kind: "request", id: "after-user" });
});

test("the publish row goes below the latest response, once the turn is over", () => {
  const { state, transcripts, renderTranscript } = setup();
  state.chats.c = makeChat();
  const t = (transcripts.c = chatTranscript([
    fixtureItem({ id: "u1", role: "user", blocks: [{ type: "text", text: "change my plugin" }] }),
    fixtureItem({ id: "a1", role: "assistant", text: "changed it" }),
  ]));
  t.running = false;
  t.requests = [
    security({ id: "p1", kind: "publish", afterItemId: "u1", plugin: { name: "hello" } }),
    security({ id: "e1", kind: "enable", afterItemId: "u1" }),
  ];
  const rows = rowsOf(renderTranscript({ id: "c" }));
  const order = rows.map((row) => (row.type === "row" ? row.id : cardOf(row)?.id));
  // The enable card follows its item; the publish row ends the transcript,
  // whatever item was last when the host made it.
  assert.deepEqual(order, ["u1", "e1", "a1", "p1"]);
  assert.deepEqual(cards(rows).at(-1), { kind: "publish", chatId: "c", id: "p1" });
  // Nothing the host sent about the plugin is in the tree: the kernel draws it.
  assert.doesNotMatch(JSON.stringify(rows), /hello/);

  // A new turn runs: no row until it is over.
  t.running = true;
  assert.deepEqual(
    cards(rowsOf(renderTranscript({ id: "c" }))).map((card) => card.id),
    ["e1"],
  );
});

test("in a fix chat the attach row takes the publish row's place", () => {
  const { state, transcripts, renderTranscript } = setup();
  state.chats.c = makeChat();
  const t = (transcripts.c = chatTranscript([
    fixtureItem({ id: "u1", role: "user", blocks: [{ type: "text", text: "fix report #12" }] }),
    fixtureItem({ id: "a1", role: "assistant", text: "fixed chat/main.js" }),
  ]));
  t.running = false;
  t.requests = [security({ id: "f1", kind: "attach_fix", afterItemId: "u1", fix: { reportId: "bug_1", number: 12 } })];
  const rows = rowsOf(renderTranscript({ id: "c" }));
  assert.deepEqual(
    rows.map((row) => (row.type === "row" ? row.id : cardOf(row)?.id)),
    ["u1", "a1", "f1"],
  );
  assert.deepEqual(cards(rows).at(-1), { kind: "publish", chatId: "c", id: "f1" });
  assert.doesNotMatch(JSON.stringify(rows), /bug_1/);
});

assert.equal(typeof applyEvent, "function", "the imported implementation is available");

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.