// subagent-list-injector 单测
//
// 两类覆盖:
// 1. 纯函数(parseAgentFrontmatter / formatAgentList):P3 正向触发引导语 + 名字约束
// + XML 结构 + 转义。discoverAllAgents 依赖文件系统 + resource-discovery,属集成层,
// 此处聚焦可快速回归的格式化契约(TC5 回归保护)。
// 2. session 级缓存行为(TC1-TC4):mock shared/resource-discovery 的 discoverResources
// (vi.hoisted 稳定 spy + vi.mock 工厂闭包)+ getCachedFileContent 返回 fixture,
// mock pi.on 捕获三 handler 手动触发;模块级缓存靠 vi.resetModules + 动态 import 重置。
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { DiscoveredResource } from "../../shared/resource-discovery.ts";
// ── 稳定 spy:vi.hoisted 保证 resetModules 后引用不变(vi.mock 工厂闭包捕获同一 fn,
// 故 fresh import 的注入器拿到的 discoverResources === spies.discoverResources,跨
// resetModules 调用计数连续,mockClear 控制每用例重置) ──
const spies = vi.hoisted(() => ({
discoverResources: vi.fn(),
getCachedFileContent: vi.fn(),
}));
vi.mock("../../shared/resource-discovery.ts", () => ({
discoverResources: spies.discoverResources,
findWorkspaceRoot: () => "/ws",
getCachedFileContent: spies.getCachedFileContent,
}));
vi.mock("@zhushanwen/pi-extension-logger", () => ({
getLogger: () => ({
debug: () => {
/* no-op */
},
info: () => {
/* no-op */
},
warn: () => {
/* no-op */
},
error: () => {
/* no-op */
},
}),
setPiHandle: () => {
/* no-op */
},
}));
// ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数;与下方缓存 describe 隔离) ──
import { formatAgentList, parseAgentFrontmatter } from "../subagent-list-injector";
describe("parseAgentFrontmatter", () => {
it("解析双引号包裹的 name + description", () => {
const md = `---
name: worker
description: "编码执行者"
---
body`;
expect(parseAgentFrontmatter(md)).toEqual({
name: "worker",
description: "编码执行者",
path: "",
});
});
it("解析单引号包裹的 name + description", () => {
const md = `---
name: 'reviewer'
description: '代码审查'
---`;
expect(parseAgentFrontmatter(md)).toEqual({
name: "reviewer",
description: "代码审查",
path: "",
});
});
it("缺 name 或 description 时返回 null", () => {
expect(parseAgentFrontmatter("---\nname: worker\n---")).toBeNull();
expect(parseAgentFrontmatter("---\ndescription: x\n---")).toBeNull();
});
it("无 frontmatter(不以 --- 开头)返回 null", () => {
expect(parseAgentFrontmatter("just markdown")).toBeNull();
});
it("frontmatter 未闭合(无结束 ---)返回 null", () => {
expect(parseAgentFrontmatter("---\nname: worker\ndescription: x")).toBeNull();
});
});
describe("formatAgentList", () => {
it("空列表返回空串(不注入)", () => {
expect(formatAgentList([])).toBe("");
});
it("TC1: when + examples 注入(原样渲染 + escapeXml + 缺省兼容)", () => {
const out = formatAgentList([
{
name: "reviewer",
description: "代码审查",
path: "/agents/reviewer.md",
when: "用户要求 review 代码",
examples: [
{ match: "帮我 review 这段代码", action: "调用 reviewer 对抗式审查", positive: true },
{ match: "帮我 review 设计文档", action: "不调用(文档审查应选 doc-reviewer)", positive: false },
],
},
{ name: "legacy", description: "未迁移 agent", path: "/agents/legacy.md" },
]);
expect(out).toContain("用户要求 review 代码");
// 正反原样渲染——negative action 含原因文本(评审 M5:渲染器不硬编码)
expect(out).toContain('"帮我 review 这段代码" → 调用 reviewer 对抗式审查');
expect(out).toContain('"帮我 review 设计文档" → 不调用(文档审查应选 doc-reviewer)');
// escapeXml:match 含 < > 被转义
const xmlOut = formatAgentList([
{
name: "x",
description: "d",
path: "/agents/x.md",
examples: [{ match: "处理 的 diff", action: "调用 x", positive: true }],
},
]);
expect(xmlOut).toContain("<task>");
// 缺省兼容:无 when/examples 的 agent 不渲染该段
const legacyOut = formatAgentList([{ name: "legacy", description: "未迁移 agent", path: "/agents/legacy.md" }]);
expect(legacyOut).not.toContain("");
expect(legacyOut).not.toContain("");
});
it("包含 P3 正向触发引导语(何时该 delegate)", () => {
const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }]);
expect(out).toContain("PRIORITY");
expect(out).toContain("3+ files");
expect(out).toContain("delegate");
expect(out).toContain("FIRST");
});
it("保留原 'ONLY use agent names from this list' 名字约束", () => {
const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }]);
expect(out).toContain("ONLY use agent names from this list");
});
it("包含 'Do NOT call list to discover' 引导语", () => {
const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }]);
expect(out).toContain("Do NOT call list to discover");
expect(out).toContain("use list only for running state");
});
it("用 标签包裹并列出每个 agent", () => {
const out = formatAgentList([
{ name: "worker", description: "does work", path: "/agents/worker.md" },
{ name: "reviewer", description: "reviews code", path: "/agents/reviewer.md" },
]);
expect(out).toContain("");
expect(out).toContain("");
expect(out).toContain("worker");
expect(out).toContain("does work");
expect(out).toContain("reviewer");
expect(out).toContain("reviews code");
// S1:每项含 完整路径(agentRef,模型直接引用)
expect(out).toContain("/agents/worker.md");
expect(out).toContain("/agents/reviewer.md");
});
it("转义 XML 特殊字符", () => {
const out = formatAgentList([{ name: "a&b", description: "\"q\"", path: "/agents/a&b.md" }]);
expect(out).toContain("a&b<c>");
expect(out).toContain(""q"");
});
});
// ──────────────────────────────────────────────────────────────
// session 级缓存行为(TC1-TC4)
// ──────────────────────────────────────────────────────────────
/** before_agent_start handler 的返回结构(取 systemPrompt 断言)。 */
interface BeforeAgentResult {
systemPrompt: string;
}
/** 三 handler 捕获引用(setupSubagentListInjector 注册后填充)。 */
interface CapturedHandlers {
sessionStart?: (event: unknown, ctx: unknown) => Promise | void;
beforeAgentStart?: (event: { systemPrompt: string }, ctx: unknown) => Promise | BeforeAgentResult | void;
sessionShutdown?: (event: unknown, ctx: unknown) => void;
}
/** 构造 mock pi:捕获三 handler 引用,其余 prop 走 noop(仅 on 被调用)。 */
function createMockPi(handlers: CapturedHandlers): ExtensionAPI {
const on = (event: string, handler: (...args: unknown[]) => unknown): void => {
if (event === "session_start") {
handlers.sessionStart = handler as CapturedHandlers["sessionStart"];
} else if (event === "before_agent_start") {
handlers.beforeAgentStart = handler as CapturedHandlers["beforeAgentStart"];
} else if (event === "session_shutdown") {
handlers.sessionShutdown = handler as CapturedHandlers["sessionShutdown"];
}
};
const noop = (): void => {
/* mock */
};
// setupSubagentListInjector 仅调 pi.on;用最小对象 + 双重断言满足 ExtensionAPI 契约
// (测试 mock 约定,见 crash-recovery.test.ts 的 Proxy 模式)
return { on, appendEntry: noop, registerTool: noop, registerCommand: noop, registerMessageRenderer: noop, events: { emit: noop, on: noop } } as unknown as ExtensionAPI;
}
/** 最小 ctx mock(注入器只读 ctx.cwd)。 */
function createMockCtx(): Record {
return { cwd: "/ws", mode: "tui" };
}
/** fixture:单个 worker agent 的 DiscoveredResource。 */
function agentResource(path: string): DiscoveredResource {
return { path, source: "project-agents", available: true };
}
/** fixture:agent .md frontmatter 内容。 */
function agentMd(name: string, description: string): string {
return `---\nname: ${name}\ndescription: "${description}"\n---\nbody`;
}
describe("subagent-list-injector session 级缓存", () => {
let setupSubagentListInjector: typeof import("../subagent-list-injector").setupSubagentListInjector;
let handlers: CapturedHandlers;
beforeEach(async () => {
// resetModules + 动态 import:拿 fresh 模块实例 → 模块级 agentCache 重置为 null
vi.resetModules();
spies.discoverResources.mockReset();
spies.getCachedFileContent.mockReset();
// 默认空发现(各 TC 按需覆盖)
spies.discoverResources.mockResolvedValue([]);
spies.getCachedFileContent.mockReturnValue(null);
handlers = {};
const mod = await import("../subagent-list-injector");
setupSubagentListInjector = mod.setupSubagentListInjector;
setupSubagentListInjector(createMockPi(handlers));
});
it("TC1: session_start 发现+缓存后,两次 before_agent_start 命中缓存(discoverResources 只调 1 次)", async () => {
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码执行者"));
// session_start 触发发现+缓存
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
// 两次 before_agent_start:均读缓存,不再触发 discoverResources
const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
// 两次注入段都含 且内容一致(同一缓存)
expect(r1?.systemPrompt).toContain("");
expect(r1?.systemPrompt).toContain("worker");
expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
});
it("TC2: session_shutdown 清缓存后 before_agent_start miss → fallback 重新发现+缓存", async () => {
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
// session_start 发现+缓存(discoverResources count=1)
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
// session_shutdown 清缓存
handlers.sessionShutdown!({ type: "session_shutdown", reason: "quit" }, createMockCtx());
// before_agent_start 读到空缓存 → fallback 重新发现(count=2)+ 重新缓存
const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
expect(r1?.systemPrompt).toContain("worker");
// 再次 before_agent_start 命中缓存(不再调 discoverResources,count 仍 2)
const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
});
it("TC3: 无 session_start 直接 before_agent_start(miss fallback)→ 发现+缓存", async () => {
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
// 未触发 session_start,直接两次 before_agent_start
const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
// 第一次 fallback 发现+缓存(count=1);第二次命中缓存(count 仍 1)
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
expect(r1?.systemPrompt).toContain("worker");
expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
});
it("TC4: session_start(reload) 覆盖缓存(新资源生效)", async () => {
// 第一次 session_start(new):发现 worker
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
// 资源变化:discoverResources 改返回 reviewer,getCachedFileContent 改返回 reviewer 内容
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/reviewer.md")]);
spies.getCachedFileContent.mockReturnValue(agentMd("reviewer", "审查"));
// session_start(reload) 重新发现覆盖缓存(count=2)
await handlers.sessionStart!({ type: "session_start", reason: "reload" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
// before_agent_start 命中新缓存:注入段反映 reviewer(非旧 worker)
const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
expect(r?.systemPrompt).toContain("reviewer");
expect(r?.systemPrompt).not.toContain("worker");
});
it("session_start 发现空列表缓存后 before_agent_start 命中(不重扫,不注入)", async () => {
// 空列表是有效缓存态(非 null):命中后不重扫,formatAgentList([]) 返空串不注入
spies.discoverResources.mockResolvedValue([]);
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
const r1 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
const r2 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(1);
// 空注入:handler 返 void(无 systemPrompt 字段)
expect(r1).toBeUndefined();
expect(r2).toBeUndefined();
});
it("session_start 发现异常不阻断(fail-safe,缓存保持 null,before_agent_start fallback)", async () => {
spies.discoverResources.mockRejectedValueOnce(new Error("disk io"));
// session_start 发现抛错:记日志不阻断,缓存保持 null
await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
// before_agent_start 读到 null 缓存 → fallback 重新发现(recover)
spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
expect(spies.discoverResources).toHaveBeenCalledTimes(2);
expect(r?.systemPrompt).toContain("worker");
});
});