@zhushanwen/pi-subagent-workflow 8.14.7 → 9.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 (58) hide show
  1. package/README.md +6 -5
  2. package/package.json +11 -7
  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 +376 -85
  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
@@ -14,9 +14,10 @@
14
14
 
15
15
  import { join, dirname } from "node:path";
16
16
  import { createRequire } from "node:module";
17
- import { existsSync } from "node:fs";
17
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
18
19
 
19
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
+ import { afterEach, beforeEach, afterAll, describe, expect, it, vi } from "vitest";
20
21
 
21
22
  // ── hoisted mocks(vi.mock 工厂 hoisting 不能引用外部 let/const) ──
22
23
 
@@ -43,12 +44,31 @@ vi.mock("@zhushanwen/pi-extension-logger", () => extensionLoggerMock);
43
44
  vi.mock("@zhushanwen/pi-pending-notifications", () => pendingNotificationsMock);
44
45
  vi.mock("@zhushanwen/session-delivery", () => sessionDeliveryMock);
45
46
 
47
+ // 端到端用例(P5 staged 入扫描面)隔离真实用户目录:resource-discovery 用 homedir()
48
+ // 推导 user-agents 根(~/.agents/workflows),不 mock 会让「内置条目存在/两次一致」
49
+ // 断言依赖本机用户资产状态(resource-discovery.test.ts 同款手法)。
50
+ const mockHomeDir = vi.hoisted(() => {
51
+ const fs = require("node:fs");
52
+ const os = require("node:os");
53
+ const path = require("node:path");
54
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pihost-p5-home-"));
55
+ });
56
+ vi.mock("node:os", async (importOriginal) => {
57
+ const actual = await importOriginal<typeof import("node:os")>();
58
+ return { ...actual, homedir: () => mockHomeDir };
59
+ });
60
+
61
+ import { discoverResources } from "@zhushanwen/subagent-core";
62
+ // clearFileCache 仅测试消费(barrel 收窄面外)——测试深路径经 vitest alias 正则
63
+ // 重写到 core src 物理路径(B-2/D3 既有先例形态;specifier 不含 src/ 段)
64
+ import { clearFileCache } from "@zhushanwen/subagent-core/shared/resource-discovery.ts";
65
+
46
66
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
47
67
  import { getLogger } from "@zhushanwen/pi-extension-logger";
48
68
  import { countActiveFromEntries } from "@zhushanwen/pi-pending-notifications";
49
69
  import { createDelivery } from "@zhushanwen/session-delivery";
50
70
 
51
- import { createPiHostServices, createPiNotifyDomainPorts } from "../pi-host.ts";
71
+ import { createPiHostServices, createPiNotifyDomainPorts, resolveGrandchildExtensionPaths } from "../pi-host.ts";
52
72
 
