@zhushanwen/pi-subagent-workflow 5.0.2 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/agents/{reviewer.md → code-reviewer.md} +20 -3
  2. package/agents/context-builder.md +5 -0
  3. package/agents/doc-reviewer.md +9 -2
  4. package/agents/explorer.md +5 -0
  5. package/agents/general-purpose.md +5 -0
  6. package/agents/oracle.md +20 -4
  7. package/agents/orchestrator.md +6 -1
  8. package/agents/planner.md +5 -0
  9. package/agents/researcher.md +5 -0
  10. package/agents/worker.md +5 -0
  11. package/package.json +6 -4
  12. package/src/execution/__tests__/agent-registry.test.ts +189 -119
  13. package/src/execution/__tests__/crash-recovery.test.ts +0 -1
  14. package/src/execution/__tests__/execute-options-mapper.test.ts +4 -4
  15. package/src/execution/__tests__/index-session-start.test.ts +0 -1
  16. package/src/execution/__tests__/model-resolver.test.ts +20 -0
  17. package/src/execution/__tests__/session-start-reaper.test.ts +0 -2
  18. package/src/execution/__tests__/subprocess-agent-runner.test.ts +1 -1
  19. package/src/execution/agent-registry.ts +92 -169
  20. package/src/execution/execute-options-mapper.ts +2 -2
  21. package/src/execution/model-config-service.ts +13 -34
  22. package/src/execution/model-resolver.ts +5 -3
  23. package/src/execution/subagent-service.ts +9 -6
  24. package/src/execution/subprocess-agent-runner.ts +3 -2
  25. package/src/index.ts +4 -25
  26. package/src/injectors/__tests__/subagent-list-injector.test.ts +266 -14
  27. package/src/injectors/__tests__/workflow-list-injector.test.ts +236 -32
  28. package/src/injectors/subagent-list-injector.ts +99 -48
  29. package/src/injectors/workflow-list-injector.ts +65 -50
  30. package/src/interface/__tests__/detectors.test.ts +100 -43
  31. package/src/interface/__tests__/subagent-tool-prompt.test.ts +8 -12
  32. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +163 -0
  33. package/src/interface/__tests__/workflow-tool-prompt.test.ts +55 -9
  34. package/src/interface/subagent-tool.ts +7 -4
  35. package/src/interface/tool-workflow-script.ts +27 -10
  36. package/src/interface/tool-workflow.ts +174 -81
  37. package/src/orchestration/__tests__/args-validator.test.ts +143 -0
  38. package/src/orchestration/__tests__/config-loader.test.ts +124 -40
  39. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +33 -2
  40. package/src/orchestration/__tests__/lifecycle.test.ts +59 -2
  41. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +116 -51
  42. package/src/orchestration/__tests__/script-lint.test.ts +167 -1
  43. package/src/orchestration/__tests__/worker-host.test.ts +120 -0
  44. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +69 -0
  45. package/src/orchestration/__tests__/worker-script-builder.test.ts +51 -1
  46. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +2 -2
  47. package/src/orchestration/__tests__/workflows-e2e.test.ts +177 -24
  48. package/src/orchestration/agent-opts-resolver.ts +51 -101
  49. package/src/orchestration/args-validator.ts +127 -0
  50. package/src/orchestration/config-loader.ts +63 -94
  51. package/src/orchestration/error-recovery.ts +6 -9
  52. package/src/orchestration/launcher.ts +77 -41
  53. package/src/orchestration/lifecycle.ts +7 -0
  54. package/src/orchestration/models/ports.ts +0 -14
  55. package/src/orchestration/models/run-spec.ts +24 -1
  56. package/src/orchestration/models/types.ts +15 -8
  57. package/src/orchestration/models/workflow-script-registry.ts +3 -0
  58. package/src/orchestration/models/workflow-script.ts +10 -14
  59. package/src/orchestration/script-lint.ts +159 -0
  60. package/src/orchestration/worker-host.ts +5 -0
  61. package/src/orchestration/worker-script-builder.ts +15 -4
  62. package/src/orchestration/workflow-script-registry-impl.ts +34 -29
  63. package/src/shared/__tests__/meta-parser.test.ts +304 -0
  64. package/src/shared/__tests__/resource-discovery.test.ts +167 -7
  65. package/src/shared/__tests__/resource-meta.test.ts +51 -0
  66. package/src/shared/agent-ref.ts +36 -0
  67. package/src/shared/meta-parser.ts +257 -0
  68. package/src/shared/resource-discovery.ts +88 -2
  69. package/src/shared/resource-meta.ts +60 -0
  70. package/workflows/README.md +2 -2
  71. package/workflows/_shared/agent-refs.cjs +40 -0
  72. package/workflows/chain.js +30 -6
  73. package/workflows/map-reduce.js +33 -5
  74. package/workflows/parallel.js +34 -7
  75. package/workflows/review-fix-loop-utils.cjs +23 -103
  76. package/workflows/review-fix-loop.js +121 -58
  77. package/workflows/scatter-gather.js +30 -6
