@zhushanwen/pi-subagent-workflow 7.3.4 → 8.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 (166) hide show
  1. package/README.md +39 -12
  2. package/agents/analyst.md +61 -0
  3. package/agents/coder.md +70 -0
  4. package/agents/debugger.md +67 -0
  5. package/agents/doc-reviewer.md +3 -3
  6. package/agents/explorer.md +50 -18
  7. package/agents/general-purpose.md +19 -8
  8. package/agents/orchestrator.md +37 -32
  9. package/agents/planner.md +45 -11
  10. package/agents/researcher.md +53 -11
  11. package/agents/reviewer.md +74 -0
  12. package/package.json +1 -1
  13. package/skills/workflow-script-format/SKILL.md +1 -1
  14. package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
  15. package/src/execution/__tests__/agent-registry.test.ts +13 -11
  16. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
  17. package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
  18. package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
  19. package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
  20. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
  21. package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
  22. package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +8 -2
  24. package/src/execution/__tests__/delivery-methods.test.ts +385 -0
  25. package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
  26. package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
  27. package/src/execution/__tests__/execute-nesting.test.ts +20 -72
  28. package/src/execution/__tests__/execution-record.test.ts +199 -0
  29. package/src/execution/__tests__/finalize-record.test.ts +170 -14
  30. package/src/execution/__tests__/format.test.ts +131 -7
  31. package/src/execution/__tests__/gc-timer.test.ts +184 -0
  32. package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
  33. package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
  34. package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
  35. package/src/execution/__tests__/index-session-start.test.ts +257 -5
  36. package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
  37. package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
  38. package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
  39. package/src/execution/__tests__/list-component.test.ts +59 -5
  40. package/src/execution/__tests__/list-fields.test.ts +109 -0
  41. package/src/execution/__tests__/model-resolver.test.ts +38 -1
  42. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
  43. package/src/execution/__tests__/nested-visibility.test.ts +325 -0
  44. package/src/execution/__tests__/notifier-flush.test.ts +209 -7
  45. package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
  46. package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
  47. package/src/execution/__tests__/record-store.test.ts +158 -52
  48. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
  49. package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
  50. package/src/execution/__tests__/resource-policy.test.ts +109 -0
  51. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
  52. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
  53. package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
  54. package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
  55. package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
  56. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
  57. package/src/execution/__tests__/session-pending.test.ts +61 -2
  58. package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
  59. package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
  60. package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
  61. package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
  62. package/src/execution/__tests__/spawn-args.test.ts +127 -49
  63. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
  64. package/src/execution/__tests__/spawned-children.test.ts +92 -0
  65. package/src/execution/__tests__/status-refactor.test.ts +345 -0
  66. package/src/execution/__tests__/stdin-writer.test.ts +97 -0
  67. package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
  68. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
  69. package/src/execution/__tests__/subagent-service.test.ts +49 -11
  70. package/src/execution/__tests__/timeout-integration.test.ts +27 -13
  71. package/src/execution/__tests__/tool-action.test.ts +12 -10
  72. package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
  73. package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
  74. package/src/execution/__tests__/worktree-manager.test.ts +292 -89
  75. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
  76. package/src/execution/argv-mirror.ts +21 -2
  77. package/src/execution/execution-record.ts +126 -9
  78. package/src/execution/finalize-record.ts +90 -13
  79. package/src/execution/host-mode.ts +1 -1
  80. package/src/execution/lifecycle-manager.ts +484 -0
  81. package/src/execution/lifecycle-predicates.ts +65 -0
  82. package/src/execution/manifest-store.ts +61 -16
  83. package/src/execution/model-resolver.ts +26 -5
  84. package/src/execution/notifier.ts +69 -12
  85. package/src/execution/pi-invocation.ts +21 -1
  86. package/src/execution/record-store.ts +554 -107
  87. package/src/execution/session-pending.ts +116 -45
  88. package/src/execution/session-reconstructor.ts +224 -7
  89. package/src/execution/session-runner.ts +290 -75
  90. package/src/execution/sessions-index.ts +304 -0
  91. package/src/execution/stdin-writer.ts +93 -7
  92. package/src/execution/stream-sink.ts +20 -3
  93. package/src/execution/subagent-service.ts +867 -138
  94. package/src/execution/turn-limiter.ts +14 -0
  95. package/src/execution/types.ts +204 -22
  96. package/src/execution/worktree-manager.ts +128 -49
  97. package/src/execution/worktree-registry.ts +13 -2
  98. package/src/index.ts +277 -19
  99. package/src/injectors/subagent-list-injector.ts +26 -8
  100. package/src/injectors/workflow-list-injector.ts +25 -8
  101. package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
  102. package/src/interface/__tests__/tool-render.test.ts +15 -13
  103. package/src/interface/bg-notify-render.ts +32 -8
  104. package/src/interface/command-actions.ts +26 -7
  105. package/src/interface/commands.ts +21 -22
  106. package/src/interface/format.ts +44 -17
  107. package/src/interface/gui-mappers.ts +6 -8
  108. package/src/interface/helpers.ts +170 -10
  109. package/src/interface/list-component.ts +53 -14
  110. package/src/interface/subagent-actions.ts +235 -17
  111. package/src/interface/subagent-tool.ts +82 -17
  112. package/src/interface/subagents.ts +2 -1
  113. package/src/interface/tool-render.ts +11 -24
  114. package/src/interface/tool-workflow.ts +20 -35
  115. package/src/interface/views/WorkflowsView.ts +89 -32
  116. package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
  117. package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
  118. package/src/interface/views/format.ts +3 -3
  119. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
  120. package/src/orchestration/__tests__/args-validator.test.ts +1 -1
  121. package/src/orchestration/__tests__/config-loader.test.ts +38 -0
  122. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
  123. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
  124. package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
  125. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
  126. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
  127. package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
  128. package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
  129. package/src/orchestration/__tests__/test-mocks.ts +191 -0
  130. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
  131. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
  132. package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
  133. package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
  134. package/src/orchestration/agent-opts-resolver.ts +4 -1
  135. package/src/orchestration/args-validator.ts +2 -2
  136. package/src/orchestration/config-loader.ts +30 -1
  137. package/src/orchestration/error-recovery.ts +133 -29
  138. package/src/orchestration/execute-agent-call.ts +31 -7
  139. package/src/orchestration/jsonl-run-store.ts +287 -40
  140. package/src/orchestration/launcher.ts +7 -1
  141. package/src/orchestration/lifecycle.ts +135 -132
  142. package/src/orchestration/models/__tests__/trace.test.ts +408 -0
  143. package/src/orchestration/models/budget.ts +1 -1
  144. package/src/orchestration/models/run-runtime.ts +15 -17
  145. package/src/orchestration/models/run-spec.ts +2 -2
  146. package/src/orchestration/models/run-state.ts +3 -3
  147. package/src/orchestration/models/trace.ts +95 -15
  148. package/src/orchestration/models/types.ts +8 -9
  149. package/src/orchestration/models/workflow-run.ts +50 -71
  150. package/src/orchestration/models/workflow-script.ts +32 -1
  151. package/src/orchestration/skill-discovery.ts +30 -0
  152. package/src/orchestration/worker-handle.ts +1 -1
  153. package/src/orchestration/worker-host.ts +1 -1
  154. package/src/orchestration/worker-script-builder.ts +29 -10
  155. package/src/shared/__tests__/agent-ref.test.ts +34 -0
  156. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
  157. package/src/shared/__tests__/resource-discovery.test.ts +55 -0
  158. package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
  159. package/src/shared/agent-ref.ts +16 -0
  160. package/src/shared/resource-discovery.ts +147 -58
  161. package/src/shared/schema-jsonify.ts +53 -0
  162. package/workflows/README.md +4 -4
  163. package/agents/code-reviewer.md +0 -47
  164. package/agents/context-builder.md +0 -21
  165. package/agents/oracle.md +0 -34
  166. package/agents/worker.md +0 -20