53
73
  beforeEach(() => {
54
74
  vi.mocked(getAgentDir).mockClear();
@@ -69,11 +89,23 @@ describe("createPiHostServices.discoveryRoots(与现推导逐项一致)", ()
69
89
  // skill-discovery.ts resolveSkillPath(join(getAgentDir(), "skills") +
70
90
  // join(getAgentDir(), "npm/node_modules"))。根列表按优先级低→高排列(D2)。
71
91
  const AGENT_DIR = "/fake/agent-dir";
92
+ const argvSaved = process.argv;
72
93
 
73
94
  function stubAgentDir(): void {
74
95
  vi.mocked(getAgentDir).mockReturnValue(AGENT_DIR);
75
96
  }
76
97
 
98
+ beforeEach(() => {
99
+ // 独立 pi 形态基线(argv 无 --extension):P5 扫描根修正后 npm/npm-dev 根的
100
+ // 指向随形态分流——本 describe 全部锚定独立态推导,runner argv 形态不构成
101
+ // 契约,显式钉死防漂移(taiji 态断言见下方专属 describe)。
102
+ process.argv = ["node", "/pi", "--mode", "rpc"];
103
+ });
104
+
105
+ afterEach(() => {
106
+ process.argv = argvSaved;
107
+ });
108
+
77
109
  it("agents 四根:user-pi → npm → npm-dev → core 包父目录(C5⑥,source npm 追加末位)", () => {
78
110
  stubAgentDir();
79
111
  const roots = createPiHostServices().discoveryRoots?.().agents;
@@ -104,17 +136,33 @@ describe("createPiHostServices.discoveryRoots(与现推导逐项一致)", ()
104
136
  expect(existsSync(join(coreRoot!.dir, "subagent-core", "agents"))).toBe(true);
105
137
  });
106
138
 
107
- it("workflows 三根:与 agents 同构,末级目录名切换为 workflows", () => {
139
+ it("workflows 四根:与 agents 同构,末级目录名切换为 workflows,第 4 根 core 包父目录(P5 staged 副本入扫描面)", () => {
108
140
  stubAgentDir();
109
141
  const roots = createPiHostServices().discoveryRoots?.().workflows;
110
142
 
143
+ const require = createRequire(import.meta.url);
144
+ const anchor = require.resolve("@zhushanwen/subagent-core/workflows/README.md");
145
+ const coreParent = dirname(dirname(dirname(anchor)));
146
+
111
147
  expect(roots).toEqual([
112
148
  { dir: join(AGENT_DIR, "workflows"), source: "user-pi" },
113
149
  { dir: join(AGENT_DIR, "npm", "node_modules"), source: "npm" },
114
150
  { dir: join(AGENT_DIR, "extensions"), source: "npm-dev" },
151
+ // P5:workflows kind 的「刻意不注入」禁区拆除——内置 workflow 随 core 包入
152
+ // 扫描面(P-C6 裁决见 pi-host.ts 注释)
153
+ { dir: coreParent, source: "npm" },
115
154
  ]);
116
155
  });
117
156
 
157
+ it("workflows 第 4 根下 subagent-core/workflows/ 存在内置 workflow 脚本(可派发前提)", () => {
158
+ stubAgentDir();
159
+ const roots = createPiHostServices().discoveryRoots?.().workflows;
160
+ const coreRoot = roots?.[3];
161
+
162
+ expect(coreRoot?.source).toBe("npm");
163
+ expect(existsSync(join(coreRoot!.dir, "subagent-core", "workflows"))).toBe(true);
164
+ });
165
+
118
166
  it("skills 两根:user-pi + npm,无 npm-dev(对齐 skill-discovery 现状)", () => {
119
167
  stubAgentDir();
120
168
  const roots = createPiHostServices().discoveryRoots?.().skills;
@@ -137,6 +185,78 @@ describe("createPiHostServices.discoveryRoots(与现推导逐项一致)", ()
137
185
  });
138
186
  });
139
187
 
188
+ describe("createPiHostServices.discoveryRoots(P5 D4-2 扫描根修正:taiji 态形态感知)", () => {
189
+ const AGENT_DIR = "/fake/data-dir/agent";
190
+ const argvSaved = process.argv;
191
+
192
+ function stubAgentDir(): void {
193
+ vi.mocked(getAgentDir).mockReturnValue(AGENT_DIR);
194
+ }
195
+
196
+ afterEach(() => {
197
+ process.argv = argvSaved;
198
+ });
199
+
200
+ /** taiji 宿主注入态 argv(runtime spawn 主 pi 恒带 --extension staged 全量集)。 */
201
+ function withTaijiArgv(fn: () => void): void {
202
+ process.argv = [
203
+ "node", "/pi",
204
+ "--mode", "rpc",
205
+ "--extension", "/staged/resources/extensions/@zhushanwen/pi-subagent-workflow",
206
+ ];
207
+ fn();
208
+ }
209
+
210
+ it("taiji 态:npm/npm-dev 根改指 agentDir 父目录(= dataDir,实际安装目录)", () => {
211
+ stubAgentDir();
212
+ withTaijiArgv(() => {
213
+ const roots = createPiHostServices().discoveryRoots?.();
214
+ const installBase = dirname(AGENT_DIR); // /fake/data-dir
215
+
216
+ // agents kind
217
+ expect(roots?.agents).toEqual([
218
+ { dir: join(AGENT_DIR, "agents"), source: "user-pi" },
219
+ { dir: join(installBase, "npm", "node_modules"), source: "npm" },
220
+ { dir: join(installBase, "extensions"), source: "npm-dev" },
221
+ expect.objectContaining({ source: "npm" }), // core 根(锚点解析,见独立态用例)
222
+ ]);
223
+ // workflows kind(core 根注入同理)
224
+ expect(roots?.workflows?.slice(0, 3)).toEqual([
225
+ { dir: join(AGENT_DIR, "workflows"), source: "user-pi" },
226
+ { dir: join(installBase, "npm", "node_modules"), source: "npm" },
227
+ { dir: join(installBase, "extensions"), source: "npm-dev" },
228
+ ]);
229
+ });
230
+ });
231
+
232
+ it("独立态:npm/npm-dev 根保留 getAgentDir() 派生的 pi 语义根(现状不变)", () => {
233
+ stubAgentDir();
234
+ process.argv = ["node", "/pi", "--mode", "rpc"];
235
+ const roots = createPiHostServices().discoveryRoots?.();
236
+
237
+ expect(roots?.agents?.slice(0, 3)).toEqual([
238
+ { dir: join(AGENT_DIR, "agents"), source: "user-pi" },
239
+ { dir: join(AGENT_DIR, "npm", "node_modules"), source: "npm" },
240
+ { dir: join(AGENT_DIR, "extensions"), source: "npm-dev" },
241
+ ]);
242
+ expect(roots?.workflows?.slice(0, 3)).toEqual([
243
+ { dir: join(AGENT_DIR, "workflows"), source: "user-pi" },
244
+ { dir: join(AGENT_DIR, "npm", "node_modules"), source: "npm" },
245
+ { dir: join(AGENT_DIR, "extensions"), source: "npm-dev" },
246
+ ]);
247
+ });
248
+
249
+ it("user-pi 根两形态同指 agentDir(taiji 态布局迁移不动 user 级语义)", () => {
250
+ stubAgentDir();
251
+ withTaijiArgv(() => {
252
+ expect(createPiHostServices().discoveryRoots?.().workflows?.[0]).toEqual({
253
+ dir: join(AGENT_DIR, "workflows"),
254
+ source: "user-pi",
255
+ });
256
+ });
257
+ });
258
+ });
259
+
140
260
  describe("createPiHostServices.dataRoot(每次现取 getAgentDir)", () => {
141
261
  it("返回 getAgentDir() 本身(env 覆盖段与 warn-once 留 core data-dir)", () => {
142
262
  vi.mocked(getAgentDir).mockReturnValue("/data/root");
@@ -196,6 +316,170 @@ describe("createPiHostServices.log(桥接 pi-extension-logger)", () => {
196
316
  });
197
317
  });
198
318
 
319
+ describe("resolveGrandchildExtensionPaths / createPiHostServices.extensionPaths([D2] 双形态注入源)", () => {
320
+ const argvSaved = process.argv;
321
+
322
+ function withArgv(argv: string[], fn: () => void): void {
323
+ process.argv = argv;
324
+ try {
325
+ fn();
326
+ } finally {
327
+ process.argv = argvSaved;
328
+ }
329
+ }
330
+
331
+ it("宿主形态:主进程 argv --extension 全量(extension-service 下发集)→ 白名单收窄仅 structured-output", () => {
332
+ // taiji 宿主形态:runtime spawn 主 pi 时 --extension = getExtensionPaths 全量 staged 集
333
+ const argv = [
334
+ "node", "/pi",
335
+ "--mode", "rpc", "--no-extensions", "--approve",
336
+ "--extension", "/staged/resources/extensions/@zhushanwen/pi-subagent-workflow",
337
+ "--extension=/staged/resources/extensions/@zhushanwen/pi-structured-output",
338
+ "--extension", "/staged/resources/extensions/@zhushanwen/pi-goal",
339
+ "--extension", "/staged/resources/extensions/@zhushanwen/pi-structured-output/index.js",
340
+ ];
341
+ expect(resolveGrandchildExtensionPaths(argv, () => undefined)).toEqual([
342
+ "/staged/resources/extensions/@zhushanwen/pi-structured-output",
343
+ "/staged/resources/extensions/@zhushanwen/pi-structured-output/index.js",
344
+ ]);
345
+ });
346
+
347
+ it("宿主形态 dev 源码布局:extensions/universal/structured-output(目录与入口文件形态)命中收窄集", () => {
348
+ // dev 下 runtime extension-service 下发的是源码目录(无 @zhushanwen scope 段)——
349
+ // L4 A1 根因回归锁:白名单须识别 dev 源码布局别名段,否则孙进程扩展集恒空。
350
+ const argv = [
351
+ "node", "/pi",
352
+ "--mode", "rpc", "--no-extensions", "--approve",
353
+ "--extension", "/repo/extensions/universal/subagent-workflow",
354
+ "--extension", "/repo/extensions/universal/structured-output",
355
+ "--extension", "/repo/extensions/universal/structured-output/index.js",
356
+ "--extension", "/repo/extensions/taiji/system-prompt",
357
+ ];
358
+ expect(resolveGrandchildExtensionPaths(argv, () => undefined)).toEqual([
359
+ "/repo/extensions/universal/structured-output",
360
+ "/repo/extensions/universal/structured-output/index.js",
361
+ ]);
362
+ });
363
+
364
+ it("宿主形态 dev 布局形似误报不命中(尾段同名前缀目录 / 同名异分组不误收)", () => {
365
+ const argv = [
366
+ "node", "/pi",
367
+ "--mode", "rpc",
368
+ "--extension", "/repo/extensions/universal/structured-output-lookalike",
369
+ "--extension", "/some/other/structured-output",
370
+ "--extension", "/repo/extensions/universal/structured-output-extra/index.js",
371
+ ];
372
+ expect(resolveGrandchildExtensionPaths(argv, () => undefined)).toEqual([]);
373
+ });
374
+
375
+ it("宿主形态解析不误吃其他 flag 值(--skill 值被跳过、真 flag 不吃值、单 - 路径是值)", () => {
376
+ const argv = [
377
+ "node", "/pi",
378
+ "--mode", "rpc",
379
+ "--skill", "@zhushanwen/pi-structured-output-skill-lookalike",
380
+ "--extension", "--approve", // 后跟真 flag 不吃值
381
+ "--extension", "-weird-dir/@zhushanwen/pi-structured-output", // 单 - 开头路径是值(原 MF-7a)
382
+ "some positional prompt",
383
+ ];
384
+ expect(resolveGrandchildExtensionPaths(argv, () => undefined)).toEqual([
385
+ "-weird-dir/@zhushanwen/pi-structured-output",
386
+ ]);
387
+ });
388
+
389
+ it("独立形态:argv 无 --extension → optional peerDep sibling 解析回退(resolvePeer 命中)", () => {
390
+ expect(resolveGrandchildExtensionPaths(["node", "/pi", "--mode", "rpc"], () => "/npm/node_modules/@zhushanwen/pi-structured-output")).toEqual([
391
+ "/npm/node_modules/@zhushanwen/pi-structured-output",
392
+ ]);
393
+ });
394
+
395
+ it("独立形态真实解析链:workspace 布局下 peerDep sibling 可解析(createRequire 实测命中包根)", () => {
396
+ withArgv(["node", "/pi", "--mode", "rpc"], () => {
397
+ const paths = createPiHostServices().extensionPaths?.();
398
+ expect(paths).toHaveLength(1);
399
+ // workspace 布局下 pnpm symlink 解析到源目录(目录名 structured-output,
400
+ // 无 @zhushanwen 段——npm 安装形态则命中 node_modules/@zhushanwen/… 布局)
401
+ expect(paths![0]).toMatch(/structured-output$/);
402
+ });
403
+ });
404
+
405
+ it("双源皆空(argv 无 --extension + peerDep 未安装)→ 空数组不炸", () => {
406
+ expect(resolveGrandchildExtensionPaths(["node", "/pi", "--mode", "rpc"], () => undefined)).toEqual([]);
407
+ });
408
+
409
+ // e2e 门(faux 轨装配):TAIJI_E2E=1 且 <agentDir>/extensions/faux-llm-ext.ts 在盘
410
+ // 时追加进孙进程显式 --extension 集;非 e2e 环境该路径恒被收窄(生产语义不变)。
411
+ describe("e2e 门:faux-llm-ext 放行(TAIJI_E2E=1)", () => {
412
+ let fauxAgentDir: string;
413
+
414
+ beforeEach(() => {
415
+ fauxAgentDir = mkdtempSync(join(tmpdir(), "pihost-e2e-faux-"));
416
+ });
417
+
418
+ afterEach(() => {
419
+ rmSync(fauxAgentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
420
+ vi.unstubAllEnvs();
421
+ });
422
+
423
+ function seedFauxExt(): string {
424
+ const extDir = join(fauxAgentDir, "extensions");
425
+ mkdirSync(extDir, { recursive: true });
426
+ const p = join(extDir, "faux-llm-ext.ts");
427
+ writeFileSync(p, "// faux provider stub\n");
428
+ return p;
429
+ }
430
+
431
+ it("TAIJI_E2E=1 + faux-llm-ext.ts 在盘 → 追加进白名单收窄集末尾", () => {
432
+ const fauxPath = seedFauxExt();
433
+ vi.mocked(getAgentDir).mockReturnValue(fauxAgentDir);
434
+ vi.stubEnv("TAIJI_E2E", "1");
435
+ const argv = [
436
+ "node", "/pi", "--mode", "rpc",
437
+ "--extension", "/staged/resources/extensions/@zhushanwen/pi-structured-output",
438
+ ];
439
+ expect(resolveGrandchildExtensionPaths(argv, () => undefined)).toEqual([
440
+ "/staged/resources/extensions/@zhushanwen/pi-structured-output",
441
+ fauxPath,
442
+ ]);
443
+ });
444
+
445
+ it("非 e2e(env 缺失)仍收窄:faux-llm-ext 在盘不追加,argv 显式携带也被白名单排除", () => {
446
+ seedFauxExt();
447
+ vi.mocked(getAgentDir).mockReturnValue(fauxAgentDir);
448
+ vi.stubEnv("TAIJI_E2E", "");
449
+ const argv = [
450
+ "node", "/pi", "--mode", "rpc",
451
+ "--extension", "/staged/resources/extensions/@zhushanwen/pi-structured-output",
452
+ "--extension", join(fauxAgentDir, "extensions", "faux-llm-ext.ts"),
453
+ ];
454
+ expect(resolveGrandchildExtensionPaths(argv, () => undefined)).toEqual([
455
+ "/staged/resources/extensions/@zhushanwen/pi-structured-output",
456
+ ]);
457
+ });
458
+
459
+ it("TAIJI_E2E=1 但 faux-llm-ext.ts 不在盘 → 不追加(非 faux e2e 用例行为不变)", () => {
460
+ vi.mocked(getAgentDir).mockReturnValue(fauxAgentDir);
461
+ vi.stubEnv("TAIJI_E2E", "1");
462
+ const argv = [
463
+ "node", "/pi", "--mode", "rpc",
464
+ "--extension", "/staged/resources/extensions/@zhushanwen/pi-structured-output",
465
+ ];
466
+ expect(resolveGrandchildExtensionPaths(argv, () => undefined)).toEqual([
467
+ "/staged/resources/extensions/@zhushanwen/pi-structured-output",
468
+ ]);
469
+ });
470
+ });
471
+
472
+ it("惰性求值:每次调用现解析(argv 变更后结果跟随,configureCore 只挂函数引用)", () => {
473
+ const host = createPiHostServices();
474
+ withArgv(["node", "/pi", "--mode", "rpc"], () => {
475
+ expect(host.extensionPaths?.()).not.toContain("/staged/x");
476
+ });
477
+ withArgv(["node", "/pi", "--extension", "/staged/@zhushanwen/pi-structured-output"], () => {
478
+ expect(host.extensionPaths?.()).toEqual(["/staged/@zhushanwen/pi-structured-output"]);
479
+ });
480
+ });
481
+ });
482
+
199
483
  describe("createPiNotifyDomainPorts.countActiveFromEntries(适配读 .count)", () => {
200
484
  it("拆 CountActiveResult.count 为 number(core 端口契约),entries 透传(缺省过滤基准 undefined = 不过滤)", () => {
201
485
  const entries: unknown[] = [{ customType: "pending:register" }];
@@ -222,6 +506,51 @@ describe("createPiNotifyDomainPorts.countActiveFromEntries(适配读 .count)
222
506
  vi.mocked(countActiveFromEntries).mockReturnValue({ count: 0, ids: [], entries: [] });
223
507
  expect(createPiNotifyDomainPorts().countActiveFromEntries?.([])).toBe(0);
224
508
  });
509
+
510
+ // ── 读侧过滤②(per-call 基准)装配冒烟(自 src/__tests__/pi-host-notify-ports.test.ts 迁入)──
511
+ // [W4 读侧过滤② / F1] countActiveFromEntries 第二参(core 端口契约 CountActivePortOptions)
512
+ // 透传 pending-notifications 的 currentSessionId——传入时按「register entry 的
513
+ // sessionId ≠ 基准 → 跳过」过滤(fork 继承的父级注册残留不进后代判定差集);
514
+ // 缺省不过滤(向后兼容)。差集语义权威在 pending-notifications 包自身,这两条用
515
+ // 真实实现(importActual,文件级 mock 面内恢复)验证 createPiNotifyDomainPorts
516
+ // 装配贯通 + per-call 基准形态(本 describe 前三条的 mockReturnValue 在用例体内
517
+ // 后置覆盖,与该钩子不冲突)。基准来源 [F1]:core 读侧按「被读 entries 所属
518
+ // session」提供——本装配在扩展启动时一次定型,factory 定型表达不了 per-call 基准。
519
+ beforeEach(async () => {
520
+ const actual = await vi.importActual<typeof import("@zhushanwen/pi-pending-notifications")>(
521
+ "@zhushanwen/pi-pending-notifications",
522
+ );
523
+ vi.mocked(countActiveFromEntries).mockImplementation(actual.countActiveFromEntries);
524
+ });
525
+
526
+ /** pending:register entry 最小夹具(真实 countActiveFromEntries 可消费的形状)。 */
527
+ function registerEntry(id: string, type: string, sessionId: string): unknown {
528
+ return {
529
+ type: "custom",
530
+ customType: "pending:register",
531
+ data: { id, type, name: id, registeredAt: 1, sessionId },
532
+ };
533
+ }
534
+
535
+ it("调用时传 currentSessionId → 跨 session 残留不进后代判定差集", () => {
536
+ const entries = [
537
+ registerEntry("bg-parent", "subagent", "sess-parent"),
538
+ registerEntry("wf-parent", "workflow", "sess-parent"),
539
+ registerEntry("bg-own", "subagent", "sess-child"),
540
+ ];
541
+ const ports = createPiNotifyDomainPorts();
542
+ expect(ports.countActiveFromEntries?.(entries, { currentSessionId: "sess-child" })).toBe(1);
543
+ });
544
+
545
+ it("调用时不传基准 → 不过滤(向后兼容:无基准调用方零改动)", () => {
546
+ const entries = [
547
+ registerEntry("bg-parent", "subagent", "sess-parent"),
548
+ registerEntry("wf-parent", "workflow", "sess-parent"),
549
+ registerEntry("bg-own", "subagent", "sess-child"),
550
+ ];
551
+ const ports = createPiNotifyDomainPorts();
552
+ expect(ports.countActiveFromEntries?.(entries)).toBe(3);
553
+ });
225
554
  });
226
555
 
227
556
  describe("createPiNotifyDomainPorts.createDelivery(透传 session-delivery)", () => {
@@ -244,3 +573,88 @@ describe("createPiNotifyDomainPorts.createDelivery(透传 session-delivery)"
244
573
  expect(result).toBe(fakeHandle);
245
574
  });
246
575
  });
576
+
577
+ // ──────────────────────────────────────────────────────────────
578
+ // P5 staged workflows 入扫描面(端到端):taiji 态 roots → discoverResources
579
+ // 验收条款 b:location 可派发(<location> = 真实存在的脚本路径)+ 同环境两次
580
+ // 扫描 location 形态确定(P-C6 裁决「实际路径直渲染」的稳定性构造性证据)。
581
+ // ──────────────────────────────────────────────────────────────
582
+
583
+ describe("P5 staged workflows 入扫描面(端到端)", () => {
584
+ const AGENT_DIR = "/fake/data-dir/agent";
585
+ const argvSaved = process.argv;
586
+ let ws: string;
587
+
588
+ function taijiWorkflowsRoots(): ReturnType<NonNullable<ReturnType<typeof createPiHostServices>["discoveryRoots"]>["workflows"]> {
589
+ vi.mocked(getAgentDir).mockReturnValue(AGENT_DIR);
590
+ process.argv = [
591
+ "node", "/pi",
592
+ "--mode", "rpc",
593
+ "--extension", "/staged/resources/extensions/@zhushanwen/pi-subagent-workflow",
594
+ ];
595
+ return createPiHostServices().discoveryRoots?.().workflows ?? [];
596
+ }
597
+
598
+ beforeEach(() => {
599
+ ws = mkdtempSync(join(tmpdir(), "pihost-p5-ws-"));
600
+ });
601
+
602
+ afterEach(() => {
603
+ process.argv = argvSaved;
604
+ rmSync(ws, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
605
+ clearFileCache();
606
+ });
607
+
608
+ it("内置 workflow 条目被发现且 path 指向真实存在的脚本(location 可派发)", async () => {
609
+ const discovered = await discoverResources({
610
+ kind: "workflows",
611
+ workspaceRoot: ws,
612
+ hostRoots: taijiWorkflowsRoots(),
613
+ includeTmp: true,
614
+ });
615
+
616
+ const builtin = discovered.filter((r) => r.path.includes("subagent-core/workflows"));
617
+ expect(builtin.length).toBeGreaterThan(0);
618
+ for (const r of builtin) {
619
+ expect(r.available).toBe(true);
620
+ // <location> 渲染源 = r.path——可派发 = 文件真实存在
621
+ expect(existsSync(r.path)).toBe(true);
622
+ expect(r.path.endsWith(".js")).toBe(true);
623
+ }
624
+ // 内置代表脚本(review-fix-loop = workflow 域核心脚本)在扫描面内
625
+ expect(builtin.some((r) => r.path.endsWith("review-fix-loop.js"))).toBe(true);
626
+ });
627
+
628
+ it("同环境两次扫描(含缓存清空重建)location 形态确定(字节稳定断言)", async () => {
629
+ const scan = (): Promise<ReturnType<typeof discoverResources>> =>
630
+ discoverResources({
631
+ kind: "workflows",
632
+ workspaceRoot: ws,
633
+ hostRoots: taijiWorkflowsRoots(),
634
+ includeTmp: true,
635
+ });
636
+
637
+ const first = await scan();
638
+ clearFileCache(); // 强制重建(不走 mtime/workspaceRoot 缓存——重建路径也确定)
639
+ const second = await scan();
640
+
641
+ // readdir 枚举序无契约(discoverResources 返回按扫描插入序)——确定性断言
642
+ // 以 path 排序投影比较(条目集合与形态稳定,序不在契约内)
643
+ const sorted = (rs: Awaited<ReturnType<typeof discoverResources>>) =>
644
+ rs.map((r) => ({ path: r.path, source: r.source, available: r.available })).sort((a, b) => (a.path < b.path ? -1 : 1));
645
+ expect(sorted(second)).toEqual(sorted(first));
646
+ const builtinPaths = first
647
+ .filter((r) => r.path.includes("subagent-core/workflows"))
648
+ .map((r) => r.path)
649
+ .sort();
650
+ // 同环境两次扫描的 <location> 渲染源逐字节一致(staged 路径在 session 内不变——
651
+ // KV-cache 契约的构造性证据,P-C6 裁决「实际路径直渲染」的稳定性前提)
652
+ expect(
653
+ second.filter((r) => r.path.includes("subagent-core/workflows")).map((r) => r.path).sort(),
654
+ ).toEqual(builtinPaths);
655
+ });
656
+ });
657
+
658
+ afterAll(() => {
659
+ rmSync(mockHomeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
660
+ });
@@ -26,12 +26,12 @@
26
26
  // (session_start 是 pi 启动序列里最早带 ctx 的钩子 = 「session 就绪」,即设计所指
27
27
  // 加载完成时点;不挂任何懒触发(无 subagent 的 session 也必上报)。
28
28
  //
29
- // 失败语义(D5 缺席语义②,2026-09-13 oe-audit 修订):select 失败(超时/通道异常/
30
- // 非确认回包)折叠后延迟重试,**累计 MAX_REPORT_ATTEMPTS 次放弃**(原「重试直至
31
- // 成功一次」的无界语义为「旧版 runtime」版本错配场景设计——该场景已被同 bundle
32
- // 发布 + pi 随 runtime 退出销毁两条事实证伪;有界化的防挂死兜底对齐 plugin-bridge
33
- // MAX_SYNC_ATTEMPTS 形态。session_start 首帧早于 runtime adapter attach 的竞态
34
- // (R2 实证)在 30×2s=60s 窗口内必然自愈)。送达判据 = runtime resolve 的确认回包
29
+ // 失败语义(D5 缺席语义②):select 失败(超时/通道异常/非确认回包)折叠后延迟重试,
30
+ // **累计 MAX_REPORT_ATTEMPTS(3)次放弃**。上限刻意取小(增强面降级):GUI live
31
+ // 在途镜像短暂滞后可接受,重试 3 次封顶后放弃,不做长尾可达保证——子任务完成通知
32
+ // 另有账本必达通道,不依赖此镜像。session_start 首帧早于 runtime adapter attach 的
33
+ // 竞态(R2 实证)在 3×2s=6s 窗口内自愈,错过窗口即放弃。送达判据 = runtime resolve
34
+ // 的确认回包
35
35
  // (INFLIGHT_REPORT_ACK)——fire-and-forget 下 resolve(undefined) 与超时不可区分,
36
36
  // 靠显式 ack 区分「已送达」与「无路由」;放弃后镜像按 absent-report 走 errs 推迟
37
37
  // (30min 有界),不丢 errs-safe 兜底。
@@ -44,15 +44,18 @@ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
44
44
 
45
45
  /** select 通道级超时(控制面单请求,秒级校准——超时默认原则规则 19)。取值对齐
46
46
  * plugin-bridge 启动 sync 的 2s 自愈闸:session_start 首帧可能早于 runtime adapter
47
- * attach(R2 实证),超时折叠后重试必然自愈;fire-and-forget 帧不留 pending 挂死面。 */
47
+ * attach(R2 实证),超时折叠后靠有限次重试覆盖 attach 竞态窗口(约 6s,见
48
+ * MAX_REPORT_ATTEMPTS);fire-and-forget 帧不留 pending 挂死面。 */
48
49
  const SELECT_TIMEOUT_MS = 2_000;
49
50
 
50
51
  /** 失败重试退避(对齐 plugin-bridge SYNC_RETRY_MS 控制面节奏)。 */
51
52
  const RETRY_DELAY_MS = 2_000;
52
53
 
53
- /** 累计失败放弃上限(对齐 plugin-bridge MAX_SYNC_ATTEMPTS:60s 窗口覆盖 attach 竞态,
54
- * 有界防 rpc-but-非-taiji orchestrator 场景的永久空转)。 */
55
- const MAX_REPORT_ATTEMPTS = 30;
54
+ /** 累计失败放弃上限(刻意取小——增强面降级):GUI live 在途镜像短暂滞后可接受,
55
+ * 3 次封顶(约 6s)后放弃,不做长尾可达保证;完成通知另有账本必达通道,不依赖
56
+ * 此镜像。重试窗口同时覆盖 session_start 首帧早于 runtime adapter attach 的竞态
57
+ * (R2 实证),并有界防 rpc-but-非-taiji orchestrator 场景的永久空转。 */
58
+ const MAX_REPORT_ATTEMPTS = 3;
56
59
 
57
60
  /** 在途上报器(组合根 index.ts 持有;per-factory 实例,session_start/shutdown 驱动)。 */
58
61
  export interface InFlightReporter {