/** * 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- 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( '\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', ); }); it("names the agent it was given", () => { expect(agentMentionReminder("Plan")).toContain('invoke the agent "Plan"'); }); });