package/src/index.ts CHANGED
@@ -21,7 +21,6 @@ import type { ExtensionAPI, ExtensionContext, SessionShutdownEvent, SessionStart
21
21
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
22
  import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
23
23
 
24
- import type { AgentRegistry } from "./execution/agent-registry.ts";
25
24
  import { bestEffort } from "./execution/best-effort.ts";
26
25
  // ═══ execution/ 层(subagents 核心 + 运行时) ═══
27
26
  import { getOrCreateChannelRegistry } from "./execution/channel-registry-access.ts";
@@ -51,7 +50,6 @@ import { registerSubagentTool } from "./interface/subagent-tool.ts";
51
50
  import { registerSubagentsCommand } from "./interface/subagents.ts";
52
51
  import { registerWorkflowTool } from "./interface/tool-workflow.ts";
53
52
  import { registerWorkflowScriptTool } from "./interface/tool-workflow-script.ts";
54
- import { cleanupAllTempFiles as cleanupAllFiles } from "./orchestration/agent-opts-resolver.ts";
55
53
  import { JsonlRunStore } from "./orchestration/jsonl-run-store.ts";
56
54
  // ═══ orchestration/ 层(workflow engine + infra) ═══
57
55
  import type { LauncherDeps } from "./orchestration/launcher.ts";
@@ -136,8 +134,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
136
134
  {
137
135
  store: JsonlRunStore;
138
136
  runs: Map<string, WorkflowRun>;
139
- activeTempFiles: Set<string>;
140
- agentRegistry: AgentRegistry;
141
137
  sessionDir: string;
142
138
  /** D-008 per-session SAR(需要 ctxModel + subagentService) */
143
139
  runner: SubprocessAgentRunner;
@@ -180,8 +176,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
180
176
  state: {
181
177
  store: JsonlRunStore;
182
178
  runs: Map<string, WorkflowRun>;
183
- activeTempFiles: Set<string>;
184
- agentRegistry: AgentRegistry;
185
179
  sessionDir: string;
186
180
  runner: SubprocessAgentRunner;
187
181
  },
@@ -194,9 +188,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
194
188
  runs: state.runs,
195
189
  registry,
196
190
  onRunDone: (run: WorkflowRun) => notifyDone(pi, run.runId, run, notifiedRunIds, toGuiCtx(sessionCtx)),
197
- agentRegistry: state.agentRegistry,
198
- sessionDir: state.sessionDir,
199
- activeTempFiles: state.activeTempFiles,
200
191
  eventBus: pi.events,
201
192
  scheduleTimeBudget: (runId: string, budgetTimeMs: number) =>
202
193
  scheduleTimeBudget(runId, deps, budgetTimeMs),
@@ -317,10 +308,10 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
317
308
  });
318
309
  const runs = new Map<string, WorkflowRun>();
319
310
 
320
- // F-4/D-003: 复用 modelService 的 AgentRegistry(统一资源发现 + 包内 builtin),
321
- // 取代旧 orchestration/agent-discovery.ts 的 7 路径自爬。agent 发现走
322
- // shared/resource-discoveryADR-031),与 subagents 域共用同一份发现结果。
323
- const agentRegistry = modelService.getAgentRegistry();
311
+ // F-4/D-003: agent 发现走 shared/resource-discovery(ADR-031),modelService
312
+ // 自持 AgentRegistry(subagents/workflow 两域共用同一发现结果)。
313
+ // M2 修正:workflow 域 resolveAgentOpts 不再消费 agentRegistryagent ref
314
+ // resolveIdentity),无需经 state 透传——modelService 是唯一 registry 源。
324
315
 
