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

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

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

Reviews and comments

0 threads · 0 reviews

No comments yet.