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