325
316
  // MF-1: store 健康度跟踪。loadAll 失败 → storeHealthy=false,workflow 域启动时 fail-fast。
326
317
  let storeHealthy = true;
@@ -356,8 +347,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
356
347
  sessionState.set(sessionId, {
357
348
  store,
358
349
  runs,
359
- activeTempFiles: new Set(),
360
- agentRegistry,
361
350
  sessionDir,
362
351
  runner,
363
352
  ctx,
@@ -417,7 +406,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
417
406
  await Promise.allSettled(
418
407
  running.map((run) => pauseRun(run.runId, makeDeps(state, _ctx))),
419
408
  );
420
- cleanupAllFiles(state.activeTempFiles);
421
409
  sessionState.delete(sessionId);
422
410
  }
423
411
 
@@ -504,15 +492,6 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
504
492
  get onRunDone() {
505
493
  return getDeps().onRunDone;
506
494
  },
507
- get agentRegistry() {
508
- return getDeps().agentRegistry;
509
- },
510
- get sessionDir() {
511
- return getDeps().sessionDir;
512
- },
513
- get activeTempFiles() {
514
- return getDeps().activeTempFiles;
515
- },
516
495
  get eventBus() {
517
496
  return getDeps().eventBus;
518
497
  },
@@ -1,15 +1,55 @@
1
1
  // subagent-list-injector 单测
2
2
  //
3
- // 覆盖纯函数:parseAgentFrontmatter(frontmatter 解析)+ formatAgentList(P3 正向
4
- // 触发引导语 + 名字约束 + XML 结构 + 转义)。discoverAllAgents 依赖文件系统 +
5
- // resource-discovery,属集成层,此处聚焦可快速回归的格式化契约。
3
+ // 两类覆盖:
4
+ // 1. 纯函数(parseAgentFrontmatter / formatAgentList):P3 正向触发引导语 + 名字约束
5
+ // + XML 结构 + 转义。discoverAllAgents 依赖文件系统 + resource-discovery,属集成层,
6
+ // 此处聚焦可快速回归的格式化契约(TC5 回归保护)。
7
+ // 2. session 级缓存行为(TC1-TC4):mock shared/resource-discovery 的 discoverResources
8
+ // (vi.hoisted 稳定 spy + vi.mock 工厂闭包)+ getCachedFileContent 返回 fixture,
9
+ // mock pi.on 捕获三 handler 手动触发;模块级缓存靠 vi.resetModules + 动态 import 重置。
6
10
 
7
- import { describe, expect, it } from "vitest";
11
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
12
 
9
- import {
10
- formatAgentList,
11
- parseAgentFrontmatter,
12
- } from "../subagent-list-injector";
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+
15
+ import type { DiscoveredResource } from "../../shared/resource-discovery.ts";
16
+
17
+ // ── 稳定 spy:vi.hoisted 保证 resetModules 后引用不变(vi.mock 工厂闭包捕获同一 fn,
18
+ // 故 fresh import 的注入器拿到的 discoverResources === spies.discoverResources,跨
19
+ // resetModules 调用计数连续,mockClear 控制每用例重置) ──
20
+ const spies = vi.hoisted(() => ({
21
+ discoverResources: vi.fn(),
22
+ getCachedFileContent: vi.fn(),
23
+ }));
24
+
25
+ vi.mock("../../shared/resource-discovery.ts", () => ({
26
+ discoverResources: spies.discoverResources,
27
+ findWorkspaceRoot: () => "/ws",
28
+ getCachedFileContent: spies.getCachedFileContent,
29
+ }));
30
+
31
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
32
+ getLogger: () => ({
33
+ debug: () => {
34
+ /* no-op */
35
+ },
36
+ info: () => {
37
+ /* no-op */
38
+ },
39
+ warn: () => {
40
+ /* no-op */
41
+ },
42
+ error: () => {
43
+ /* no-op */
44
+ },
45
+ }),
46
+ setPiHandle: () => {
47
+ /* no-op */
48
+ },
49
+ }));
50
+
51
+ // ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数;与下方缓存 describe 隔离) ──
52
+ import { formatAgentList, parseAgentFrontmatter } from "../subagent-list-injector";
13
53
 
