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

  • Use your agents' models models.useHighRuns prompts through the agents you enabled.Run each subagent through the agent and model it names, on your own accounts
  • Provide agent tools tools.provideMediumGives tools to agents.Give agents the spawn_subagent tool

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

VersionPublishedPlugin APISizePermissionsStatus
0.1.0latestOct 5, 2026>=2 <38.6 KB2 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.