@zhushanwen/pi-subagent-workflow 8.14.7 → 9.0.1

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 (58) hide show
  1. package/README.md +6 -5
  2. package/package.json +14 -9
  3. package/relay/relay.mjs +45 -2
  4. package/skills/workflow-script-format/SKILL.md +2 -56
  5. package/src/host/__tests__/inflight-reporter.test.ts +26 -1
  6. package/src/host/__tests__/pi-host.test.ts +418 -4
  7. package/src/host/inflight-reporter.ts +13 -10
  8. package/src/host/pi-host.ts +205 -17
  9. package/src/index.ts +21 -72
  10. package/src/injectors/__tests__/engine-awareness.test.ts +236 -33
  11. package/src/injectors/__tests__/helpers/injector-test-mocks.ts +15 -7
  12. package/src/injectors/__tests__/workflow-list-injector.test.ts +447 -40
  13. package/src/injectors/resource-list-injector.ts +103 -32
  14. package/src/injectors/subagent-list-injector.ts +4 -28
  15. package/src/injectors/workflow-list-injector.ts +8 -4
  16. package/src/interface/__tests__/capture-tool.ts +56 -0
  17. package/src/interface/__tests__/commands-resume.test.ts +103 -0
  18. package/src/interface/__tests__/tool-prompt-contract.test.ts +361 -0
  19. package/src/interface/__tests__/tool-render.test.ts +105 -2
  20. package/src/interface/__tests__/tool-subagents.test.ts +24 -26
  21. package/src/interface/__tests__/tool-workflow-resume.test.ts +227 -0
  22. package/src/interface/__tests__/tool-workflow-script.test.ts +557 -0
  23. package/src/interface/__tests__/tool-workflow.test.ts +884 -0
  24. package/src/interface/bg-notify-render.ts +18 -9
  25. package/src/interface/command-actions.ts +22 -17
  26. package/src/interface/commands.ts +58 -12
  27. package/src/interface/format.ts +50 -16
  28. package/src/interface/id-preview.ts +4 -4
  29. package/src/interface/subagent-actions.ts +18 -50
  30. package/src/interface/subagent-tool.ts +26 -18
  31. package/src/interface/subagents.ts +5 -11
  32. package/src/interface/tool-render.ts +14 -30
  33. package/src/interface/tool-shared.ts +55 -12
  34. package/src/interface/tool-subagents.ts +21 -11
  35. package/src/interface/tool-workflow-script.ts +52 -72
  36. package/src/interface/tool-workflow.ts +345 -139
  37. package/src/interface/views/WorkflowsView.ts +10 -7
  38. package/src/interface/views/__tests__/{WorkflowsView-signature.test.ts → WorkflowsView.test.ts} +155 -11
  39. package/src/interface/views/detail-content.ts +3 -5
  40. package/src/jsonl-run-store.ts +656 -544
  41. package/src/model-events.ts +35 -0
  42. package/src/session-lifecycle.ts +349 -86
  43. package/src/subagents-events.ts +56 -0
  44. package/src/workflow-events.ts +285 -200
  45. package/src/workflow-notify.ts +456 -0
  46. package/src/injectors/__tests__/engine-section-stability.test.ts +0 -353
  47. package/src/injectors/__tests__/model-list-injector.test.ts +0 -136
  48. package/src/injectors/__tests__/subagent-list-injector.test.ts +0 -376
  49. package/src/interface/__tests__/detectors.test.ts +0 -161
  50. package/src/interface/__tests__/subagent-tool-prompt.test.ts +0 -183
  51. package/src/interface/__tests__/tool-workflow-run-builtin-name.test.ts +0 -219
  52. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +0 -251
  53. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +0 -215
  54. package/src/interface/__tests__/workflow-state-file-exposure.test.ts +0 -38
  55. package/src/interface/__tests__/workflow-tool-prompt.test.ts +0 -129
  56. package/src/interface/helpers.ts +0 -272
  57. package/src/interface/views/__tests__/detail-content-session-file.test.ts +0 -73
  58. package/src/interface/views/__tests__/record-progress.test.ts +0 -235
@@ -1,12 +1,21 @@
1
- // workflow-list-injector 单测
1
+ // workflow-list-injector 单测(injectors 域合并宿主:workflow + subagent 两份工厂配置)
2
2
  //
3
- // 两类覆盖(与 subagent-list-injector.test.ts 对称):
3
+ // 覆盖面:
4
4
  // 1. 纯函数:summarizeDescription(截断)+ parseWorkflowMeta(meta 块解析)+
