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
mentions.test.ts5.8 KB
// Regression coverage for mentions using the real chat modules.
// Fixtures exercise the same behavior and assertions as the JavaScript tests.
import assert from "node:assert/strict";
import test from "node:test";
import {
acceptMention,
findTags,
keepSegments,
matchMentions,
mentionInstructions,
partOf,
removeTag,
tagTokens,
} from "./mentions.ts";
import { sendBlocks } from "./composer.ts";
import { harness } from "./ui-for-tests.test.ts";
import * as Effect from "effect/Effect";
const mention = (token: string, extra: Partial<import("convergence").Mention> = {}) => ({
plugin: "subagents",
id: token,
token,
label: token.split("/").join(" · "),
prompt: `Delegate {token} with spawn_subagent: {part}`,
...extra,
});
const mentions = [
mention("codex/gpt-5.5"),
mention("codex/gpt-5.5/high", { keywords: "Codex GPT-5.5 High" }),
mention("claude/haiku"),
];
test("a tag is a known token after @, between spaces", () => {
const text = "@codex/gpt-5.5/high fix the parser, then @claude/haiku reviews @src/main.rs and me@codex/gpt-5.5";
const tags = findTags(text, mentions);
assert.deepEqual(
tags.map((t) => [t.start, t.end, t.mention.token]),
[
[0, 19, "codex/gpt-5.5/high"],
[41, 54, "claude/haiku"],
],
);
assert.deepEqual([...tagTokens(text, mentions)], ["@codex/gpt-5.5/high", "@claude/haiku"]);
assert.deepEqual(findTags(text, []), [], "no mentions offered, no tags");
});
test("the picker matches tokens first, then names and keywords", () => {
assert.deepEqual(
matchMentions(mentions, "codex").map((m) => m.token),
["codex/gpt-5.5", "codex/gpt-5.5/high"],
);
assert.deepEqual(
matchMentions(mentions, "haiku").map((m) => m.token),
["claude/haiku"],
);
assert.deepEqual(
matchMentions(mentions, "High").map((m) => m.token),
["codex/gpt-5.5/high"],
);
assert.deepEqual(matchMentions(mentions, "src/ma"), []);
assert.equal(matchMentions(mentions, "", 2).length, 2);
});
test("choosing a tag replaces what was typed after @", () => {
assert.equal(acceptMention("please @cod", mentions[1]), "please @codex/gpt-5.5/high ");
});
test("deleting into a tag deletes the whole tag", () => {
const text = "ask @codex/gpt-5.5/high about it";
// Backspace at the end of the tag.
assert.equal(keepSegments(text, "ask @codex/gpt-5.5/hig about it", mentions), "ask about it");
// Delete at its start.
assert.equal(keepSegments(text, "ask codex/gpt-5.5/high about it", mentions), "ask about it");
// A selection across the tag and some text takes both.
assert.equal(keepSegments(text, "ask @codex/gpt-5.5", mentions), "ask ");
assert.equal(keepSegments(text, "ask @codex/gpt-5.5t", mentions), "ask t");
// The space after it is text: deleting it keeps the tag.
const tail = "ask @codex/gpt-5.5/high ";
assert.equal(keepSegments(tail, "ask @codex/gpt-5.5/high", mentions), "ask @codex/gpt-5.5/high");
// Deleting the whole tag, typing, or editing elsewhere is left alone.
assert.equal(keepSegments(text, "ask about it", mentions), "ask about it");
assert.equal(keepSegments(text, "ask @codex/gpt-5.5/high about its", mentions), "ask @codex/gpt-5.5/high about its");
assert.equal(keepSegments(text, "ak @codex/gpt-5.5/high about it", mentions), "ak @codex/gpt-5.5/high about it");
// A deletion that also types (autocorrect) is not a plain deletion.
assert.equal(keepSegments(text, "ask @codex/gpt-5.5/X about it", mentions), "ask @codex/gpt-5.5/X about it");
});
test("a chip's x takes the tag out without doubling a space", () => {
const text = "ask @claude/haiku about it";
assert.equal(removeTag(text, findTags(text, mentions)[0]), "ask about it");
const first = "@claude/haiku review this";
assert.equal(removeTag(first, findTags(first, mentions)[0]), "review this");
});
test("each tag stands for the text after it, or before it when it ends the message", () => {
const text = "@codex/gpt-5.5/high write the tests @claude/haiku review them";
const tags = findTags(text, mentions);
assert.equal(partOf(text, tags, 0), "write the tests");
assert.equal(partOf(text, tags, 1), "review them");
const trailing = "summarize the design doc @claude/haiku";
assert.equal(partOf(trailing, findTags(trailing, mentions), 0), "summarize the design doc");
const alone = "@claude/haiku";
assert.equal(partOf(alone, findTags(alone, mentions), 0), "@claude/haiku");
});
test("the instructions fill each mention's prompt once", () => {
const text = "@codex/gpt-5.5/high write the tests @claude/haiku review them";
assert.equal(
mentionInstructions(text, mentions),
"Delegate @codex/gpt-5.5/high with spawn_subagent: write the tests\n\nDelegate @claude/haiku with spawn_subagent: review them",
);
assert.equal(mentionInstructions("no tags @src/a.rs", mentions), "");
});
// The chat plugin's own send path, run with a fake api: a tag is not a
// file, and what it asks follows the message.
test("the composer sends a tag's instruction and no file for it", async () => {
const h = harness();
h.mentions.push(...mentions);
// This focused mentions fixture retains Alpha's idle send path; the
// stateful scheduler fixtures exercise enqueue and admission separately.
h.controller.scheduler.available = false;
await Effect.runPromise(sendBlocks(h.controller, "c1", "@codex/gpt-5.5/high check @src/main.rs", []));
assert.deepEqual(h.notices, [], "sending must not report an error");
const sent = h.calls.filter((call) => call.method === "host/chats.send").map((call) => call.params);
assert.deepEqual(sent, [
{
chatId: "c1",
blocks: [
{ type: "text", text: "@codex/gpt-5.5/high check @src/main.rs" },
{ type: "file_ref", path: "src/main.rs" },
{ type: "text", text: "Delegate @codex/gpt-5.5/high with spawn_subagent: check @src/main.rs" },
],
},
]);
});
assert.equal(typeof sendBlocks, "function", "the imported implementation is available");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.