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
options.test.ts3.1 KB
// A chat's settings belong to its agent: switching agents never leaves the
// last agent's model in the picker, even when the new one's fail to load,
// and a draft whose agent is gone says so.
import assert from "node:assert/strict";
import test from "node:test";
import * as Effect from "effect/Effect";
import { HostCallFailed } from "convergence/effect";
import type { AgentInfo } from "convergence";
import { loadOptions } from "./data.ts";
import { renderComposer } from "./views/composer.ts";
import { harness, chat, nodes } from "./ui-for-tests.test.ts";
test("a new agent's failed options leave none, not the last agent's", async () => {
const h = harness((method) =>
method === "host/chats.options" ? Effect.fail(new HostCallFailed({ method, code: -32603, message: "no catalog" })) : undefined,
);
h.controller.state.chats.c = chat({ id: "c", agentId: "codex" });
const composer = h.controller.composer("c");
composer.agentId = "codex";
composer.options = [
{ id: "model", name: "Model", kind: "select", category: "model", value: "gpt", choices: [{ value: "gpt", name: "GPT" }] },
];
h.controller.state.chats.c = chat({ id: "c", agentId: "opencode" });
await Effect.runPromise(loadOptions(h.controller, "c"));
assert.deepEqual(composer.options, []);
assert.equal(composer.agentId, "opencode");
assert.ok(h.notices.some((notice) => notice.includes("no catalog")), "the failure is said");
});
test("a draft whose agent was turned off asks for another", () => {
const empty: Record<string, unknown> = {
"host/chats.options": { options: [] },
"host/chats.commands": { commands: [] },
"host/chats.skills": { skills: [] },
};
const h = harness((method) => (method in empty ? Effect.succeed(empty[method]) : undefined));
const draft = chat({ id: "d", agentId: "gone" });
h.controller.state.chats.d = draft;
h.controller.state.agentList = [{ id: "codex", name: "Codex", capabilities: {} } as AgentInfo];
const picker = nodes(renderComposer(h.v, draft)).find((node) => node.id === "agent-d");
assert.equal(picker?.placeholder, "Choose an agent");
});
test("an agent's options that arrive after a switch to another are dropped", async () => {
let release!: () => void;
const late = new Promise<void>((resolve) => (release = resolve));
const h = harness((method, params) =>
method === "host/chats.options" && (params as { chatId: string }).chatId === "c"
? Effect.promise(() => late).pipe(
Effect.as({ options: [{ id: "model", name: "Model", kind: "select", category: "model", value: "old", choices: [] }] }),
)
: undefined,
);
h.controller.state.chats.c = chat({ id: "c", agentId: "codex" });
const first = Effect.runPromise(loadOptions(h.controller, "c"));
// The user picks another agent before Codex answered.
h.controller.state.chats.c = chat({ id: "c", agentId: "opencode" });
h.controller.composer("c").agentId = "opencode";
h.controller.composer("c").loading = true;
release();
await first;
assert.deepEqual(h.controller.composer("c").options, [], "Codex's model is not OpenCode's");
assert.equal(h.controller.composer("c").loading, true, "OpenCode's own read is still on its way");
});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.