feat: enable permission-aware subagents

This commit is contained in:
叶林立
2026-08-26 10:49:26 +08:00
parent d3bf562189
commit 7571ba6dd9
185 changed files with 48365 additions and 26 deletions
+202
View File
@@ -0,0 +1,202 @@
/**
* mention.test.ts — the `@handle` grammar.
*
* Both halves are load-bearing in a way that fails silently. A handle that
* isn't `[\w-]` can never be typed back (the trigger regex would not match it),
* and a collision that reuses a name makes an older sibling permanently
* unreachable. On the parse side, every rejection here is a case where being
* too eager would swallow input the user meant for the main model — a leading
* file path, a bare handle, a mention mid-sentence.
*/
import { describe, expect, it } from "vitest";
import { agentMentionReminder, assignHandle, describeMention, handleBase, isReservedHandle, MENTION_TRIGGER, parseMention, resolveHandleToType, stripAgentPrefix } from "../src/mention.js";
describe("handleBase", () => {
it("lowercases so the handle matches how it is typed", () => {
expect(handleBase("Explore")).toBe("explore");
});
it("keeps a hyphenated type as-is", () => {
expect(handleBase("general-purpose")).toBe("general-purpose");
});
it("reduces anything outside [\\w-] to hyphens, without leaving edge hyphens", () => {
expect(handleBase("Code Review!")).toBe("code-review");
expect(handleBase(" spaced out ")).toBe("spaced-out");
});
it("always produces something typeable", () => {
// A type made entirely of stripped characters would otherwise slug to "",
// and `@` alone can address nothing.
expect(handleBase("!!!")).toBe("agent");
expect(handleBase("")).toBe("agent");
});
it("caps a long name so one agent can't own an unreadable row", () => {
const slug = handleBase("x".repeat(200));
expect(slug).toHaveLength(64);
});
it("never leaves a trailing hyphen the cap sliced into", () => {
// 63 chars, then a hyphen run: a naive slice(0, 64) keeps that hyphen.
expect(handleBase(`${"x".repeat(63)} tail`).endsWith("-")).toBe(false);
});
it("only ever produces handles the suggestion trigger can match", () => {
for (const type of ["Explore", "general-purpose", "Code Review!", "!!!", "デバッグ"]) {
expect(MENTION_TRIGGER.test(`@${handleBase(type)}`)).toBe(true);
}
});
});
describe("assignHandle", () => {
it("takes the plain base when it is free", () => {
expect(assignHandle("explore", new Set())).toBe("explore");
});
it("numbers from 2 on the first collision", () => {
expect(assignHandle("explore", new Set(["explore"]))).toBe("explore-2");
});
it("keeps counting past every taken form", () => {
expect(assignHandle("explore", new Set(["explore", "explore-2"]))).toBe("explore-3");
});
it("never hands out the reserved main handle", () => {
// `@main` addresses the main conversation. An agent holding that name
// would silently swallow the one escape hatch out of the mention grammar.
expect(assignHandle("main", new Set())).toBe("main-2");
});
it("skips a gap rather than reusing a live handle", () => {
// explore-2 finished and was evicted; reusing it is fine, but explore-3
// is still running and must not be shadowed.
expect(assignHandle("explore", new Set(["explore", "explore-3"]))).toBe("explore-2");
});
});
describe("resolveHandleToType", () => {
const TYPES = ["general-purpose", "Explore", "Code Review!"];
it("finds the type a handle was derived from, whatever its casing", () => {
expect(resolveHandleToType("explore", TYPES)).toBe("Explore");
expect(resolveHandleToType("EXPLORE", TYPES)).toBe("Explore");
});
it("resolves a type whose slug differs from its name", () => {
expect(resolveHandleToType("code-review", TYPES)).toBe("Code Review!");
});
it("is exact, not a prefix match — a partial handle must not start an agent", () => {
expect(resolveHandleToType("ex", TYPES)).toBeUndefined();
expect(resolveHandleToType("explore-2", TYPES)).toBeUndefined();
});
it("round-trips every registered type", () => {
for (const type of TYPES) expect(resolveHandleToType(handleBase(type), TYPES)).toBe(type);
});
it("refuses to resolve the reserved handle, even to a type named for it", () => {
// Otherwise `@main do this` would start an agent instead of reaching the
// main model — and `assignHandle` already denies its instances that name,
// so resolving the type here would promise something unreachable.
expect(resolveHandleToType("main", ["main", ...TYPES])).toBeUndefined();
});
});
describe("isReservedHandle", () => {
it("recognizes main whatever its casing", () => {
expect(isReservedHandle("main")).toBe(true);
expect(isReservedHandle("MAIN")).toBe(true);
});
it("leaves every ordinary handle alone", () => {
for (const handle of ["explore", "mainframe", "main-2", "ma"]) {
expect(isReservedHandle(handle)).toBe(false);
}
});
});
describe("stripAgentPrefix", () => {
it("unwraps Claude Code's manual @agent-<type> spelling", () => {
expect(stripAgentPrefix("agent-explore")).toBe("explore");
});
it("keeps the remainder intact when it is itself prefixed", () => {
expect(stripAgentPrefix("agent-agent-foo")).toBe("agent-foo");
});
it("returns nothing when there is no prefix or nothing behind it", () => {
expect(stripAgentPrefix("explore")).toBeUndefined();
expect(stripAgentPrefix("agent-")).toBeUndefined();
expect(stripAgentPrefix("agentexplore")).toBeUndefined();
});
it("only unwraps a prefix at the very start", () => {
// `@sub-agent-explore` names an agent called `sub-agent-explore`. Matching
// `agent-` anywhere would silently redirect it to `@explore`.
expect(stripAgentPrefix("sub-agent-explore")).toBeUndefined();
});
});
describe("describeMention", () => {
it("uses the message as the agent's short label", () => {
expect(describeMention("find every retry marker")).toBe("find every retry marker");
});
it("takes the first line and collapses whitespace", () => {
expect(describeMention(" audit the RPC path\nthen report back ")).toBe("audit the RPC path");
});
it("clips a long message rather than putting a paragraph in every agent surface", () => {
const label = describeMention("x".repeat(200));
expect(label).toHaveLength(40);
expect(label.endsWith("…")).toBe(true);
});
});
describe("parseMention", () => {
it("splits a leading handle from its message", () => {
expect(parseMention("@explore check the RPC path")).toEqual({
handle: "explore",
message: "check the RPC path",
});
});
it("trims the message and accepts a newline as the separator", () => {
expect(parseMention("@explore spaced ")).toEqual({ handle: "explore", message: "spaced" });
expect(parseMention("@explore\nline1\nline2")).toEqual({ handle: "explore", message: "line1\nline2" });
});
it("rejects a bare handle — that belongs to the main model", () => {
expect(parseMention("@explore")).toBeNull();
expect(parseMention("@explore ")).toBeNull();
expect(parseMention("@explore \t ")).toBeNull();
});
it("rejects a leading file path so pi's @-attachment keeps working", () => {
expect(parseMention("@src/index.ts summarize this")).toBeNull();
expect(parseMention("@README.md what changed")).toBeNull();
});
it("rejects a mention that is not at the start of the input", () => {
expect(parseMention("hey @explore look at this")).toBeNull();
expect(parseMention(" @explore look at this")).toBeNull();
});
});
describe("agentMentionReminder", () => {
it("is Claude Code's string, byte for byte", () => {
// Ported from the 2.1.233 bundle rather than paraphrased, so the model gets
// the wording it was trained against. Asserted whole — including the
// trailing space before the closing newline, which is in the original
// template literal and is exactly the kind of thing a tidy-up would drop.
expect(agentMentionReminder("code-review")).toBe(
'<system-reminder>\nThe user has expressed a desire to invoke the agent "code-review". Please invoke the agent appropriately, passing in the required context to it. \n</system-reminder>',
);
});
it("names the agent it was given", () => {
expect(agentMentionReminder("Plan")).toContain('invoke the agent "Plan"');
});
});