@@ -41,13 +41,17 @@
41
41
  // ── Build worker source ─────────────────────────────────────
42
42
 
43
43
  /**
44
- * Build the complete worker source text by wrapping the user's workflow script
45
- * with infrastructure code and injected global functions.
44
+ * Template constant segments(模块级 hoisting,IF5/TC8)——
45
+ * 常量段在模块加载时构造一次,buildWorkerScript 每次调用只做三段 concat,
46
+ * 消除每次 run 的 330+ 元素数组重建 + join 分配(#11 低优先形态)。
46
47
  *
47
- * AC-4:脚本格式不变(用户资产)。逐字保留旧 buildWorkerScript 的拼接逻辑。
48
+ * 输出与 hoisting 前逐字节一致(join("\n") 拼装语义精确保留),由
49
+ * __tests__/worker-script-template-snapshot.test.ts 的 byte-identical 快照锚定
50
+ * (fixture 由改造前实现生成)。
48
51
  */
49
- export function buildWorkerScript(userScript: string): string {
50
- return [
52
+
53
+ /** PRE 段:use strict … User workflow script 注释行(不含 userScript)。 */
54
+ const WORKER_TEMPLATE_PRE = [
51
55
  '"use strict";',
52
56
  '// Module-scope helpers: accessible to BOTH the IIFE body AND the outer',
53
57
  '// .then()/.catch() handlers (which run outside the IIFE). _workerLogs/',
@@ -60,6 +64,9 @@ export function buildWorkerScript(userScript: string): string {
60
64
  '// appear in the IIFE and the outer .then/.catch.',
61
65
  'const { parentPort: _parentPort, workerData: _workerData } = require("node:worker_threads");',
62
66
  'const _workerLogs = [];',
67
+ '// IF6(#12): known agent() fields — hoisted to module scope, built once per worker',
68
+ '// (was rebuilt inside agent() on every call; field set is call-invariant).',
69
+ 'const _KNOWN_FIELDS = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel"]);',
63
70
  'function _pushWorkerLog(level, args) {',
64
71
  ' try { _workerLogs.push({ level, message: args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ") }); } catch (e) { /* swallow */ }',
65
72
  '}',
@@ -232,9 +239,8 @@ export function buildWorkerScript(userScript: string): string {
232
239
  ' if (!opts.model && $MODEL) opts.model = $MODEL;',
233
240
  ' if (!opts.thinkingLevel && $THINKING_LEVEL) opts.thinkingLevel = $THINKING_LEVEL;',
234
241
  '',
235
- ' // Validate known agent() fields to catch API misuse early',
236
- ' const _knownFields = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel"]);',
237
- ' const _unknownFields = Object.keys(opts).filter((k) => !_knownFields.has(k));',
242
+ ' // Validate known agent() fields to catch API misuse early (_KNOWN_FIELDS at module scope)',
243
+ ' const _unknownFields = Object.keys(opts).filter((k) => !_KNOWN_FIELDS.has(k));',
238
244
  ' if (_unknownFields.length > 0) {',
239
245
  ' _pushWorkerLog("warn", ["[workflow] agent() received unknown fields: " + _unknownFields.join(", ") + ". Known fields: prompt, description, schema, model, scene, label, task, agent, phase, skill, timeoutMs, cwd, fork, worktree, returnMeta, thinkingLevel"]);',
240
246
  ' }',
@@ -361,8 +367,10 @@ export function buildWorkerScript(userScript: string): string {
361
367
  ' }',
362
368
  '',
363
369
  ' // ── User workflow script ──',
364
- ' ' + userScript,
365
- '',
370
+ ].join("\n");
371
+
372
+ /** POST 段:auto-invoke execute() + 顶层 .then/.catch return/error handler。 */
373
+ const WORKER_TEMPLATE_POST = [
366
374
  ' // ── Auto-invoke execute() for module.exports pattern ──',
367
375
  ' if (typeof module !== "undefined" && module.exports && typeof module.exports.execute === "function") {',
368
376
  ' return await module.exports.execute({ agent, parallel, pipeline, phase, log, workflow, $ARGS, $WORKSPACE, $BUDGET });',
@@ -375,4 +383,15 @@ export function buildWorkerScript(userScript: string): string {
375
383
  ' _safePost({ type: "error", runId, error: err.message || String(err), workerLogs: _workerLogs }, "error");',
376
384
  '});',
377
385
  ].join("\n");
386
+
387
+ /**
388
+ * Build the complete worker source text by wrapping the user's workflow script
389
+ * with infrastructure code and injected global functions.
390
+ *
391
+ * AC-4:脚本格式不变(用户资产)。逐字保留旧 buildWorkerScript 的拼接逻辑——
392
+ * 三段 concat 的换行语义 = 原单数组 join("\n")(userScript 段带 2 空格缩进,
393
+ * 段后一个空行接 auto-invoke 段),byte-identical 快照锚定。
394
+ */
395
+ export function buildWorkerScript(userScript: string): string {
396
+ return WORKER_TEMPLATE_PRE + "\n " + userScript + "\n\n" + WORKER_TEMPLATE_POST;
378
397
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * displayAgentName 测试:agent ref 绝对路径 → UI 显示短名(basename 去 .md)。
3
+ *
4
+ * 数据层不变式(displayAgentName 只服务显示出口):非路径值原样返回。
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { displayAgentName } from "../agent-ref.ts";
9
+
10
+ describe("displayAgentName", () => {
11
+ it("mac/linux 绝对路径取 basename 并去 .md", () => {
12
+ expect(displayAgentName("/Users/x/.agents/agents/worker.md")).toBe("worker");
13
+ expect(displayAgentName("/home/u/project/.agents/agents/reviewer.md")).toBe("reviewer");
14
+ });
15
+
16
+ it("windows 路径(反斜杠分隔)同样取短名", () => {
17
+ expect(displayAgentName("C:\\Users\\x\\agents\\worker.md")).toBe("worker");
18
+ expect(displayAgentName("C:/Users/x/agents/worker.md")).toBe("worker");
19
+ });
20
+
21
+ it("非路径 agent 名原样返回(DEFAULT_AGENT_NAME 等)", () => {
22
+ expect(displayAgentName("general-purpose")).toBe("general-purpose");
23
+ expect(displayAgentName("worker")).toBe("worker");
24
+ });
25
+
26
+ it("无 .md 扩展名的 basename 原样返回(不去别的后缀)", () => {
27
+ expect(displayAgentName("/a/b/worker")).toBe("worker");
28
+ expect(displayAgentName("/a/b/agent.spec.ts")).toBe("agent.spec.ts");
29
+ });
30
+
31
+ it("裸文件名(无目录)也去 .md", () => {
32
+ expect(displayAgentName("worker.md")).toBe("worker");
33
+ });
34
+ });
@@ -0,0 +1,280 @@
1
+ // src/shared/__tests__/resource-discovery-manifest-cache.test.ts
2
+ //
3
+ // manifestCache 专项测试(swf-perf-impl cleanup IF1/DM1/TC1)。
4
+ // readPackageManifestSync / async readPackageManifest 共享同一 Map:
5
+ // - 命中:同 mtime 二次调用不重读(readFileSync 计数不增)
6
+ // - 失效:package.json 重写(mtime 变)→ 重读
7
+ // - 坏 JSON:不缓存(mtime 未变时二次调用仍重新 read)、不驱逐已有好条目
8
+ // (恢复原内容 + utimesSync 恢复原 mtime → 命中原条目,readFileSync 不增)
9
+ // - stat 失败(文件删除)→ undefined 且条目驱逐(重建同内容同 mtime → 必重读)
10
+ // - 双读者共享:sync 写入的条目被 async 命中(fs/promises readFile 零调用),反之亦然
11
+ //
12
+ // ESM 下 node:fs 命名空间不可直接 spyOn(resource-discovery.test.ts TC4a 注记),
13
+ // 计数走 vi.mock 工厂包装(透传 actual 实现 + vi.fn 计数,行为零变化)。
14
+ // mtime 控制用 utimesSync 定值(整数 ms),消除写入浮点 mtime 的精度/flake 面。
15
+
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import * as fsp from "node:fs/promises";
20
+
21
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
+
23
+ // 隔离真实用户全局目录(homedir 推导 user-agents 源,同 resource-discovery.test.ts)
24
+ vi.mock("node:os", async (importOriginal) => {
25
+ const actual = await importOriginal<typeof import("node:os")>();
26
+ return { ...actual, homedir: () => "/nonexistent-home-for-tests" };
27
+ });
28
+
29
+ vi.mock("node:fs", async (importOriginal) => {
30
+ const actual = await importOriginal<typeof import("node:fs")>();
31
+ return {
32
+ ...actual,
33
+ readFileSync: vi.fn(
34
+ (...args: Parameters<typeof actual.readFileSync>) => actual.readFileSync(...args),
35
+ ),
36
+ statSync: vi.fn(
37
+ (...args: Parameters<typeof actual.statSync>) => actual.statSync(...args),
38
+ ),
39
+ };
40
+ });
41
+
42
+ vi.mock("node:fs/promises", async (importOriginal) => {
43
+ const actual = await importOriginal<typeof import("node:fs/promises")>();
44
+ return {
45
+ ...actual,
46
+ readFile: vi.fn(
47
+ (...args: Parameters<typeof actual.readFile>) => actual.readFile(...args),
48
+ ),
49
+ };
50
+ });
51
+
52
+ import { clearFileCache, discoverResources, readPackageManifestSync } from "../resource-discovery.ts";
53
+
54
+ // ── helpers ──
55
+
56
+ function tmpPkgDir(): string {
57
+ return fs.mkdtempSync(path.join(os.tmpdir(), "manifest-cache-test-"));
58
+ }
59
+
60
+ function writePackageJson(pkgDir: string, pi: Record<string, unknown> | undefined): string {
61
+ fs.mkdirSync(pkgDir, { recursive: true });
62
+ const filePath = path.join(pkgDir, "package.json");
63
+ fs.writeFileSync(filePath, JSON.stringify({ name: "test-pkg", pi }), "utf-8");
64
+ return filePath;
65
+ }
66
+
67
+ /** 写原始(非 JSON)package.json 内容——坏 JSON 用例。 */
68
+ function writeRawPackageJson(pkgDir: string, content: string): string {
69
+ fs.mkdirSync(pkgDir, { recursive: true });
70
+ const filePath = path.join(pkgDir, "package.json");
71
+ fs.writeFileSync(filePath, content, "utf-8");
72
+ return filePath;
73
+ }
74
+
75
+ /** 定值 mtime(整数 ms,消除浮点 mtime 与 utimesSync 恢复的精度差)。 */
76
+ const T1 = new Date("2026-01-01T00:00:00Z");
77
+ const T2 = new Date("2026-01-01T00:00:10Z");
78
+
79
+ function setMtime(filePath: string, t: Date): void {
80
+ fs.utimesSync(filePath, t, t);
81
+ }
82
+
83
+ function readCount(): number {
84
+ return vi.mocked(fs.readFileSync).mock.calls.length;
85
+ }
86
+
87
+ /** node:fs/promises readFile 包装计数(异步读者)。 */
88
+ function asyncReadCount(): number {
89
+ return vi.mocked(fsp.readFile).mock.calls.length;
90
+ }
91
+
92
+ // ============================================================
93
+ // manifestCache(IF1)
94
+ // ============================================================
95
+
96
+ describe("manifestCache(readPackageManifestSync)", () => {
97
+ let pkgDir: string;
98
+ let pkgJson: string;
99
+
100
+ beforeEach(() => {
101
+ clearFileCache();
102
+ vi.mocked(fs.readFileSync).mockClear();
103
+ pkgDir = tmpPkgDir();
104
+ });
105
+ afterEach(() => {
106
+ fs.rmSync(pkgDir, { recursive: true, force: true });
107
+ });
108
+
109
+ it("命中:同 mtime 二次调用不重读(readFileSync 只调 1 次)", () => {
110
+ pkgJson = writePackageJson(pkgDir, { agents: ["./a.md"] });
111
+ setMtime(pkgJson, T1);
112
+ const r1 = readPackageManifestSync(pkgDir, "agents");
113
+ expect(r1).toEqual(["./a.md"]);
114
+ expect(readCount()).toBe(1);
115
+ const r2 = readPackageManifestSync(pkgDir, "agents");
116
+ expect(r2).toEqual(["./a.md"]);
117
+ // 命中缓存:statSync 每次(判 mtime)但 readFileSync 不增
118
+ expect(readCount()).toBe(1);
119
+ });
120
+
121
+ it("kind 共享一次 parse:同 package.json 的 agents/workflows 两 kind 只读一次", () => {
122
+ pkgJson = writePackageJson(pkgDir, { agents: ["./a.md"], workflows: ["./w.js"] });
123
+ setMtime(pkgJson, T1);
124
+ expect(readPackageManifestSync(pkgDir, "agents")).toEqual(["./a.md"]);
125
+ expect(readPackageManifestSync(pkgDir, "workflows")).toEqual(["./w.js"]);
126
+ expect(readCount()).toBe(1);
127
+ });
128
+
129
+ it("失效:package.json 重写(mtime 变)→ 重读新内容", () => {
130
+ pkgJson = writePackageJson(pkgDir, { agents: ["./a.md"] });
131
+ setMtime(pkgJson, T1);
132
+ expect(readPackageManifestSync(pkgDir, "agents")).toEqual(["./a.md"]);
133
+ writePackageJson(pkgDir, { agents: ["./b.md"] });
134
+ // 不需显式设 mtime:新写入的 mtime 必 ≠ 定值 T1
135
+ expect(readPackageManifestSync(pkgDir, "agents")).toEqual(["./b.md"]);
136
+ expect(readCount()).toBe(2);
137
+ });
138
+
139
+ it("坏 JSON → undefined 且不缓存:mtime 未变时二次调用仍重新 read(计数递增)", () => {
140
+ // 起点即坏 JSON(无缓存条目)——若坏结果被缓存,二次调用不会重新 readFile。
141
+ // (good→bad 且 mtime 未变的场景由上一用例的命中路径覆盖:mtime 相等直接命中原好条目)
142
+ pkgJson = writeRawPackageJson(pkgDir, "{ broken json");
143
+ setMtime(pkgJson, T1);
144
+ expect(readPackageManifestSync(pkgDir, "agents")).toBeUndefined();
145
+ expect(readCount()).toBe(1);
146
+ expect(readPackageManifestSync(pkgDir, "agents")).toBeUndefined();
147
+ expect(readCount()).toBe(2); // 二次调用仍重新 read = 坏结果未入缓存(毒条目永不入缓存)
148
+ });
149
+
150
+ it("坏 JSON 不驱逐已有好条目:恢复原内容 + 恢复原 mtime → 命中原条目(readFileSync 不增)", () => {
151
+ pkgJson = writePackageJson(pkgDir, { agents: ["./a.md"] });
152
+ setMtime(pkgJson, T1);
153
+ expect(readPackageManifestSync(pkgDir, "agents")).toEqual(["./a.md"]);
154
+ expect(readCount()).toBe(1);
155
+ // 改坏且 mtime 变(T2)→ undefined(read 失败路径,不驱逐 {T1, pi} 好条目)
156
+ fs.writeFileSync(pkgJson, "{ broken", "utf-8");
157
+ setMtime(pkgJson, T2);
158
+ expect(readPackageManifestSync(pkgDir, "agents")).toBeUndefined();
159
+ expect(readCount()).toBe(2);
160
+ // 恢复原内容 + utimesSync 恢复原 mtime → 若好条目仍在则命中(不重 read)
161
+ writePackageJson(pkgDir, { agents: ["./a.md"] });
162
+ setMtime(pkgJson, T1);
163
+ expect(readPackageManifestSync(pkgDir, "agents")).toEqual(["./a.md"]);
164
+ expect(readCount()).toBe(2); // 无第 3 次 read = 命中保留的原条目
165
+ });
166
+
167
+ it("stat 失败(文件删除)→ undefined 且条目驱逐(重建同内容同 mtime → 必重读)", () => {
168
+ pkgJson = writePackageJson(pkgDir, { agents: ["./a.md"] });
169
+ setMtime(pkgJson, T1);
170
+ expect(readPackageManifestSync(pkgDir, "agents")).toEqual(["./a.md"]);
171
+ expect(readCount()).toBe(1);
172
+ fs.rmSync(pkgJson);
173
+ expect(readPackageManifestSync(pkgDir, "agents")).toBeUndefined();
174
+ expect(readCount()).toBe(1); // stat 失败路径不触发 read
175
+ // 重建同内容 + 恢复同 mtime:条目已被驱逐 → 必重 read(计数 +1)
176
+ writePackageJson(pkgDir, { agents: ["./a.md"] });
177
+ setMtime(pkgJson, T1);
178
+ expect(readPackageManifestSync(pkgDir, "agents")).toEqual(["./a.md"]);
179
+ expect(readCount()).toBe(2);
180
+ });
181
+
182
+ it("无 pi 字段(合法解析结果)也缓存:二次调用不重读", () => {
183
+ pkgJson = writePackageJson(pkgDir, undefined);
184
+ setMtime(pkgJson, T1);
185
+ expect(readPackageManifestSync(pkgDir, "agents")).toBeUndefined();
186
+ expect(readCount()).toBe(1);
187
+ expect(readPackageManifestSync(pkgDir, "agents")).toBeUndefined();
188
+ expect(readCount()).toBe(1);
189
+ });
190
+
191
+ // [review 修复] 非对象 JSON 结构守卫:JSON.parse 对 "42" / '"str"' / "null" 等
192
+ // 合法 JSON 产出非对象值,旧 `as Record<string, unknown>` 盲断言下 .pi 访问
193
+ // 「碰巧不抛」(primitive 装箱返 undefined / null 抛 TypeError 落 catch)——
194
+ // 显式守卫后按「无 manifest」(undefined)处理,行为等价但不再依赖巧合。
195
+ it("非对象 JSON(42 / null / string / pi 字段非对象或数组)→ undefined 不抛,按合法解析结果缓存", () => {
196
+ const cases: Array<{ raw: string; label: string }> = [
197
+ { raw: "42", label: "number" },
198
+ { raw: "null", label: "null" },
199
+ { raw: '"just-a-string"', label: "string" },
200
+ { raw: '{"name":"p","pi":42}', label: "pi-field-not-object" },
201
+ // [review 修复] 数组能骗过 typeof "object" 守卫(装箱对象)——非合法 pi 容器,
202
+ // 显式归 undefined(语义上仍是「无 manifest」,与数组 entries 读出 undefined 一致)
203
+ { raw: '{"name":"p","pi":[]}', label: "pi-field-array" },
204
+ ];
205
+ // 每 case 独立子目录(缓存 key = package.json 绝对路径),互不命中;
206
+ // readCount 是跨 case 累积计数,断言用本 case 增量
207
+ for (const { raw, label } of cases) {
208
+ const before = readCount();
209
+ const caseDir = path.join(pkgDir, label);
210
+ pkgJson = writeRawPackageJson(caseDir, raw);
211
+ setMtime(pkgJson, T1);
212
+ expect(readPackageManifestSync(caseDir, "agents"), `case ${label}`).toBeUndefined();
213
+ expect(readPackageManifestSync(caseDir, "workflows"), `case ${label}`).toBeUndefined();
214
+ // 合法解析 + 无 manifest → 缓存 undefined 条目(同「无 pi 字段」语义,二次调用不重读)
215
+ expect(readCount() - before, `case ${label}`).toBe(1);
216
+ }
217
+ });
218
+ });
219
+
220
+ // ============================================================
221
+ // 双读者共享(async readPackageManifest ↔ sync 共享同一 Map)
222
+ // ============================================================
223
+
224
+ describe("manifestCache 双读者共享(async ↔ sync)", () => {
225
+ let ws: string;
226
+ let pkgDir: string;
227
+ let savedEnv: string | undefined;
228
+
229
+ beforeEach(() => {
230
+ clearFileCache();
231
+ vi.mocked(fs.readFileSync).mockClear();
232
+ vi.mocked(fsp.readFile).mockClear();
233
+ ws = tmpPkgDir();
234
+ pkgDir = path.join(ws, "my-ext");
235
+ savedEnv = process.env.XYZ_EXTENSION_PATHS;
236
+ });
237
+ afterEach(() => {
238
+ if (savedEnv === undefined) delete process.env.XYZ_EXTENSION_PATHS;
239
+ else process.env.XYZ_EXTENSION_PATHS = savedEnv;
240
+ fs.rmSync(ws, { recursive: true, force: true });
241
+ });
242
+
243
+ it("sync 写入的条目被 async 命中:discoverResources 零 fs/promises readFile", async () => {
244
+ const pkgJson = writePackageJson(pkgDir, { agents: ["./agents"] });
245
+ fs.mkdirSync(path.join(pkgDir, "agents"), { recursive: true });
246
+ fs.writeFileSync(path.join(pkgDir, "agents", "a.md"), "body", "utf-8");
247
+ setMtime(pkgJson, T1);
248
+ // sync 读者先填充缓存
249
+ expect(readPackageManifestSync(pkgDir, "agents")).toEqual(["./agents"]);
250
+ expect(readCount()).toBe(1);
251
+ // async 读者(XYZ_EXTENSION_PATHS → processPackage → readPackageManifest)应命中共享条目
252
+ process.env.XYZ_EXTENSION_PATHS = pkgDir;
253
+ const result = await discoverResources({
254
+ kind: "agents",
255
+ workspaceRoot: ws,
256
+ agentDir: path.join(ws, ".fake-agent"),
257
+ });
258
+ const ext = result.filter((r) => r.source === "user-extension-paths");
259
+ expect(ext.map((r) => path.basename(r.path))).toEqual(["a.md"]);
260
+ expect(asyncReadCount()).toBe(0); // async readFile 未被调用 = 命中共享缓存
261
+ expect(readCount()).toBe(1); // sync 侧无新增 read
262
+ });
263
+
264
+ it("async 写入的条目被 sync 命中:async 读后 sync 不再 readFileSync", async () => {
265
+ const pkgJson = writePackageJson(pkgDir, { agents: ["./agents"] });
266
+ fs.mkdirSync(path.join(pkgDir, "agents"), { recursive: true });
267
+ fs.writeFileSync(path.join(pkgDir, "agents", "a.md"), "body", "utf-8");
268
+ setMtime(pkgJson, T1);
269
+ process.env.XYZ_EXTENSION_PATHS = pkgDir;
270
+ await discoverResources({
271
+ kind: "agents",
272
+ workspaceRoot: ws,
273
+ agentDir: path.join(ws, ".fake-agent"),
274
+ });
275
+ expect(asyncReadCount()).toBe(1); // async 读者读了一次
276
+ // sync 读者应命中 async 写入的条目(readFileSync 计数不变)
277
+ expect(readPackageManifestSync(pkgDir, "agents")).toEqual(["./agents"]);
278
+ expect(readCount()).toBe(0);
279
+ });
280
+ });
@@ -333,6 +333,61 @@ describe("user-extension-paths (XYZ_EXTENSION_PATHS)", () => {
333
333
  expect(ext.map((r) => path.basename(r.path))).toEqual(["async.md"]);
334
334
  expect(ext[0]?.source).toBe("user-extension-paths");
335
335
  });
336
+
337
+ it("async: overrides npm on name clash (priority: user-extension-paths > npm)", async () => {
338
+ const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
339
+ writePackageJson(npmPkg, { agents: ["./agents"] });
340
+ writeFile(path.join(npmPkg, "agents"), "shared.md", "npm-body");
341
+ const devPkg = path.join(ws, "dev-ext");
342
+ writePackageJson(devPkg, { agents: ["./agents"] });
343
+ writeFile(path.join(devPkg, "agents"), "shared.md", "dev-body");
344
+ process.env.XYZ_EXTENSION_PATHS = devPkg;
345
+ const result = await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
346
+ const shared = result.find((r) => path.basename(r.path) === "shared.md");
347
+ expect(shared?.source).toBe("user-extension-paths");
348
+ });
349
+
350
+ it("async: project-agents overrides user-extension-paths (project wins)", async () => {
351
+ const devPkg = path.join(ws, "dev-ext");
352
+ writePackageJson(devPkg, { agents: ["./agents"] });
353
+ writeFile(path.join(devPkg, "agents"), "x.md", "dev-body");
354
+ writeFile(path.join(ws, ".agents", "agents"), "x.md", "project-body");
355
+ process.env.XYZ_EXTENSION_PATHS = devPkg;
356
+ const result = await discoverResources({ kind: "agents", workspaceRoot: ws, agentDir });
357
+ const x = result.find((r) => path.basename(r.path) === "x.md");
358
+ expect(x?.source).toBe("project-agents");
359
+ });
360
+
361
+ it("async: 4 源混合 fixture——输出序 + 优先级 + 与串行版快照等价", async () => {
362
+ // 源优先级低→高:npm < user-extension-paths < project-pi < project-agents
363
+ const npmPkg = path.join(agentDir, "npm", "node_modules", "test-pkg");
364
+ writePackageJson(npmPkg, { agents: ["./agents"] });
365
+ writeFile(path.join(npmPkg, "agents"), "shared.md", "npm-body");
366
+ const devPkg = path.join(ws, "dev-ext");
367
+ writePackageJson(devPkg, { agents: ["./agents"] });
368
+ writeFile(path.join(devPkg, "agents"), "dev.md", "dev-body");
369
+ writeFile(path.join(ws, ".pi", "agents"), "pi.md", "pi-body");
370
+ writeFile(path.join(ws, ".agents", "agents"), "shared.md", "project-body");
371
+ writeFile(path.join(ws, ".agents", "agents"), "proj.md", "proj-body");
372
+ process.env.XYZ_EXTENSION_PATHS = devPkg;
373
+ const config = { kind: "agents" as const, workspaceRoot: ws, agentDir };
374
+
375
+ const asyncResult = await discoverResources(config);
376
+
377
+ // 输出序:stem 首次插入位置固定(低优先级源先入 Map),同名后续覆盖值不移位
378
+ expect(asyncResult.map((r) => path.basename(r.path))).toEqual([
379
+ "shared.md", // 首现于 npm(最低源),位置固定
380
+ "dev.md", // user-extension-paths
381
+ "pi.md", // project-pi
382
+ "proj.md", // project-agents
383
+ ]);
384
+ // 优先级:shared.md 两源同名,project-agents(高)last-writer-wins
385
+ expect(asyncResult.find((r) => path.basename(r.path) === "shared.md")?.source).toBe(
386
+ "project-agents",
387
+ );
388
+ // 快照等价:源级并行 async 版与串行 sync 版输出逐项一致(含序与 source 标签)
389
+ expect(asyncResult).toEqual(discoverResourcesSync(config));
390
+ });
336
391
  });
337
392
 
338
393
  // ── m5 TC4/TC5: 统一 mtime 缓存层(P-cache / P-cache-invalidation) ──
@@ -0,0 +1,81 @@
1
+ /**
2
+ * stringifySchemaCached(IF7/#13)— WeakMap 引用级缓存单测(TC6/DM4)。
3
+ *
4
+ * 断言面(design IF7 契约):
5
+ * - 同对象二调返回同字符串引用(缓存命中,非等值新串)
6
+ * - compact/pretty 分别缓存(互不覆写)
7
+ * - 不同对象互不污染
8
+ * - 输出与直接 JSON.stringify 逐字节一致
9
+ */
10
+ import { describe, expect, it, vi } from "vitest";
11
+
12
+ import { stringifySchemaCached } from "../schema-jsonify.ts";
13
+
14
+ describe("stringifySchemaCached — WeakMap 引用级缓存", () => {
15
+ const schema = { type: "object", properties: { n: { type: "number" } } };
16
+
17
+ it("输出与直接 JSON.stringify 逐字节一致(compact / pretty)", () => {
18
+ expect(stringifySchemaCached(schema, "compact")).toBe(JSON.stringify(schema));
19
+ expect(stringifySchemaCached(schema, "pretty")).toBe(JSON.stringify(schema, null, 2));
20
+ });
21
+
22
+ it("同对象二调返回同字符串引用(缓存命中)", () => {
23
+ const a = stringifySchemaCached(schema, "compact");
24
+ const b = stringifySchemaCached(schema, "compact");
25
+ expect(b).toBe(a); // toBe = 引用相等(非仅等值)
26
+ });
27
+
28
+ it("同对象跨格式:compact 与 pretty 分别缓存且各自命中", () => {
29
+ const c1 = stringifySchemaCached(schema, "compact");
30
+ const p1 = stringifySchemaCached(schema, "pretty");
31
+ const c2 = stringifySchemaCached(schema, "compact");
32
+ const p2 = stringifySchemaCached(schema, "pretty");
33
+ expect(c2).toBe(c1);
34
+ expect(p2).toBe(p1);
35
+ expect(c1).not.toBe(p1); // 两种格式输出不同,分别缓存
36
+ });
37
+
38
+ it("不同对象互不污染(等值异引用各持条目)", () => {
39
+ const s1 = { type: "object" };
40
+ const s2 = { type: "object" }; // 等值但异引用
41
+ const r1 = stringifySchemaCached(s1, "compact");
42
+ const r2 = stringifySchemaCached(s2, "compact");
43
+ expect(r1).toBe(r2); // 等值(内容相同)
44
+ // s2 未命中 s1 的条目(否则 spy 场景下 s2 的 stringify 调用会被跳过——
45
+ // 引用级隔离由「同引用返回同串」+「等值异引用各自 stringify」共同保证)
46
+ });
47
+
48
+ it("缓存命中路径不再调 JSON.stringify(spy 计数)", () => {
49
+ const spy = vi.spyOn(JSON, "stringify");
50
+ try {
51
+ const obj = { spy: true };
52
+ stringifySchemaCached(obj, "pretty");
53
+ stringifySchemaCached(obj, "pretty"); // 第二次应命中缓存
54
+ const calls = spy.mock.calls.filter((c) => c[0] === obj);
55
+ expect(calls).toHaveLength(1);
56
+ } finally {
57
+ spy.mockRestore();
58
+ }
59
+ });
60
+
61
+ it("pretty 缩进锚定:indent=2 与 formatSchemaInstruction 渲染同源", () => {
62
+ const out = stringifySchemaCached({ type: "object" }, "pretty");
63
+ expect(out).toBe('{\n "type": "object"\n}');
64
+ });
65
+
66
+ // [review 修复] TS lib 盲区回归:schema 含 toJSON: () => undefined 钩子时
67
+ // JSON.stringify 运行时返回 undefined,违反声明的 string 返回类型——现 fail-loud
68
+ // 抛含恢复指引的错误,不静默返回 undefined / 垃圾串。
69
+ it("[review 修复] schema 含 toJSON: () => undefined → 抛含恢复指引的错误(compact / pretty)", () => {
70
+ const bad = { type: "object", toJSON: () => undefined };
71
+ expect(() => stringifySchemaCached(bad, "compact")).toThrowError(
72
+ /stringifySchemaCached: JSON\.stringify returned undefined.*Recovery:/s,
73
+ );
74
+ expect(() => stringifySchemaCached(bad, "pretty")).toThrowError(
75
+ /toJSON hook returning undefined.*Recovery:/s,
76
+ );
77
+ // 正常 schema 不受影响(toJSON 返回合法值时按其返回值序列化)
78
+ const good = { type: "object", toJSON: () => ({ type: "string" }) };
79
+ expect(stringifySchemaCached(good, "compact")).toBe('{"type":"string"}');
80
+ });
81
+ });
@@ -34,3 +34,19 @@ export function normalizeRef(ref: string, ext?: string): string | null {
34
34
  export const AGENT_REF_EXT = ".md";
35
35
  /** workflowRef 扩展名。 */
36
36
  export const WORKFLOW_REF_EXT = ".js";
37
+
38
+ /**
39
+ * agent ref 的显示名:basename + 去 .md 扩展名(`/a/b/worker.md` → `worker`)。
40
+ *
41
+ * agentRef 是绝对路径,UI 显示层(TUI tool block 标题 / list / 完成通知、GUI list item /
42
+ * pending 通知 name)统一经本函数取短名,避免长路径挤占显示宽度。数据层不动——
43
+ * record.agent / env 注入(PI_SUBAGENT_AGENT)/ 持久化 / LLM 通知文本保持完整路径。
44
+ *
45
+ * 非路径值(DEFAULT_AGENT_NAME "general-purpose")与无 .md 后缀的值原样返回。
46
+ * 手动 split(/[\\/]) 而非 path.basename:跨平台统一(macOS 的 path.basename
47
+ * 不切 Windows `\` 分隔符,反之类推),且本模块避免引入平台分支。
48
+ */
49
+ export function displayAgentName(ref: string): string {
50
+ const base = ref.split(/[\\/]/).pop() ?? ref;
51
+ return base.endsWith(AGENT_REF_EXT) ? base.slice(0, -AGENT_REF_EXT.length) : base;
52
+ }