5
- // formatWorkflowList(B2 注入段格式 + 引导语)。discoverAllWorkflows 依赖文件系统
6
- // + resource-discovery,属集成层,此处聚焦可快速回归的格式化契约(TC5 回归保护)。
7
- // 2. session 级缓存行为(TC1-TC4):与 subagent 对称,mock shared/resource-discovery
8
- // 的 discoverResources + getCachedFileContent,mock pi.on 捕获三 handler 手动触发;
9
- // 模块级缓存靠 vi.resetModules + 动态 import 重置。
5
+ // formatWorkflowList(B2 注入段格式 + 引导语)+ agent 侧 CA2 字节快照
6
+ // (SUBAGENT_LIST_GUIDE 壳自有文案唯一字节锁)。渲染/解析算法下沉 core 的
7
+ // 契约(frontmatter 解析、formatAgentList/formatModelList 渲染)由 core
8
+ // meta-parser / injection-render 测试承载,本文件不重复。
9
+ // 2. session 级缓存行为:直测工厂 createResourceListInjector——每例新建工厂
10
+ // 实例即得全新缓存闭包(不再 vi.resetModules + 动态 import 逼出模块级单例)。
11
+ // 同一工厂两份配置(workflow 内置装配循环 / agent assemble 覆写委托 core
12
+ // discoverAgents)对称覆盖:修一个工厂 bug 两份配置同时红。
13
+ // 生产模块单例接线(setupWorkflowListInjector = injector.setup)保留一例冒烟。
14
+ // 3. KV-cache 顺序契约:注入段按 name 码点序,重建(两次发现)逐字节一致。
15
+ // 4. invalid 具名上报(P5 D4-3):损坏文件在注入段具名呈现,不静默跳过。
16
+ //
17
+ // mock 面:shared/resource-discovery 的 discoverResources + getCachedFileContent
18
+ //(vi.hoisted 稳定 spy),mock pi.on 捕获 handler 手动触发。
10
19
 
11
20
  import { beforeEach, describe, expect, it, vi } from "vitest";
12
21
 
@@ -15,19 +24,61 @@ import type { DiscoveredResource } from "@zhushanwen/subagent-core/shared/resour
15
24
  // ——必须先于 barrel import 初始化:barrel 的 re-export 链会触发深路径 mock 工厂,
16
25
  // 工厂闭包引用本 helpers 的绑定(helpers 文件头「惰性求值」约束)
17
26
  import { createDiscoveryModuleMock, createLoggerModuleMock, createMockCtx, createMockPi, type CapturedHandlers } from "./helpers/injector-test-mocks.ts";
18
- // C5①:formatWorkflowList/summarizeDescription 下沉 core barrel——测试改从 barrel 取实现
19
- import { formatWorkflowList, summarizeDescription } from "@zhushanwen/subagent-core";
20
-
21
- // ── 稳定 spy(vi.hoisted 保证 resetModules 后引用不变,见 subagent 测试同款注释) ──
27
+ import {
28
+ discoverAgents,
29
+ formatAgentList,
30
+ formatWorkflowList,
31
+ getHostServices,
32
+ summarizeDescription,
33
+ type AgentEntry,
34
+ type WorkflowEntry,
35
+ } from "@zhushanwen/subagent-core";
36
+ // 被测工厂 + 生产模块导出(parse/guide/单例接线)
37
+ import { createResourceListInjector } from "../resource-list-injector.ts";
38
+ import { parseWorkflowMeta, setupWorkflowListInjector, WORKFLOW_LIST_GUIDE } from "../workflow-list-injector";
39
+ import { SUBAGENT_LIST_GUIDE } from "../subagent-list-injector";
40
+
41
+ // ── 稳定 spy(vi.hoisted 保证 resetModules 后引用不变)──
22
42
  const spies = vi.hoisted(() => ({ discoverResources: vi.fn(), getCachedFileContent: vi.fn() }));
23
43
 
24
- vi.mock("@zhushanwen/subagent-core/shared/resource-discovery.ts", () => createDiscoveryModuleMock(spies));
44
+ vi.mock("@zhushanwen/subagent-core/shared/resource-discovery.ts", async (importOriginal) =>
45
+ createDiscoveryModuleMock(spies, importOriginal),
46
+ );
25
47
 
26
48
  // 工厂必须写成箭头惰性形式(vi.mock 提升后直接传引用会 TDZ,见 helper 文件头注释)
27
49
  vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
28
50
 
