// 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 { DiscoveredResource } from "@zhushanwen/subagent-core/shared/resource-discovery.ts";
// 共享 mock 基建(vi.mock 工厂 / mock pi / mock ctx):helpers/injector-test-mocks.ts
// ——必须先于 barrel import 初始化:barrel 的 re-export 链会触发深路径 mock 工厂,
// 工厂闭包引用本 helpers 的绑定(helpers 文件头「惰性求值」约束)
import { createDiscoveryModuleMock, createLoggerModuleMock, createMockCtx, createMockPi, type CapturedHandlers } from "./helpers/injector-test-mocks.ts";
// C5①:formatAgentList 下沉 core barrel——测试改从 barrel 取实现,guide 用 pi 宿主注入文案
import { formatAgentList } from "@zhushanwen/subagent-core";
// ── 稳定 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("@zhushanwen/subagent-core/shared/resource-discovery.ts", () => createDiscoveryModuleMock(spies));
// 工厂必须写成箭头惰性形式:vi.mock 被提升到 import 之前执行,直接传
// createLoggerModuleMock 引用会在提升位置立即求值 import 绑定 → TDZ ReferenceError
vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
// ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数;与下方缓存 describe 隔离) ──
import { parseAgentFrontmatter, SUBAGENT_LIST_GUIDE } 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([], { guide: SUBAGENT_LIST_GUIDE })).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" },
], { guide: SUBAGENT_LIST_GUIDE });
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 }],
},
], { guide: SUBAGENT_LIST_GUIDE });
expect(xmlOut).toContain("<task>");
// 缺省兼容:无 when/examples 的 agent 不渲染该段
const legacyOut = formatAgentList([{ name: "legacy", description: "未迁移 agent", path: "/agents/legacy.md" }], { guide: SUBAGENT_LIST_GUIDE });
expect(legacyOut).not.toContain("");
expect(legacyOut).not.toContain("");
});
it("包含 P3 正向触发引导语(何时该 delegate)", () => {
const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }], { guide: SUBAGENT_LIST_GUIDE });
expect(out).toContain("PRIORITY");
expect(out).toContain("3+ files");
expect(out).toContain("delegate");
expect(out).toContain("FIRST");
});
it("C5① guide 更新:agent 引用改为 路径形态,动态 agent 指引改为缺省 general-purpose(systemPrompt 参数已不在 schema)", () => {
const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }], { guide: SUBAGENT_LIST_GUIDE });
expect(out).toContain("ONLY use agents from this list");
expect(out).toContain("pass the path (absolute .md path) as the agent param");
expect(out).toContain("omit agent (a general-purpose agent is used)");
// 旧句已过期(subagent tool schema 无 systemPrompt 参数,见 subagent-tool-schema.ts)
expect(out).not.toContain("systemPrompt");
expect(out).not.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" }], { guide: SUBAGENT_LIST_GUIDE });
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" },
], { guide: SUBAGENT_LIST_GUIDE });
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" }], { guide: SUBAGENT_LIST_GUIDE });
expect(out).toContain("a&b<c>");
expect(out).toContain(""q"");
});
it("CA2 快照等价锚定:骨架 + 条目段逐字节等于 pi 旧本地实现模板(除 location 前缀外零变化)", () => {
// 条目模板逐字复制自 C5① 前的本地 formatAgentList 实现(escapeXml 同函数),
// 锚定 core 下沉未改变渲染字节(红线 8:等价豁免仅 location 前缀 + guide 段)
const agents = [
{
name: "reviewer",
description: "代码审查",
path: "/OLD-PREFIX/agents/reviewer.md",
when: "用户要求 review",
},
{ name: "worker", description: "does work", path: "/OLD-PREFIX/agents/worker.md" },
];
const expectedItems = [
' reviewer代码审查用户要求 review/OLD-PREFIX/agents/reviewer.md',
' workerdoes work/OLD-PREFIX/agents/worker.md',
];
const out = formatAgentList(agents, { guide: SUBAGENT_LIST_GUIDE });
expect(out).toBe(
`\n\n\n${SUBAGENT_LIST_GUIDE}\n${expectedItems.join("\n")}\n`,
);
// location 前缀替换(资产来源迁移:pi-sw 安装目录 → core 包)后其余字节零变化
const migrated = formatAgentList(
agents.map((a) => ({ ...a, path: a.path.replace("/OLD-PREFIX/", "/NEW-CORE-PKG/") })),
{ guide: SUBAGENT_LIST_GUIDE },
);
expect(migrated).toBe(out.replaceAll("/OLD-PREFIX/", "/NEW-CORE-PKG/"));
});
});
// ──────────────────────────────────────────────────────────────
// session 级缓存行为(TC1-TC4;mock pi/ctx 构造在 helpers/injector-test-mocks.ts)
// ──────────────────────────────────────────────────────────────
/** 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");
});
});
// ──────────────────────────────────────────────────────────────
// KV-cache 顺序契约:输出按 name 码点序,重建(两次发现)逐字节一致
// ──────────────────────────────────────────────────────────────
describe("discoverAllAgents 顺序契约(KV-cache)", () => {
it("输出按 name 码点序排序,与发现层返回顺序(readdir 枚举序)无关", async () => {
const byPath: Record = {
"/ws/.agents/agents/zeta.md": agentMd("zeta", "z"),
"/ws/.agents/agents/worker.md": agentMd("worker", "w"),
"/ws/.agents/agents/alpha.md": agentMd("alpha", "a"),
};
// 刻意以非字母序返回(模拟 readdir 无契约枚举序)
spies.discoverResources.mockResolvedValue([
agentResource("/ws/.agents/agents/zeta.md"),
agentResource("/ws/.agents/agents/worker.md"),
agentResource("/ws/.agents/agents/alpha.md"),
]);
spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
const { discoverAllAgents } = await import("../subagent-list-injector");
const agents = await discoverAllAgents("/ws");
expect(agents.map((a) => a.name)).toEqual(["alpha", "worker", "zeta"]);
});
it("重建(两次发现)输出与渲染结果逐字节一致——目录不变时 session_start/fallback/resume 任意重建等价", async () => {
const byPath: Record = {
"/ws/.agents/agents/b.md": agentMd("beta", "b"),
"/ws/.agents/agents/a.md": agentMd("alpha", "a"),
};
// 两次发现返回顺序不同(模拟跨进程 readdir 漂移)
spies.discoverResources
.mockResolvedValueOnce([
agentResource("/ws/.agents/agents/b.md"),
agentResource("/ws/.agents/agents/a.md"),
])
.mockResolvedValueOnce([
agentResource("/ws/.agents/agents/a.md"),
agentResource("/ws/.agents/agents/b.md"),
]);
spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
const { discoverAllAgents } = await import("../subagent-list-injector");
const first = await discoverAllAgents("/ws");
const second = await discoverAllAgents("/ws");
expect(second).toEqual(first);
expect(formatAgentList(second, { guide: SUBAGENT_LIST_GUIDE })).toBe(
formatAgentList(first, { guide: SUBAGENT_LIST_GUIDE }),
);
});
});