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

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.