29
- // ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数) ──
30
- import { parseWorkflowMeta, WORKFLOW_LIST_GUIDE } from "../workflow-list-injector";
51
+ // ── 工厂配置(复刻 workflow-list-injector.ts / subagent-list-injector.ts 模块内
52
+ // config;生产模块的 config 接线由下方「生产模块单例接线冒烟」条与
53
+ // injector-chain-order 源码锚点兜底)──
54
+
55
+ /** workflow 侧:内置装配循环(parse + includeTmp),description 截断内聚 parseWorkflowMeta。 */
56
+ function makeWorkflowInjector() {
57
+ return createResourceListInjector<WorkflowEntry>({
58
+ kind: "workflows",
59
+ logTag: "[workflow-list-injector]",
60
+ parse: parseWorkflowMeta,
61
+ format: (workflows, invalids) =>
62
+ formatWorkflowList(workflows, {
63
+ guide: WORKFLOW_LIST_GUIDE,
64
+ ...(invalids.length > 0 ? { invalids } : {}),
65
+ }),
66
+ includeTmp: true,
67
+ });
68
+ }
69
+
70
+ /** agent 侧:assemble 覆写(U11 委托 core discoverAgents,parse 不参与)。 */
71
+ function makeAgentInjector() {
72
+ return createResourceListInjector<AgentEntry>({
73
+ kind: "agents",
74
+ logTag: "[subagent-list-injector]",
75
+ format: (agents, _invalids) => formatAgentList(agents, { guide: SUBAGENT_LIST_GUIDE }),
76
+ assemble: (workspaceRoot) =>
77
+ discoverAgents(workspaceRoot, getHostServices().discoveryRoots?.()?.agents ?? []),
78
+ });
79
+ }
80
+
81
+ // ── 纯函数测试:静态 import(工厂缓存状态不影响纯函数) ──
31
82
 
