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
lifecycle.test.ts21.3 KB
// The chat plugin activated against a fake window: a streamed delta updates
// only its pane, and unloading releases everything it registered.
import assert from "node:assert/strict";
import test from "node:test";
import * as z from "zod";
import { ui } from "convergence";
import type {
Api,
Chat,
AppState,
AppStatePatch,
HostEvent,
RenderResult,
SlotOptions,
ViewContext,
AgentEventKind,
} from "convergence";
import { testLayer } from "convergence/effect/testing";
import { activate } from "./main.ts";
import { chat, nodes, fixtureTask, item } 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));
function fake(
initial: AppState = { route: "chat", chatId: "c1", workspaceId: "w" },
saved: Record<string, unknown> = {},
catalog?: { chats: Chat[]; checkpoints?: Promise<{ checkpoints: [] }> },
) {
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);
const chats = catalog?.chats ?? [chat({ id: "c1" }), chat({ id: "c2", title: "Second" })];
let pendingList: Promise<{ chats: Chat[] }> | undefined;
const updates: number[] = [];
const response = (method: string, params: unknown): unknown => {
// This fixture is the older Alpha host, not a transport failure.
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`); one fixture answers the
// enabled list too.
if (method === "host/agents.list" || method === "host/agents.all")
return {
agents: [
{
id: "agent",
name: "Agent",
icon: null,
enabled: true,
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 catalog?.checkpoints ?? { 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 });
if (method === "host/chats.list" && pendingList) return pendingList;
return response(method, params);
};
const update = (patch: AppStatePatch) => {
recordShared(patch);
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 methods = Object.fromEntries(
["list", "options", "commands", "skills", "transcript", "checkpoints", "send", "cancel", "respondQuestion"].map(
(name) => [
name,
(params: unknown) => call(`host/chats.${name === "respondQuestion" ? "respond_question" : name}`, params),
],
),
);
const broker = testLayer();
// A service call or event both sides record in the same shape: the stub
// bus below logs the new code's real ones, and update() logs the
// baseline's shared writes as their replacements.
type ServiceEntry = {
op: string;
name: string;
method?: string;
event?: string;
payload?: unknown;
params?: unknown;
};
const serviceLog: ServiceEntry[] = [];
// Comparison only: each rule mirrors a row of the harness mapping table.
// openTab/closeTab requests become tabs open/close calls (without the
// nonce); queuedChats/draftTitles writes become the chat service's events.
// An empty write where nothing was shared before (the baseline's initial
// draftTitles) maps to nothing, and the null writes that only consumed a
// request are dropped with the keys.
const withoutNonce = (value: unknown) => {
if (typeof value === "object" && value !== null && "nonce" in value) {
const { nonce: _nonce, ...rest } = value as Record<string, unknown>;
return rest;
}
return value;
};
const recordShared = (patch: AppStatePatch) => {
const shared = patch.shared as Record<string, unknown> | undefined;
if (!shared) return;
const prev = (state.shared ?? {}) as Record<string, unknown>;
if (shared.openTab != null)
serviceLog.push({ op: "call", name: "convergence/tabs", method: "open", params: withoutNonce(shared.openTab) });
if (shared.closeTab != null)
serviceLog.push({ op: "call", name: "convergence/tabs", method: "close", params: withoutNonce(shared.closeTab) });
if (
shared.queuedChats !== undefined &&
(prev.queuedChats !== undefined || (shared.queuedChats as unknown[]).length > 0)
)
serviceLog.push({ op: "emit", name: "convergence/chat", event: "queuedChanged", payload: shared.queuedChats });
if (
shared.draftTitles !== undefined &&
(prev.draftTitles !== undefined || Object.keys(shared.draftTitles as Record<string, unknown>).length > 0)
)
serviceLog.push({
op: "emit",
name: "convergence/chat",
event: "draftTitlesChanged",
payload: shared.draftTitles,
});
};
const serviceSubs = new Map<string, Set<(payload: unknown) => void>>();
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 }) => {
serviceLog.push({ op: "call", name: "convergence/tabs", 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) => {
serviceLog.push({ op: "call", name: "convergence/tabs", method: "close", params });
return { ok: true as const, value: null };
},
}),
active: Object.assign(async () => null, {
result: async () => {
serviceLog.push({ op: "call", name: "convergence/tabs", method: "active", params: {} });
return { 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 }) => {
serviceLog.push({ op: "provide", name: def.name });
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") return tabsClient;
throw new Error(`no provider for ${name}`);
},
available: (name: string) => name === "convergence/tabs",
when: (name: string, fn: (client: unknown) => void) => {
fn(name === "convergence/tabs" ? tabsClient : undefined);
return () => {};
},
};
// 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,
storage: { get: (p: unknown) => call("host/storage.get", p), set: (p: unknown) => call("host/storage.set", p) },
chats: methods,
security: { pending: (p: unknown) => call("host/security.pending", p) },
agents: { list: (p: unknown) => call("host/agents.list", p) },
workspaces: { list: (p: unknown) => call("host/workspaces.list", p) },
tasks: { list: (p: unknown) => call("host/tasks.list", p) },
fs: { search: (p: unknown) => call("host/fs.search", p) },
},
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(
event === "onDrop"
? z.object({ drop: z.object({ onDrop: z.unknown() }) }).parse(node).drop.onDrop
: node[event],
);
handler(payload);
};
const pressText = (text: string) => {
const target = trees
.flatMap((view) => nodes(view.tree))
// A row's text is a text child, or an item's label.
.filter(
(node) =>
typeof node.onClick === "function" &&
nodes(node).some((child) => child.text === text || child.label === text),
)
.at(-1);
assert.ok(target, text);
target.onClick();
};
return {
pressText,
api,
views,
listeners,
appListeners,
calls,
updates,
focus,
notices,
commands,
update,
settle,
invoke,
snapshot: () => stable({ trees, state, calls, notices, focus, serviceLog }),
serviceLog,
nodes: () => trees.flatMap((view) => nodes(view.tree)),
holdList: () => {
const snapshot = chats.map((chat) => ({ ...chat }));
const pending = Promise.withResolvers<{ chats: Chat[] }>();
pendingList = pending.promise;
return () => {
pendingList = undefined;
pending.resolve({ chats: snapshot });
};
},
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("a stale list refresh keeps the Stop button while the new turn runs", async () => {
const current = fake(
undefined,
{},
{ chats: [chat({ id: "c1", sessionId: "s", updatedAt: "2026-10-04T17:00:00Z" })] },
);
const dispose = activate(current.api);
try {
await current.settle();
const release = current.holdList();
current.emit({ event: "agents_changed" });
await current.settle();
current.emit({
event: "chat_changed",
chat: chat({ id: "c1", sessionId: "s", status: "running", updatedAt: "2026-10-04T17:01:00Z" }),
});
current.emit(agent({ event: "run_started" }));
current.emit(
agent({
event: "tool_call_started",
id: "tool",
name: "Bash",
kind: "execute",
title: "Run tests",
status: "running",
}),
);
await current.settle();
assert.ok(current.nodes().some((node) => node.id === "stop-c1"));
release();
await current.settle();
assert.ok(
current.nodes().some((node) => node.id === "stop-c1"),
"the live turn must retain its Stop button after the older list arrives",
);
current.invoke("stop-c1");
await current.settle();
assert.ok(current.calls.some((call) => call.method === "host/chats.cancel"));
current.emit({
event: "chat_changed",
chat: chat({ id: "c1", sessionId: "s", status: "idle", updatedAt: "2026-10-04T17:02:00Z" }),
});
current.emit(agent({ event: "run_finished", outcome: { status: "cancelled" } }));
await current.settle();
assert.ok(!current.nodes().some((node) => node.id === "stop-c1"), "Stop disappears when the run actually ends");
} finally {
await dispose();
}
});
test("a stale running list cannot bring back Stop after completion", async () => {
const current = fake(
undefined,
{},
{ chats: [chat({ id: "c1", sessionId: "s", status: "running", updatedAt: "2026-10-04T17:00:00Z" })] },
);
const dispose = activate(current.api);
try {
await current.settle();
assert.ok(current.nodes().some((node) => node.id === "stop-c1"));
const release = current.holdList();
current.emit({ event: "agents_changed" });
await current.settle();
current.emit({
event: "chat_changed",
chat: chat({ id: "c1", sessionId: "s", status: "idle", updatedAt: "2026-10-04T17:01:00Z" }),
});
current.emit(agent({ event: "run_finished", outcome: { status: "completed" } }));
await current.settle();
release();
await current.settle();
assert.ok(!current.nodes().some((node) => node.id === "stop-c1"));
} finally {
await dispose();
}
});
test("chat streaming refreshes its pane and unload releases every registration and stream", async () => {
const current = fake();
const dispose = activate(current.api);
await current.settle();
current.updates.length = 0;
current.emit(agent({ event: "text_delta", itemId: "a", text: "Hello", mode: "append" }));
await current.settle();
assert.deepEqual(current.updates, [current.views.find((view) => view.slot === "pane")?.id], "a delta updates only the chat's pane");
await dispose();
assert.ok(current.views.every((view) => !view.active));
assert.equal(current.appListeners.size, 0);
assert.ok([...current.listeners.values()].every((listeners) => !listeners.size));
assert.equal(current.commands.size, 0);
});
test("a fresh chat keeps its prompt and progress while checkpoint metadata loads", async () => {
const checkpoints = Promise.withResolvers<{ checkpoints: [] }>();
const current = fake(undefined, {}, { chats: [chat({ id: "c1" })], checkpoints: checkpoints.promise });
const dispose = activate(current.api);
try {
await current.settle();
assert.ok(current.calls.some((call) => call.method === "host/chats.transcript"));
current.invoke("prompt-c1", "onChange", { value: "Inspect README.md" });
await current.settle();
current.invoke("prompt-c1", "onSubmit");
await current.settle();
assert.ok(current.calls.some((call) => call.method === "host/chats.send"));
current.emit({
event: "user_message",
chatId: "c1",
item: item({ id: "u", role: "user", text: "Inspect README.md" }),
});
current.emit({
event: "chat_changed",
chat: chat({ id: "c1", title: "Inspect README.md", status: "running", sessionId: "s" }),
});
current.emit(
agent({ event: "text_delta", itemId: "a", text: "I’m reading the project instructions.", mode: "append" }),
);
await current.settle();
current.emit(
agent({
event: "tool_call_started",
id: "read",
name: "read_file",
kind: "read",
title: "Read README.md",
status: "running",
content: [],
locations: [],
}),
);
current.emit(agent({ event: "run_finished", outcome: { status: "completed" } }));
await current.settle();
checkpoints.resolve({ checkpoints: [] });
await current.settle();
const rendered = current.nodes();
assert.ok(
rendered.some((node) => node.id === "transcript-c1"),
"the center shows the transcript, not the new-chat page",
);
assert.ok(
rendered.some((node) => node.text === "Inspect README.md"),
"the sent prompt remains visible",
);
assert.ok(
rendered.some((node) => node.text === "I’m reading the project instructions."),
"live progress remains visible",
);
assert.ok(
rendered.some((node) => node.text === "Read README.md"),
"tool activity remains visible",
);
assert.ok(
rendered.some((node) => node.id === "restore-u"),
"checkpoint loading keeps the new prompt's checkpoint",
);
assert.deepEqual(current.notices, []);
} finally {
checkpoints.resolve({ checkpoints: [] });
await dispose();
}
});
test("checkpoint loading errors leave the transcript usable and surface the error", async () => {
const checkpoints = Promise.withResolvers<{ checkpoints: [] }>();
const current = fake(undefined, {}, { chats: [chat({ id: "c1" })], checkpoints: checkpoints.promise });
const dispose = activate(current.api);
try {
await current.settle();
current.emit({
event: "user_message",
chatId: "c1",
item: item({ id: "u", role: "user", text: "Inspect README.md" }),
});
await current.settle();
checkpoints.reject(new Error("checkpoint store unavailable"));
await current.settle();
assert.ok(current.nodes().some((node) => node.id === "transcript-c1"));
assert.ok(current.nodes().some((node) => node.text === "Inspect README.md"));
assert.deepEqual(current.notices, ["Checkpoints unavailable: checkpoint store unavailable"]);
} finally {
checkpoints.resolve({ checkpoints: [] });
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.