Official
subagents
Agents start subagents on any of your enabled agents and models, and you can @-tag an agent, model and effort in the composer to hand part of a message to one.
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/subagents@0.1.0
Permissions in 0.1.0
Files
subagents.test.ts15.6 KB
// Subagent tool behavior and scoped catalog refresh, using real module imports.
import assert from "node:assert/strict";
import test from "node:test";
import { MENTION_LIMIT, TOOL, instruction, mentionsFor, titleOf, toolSpec } from "./lib.ts";
import * as Effect from "effect/Effect";
import * as z from "zod";
import type { CatalogAgent, SubagentType } from "./lib.ts";
import type {
Api,
MentionDefinition,
ModelPromptOptions,
ModelPromptResult,
SubagentHandle,
SubagentStartOptions,
ToolContext,
ToolDefinition,
} from "convergence";
import { testLayer } from "convergence/effect/testing";
import { spawn as spawnEffect } from "./spawn.ts";
import { activate } from "./main.ts";
const spawn = (...args: Parameters<typeof spawnEffect>) => Effect.runPromise(spawnEffect(...args));
const errorResult = z.object({
isError: z.boolean(),
content: z.array(z.object({ type: z.string(), text: z.string() })),
});
const catalog = [
{
id: "codex",
name: "Codex",
model: "gpt-5.5",
models: [
{
value: "gpt-5.5",
name: "GPT-5.5",
group: null,
efforts: [
{ value: "low", name: "Low" },
{ value: "high", name: "High" },
],
},
{ value: "gpt-5.5 mini", name: "GPT-5.5 mini", group: null, efforts: [] },
],
},
{
id: "claude",
name: "Claude Code",
models: [{ value: "claude-haiku-4-5", name: "Haiku 4.5", group: null, efforts: [] }],
},
{ id: "broken", name: "Broken", models: [], error: "not signed in" },
];
test("every agent/model and agent/model/effort is a tag, without spaces", () => {
const mentions = mentionsFor(catalog);
assert.deepEqual(
mentions.map((m) => m.token),
["codex/gpt-5.5", "codex/gpt-5.5/low", "codex/gpt-5.5/high", "codex/gpt-5.5-mini", "claude/claude-haiku-4-5"],
);
const high = mentions.find((m) => m.token === "codex/gpt-5.5/high");
assert.ok(high);
assert.equal(high.label, "Codex · GPT-5.5 · High");
assert.equal(high.prompt, instruction("codex", "gpt-5.5", "high"));
assert.match(high.prompt, /spawn_subagent tool with agent "codex", model "gpt-5.5", effort "high"/);
assert.match(high.prompt, /\{token\}/);
assert.match(high.prompt, /\{part\}$/);
// The model keeps its real id in the instruction, spaces and all.
assert.match(mentions.find((m) => m.token === "codex/gpt-5.5-mini")?.prompt ?? "", /model "gpt-5\.5 mini"/);
});
test("a huge catalog is cut to the limit", () => {
const models = Array.from({ length: 2000 }, (_, i) => ({ value: `m${i}`, name: `M${i}`, group: null, efforts: [] }));
assert.equal(mentionsFor([{ id: "opencode", name: "OpenCode", models }]).length, MENTION_LIMIT);
});
test("the tool names the agents, their models and efforts, and the types", () => {
const spec = toolSpec(catalog, [
{ plugin: "reviewers", id: "security", title: "Security review", description: "Looks for holes." },
]);
assert.equal(spec.name, TOOL);
assert.match(spec.description, /- codex \(Codex\): gpt-5\.5 \[low\|high\], gpt-5\.5 mini/);
assert.match(spec.description, /- broken \(Broken\): unavailable \(not signed in\)/);
assert.match(spec.description, /- reviewers\/security \(Security review\): Looks for holes\./);
const inputSchema = z.toJSONSchema(spec.input);
assert.ok(inputSchema.properties);
assert.ok(typeof inputSchema.properties.agent === "object");
assert.ok(typeof inputSchema.properties.type === "object");
assert.deepEqual(inputSchema.required, ["prompt"]);
assert.equal(inputSchema.properties.agent.description, "The agent id. Without it, the agent of this chat.");
assert.deepEqual(
inputSchema.properties?.agent.enum,
["codex", "claude"],
"an agent that cannot list its models is left out",
);
assert.deepEqual(inputSchema.properties?.type.enum, ["reviewers/security"]);
assert.equal(z.toJSONSchema(toolSpec([], []).input).properties?.type, undefined, "no types, no type parameter");
});
test("a title is the prompt's first line, shortened", () => {
assert.equal(titleOf("Fix the parser\nand more"), "Fix the parser");
assert.equal(titleOf("x".repeat(80)).length, 60);
});
// A tool call's ctx and the api, recording what the plugin does.
function fakes({ answer, fail }: { answer?: Partial<ModelPromptResult>; fail?: Error } = {}) {
const seen = {
started: [] as SubagentStartOptions[],
ended: [] as Parameters<SubagentHandle["end"]>[0][],
prompts: [] as ModelPromptOptions[],
runs: [] as unknown[],
};
const handle: SubagentHandle = {
id: "sub-1",
scope: "scope-1",
shown: true,
signal: new AbortController().signal,
end: (o) => {
seen.ended.push(o);
},
text() {},
reasoning() {},
step() {},
usage() {},
notice() {},
event() {},
update() {},
tool: () => ({ id: "tool", update() {}, output() {}, end() {} }),
};
const ctx: ToolContext = {
agentId: "claude",
workspaceId: "w1",
signal: new AbortController().signal,
subagent: { start: async (o = {}) => (seen.started.push(o), handle) },
};
const api = {
models: {
prompt: async (o: ModelPromptOptions): Promise<ModelPromptResult> => {
seen.prompts.push(o);
if (fail) throw fail;
return {
text: "",
status: "completed",
usage: null,
message: null,
agentId: "codex",
model: null,
effort: null,
...answer,
};
},
},
subagents: {
run: async (o: Parameters<Api["subagents"]["run"]>[0], c?: Parameters<Api["subagents"]["run"]>[1]) => (
seen.runs.push([o, c]),
{ verdict: "ok" }
),
},
};
return { api, ctx, seen, handle };
}
test("a subagent runs its prompt on the agent and model named, shown under the call", async () => {
const { api, ctx, seen, handle } = fakes({ answer: { status: "completed", text: "42", usage: { usedTokens: 10 } } });
const result = await spawn(
api,
{ agent: "codex", model: "gpt-5.5", effort: "high", prompt: "What is six times seven?" },
ctx,
);
assert.equal(result, "42");
assert.deepEqual(seen.started, [
{
title: "What is six times seven?",
agent: "codex",
model: "gpt-5.5",
effort: "high",
prompt: "What is six times seven?",
},
]);
assert.equal(seen.prompts[0].agentId, "codex");
assert.equal(seen.prompts[0].workspaceId, "w1");
assert.equal(seen.prompts[0].subagent, handle, "the host shows the run in the subagent");
assert.deepEqual(seen.ended, [{ status: "completed", summary: "42" }]);
});
test("without an agent the subagent runs on the chat's own", async () => {
const { api, ctx, seen } = fakes({ answer: { status: "completed", text: "done" } });
await spawn(api, { prompt: "Summarize README.md", title: "Summary" }, ctx);
assert.equal(seen.prompts[0].agentId, "claude");
assert.equal(seen.started[0].title, "Summary");
});
test("a failed or stopped subagent is a failed result the agent reads", async () => {
{
const { api, ctx, seen } = fakes({ answer: { status: "failed", text: "", message: "model gone" } });
const result = errorResult.parse(await spawn(api, { agent: "codex", prompt: "x" }, ctx));
assert.equal(result.isError, true);
assert.match(result.content[0].text, /model gone/);
assert.equal(seen.ended[0]?.status, "failed");
}
{
const stop = Object.assign(new Error("aborted"), { name: "AbortError" });
const { api, ctx, seen } = fakes({ fail: stop });
const result = errorResult.parse(await spawn(api, { agent: "codex", prompt: "x" }, ctx));
assert.equal(result.isError, true);
assert.deepEqual(seen.ended, [{ status: "cancelled", summary: "Stopped." }]);
}
{
const { api, ctx, seen } = fakes({ fail: new Error("the agent codex is not enabled") });
const result = errorResult.parse(await spawn(api, { agent: "codex", prompt: "x" }, ctx));
assert.match(result.content[0].text, /not enabled/);
assert.equal(seen.ended[0]?.status, "failed");
}
const { api, ctx } = fakes();
assert.equal(errorResult.parse(await spawn(api, { prompt: " " }, ctx)).isError, true, "a prompt is required");
});
test("a subagent type runs in the plugin that defined it, under this call", async () => {
const { api, ctx, seen } = fakes();
const result = await spawn(api, { type: "reviewers/security", prompt: "Check auth.rs" }, ctx);
assert.deepEqual(result, { verdict: "ok" });
assert.deepEqual(seen.runs, [
[{ type: "reviewers/security", input: { type: "reviewers/security", prompt: "Check auth.rs" } }, ctx],
]);
assert.equal(seen.started.length, 0, "the type shows its own subagents");
});
test("the plugin offers its tool and tags, and follows the agents without re-registering the same tool", async (t) => {
const fixture = catalogFixture();
const { registered, listeners, dispose } = fixture;
t.after(dispose);
assert.equal(registered.length, 1, "the tool is there at once");
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(fixture.lists, 1);
assert.equal(fixture.mentions.length, 5);
assert.equal(registered.length, 2, "the tool now names the agents");
assert.ok(registered[0].removed && !registered[1].removed);
assert.match(registered[1].def.description ?? "", /codex \(Codex\)/);
assert.equal(typeof registered[1].def.run, "function");
assert.deepEqual([...listeners.keys()].sort(), [
"agents_changed",
"plugins_changed",
"settings_changed",
"workspaces_changed",
]);
// A change the tool does not show leaves it as it is.
const changed = listeners.get("agents_changed");
assert.ok(changed);
changed();
await new Promise((resolve) => setTimeout(resolve, 1700));
assert.equal(fixture.lists, 2);
assert.equal(registered.length, 2);
});
function catalogFixture(
options: {
list?: () => Promise<{ agents: readonly CatalogAgent[] }>;
types?: () => Promise<readonly SubagentType[]>;
} = {},
) {
const registered: { def: ToolDefinition; removed: boolean }[] = [];
const listeners = new Map<string, () => void>();
let mentions: readonly MentionDefinition[] = [];
let lists = 0;
const broker = testLayer();
const api = {
...broker.api,
plugin: "subagents",
id: "convergence/subagents",
notify: (message: string) => {
throw new Error(message);
},
tools: {
register(def: ToolDefinition) {
const entry = { def, removed: false };
registered.push(entry);
return { remove: () => (entry.removed = true) };
},
},
models: {
list: async () => {
lists++;
return options.list ? options.list() : { agents: catalog };
},
},
subagents: { types: options.types ?? (async () => []) },
mentions: {
set: (list: readonly MentionDefinition[]) => {
mentions = list;
},
},
on: (event: string, fn: () => void) => {
listeners.set(event, fn);
return () => {
listeners.delete(event);
};
},
};
// This locally constructed fixture implements every API member the activation uses.
const dispose = activate(api as unknown as Api);
return {
registered,
listeners,
dispose,
get mentions() {
return mentions;
},
get lists() {
return lists;
},
};
}
test("bursts debounce together, and unloading cancels a pending refresh and registrations", async () => {
const fixture = catalogFixture();
try {
await new Promise((resolve) => setTimeout(resolve, 20));
const changed = fixture.listeners.get("agents_changed");
assert.ok(changed);
changed();
await new Promise((resolve) => setTimeout(resolve, 800));
changed();
await new Promise((resolve) => setTimeout(resolve, 800));
assert.equal(fixture.lists, 1, "a later event resets the full settle delay");
await new Promise((resolve) => setTimeout(resolve, 800));
assert.equal(fixture.lists, 2, "the burst results in one refresh");
changed();
await fixture.dispose();
assert.equal(fixture.listeners.size, 0);
assert.ok(fixture.registered.every((entry) => entry.removed));
await new Promise((resolve) => setTimeout(resolve, 1600));
assert.equal(fixture.lists, 2, "unload cancels the scheduled refresh");
} finally {
await fixture.dispose();
}
});
test("unknown type input fields survive schema parsing and delegation", async () => {
const input = toolSpec([], [{ plugin: "reviewers", id: "security", title: "Security", description: "Review" }]).input;
const parsed = input.parse({ type: "reviewers/security", prompt: " Check auth ", focus: "permissions" });
const { api, ctx, seen } = fakes();
assert.deepEqual(await spawn(api, parsed, ctx), { verdict: "ok" });
assert.deepEqual(seen.runs, [
[
{ type: "reviewers/security", input: { type: "reviewers/security", prompt: "Check auth", focus: "permissions" } },
ctx,
],
]);
});
test("an event during an in-flight refresh waits, then refreshes after settling", async () => {
const pending = Promise.withResolvers<{ agents: readonly CatalogAgent[] }>();
let calls = 0;
const fixture = catalogFixture({
list: () => (++calls === 1 ? pending.promise : Promise.resolve({ agents: catalog })),
});
try {
const changed = fixture.listeners.get("agents_changed");
assert.ok(changed);
changed();
await new Promise((resolve) => setTimeout(resolve, 1600));
assert.equal(fixture.lists, 1, "catalog requests do not overlap");
pending.resolve({ agents: catalog });
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(fixture.registered.length, 2);
assert.equal(fixture.lists, 1, "the follow-up gets its own settle delay");
await new Promise((resolve) => setTimeout(resolve, 1600));
assert.equal(fixture.lists, 2);
assert.equal(fixture.registered.length, 2);
} finally {
await fixture.dispose();
}
});
test("catalog failures warn and keep the tool and mentions from the last successful refresh", async (t) => {
const warnings: unknown[][] = [];
t.mock.method(console, "warn", (...args: unknown[]) => {
warnings.push(args);
});
let fail = false;
const fixture = catalogFixture({
list: async () => {
if (fail) throw new Error("model catalog unavailable");
return { agents: catalog };
},
types: async () => {
if (fail) throw new Error("types unavailable");
return [];
},
});
try {
await new Promise((resolve) => setTimeout(resolve, 20));
const mentions = fixture.mentions;
fail = true;
const changed = fixture.listeners.get("plugins_changed");
assert.ok(changed);
changed();
await new Promise((resolve) => setTimeout(resolve, 1600));
assert.equal(fixture.registered.length, 2);
assert.equal(fixture.mentions, mentions);
assert.deepEqual(warnings, [
["The subagents plugin could not list the agents' models: model catalog unavailable"],
["The subagents plugin could not list subagent types: types unavailable"],
]);
} finally {
await fixture.dispose();
}
});
test("empty answers, cancelled answers and missing caller agents preserve their tool results", async () => {
const completed = fakes({ answer: { status: "completed", text: "" } });
assert.equal(await spawn(completed.api, { prompt: "x" }, completed.ctx), "The subagent finished without a reply.");
const cancelled = fakes({ answer: { status: "cancelled" } });
assert.deepEqual(await spawn(cancelled.api, { prompt: "x" }, cancelled.ctx), {
content: [{ type: "text", text: "The subagent was stopped." }],
isError: true,
});
assert.deepEqual(cancelled.seen.ended, [{ status: "cancelled", summary: "The subagent was stopped." }]);
assert.deepEqual(await spawn(completed.api, { prompt: "x" }, { ...completed.ctx, agentId: undefined }), {
content: [{ type: "text", text: "Name the agent to run the subagent on." }],
isError: true,
});
});Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.1.0latest | Oct 5, 2026 | >=2 <3 | 8.6 KB | 2 permissions | Listed |
No comments yet.