14
54
  describe("parseAgentFrontmatter", () => {
15
55
  it("解析双引号包裹的 name + description", () => {
@@ -21,6 +61,7 @@ body`;
21
61
  expect(parseAgentFrontmatter(md)).toEqual({
22
62
  name: "worker",
23
63
  description: "编码执行者",
64
+ path: "",
24
65
  });
25
66
  });
26
67
 
@@ -32,6 +73,7 @@ description: '代码审查'
32
73
  expect(parseAgentFrontmatter(md)).toEqual({
33
74
  name: "reviewer",
34
75
  description: "代码审查",
76
+ path: "",
35
77
  });
36
78
  });
37
79
 
@@ -54,8 +96,42 @@ describe("formatAgentList", () => {
54
96
  expect(formatAgentList([])).toBe("");
55
97
  });
56
98
 
99
+ it("TC1: when + examples 注入(原样渲染 + escapeXml + 缺省兼容)", () => {
100
+ const out = formatAgentList([
101
+ {
102
+ name: "reviewer",
103
+ description: "代码审查",
104
+ path: "/agents/reviewer.md",
105
+ when: "用户要求 review 代码",
106
+ examples: [
107
+ { match: "帮我 review 这段代码", action: "调用 reviewer 对抗式审查", positive: true },
108
+ { match: "帮我 review 设计文档", action: "不调用(文档审查应选 doc-reviewer)", positive: false },
109
+ ],
110
+ },
111
+ { name: "legacy", description: "未迁移 agent", path: "/agents/legacy.md" },
112
+ ]);
113
+ expect(out).toContain("<when>用户要求 review 代码</when>");
114
+ // 正反原样渲染——negative action 含原因文本(评审 M5:渲染器不硬编码)
115
+ expect(out).toContain('"帮我 review 这段代码" → 调用 reviewer 对抗式审查');
116
+ expect(out).toContain('"帮我 review 设计文档" → 不调用(文档审查应选 doc-reviewer)');
117
+ // escapeXml:match 含 < > 被转义
118
+ const xmlOut = formatAgentList([
119
+ {
120
+ name: "x",
121
+ description: "d",
122
+ path: "/agents/x.md",
123
+ examples: [{ match: "处理 <task> 的 diff", action: "调用 x", positive: true }],
124
+ },
125
+ ]);
126
+ expect(xmlOut).toContain("&lt;task&gt;");
127
+ // 缺省兼容:无 when/examples 的 agent 不渲染该段
128
+ const legacyOut = formatAgentList([{ name: "legacy", description: "未迁移 agent", path: "/agents/legacy.md" }]);
129
+ expect(legacyOut).not.toContain("<when>");
130
+ expect(legacyOut).not.toContain("<examples>");
131
+ });
132
+
57
133
  it("包含 P3 正向触发引导语(何时该 delegate)", () => {
58
- const out = formatAgentList([{ name: "worker", description: "d" }]);
134
+ const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }]);
59
135
  expect(out).toContain("PRIORITY");
60
136
  expect(out).toContain("3+ files");
61
137
  expect(out).toContain("delegate");
@@ -63,20 +139,20 @@ describe("formatAgentList", () => {
63
139
  });
64
140
 
65
141
  it("保留原 'ONLY use agent names from this list' 名字约束", () => {
66
- const out = formatAgentList([{ name: "worker", description: "d" }]);
142
+ const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }]);
67
143
  expect(out).toContain("ONLY use agent names from this list");
68
144
  });
69
145
 
70
146
  it("包含 'Do NOT call list to discover' 引导语", () => {
71
- const out = formatAgentList([{ name: "worker", description: "d" }]);
147
+ const out = formatAgentList([{ name: "worker", description: "d", path: "/agents/worker.md" }]);
72
148
  expect(out).toContain("Do NOT call list to discover");
73
149
  expect(out).toContain("use list only for running state");
74
150
  });
75
151
 
76
152
  it("用 <available_subagents> 标签包裹并列出每个 agent", () => {
77
153
  const out = formatAgentList([
78
- { name: "worker", description: "does work" },
79
- { name: "reviewer", description: "reviews code" },
154
+ { name: "worker", description: "does work", path: "/agents/worker.md" },
155
+ { name: "reviewer", description: "reviews code", path: "/agents/reviewer.md" },
80
156
  ]);
81
157
  expect(out).toContain("<available_subagents>");
82
158
  expect(out).toContain("</available_subagents>");
@@ -84,11 +160,187 @@ describe("formatAgentList", () => {
84
160
  expect(out).toContain("<description>does work</description>");
85
161
  expect(out).toContain("<name>reviewer</name>");
86
162
  expect(out).toContain("<description>reviews code</description>");
163
+ // S1:每项含 <location> 完整路径(agentRef,模型直接引用)
164
+ expect(out).toContain("<location>/agents/worker.md</location>");
165
+ expect(out).toContain("<location>/agents/reviewer.md</location>");
87
166
  });
88
167
 
89
168
  it("转义 XML 特殊字符", () => {
90
- const out = formatAgentList([{ name: "a&b<c>", description: "\"q\"" }]);
169
+ const out = formatAgentList([{ name: "a&b<c>", description: "\"q\"", path: "/agents/a&b<c>.md" }]);
91
170
  expect(out).toContain("<name>a&amp;b&lt;c&gt;</name>");
92
171
  expect(out).toContain("&quot;q&quot;");
93
172
  });
94
173
  });
174
+
175
+ // ──────────────────────────────────────────────────────────────
176
+ // session 级缓存行为(TC1-TC4)
177
+ // ──────────────────────────────────────────────────────────────
178
+
179
+ /** before_agent_start handler 的返回结构(取 systemPrompt 断言)。 */
180
+ interface BeforeAgentResult {
181
+ systemPrompt: string;
182
+ }
183
+
184
+ /** 三 handler 捕获引用(setupSubagentListInjector 注册后填充)。 */
185
+ interface CapturedHandlers {
186
+ sessionStart?: (event: unknown, ctx: unknown) => Promise<void> | void;
187
+ beforeAgentStart?: (event: { systemPrompt: string }, ctx: unknown) => Promise<BeforeAgentResult | void> | BeforeAgentResult | void;
188
+ sessionShutdown?: (event: unknown, ctx: unknown) => void;
189
+ }
190
+
191
+ /** 构造 mock pi:捕获三 handler 引用,其余 prop 走 noop(仅 on 被调用)。 */
192
+ function createMockPi(handlers: CapturedHandlers): ExtensionAPI {
193
+ const on = (event: string, handler: (...args: unknown[]) => unknown): void => {
194
+ if (event === "session_start") {
195
+ handlers.sessionStart = handler as CapturedHandlers["sessionStart"];
196
+ } else if (event === "before_agent_start") {
197
+ handlers.beforeAgentStart = handler as CapturedHandlers["beforeAgentStart"];
198
+ } else if (event === "session_shutdown") {
199
+ handlers.sessionShutdown = handler as CapturedHandlers["sessionShutdown"];
200
+ }
201
+ };
202
+ const noop = (): void => {
203
+ /* mock */
204
+ };
205
+ // setupSubagentListInjector 仅调 pi.on;用最小对象 + 双重断言满足 ExtensionAPI 契约
206
+ // (测试 mock 约定,见 crash-recovery.test.ts 的 Proxy 模式)
207
+ return { on, appendEntry: noop, registerTool: noop, registerCommand: noop, registerMessageRenderer: noop, events: { emit: noop, on: noop } } as unknown as ExtensionAPI;
208
+ }
209
+
210
+ /** 最小 ctx mock(注入器只读 ctx.cwd)。 */
211
+ function createMockCtx(): Record<string, unknown> {
212
+ return { cwd: "/ws", mode: "tui" };
213
+ }
214
+
215
+ /** fixture:单个 worker agent 的 DiscoveredResource。 */
216
+ function agentResource(path: string): DiscoveredResource {
217
+ return { path, source: "project-agents", available: true };
218
+ }
219
+
220
+ /** fixture:agent .md frontmatter 内容。 */
221
+ function agentMd(name: string, description: string): string {
222
+ return `---\nname: ${name}\ndescription: "${description}"\n---\nbody`;
223
+ }
224
+
225
+ describe("subagent-list-injector session 级缓存", () => {
226
+ let setupSubagentListInjector: typeof import("../subagent-list-injector").setupSubagentListInjector;
227
+ let handlers: CapturedHandlers;
228
+
229
+ beforeEach(async () => {
230
+ // resetModules + 动态 import:拿 fresh 模块实例 → 模块级 agentCache 重置为 null
231
+ vi.resetModules();
232
+ spies.discoverResources.mockReset();
233
+ spies.getCachedFileContent.mockReset();
234
+ // 默认空发现(各 TC 按需覆盖)
235
+ spies.discoverResources.mockResolvedValue([]);
236
+ spies.getCachedFileContent.mockReturnValue(null);
237
+ handlers = {};
238
+ const mod = await import("../subagent-list-injector");
239
+ setupSubagentListInjector = mod.setupSubagentListInjector;
240
+ setupSubagentListInjector(createMockPi(handlers));
241
+ });
242
+
243
+ it("TC1: session_start 发现+缓存后,两次 before_agent_start 命中缓存(discoverResources 只调 1 次)", async () => {
244
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
245
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码执行者"));
246
+
247
+ // session_start 触发发现+缓存
248
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
249
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
250
+
251
+ // 两次 before_agent_start:均读缓存,不再触发 discoverResources
252
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
253
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
254
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
255
+
256
+ // 两次注入段都含 <available_subagents> 且内容一致(同一缓存)
257
+ expect(r1?.systemPrompt).toContain("<available_subagents>");
258
+ expect(r1?.systemPrompt).toContain("<name>worker</name>");
259
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
260
+ });
261
+
262
+ it("TC2: session_shutdown 清缓存后 before_agent_start miss → fallback 重新发现+缓存", async () => {
263
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
264
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
265
+
266
+ // session_start 发现+缓存(discoverResources count=1)
267
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
268
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
269
+
270
+ // session_shutdown 清缓存
271
+ handlers.sessionShutdown!({ type: "session_shutdown", reason: "quit" }, createMockCtx());
272
+
273
+ // before_agent_start 读到空缓存 → fallback 重新发现(count=2)+ 重新缓存
274
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
275
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
276
+ expect(r1?.systemPrompt).toContain("<name>worker</name>");
277
+
278
+ // 再次 before_agent_start 命中缓存(不再调 discoverResources,count 仍 2)
279
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
280
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
281
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
282
+ });
283
+
284
+ it("TC3: 无 session_start 直接 before_agent_start(miss fallback)→ 发现+缓存", async () => {
285
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
286
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
287
+
288
+ // 未触发 session_start,直接两次 before_agent_start
289
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
290
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
291
+
292
+ // 第一次 fallback 发现+缓存(count=1);第二次命中缓存(count 仍 1)
293
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
294
+ expect(r1?.systemPrompt).toContain("<name>worker</name>");
295
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
296
+ });
297
+
298
+ it("TC4: session_start(reload) 覆盖缓存(新资源生效)", async () => {
299
+ // 第一次 session_start(new):发现 worker
300
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
301
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
302
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
303
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
304
+
305
+ // 资源变化:discoverResources 改返回 reviewer,getCachedFileContent 改返回 reviewer 内容
306
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/reviewer.md")]);
307
+ spies.getCachedFileContent.mockReturnValue(agentMd("reviewer", "审查"));
308
+
309
+ // session_start(reload) 重新发现覆盖缓存(count=2)
310
+ await handlers.sessionStart!({ type: "session_start", reason: "reload" }, createMockCtx());
311
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
312
+
313
+ // before_agent_start 命中新缓存:注入段反映 reviewer(非旧 worker)
314
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
315
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
316
+ expect(r?.systemPrompt).toContain("<name>reviewer</name>");
317
+ expect(r?.systemPrompt).not.toContain("worker");
318
+ });
319
+
320
+ it("session_start 发现空列表缓存后 before_agent_start 命中(不重扫,不注入)", async () => {
321
+ // 空列表是有效缓存态(非 null):命中后不重扫,formatAgentList([]) 返空串不注入
322
+ spies.discoverResources.mockResolvedValue([]);
323
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
324
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
325
+
326
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
327
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
328
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
329
+ // 空注入:handler 返 void(无 systemPrompt 字段)
330
+ expect(r1).toBeUndefined();
331
+ expect(r2).toBeUndefined();
332
+ });
333
+
334
+ it("session_start 发现异常不阻断(fail-safe,缓存保持 null,before_agent_start fallback)", async () => {
335
+ spies.discoverResources.mockRejectedValueOnce(new Error("disk io"));
336
+ // session_start 发现抛错:记日志不阻断,缓存保持 null
337
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
338
+
339
+ // before_agent_start 读到 null 缓存 → fallback 重新发现(recover)
340
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
341
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
342
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
343
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
344
+ expect(r?.systemPrompt).toContain("<name>worker</name>");
345
+ });
346
+ });