32
83
  describe("summarizeDescription", () => {
33
84
  it("短描述原样返回", () => {
@@ -138,8 +189,10 @@ describe("formatWorkflowList", () => {
138
189
  [{ name: "chain", description: "三步链", path: "/workflows/chain.js" }],
139
190
  { guide: WORKFLOW_LIST_GUIDE },
140
191
  );
192
+ // guide 段硬编码(锁文本):期望值不插值生产常量——改写 WORKFLOW_LIST_GUIDE
193
+ // 即红灯,避免「两侧同源插值」下文案漂移无守卫
141
194
  expect(out).toBe(
142
- `\n\n<available_workflows>\n${WORKFLOW_LIST_GUIDE}\n <workflow><name>chain</name><description>三步链</description><location>/workflows/chain.js</location></workflow>\n</available_workflows>`,
195
+ `\n\n<available_workflows>\nThe following workflows are available. Do NOT call list to discover available workflows — they are listed below; use list only for running state. All listed workflows run directly via action:run — do NOT use workflow-script generate for any listed workflow. For parameter details, read the <location> script file (script header has @pi-meta parameters + usage). For 2+ independent tasks dispatched together, prefer the \`subagents\` tool — it drives the fan-out workflow for you.\n <workflow><name>chain</name><description>三步链</description><location>/workflows/chain.js</location></workflow>\n</available_workflows>`,
143
196
  );
144
197
  });
145
198
 
@@ -165,9 +218,46 @@ describe("formatWorkflowList", () => {
165
218
  });
166
219
  });
167
220
 
221
+ // ── agent 侧 CA2 快照:SUBAGENT_LIST_GUIDE 是壳自有文案,core 测试用假 guide
222
+ // 不锁本文案——此处是唯一字节锁(guide 改写即红灯)。渲染骨架与条目模板
223
+ // 逐字节面由 core injection-render 的 byte-exact parity 承载。
224
+
225
+ describe("formatAgentList CA2 快照(agent guide 壳自有文案唯一字节锁)", () => {
226
+ it("CA2 快照等价锚定:骨架 + 条目段逐字节等于 pi 旧本地实现模板(除 location 前缀外零变化)", () => {
227
+ // 条目模板逐字复制自 C5① 前的本地 formatAgentList 实现(escapeXml 同函数),
228
+ // 锚定 core 下沉未改变渲染字节(红线 8:等价豁免仅 location 前缀 + guide 段)
229
+ const agents = [
230
+ {
231
+ name: "reviewer",
232
+ description: "代码审查",
233
+ path: "/OLD-PREFIX/agents/reviewer.md",
234
+ when: "用户要求 review",
235
+ },
236
+ { name: "worker", description: "does work", path: "/OLD-PREFIX/agents/worker.md" },
237
+ ];
238
+ const expectedItems = [
239
+ ' <agent><name>reviewer</name><description>代码审查</description><when>用户要求 review</when><location>/OLD-PREFIX/agents/reviewer.md</location></agent>',
240
+ ' <agent><name>worker</name><description>does work</description><location>/OLD-PREFIX/agents/worker.md</location></agent>',
241
+ ];
242
+ const out = formatAgentList(agents, { guide: SUBAGENT_LIST_GUIDE });
243
+ // guide 段硬编码(锁文本):期望值不插值生产常量——改写 SUBAGENT_LIST_GUIDE
244
+ // 即红灯,避免「两侧同源插值」下文案漂移无守卫
245
+ expect(out).toBe(
246
+ `\n\n<available_subagents>\nThe following subagents are available. PRIORITY: when a task involves reading 3+ files, writing 100+ lines, parallel research, or specialized review, delegate to a matching subagent FIRST instead of doing it yourself — this keeps your context focused on orchestration. Do NOT call list to discover available subagents; use list only for running state. When using the subagent tool, ONLY use agents from this list — pass the <location> path (absolute .md path) as the agent param. If no agent matches your task, omit agent (a general-purpose agent is used) and put all role-specific instructions in the task text.\n${expectedItems.join("\n")}\n</available_subagents>`,
247
+ );
248
+
249
+ // location 前缀替换(资产来源迁移:pi-sw 安装目录 → core 包)后其余字节零变化
250
+ const migrated = formatAgentList(
251
+ agents.map((a) => ({ ...a, path: a.path.replace("/OLD-PREFIX/", "/NEW-CORE-PKG/") })),
252
+ { guide: SUBAGENT_LIST_GUIDE },
253
+ );
254
+ expect(migrated).toBe(out.replaceAll("/OLD-PREFIX/", "/NEW-CORE-PKG/"));
255
+ });
256
+ });
257
+
168
258
  // ──────────────────────────────────────────────────────────────
169
- // session 级缓存行为(TC1-TC4,与 subagent 对称;mock pi/ctx 构造在
170
- // helpers/injector-test-mocks.ts)
259
+ // session 级缓存行为(TC1-TC4 直测工厂;mock pi/ctx 构造在
260
+ // helpers/injector-test-mocks.ts)。每例新建工厂实例 = 全新缓存闭包。
171
261
  // ──────────────────────────────────────────────────────────────
172
262
 
173
263
  /** fixture:单个 workflow 的 DiscoveredResource。 */
@@ -180,21 +270,27 @@ function workflowJs(name: string, description: string): string {
180
270
  return `/* @pi-meta\nname: ${name}\ndescription: ${description}\nphases: [a]\n*/\nrest`;
181
271
  }
182
272
 
183
- describe("workflow-list-injector session 级缓存", () => {
184
- let setupWorkflowListInjector: typeof import("../workflow-list-injector").setupWorkflowListInjector;
273
+ /** fixture:单个 agent 的 DiscoveredResource。 */
274
+ function agentResource(path: string): DiscoveredResource {
275
+ return { path, source: "project-agents", available: true };
276
+ }
277
+
278
+ /** fixture:agent .md frontmatter 内容。 */
279
+ function agentMd(name: string, description: string): string {
280
+ return `---\nname: ${name}\ndescription: "${description}"\n---\nbody`;
281
+ }
282
+
283
+ describe("workflow injector session 级缓存(工厂直测)", () => {
185
284
  let handlers: CapturedHandlers;
186
285
 
187
- beforeEach(async () => {
188
- // resetModules + 动态 import:拿 fresh 模块实例 → 模块级 workflowCache 重置为 null
189
- vi.resetModules();
286
+ beforeEach(() => {
190
287
  spies.discoverResources.mockReset();
191
288
  spies.getCachedFileContent.mockReset();
289
+ // 默认空发现(各 TC 按需覆盖)
192
290
  spies.discoverResources.mockResolvedValue([]);
193
291
  spies.getCachedFileContent.mockReturnValue(null);
194
292
  handlers = {};
195
- const mod = await import("../workflow-list-injector");
196
- setupWorkflowListInjector = mod.setupWorkflowListInjector;
197
- setupWorkflowListInjector(createMockPi(handlers));
293
+ makeWorkflowInjector().setup(createMockPi(handlers));
198
294
  });
199
295
 
200
296
  it("TC1: session_start 发现+缓存后,两次 before_agent_start 命中缓存(discoverResources 只调 1 次)", async () => {
@@ -262,7 +358,7 @@ describe("workflow-list-injector session 级缓存", () => {
262
358
  expect(r?.systemPrompt).not.toContain("chain");
263
359
  });
264
360
 
265
- it("session_start 发现空列表缓存后 before_agent_start 命中(不重扫,不注入)", async () => {
361
+ it("session_start 发现空列表缓存后 before_agent_start 命中(不重扫,注入 D4-2 空态段)", async () => {
266
362
  spies.discoverResources.mockResolvedValue([]);
267
363
  await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
268
364
  expect(spies.discoverResources).toHaveBeenCalledTimes(1);
@@ -270,8 +366,13 @@ describe("workflow-list-injector session 级缓存", () => {
270
366
  const r1 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
271
367
  const r2 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
272
368
  expect(spies.discoverResources).toHaveBeenCalledTimes(1);
273
- expect(r1).toBeUndefined();
274
- expect(r2).toBeUndefined();
369
+ // 空发现不再整段消失:注入空态段(agent 可区分「功能关闭」与「确实没有」)
370
+ expect(r1?.systemPrompt).toContain("<available_workflows>");
371
+ expect(r1?.systemPrompt).toContain("(none discovered; roots: ");
372
+ // roots 含 workspaceRoot 推导的约定根(findWorkspaceRoot mock 固定 "/ws")
373
+ expect(r1?.systemPrompt).toContain("/ws/.pi/workflows");
374
+ // 缓存复用:两次注入逐字节一致(KV-cache 契约)
375
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
275
376
  });
276
377
 
277
378
  it("session_start 发现异常不阻断(fail-safe,缓存保持 null,before_agent_start fallback)", async () => {
@@ -286,11 +387,155 @@ describe("workflow-list-injector session 级缓存", () => {
286
387
  });
287
388
  });
288
389
 
390
+ describe("agent injector session 级缓存(assemble 路径工厂直测,与 workflow 侧同骨架对称)", () => {
391
+ let handlers: CapturedHandlers;
392
+
393
+ beforeEach(() => {
394
+ spies.discoverResources.mockReset();
395
+ spies.getCachedFileContent.mockReset();
396
+ spies.discoverResources.mockResolvedValue([]);
397
+ spies.getCachedFileContent.mockReturnValue(null);
398
+ handlers = {};
399
+ makeAgentInjector().setup(createMockPi(handlers));
400
+ });
401
+
402
+ it("TC1: session_start 发现+缓存后,两次 before_agent_start 命中缓存(discoverResources 只调 1 次)", async () => {
403
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
404
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码执行者"));
405
+
406
+ // session_start 触发发现+缓存
407
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
408
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
409
+
410
+ // 两次 before_agent_start:均读缓存,不再触发 discoverResources
411
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
412
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
413
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
414
+
415
+ // 两次注入段都含 <available_subagents> 且内容一致(同一缓存)
416
+ expect(r1?.systemPrompt).toContain("<available_subagents>");
417
+ expect(r1?.systemPrompt).toContain("<name>worker</name>");
418
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
419
+ });
420
+
421
+ it("TC2: session_shutdown 清缓存后 before_agent_start miss → fallback 重新发现+缓存", async () => {
422
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
423
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
424
+
425
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
426
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
427
+
428
+ handlers.sessionShutdown!({ type: "session_shutdown", reason: "quit" }, createMockCtx());
429
+
430
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
431
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
432
+ expect(r1?.systemPrompt).toContain("<name>worker</name>");
433
+
434
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
435
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
436
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
437
+ });
438
+
439
+ it("TC3: 无 session_start 直接 before_agent_start(miss fallback)→ 发现+缓存", async () => {
440
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
441
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
442
+
443
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
444
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
445
+
446
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
447
+ expect(r1?.systemPrompt).toContain("<name>worker</name>");
448
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
449
+ });
450
+
451
+ it("TC4: session_start(reload) 覆盖缓存(新资源生效)", async () => {
452
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
453
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
454
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
455
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
456
+
457
+ // 资源变化:改返回 reviewer
458
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/reviewer.md")]);
459
+ spies.getCachedFileContent.mockReturnValue(agentMd("reviewer", "审查"));
460
+
461
+ await handlers.sessionStart!({ type: "session_start", reason: "reload" }, createMockCtx());
462
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
463
+
464
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
465
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
466
+ expect(r?.systemPrompt).toContain("<name>reviewer</name>");
467
+ expect(r?.systemPrompt).not.toContain("worker");
468
+ });
469
+
470
+ it("session_start 发现空列表缓存后 before_agent_start 命中(不重扫,注入 D4-2 空态段)", async () => {
471
+ // 空列表是有效缓存态(非 null):命中后不重扫;空发现经工厂接管渲染空态段
472
+ spies.discoverResources.mockResolvedValue([]);
473
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
474
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
475
+
476
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
477
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
478
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
479
+ // 工厂共用骨架:agents kind 同样空态显式(不再整段消失)
480
+ expect(r1?.systemPrompt).toContain("<available_subagents>");
481
+ expect(r1?.systemPrompt).toContain("(none discovered; roots: ");
482
+ expect(r1?.systemPrompt).toContain("/ws/.agents/agents");
483
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
484
+ });
485
+
486
+ it("session_start 发现异常不阻断(fail-safe,缓存保持 null,before_agent_start fallback)", async () => {
487
+ spies.discoverResources.mockRejectedValueOnce(new Error("disk io"));
488
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
489
+
490
+ spies.discoverResources.mockResolvedValue([agentResource("/ws/.agents/agents/worker.md")]);
491
+ spies.getCachedFileContent.mockReturnValue(agentMd("worker", "编码"));
492
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
493
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
494
+ expect(r?.systemPrompt).toContain("<name>worker</name>");
495
+ });
496
+ });
497
+
498
+ describe("生产模块单例接线冒烟", () => {
499
+ it("setupWorkflowListInjector(模块级单例 = injector.setup)注册的 handler 端到端可驱动", async () => {
500
+ // 其余缓存用例已改直测工厂(新实例即新缓存闭包);本条锚定生产模块的模块级
501
+ // 单例接线(config 复刻漂移的兜底信号)仍可用。本 describe 独占模块单例,
502
+ // 无跨用例缓存污染。
503
+ spies.discoverResources.mockReset();
504
+ spies.getCachedFileContent.mockReset();
505
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/chain.js")]);
506
+ spies.getCachedFileContent.mockReturnValue(workflowJs("chain", "三步链"));
507
+
508
+ const handlers: CapturedHandlers = {};
509
+ setupWorkflowListInjector(createMockPi(handlers));
510
+
511
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
512
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
513
+ expect(r?.systemPrompt).toContain("<available_workflows>");
514
+ expect(r?.systemPrompt).toContain("<name>chain</name>");
515
+ });
516
+ });
517
+
289
518
  // ──────────────────────────────────────────────────────────────
290
- // KV-cache 顺序契约:输出按 name 码点序,重建(两次发现)逐字节一致
519
+ // KV-cache 顺序契约:注入段按 name 码点序,重建(两次发现)逐字节一致。
520
+ // 经真实 injector 实例的 handler 驱动(顺序契约直接验证注入面——比裸 entries
521
+ // 断言更端到端)。workflow 走内置装配循环、agent 走 assemble 委托,两路径同守。
291
522
  // ──────────────────────────────────────────────────────────────
292
523
 
293
- describe("discoverAllWorkflows 顺序契约(KV-cache)", () => {
524
+ /** 从注入段提取 <name> 条目出现序(码点序断言用)。 */
525
+ function injectedNames(prompt?: string): string[] {
526
+ return [...(prompt ?? "").matchAll(/<name>(.*?)<\/name>/g)].map((m) => m[1]);
527
+ }
528
+
529
+ describe("workflow 注入段顺序契约(KV-cache)", () => {
530
+ let handlers: CapturedHandlers;
531
+
532
+ beforeEach(() => {
533
+ spies.discoverResources.mockReset();
534
+ spies.getCachedFileContent.mockReset();
535
+ handlers = {};
536
+ makeWorkflowInjector().setup(createMockPi(handlers));
537
+ });
538
+
294
539
  it("输出按 name 码点序排序,与发现层返回顺序(readdir 枚举序)无关", async () => {
295
540
  const byPath: Record<string, string> = {
296
541
  "/ws/.pi/workflows/zeta.js": workflowJs("zeta", "z"),
@@ -304,12 +549,14 @@ describe("discoverAllWorkflows 顺序契约(KV-cache)", () => {
304
549
  ]);
305
550
  spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
306
551
 
307
- const { discoverAllWorkflows } = await import("../workflow-list-injector");
308
- const workflows = await discoverAllWorkflows("/ws");
309
- expect(workflows.map((w) => w.name)).toEqual(["alpha", "chain", "zeta"]);
552
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
553
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
554
+ // P5 D4-3:无损坏文件时 invalids 恒空——条目段无 invalid 行
555
+ expect(r?.systemPrompt).not.toContain("<invalid>");
556
+ expect(injectedNames(r?.systemPrompt)).toEqual(["alpha", "chain", "zeta"]);
310
557
  });
311
558
 
312
- it("重建(两次发现顺序不同)输出与渲染结果逐字节一致", async () => {
559
+ it("重建(两次发现顺序不同)注入段逐字节一致", async () => {
313
560
  const byPath: Record<string, string> = {
314
561
  "/ws/.pi/workflows/b.js": workflowJs("beta", "b"),
315
562
  "/ws/.pi/workflows/a.js": workflowJs("alpha", "a"),
@@ -325,12 +572,172 @@ describe("discoverAllWorkflows 顺序契约(KV-cache)", () => {
325
572
  ]);
326
573
  spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
327
574
 
328
- const { discoverAllWorkflows } = await import("../workflow-list-injector");
329
- const first = await discoverAllWorkflows("/ws");
330
- const second = await discoverAllWorkflows("/ws");
331
- expect(second).toEqual(first);
332
- expect(formatWorkflowList(second, { guide: WORKFLOW_LIST_GUIDE })).toBe(
333
- formatWorkflowList(first, { guide: WORKFLOW_LIST_GUIDE }),
575
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
576
+ const first = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
577
+
578
+ // 重建:shutdown 清缓存 → reload 重新发现(第二次发现顺序漂移)→ 渲染
579
+ handlers.sessionShutdown!({ type: "session_shutdown", reason: "quit" }, createMockCtx());
580
+ await handlers.sessionStart!({ type: "session_start", reason: "reload" }, createMockCtx());
581
+ const second = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
582
+
583
+ // 注入段(渲染产物)逐字节一致——强于原 entries 全等 + 渲染等价双断言
584
+ expect(second?.systemPrompt).toBe(first?.systemPrompt);
585
+ });
586
+ });
587
+
588
+ describe("agent 注入段顺序契约(KV-cache,assemble 路径)", () => {
589
+ let handlers: CapturedHandlers;
590
+
591
+ beforeEach(() => {
592
+ spies.discoverResources.mockReset();
593
+ spies.getCachedFileContent.mockReset();
594
+ handlers = {};
595
+ makeAgentInjector().setup(createMockPi(handlers));
596
+ });
597
+
598
+ it("输出按 name 码点序排序,与发现层返回顺序(readdir 枚举序)无关", async () => {
599
+ const byPath: Record<string, string> = {
600
+ "/ws/.agents/agents/zeta.md": agentMd("zeta", "z"),
601
+ "/ws/.agents/agents/worker.md": agentMd("worker", "w"),
602
+ "/ws/.agents/agents/alpha.md": agentMd("alpha", "a"),
603
+ };
604
+ // 刻意以非字母序返回(模拟 readdir 无契约枚举序)
605
+ spies.discoverResources.mockResolvedValue([
606
+ agentResource("/ws/.agents/agents/zeta.md"),
607
+ agentResource("/ws/.agents/agents/worker.md"),
608
+ agentResource("/ws/.agents/agents/alpha.md"),
609
+ ]);
610
+ spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
611
+
612
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
613
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
614
+ // P5 D4-3:agents 走 assemble 路径(core discoverAgents),invalids 恒空——注入段无 invalid 行
615
+ expect(r?.systemPrompt).not.toContain("<invalid>");
616
+ expect(injectedNames(r?.systemPrompt)).toEqual(["alpha", "worker", "zeta"]);
617
+ });
618
+
619
+ it("重建(两次发现顺序不同)注入段逐字节一致——目录不变时 session_start/fallback/resume 任意重建等价", async () => {
620
+ const byPath: Record<string, string> = {
621
+ "/ws/.agents/agents/b.md": agentMd("beta", "b"),
622
+ "/ws/.agents/agents/a.md": agentMd("alpha", "a"),
623
+ };
624
+ // 两次发现返回顺序不同(模拟跨进程 readdir 漂移)
625
+ spies.discoverResources
626
+ .mockResolvedValueOnce([
627
+ agentResource("/ws/.agents/agents/b.md"),
628
+ agentResource("/ws/.agents/agents/a.md"),
629
+ ])
630
+ .mockResolvedValueOnce([
631
+ agentResource("/ws/.agents/agents/a.md"),
632
+ agentResource("/ws/.agents/agents/b.md"),
633
+ ]);
634
+ spies.getCachedFileContent.mockImplementation((p: string) => byPath[p] ?? null);
635
+
636
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
637
+ const first = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
638
+
639
+ // 重建:shutdown 清缓存 → reload 重新发现(第二次发现顺序漂移)→ 渲染
640
+ handlers.sessionShutdown!({ type: "session_shutdown", reason: "quit" }, createMockCtx());
641
+ await handlers.sessionStart!({ type: "session_start", reason: "reload" }, createMockCtx());
642
+ const second = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
643
+
644
+ // 注入段(渲染产物)逐字节一致——强于原 entries 全等 + 渲染等价双断言
645
+ expect(second?.systemPrompt).toBe(first?.systemPrompt);
646
+ });
647
+ });
648
+
649
+ // ──────────────────────────────────────────────────────────────
650
+ // invalid 具名上报(P5 D4-3):三处原静默点(available=false 占位 / meta 解析
651
+ // 失败 / 读失败)收敛为 invalids 收集,注入段具名呈现,损坏文件不再静默跳过。
652
+ // ──────────────────────────────────────────────────────────────
653
+
654
+ describe("workflow-list-injector invalid 具名上报(P5 D4-3)", () => {
655
+ let handlers: CapturedHandlers;
656
+
657
+ beforeEach(() => {
658
+ spies.discoverResources.mockReset();
659
+ spies.getCachedFileContent.mockReset();
660
+ spies.discoverResources.mockResolvedValue([]);
661
+ spies.getCachedFileContent.mockReturnValue(null);
662
+ handlers = {};
663
+ makeWorkflowInjector().setup(createMockPi(handlers));
664
+ });
665
+
666
+ it("available=false 占位(manifest 声明路径缺失)→ invalid 具名(reason 来自发现层)", async () => {
667
+ spies.discoverResources.mockResolvedValue([
668
+ {
669
+ path: "/npm/pkg/missing.js",
670
+ source: "npm",
671
+ available: false,
672
+ reason: "manifest declared path not found",
673
+ },
674
+ ]);
675
+
676
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
677
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
678
+
679
+ expect(r?.systemPrompt).toContain(
680
+ "<invalid><path>/npm/pkg/missing.js</path><reason>manifest declared path not found</reason></invalid>",
334
681
  );
335
682
  });
683
+
684
+ it("meta 解析失败(parse null)→ 具名 invalid 与正常条目同段(损坏不静默、可用不丢失)", async () => {
685
+ spies.discoverResources.mockResolvedValue([
686
+ workflowResource("/ws/.pi/workflows/broken.js"),
687
+ workflowResource("/ws/.pi/workflows/good.js"),
688
+ ]);
689
+ spies.getCachedFileContent.mockImplementation((p: string) =>
690
+ p.endsWith("broken.js") ? "// no @pi-meta block" : workflowJs("good", "正常"),
691
+ );
692
+
693
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
694
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
695
+
696
+ expect(r?.systemPrompt).toContain(
697
+ "<invalid><path>/ws/.pi/workflows/broken.js</path><reason>no valid resource metadata</reason></invalid>",
698
+ );
699
+ expect(r?.systemPrompt).toContain("<name>good</name>");
700
+ });
701
+
702
+ it("读失败(content 抛错)→ reason = 错误消息具名上报", async () => {
703
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/io.js")]);
704
+ spies.getCachedFileContent.mockImplementation((p: string) => {
705
+ if (p.endsWith("io.js")) throw new Error("EACCES: permission denied");
706
+ return null;
707
+ });
708
+
709
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
710
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
711
+
712
+ expect(r?.systemPrompt).toContain("<path>/ws/.pi/workflows/io.js</path>");
713
+ expect(r?.systemPrompt).toContain("EACCES: permission denied");
714
+ });
715
+
716
+ it("全部损坏(条目为零 + invalid 非空)→ 空态段 + invalid 行同段(roots 自救 + 具名上报衔接)", async () => {
717
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/broken.js")]);
718
+ spies.getCachedFileContent.mockReturnValue("// no @pi-meta block");
719
+
720
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
721
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
722
+
723
+ expect(r?.systemPrompt).toContain("<available_workflows>");
724
+ expect(r?.systemPrompt).toContain("(none discovered; roots: ");
725
+ expect(r?.systemPrompt).toContain(
726
+ "<invalid><path>/ws/.pi/workflows/broken.js</path><reason>no valid resource metadata</reason></invalid>",
727
+ );
728
+ });
729
+
730
+ it("session_shutdown 清缓存后 invalid 状态一并清空(重新发现,无跨 session 泄漏)", async () => {
731
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/broken.js")]);
732
+ spies.getCachedFileContent.mockReturnValue("// no @pi-meta block");
733
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
734
+
735
+ handlers.sessionShutdown!({ type: "session_shutdown", reason: "quit" }, createMockCtx());
736
+ spies.discoverResources.mockResolvedValue([]);
737
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
738
+
739
+ // 重新发现后无损坏文件 → 回纯空态段(无 invalid 行残留)
740
+ expect(r?.systemPrompt).toContain("(none discovered; roots: ");
741
+ expect(r?.systemPrompt).not.toContain("<invalid>");
742
+ });
336
743
  });