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
service.test.ts15.8 KB
// The convergence/chat service and the chat plugin's use of
// convergence/tabs. Real activation against canned hosts: the queue and the
// draft titles are read through the provided methods and followed through
// its events, and subagent panes open through the tabs client.
import assert from "node:assert/strict";
import test from "node:test";
import * as z from "zod";
import { ui } from "convergence";
import type {
AgentEventKind,
Api,
AppState,
AppStatePatch,
Chat,
HostEvent,
RenderResult,
SlotOptions,
ViewContext,
} from "convergence";
import { testLayer } from "convergence/effect/testing";
import { activate } from "./main.ts";
import { draftTitle } from "./title.ts";
import { chat, fixtureTask, nodes } from "./ui-for-tests.test.ts";
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
const stable = (value: unknown) =>
JSON.stringify(value, (_key, item: unknown) => (typeof item === "function" ? "@handler" : item));
type ServiceImpl = Record<string, (params: never, ctx: unknown) => Promise<unknown>>;
type ServiceEntry = {
op: string;
name: string;
method?: string;
event?: string;
payload?: unknown;
params?: unknown;
impl?: ServiceImpl;
};
function fake(
initial: AppState = { route: "chat", chatId: "c1", workspaceId: "w" },
saved: Record<string, unknown> = {},
withTabs = true,
agentEnabled = true,
) {
let state = initial,
dirty = true,
nextId = 0;
const views: {
slot: string;
options?: SlotOptions;
render: (ctx: ViewContext<object>) => RenderResult;
id: number;
active: boolean;
}[] = [];
const listeners = new Map<string, Set<(event: HostEvent) => unknown>>(),
appListeners = new Set<(state: AppState) => void>();
const calls: { method: string; params: unknown }[] = [],
focus: string[] = [],
notices: string[] = [];
const commands = new Map<string, () => unknown>(),
stored: Record<string, unknown> = structuredClone(saved);
// c1 has started (a session), so its composer names its agent instead of
// offering the picker.
const chats = [chat({ id: "c1", sessionId: "s1" }), chat({ id: "c2", title: "Second" })];
const updates: number[] = [];
const serviceLog: ServiceEntry[] = [];
const serviceSubs = new Map<string, Set<(payload: unknown) => void>>();
const tabsCalls: { method: string; params: unknown }[] = [];
const tabsClient = {
open: Object.assign(
async (params: { kind: string; workspaceId: string; path: string }) => ({
id: `${params.kind}:${params.workspaceId}:${params.path}`,
}),
{
// The Effect adapter calls through `result`, never the direct call.
result: async (params: { kind: string; workspaceId: string; path: string }) => {
tabsCalls.push({ method: "open", params });
return { ok: true as const, value: { id: `${params.kind}:${params.workspaceId}:${params.path}` } };
},
},
),
close: Object.assign(async () => null, {
result: async (params: unknown) => {
tabsCalls.push({ method: "close", params });
return { ok: true as const, value: null };
},
}),
active: Object.assign(async () => null, {
result: async () => ({ ok: true as const, value: null }),
}),
on: (event: string, fn: (payload: unknown) => void) => {
const key = `convergence/tabs|${event}`;
const set = serviceSubs.get(key) ?? new Set<(payload: unknown) => void>();
serviceSubs.set(key, set);
set.add(fn);
return () => {
set.delete(fn);
};
},
};
const servicesStub = {
provide: async (def: { name: string }, impl: ServiceImpl) => {
serviceLog.push({ op: "provide", name: def.name, impl });
return {
emit: (event: string, payload: unknown) => {
serviceLog.push({ op: "emit", name: def.name, event, payload });
for (const fn of serviceSubs.get(`${def.name}|${event}`) ?? []) fn(payload);
},
remove: () => {},
};
},
get: (name: string) => {
if (name === "convergence/tabs" && withTabs) return tabsClient;
throw new Error(`no provider for ${name}`);
},
available: (name: string) => name === "convergence/tabs" && withTabs,
when: (name: string, fn: (client: unknown) => void) => {
fn(name === "convergence/tabs" && withTabs ? tabsClient : undefined);
return () => {};
},
};
const response = (method: string, params: unknown): unknown => {
if (method.startsWith("host/inputs.")) throw Object.assign(new Error(`unknown host method ${method}`), { code: -32601 });
if (method === "host/storage.get") return { value: stored[z.object({ key: z.string() }).parse(params).key] };
if (method === "host/storage.set") {
const p = z.object({ key: z.string(), value: z.unknown() }).parse(params);
stored[p.key] = p.value;
return {};
}
if (method === "host/chats.list") return { chats };
if (method === "host/workspaces.list")
return {
workspaces: [
{ id: "w", name: "Workspace", path: "/fixture", position: 0 },
{ id: "w2", name: "Other", path: "/other", position: 1 },
],
};
// The chat reads every agent (`agents.all`); the baseline read the
// enabled ones (`agents.list`). One fixture answers both.
// As the host: `agents.list` holds only the enabled agents.
if (method === "host/agents.list" && !agentEnabled) return { agents: [] };
if (method === "host/agents.list" || method === "host/agents.all")
return {
agents: [
{
id: "agent",
name: "Agent",
icon: agentEnabled ? null : "<svg/>",
enabled: agentEnabled,
capabilities: { images: true, cancelTask: true, compact: true, fork: true },
},
],
};
if (method === "host/chats.options")
return {
options: [
{
id: "mode",
name: "Mode",
kind: "select",
category: "mode",
value: "agent",
choices: [{ value: "agent", name: "Agent" }],
},
],
};
if (method === "host/chats.commands") return { commands: [] };
if (method === "host/chats.skills") return { skills: [] };
if (method === "host/chats.transcript")
return { items: [], pendingApprovals: [], pendingQuestions: [], plan: null, usage: {}, running: false };
if (method === "host/chats.checkpoints") return { checkpoints: [] };
if (method === "host/security.pending") return { requests: [] };
if (method === "host/fs.search") return { paths: ["src/main.ts", "README.md"] };
if (method === "host/chats.send" || method === "host/chats.cancel" || method === "host/chats.respond_question")
return {};
throw new Error(`Unexpected method ${method}`);
};
const call = async (method: string, params: unknown) => {
calls.push({ method, params });
return response(method, params);
};
const update = (patch: AppStatePatch) => {
state = {
...state,
...patch,
chatId: patch.chatId === null ? undefined : (patch.chatId ?? state.chatId),
workspaceId: patch.workspaceId === null ? undefined : (patch.workspaceId ?? state.workspaceId),
route: patch.route ?? state.route,
shared: { ...state.shared, ...patch.shared },
};
dirty = true;
for (const listener of appListeners) listener(state);
};
const broker = testLayer();
// This assertion describes a local fake; unused API methods fail rather than impersonating external replies.
const api = new Proxy(
{
...broker.api,
plugin: "chat",
id: "convergence/chat",
ui,
services: servicesStub,
host: {
...broker.api.host,
call,
kernel: call,
},
app: {
state: () => state,
update,
share: (key: string, value: unknown) => update({ shared: { [key]: value } }),
select: (chatId: string, workspaceId: string) => update({ chatId, workspaceId, route: "chat" }),
route: (route: string) => update({ route }),
onChange: (fn: (state: AppState) => void) => {
appListeners.add(fn);
return () => {
appListeners.delete(fn);
};
},
},
slot: (slot: string, render: (ctx: ViewContext<object>) => RenderResult, options?: SlotOptions) => {
const view = { slot, render, options, id: ++nextId, active: true };
views.push(view);
dirty = true;
return {
id: view.id,
update: () => {
updates.push(view.id);
dirty = true;
},
remove: () => {
view.active = false;
dirty = true;
},
};
},
on: (name: string, fn: (event: HostEvent) => unknown) => {
const subscribers = listeners.get(name) ?? new Set();
listeners.set(name, subscribers);
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
},
command: ({ id, run }: { id: string; run: (api: Api) => unknown }) => {
commands.set(id, () => run(api));
return {
remove: () => {
commands.delete(id);
},
};
},
slash: () => ({ remove() {} }),
slashCommands: () => [],
mentions: { list: () => [] },
toolRenderers: () => [],
focus: (id: string) => {
focus.push(id);
},
notify: (message: string) => {
notices.push(message);
},
sleep: (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
},
{
get(target, key, receiver) {
if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
throw new Error(`Unexpected API ${String(key)}`);
},
},
) as unknown as Api;
let trees: { slot: string; options?: SlotOptions; tree: RenderResult }[] = [];
const render = () => {
trees = views
.filter((view) => view.active)
.map((view) => ({
slot: view.slot,
options: view.options,
tree: view.render({
state: {},
app: state,
plugin: "chat",
update: () => {
dirty = true;
},
}),
}));
};
const settle = async () => {
for (let round = 0; round < 100; round++) {
await tick();
if (!dirty) {
await tick();
if (!dirty) return;
}
dirty = false;
render();
}
throw new Error("Chat did not settle");
};
const invoke = (id: string, event = "onClick", payload: unknown = undefined) => {
const node = trees.flatMap((view) => nodes(view.tree)).find((node) => node.id === id);
assert.ok(node, `element ${id}`);
const handler = z
.custom<(payload: unknown) => unknown>((value) => typeof value === "function")
.parse(node[event] as unknown);
handler(payload);
};
const pressText = (text: string) => {
const target = trees
.flatMap((view) => nodes(view.tree))
.filter((node) => typeof node.onClick === "function" && nodes(node).some((child) => child.text === text))
.at(-1);
assert.ok(target, text);
target.onClick();
};
const ctx = { caller: { plugin: "test/client", id: "test/client" }, signal: AbortSignal.timeout(5000) };
const provided = () => {
const entry = serviceLog.find((item) => item.op === "provide" && item.name === "convergence/chat");
assert.ok(entry, "chat service provided");
return entry.impl as ServiceImpl;
};
return {
pressText,
api,
calls,
notices,
update,
settle,
invoke,
serviceLog,
tabsCalls,
provided,
callChat: (method: string, params: unknown) => provided()[method]?.(params as never, ctx),
emits: (event: string) => serviceLog.filter((item) => item.op === "emit" && item.event === event),
state: () => state,
texts: () => trees.flatMap((view) => nodes(view.tree)).map((node) => node.text),
icons: () => trees.flatMap((view) => nodes(view.tree)).map((node) => node.svg),
emit: (event: HostEvent) => {
for (const name of [event.event, "*"]) for (const fn of listeners.get(name) ?? []) fn(event);
},
};
}
const agent = (kind: AgentEventKind): HostEvent => ({ event: "agent", chatId: "c1", kind });
test("the chat service reads the queue and draft titles, and emits their changes", async (ctx) => {
ctx.mock.method(Date, "now", () => 123);
const current = fake();
const dispose = activate(current.api);
try {
await current.settle();
assert.deepEqual(await current.callChat("queued", {}), []);
assert.deepEqual(await current.callChat("draftTitles", {}), {});
current.invoke("prompt-c1", "onChange", { value: "hello world" });
await current.settle();
assert.deepEqual(current.emits("draftTitlesChanged").at(-1)?.payload, { c1: draftTitle("hello world") });
// The drafts left the shared keys behind: nothing announces through them.
assert.equal(current.state().shared?.draftTitles, undefined);
assert.equal(current.state().shared?.queuedChats, undefined);
assert.deepEqual(await current.callChat("draftTitles", {}), { c1: draftTitle("hello world") });
assert.deepEqual(current.notices, []);
} finally {
await dispose();
}
});
test("restored queues and drafts reach the service on load", async (ctx) => {
ctx.mock.method(Date, "now", () => 123);
const current = fake(undefined, {
queues: { chats: [{ chatId: "c2", text: "Queued chat" }] },
unsent: { c1: "Other draft" },
});
const dispose = activate(current.api);
try {
await current.settle();
assert.deepEqual(current.emits("queuedChanged")[0]?.payload, ["c2"]);
// Draft titles need the chats: loading them computes the titles.
current.emit({ event: "agents_changed" });
await current.settle();
const drafts = current.emits("draftTitlesChanged").at(-1)?.payload as Record<string, string>;
assert.equal(drafts["c1"], draftTitle("Other draft"));
// Nothing runs, so the restored draft leaves the queue and starts.
assert.deepEqual(await current.callChat("queued", {}), []);
assert.deepEqual(await current.callChat("draftTitles", {}), drafts);
} finally {
await dispose();
}
});
test("subagent panes open through the tabs service, and stay closed without it", async (ctx) => {
ctx.mock.method(Date, "now", () => 123);
for (const withTabs of [true, false]) {
const current = fake(undefined, undefined, withTabs);
const dispose = activate(current.api);
try {
await current.settle();
current.emit(
agent({ event: "task", ...fixtureTask("child", { status: "completed", title: "Investigate", summary: "Hi" }) }),
);
await current.settle();
current.pressText("Investigate");
await current.settle();
current.invoke("open-child");
await current.settle();
if (withTabs) {
assert.equal(current.tabsCalls.length, 1);
const call = current.tabsCalls[0];
assert.equal(call?.method, "open");
assert.equal((call?.params as { kind: string }).kind, "view");
assert.equal((call?.params as { workspaceId: string }).workspaceId, "w");
} else {
assert.deepEqual(current.tabsCalls, []);
}
assert.equal(current.state().shared?.openTab, undefined);
assert.deepEqual(current.notices, []);
} finally {
await dispose();
}
}
});
// Settings turned the chat's agent off after the chat started. The chat
// read only the enabled agents, so its composer showed the raw id
// (`acp:cline`) and the generic mark.
test("a chat whose agent was turned off still names it with its mark", async () => {
const current = fake(undefined, {}, true, false);
const dispose = activate(current.api);
try {
await current.settle();
assert.ok(current.calls.some((call) => call.method === "host/agents.all"));
assert.ok(current.texts().includes("Agent"), "the agent's name, not its id");
assert.ok(current.icons().includes("<svg/>"), "the agent's own mark");
} finally {
await dispose();
}
});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.