@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
@@ -0,0 +1,157 @@
1
+ /**
2
+ * skill-discovery — resolveSkillPath 结果缓存(IF8/#14)+ 搜索序测试。
3
+ *
4
+ * 现状该文件此前零测试,本文件为首个测试(design IF8 契约):
5
+ * - hit 不再 existsSync(计数 wrapper 观察,spyOn ESM namespace 不可配置的既有教训)
6
+ * - 未命中 undefined 也缓存(不存在的名字二次调用零 stat)
7
+ * - clearSkillPathCache 后重扫
8
+ * - project 优先于 user/npm 的搜索序
9
+ *
10
+ * [merge 注] main 侧同名文件(mock-fs 版)的 5 用例语义均已覆盖:搜索序三用例
11
+ * 同名等价;「npm 候选路径构造含 agentDir」= 本文件各用例断言 join(homeRoot,
12
+ * ".pi/agent/npm/...") 完整路径 + npm 兜底用例的多包枚举;「全 miss undefined 含
13
+ * readdirSync ENOENT 兜底」= 「未命中 undefined 也缓存」用例(homeRoot 无 npm 目录,
14
+ * 真实 fs 下 ENOENT 由实现 catch)。mock 版被本集成版取代。
15
+ *
16
+ * 隔离:node:fs 的 existsSync 用 vi.mock 包一层计数(委托真实实现);
17
+ * getAgentDir 用 vi.mock 覆盖为临时 home(vitest alias 指向 mocks/pi-coding-agent.ts
18
+ * 的硬编码桩 "/home/user/.pi/agent",桩不读 os.homedir——mock node:os 无法影响它);
19
+ * process.cwd 用 spyOn。
20
+ */
21
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
22
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+
26
+ const { existsSyncCalls, osHome } = vi.hoisted(() => ({
27
+ existsSyncCalls: { count: 0 },
28
+ osHome: { dir: "/nonexistent-home-skill-tests" },
29
+ }));
30
+
31
+ vi.mock("node:fs", async (importOriginal) => {
32
+ const actual = await importOriginal<typeof import("node:fs")>();
33
+ const realExistsSync = actual.existsSync;
34
+ // 计数 wrapper(委托真实实现)——vi.spyOn(fs, "existsSync") 在 ESM 模块
35
+ // 命名空间不可配置(record-store-index.test.ts:419 同款教训),改用 mock 工厂。
36
+ const countingExistsSync = ((p: string) => {
37
+ existsSyncCalls.count++;
38
+ return realExistsSync(p);
39
+ }) as typeof actual.existsSync;
40
+ return { ...actual, existsSync: countingExistsSync };
41
+ });
42
+
43
+ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
44
+ const actual = await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
45
+ const { join } = await import("node:path");
46
+ // 桩不读 os.homedir,必须在此把 agentDir 指向 hoisted 临时 home(beforeEach 注入 homeRoot)
47
+ return { ...actual, getAgentDir: () => join(osHome.dir, ".pi", "agent") };
48
+ });
49
+
50
+ import { clearSkillPathCache, resolveSkillPath } from "../skill-discovery.ts";
51
+
52
+ // ── 临时目录工具 ──────────────────────────────────────────────
53
+
54
+ let projRoot: string;
55
+ let homeRoot: string;
56
+
57
+ beforeEach(() => {
58
+ clearSkillPathCache();
59
+ existsSyncCalls.count = 0;
60
+ projRoot = mkdtempSync(join(tmpdir(), "skill-proj-"));
61
+ homeRoot = mkdtempSync(join(tmpdir(), "skill-home-"));
62
+ osHome.dir = homeRoot;
63
+ });
64
+
65
+ afterEach(() => {
66
+ vi.restoreAllMocks();
67
+ rmSync(projRoot, { recursive: true, force: true });
68
+ rmSync(homeRoot, { recursive: true, force: true });
69
+ });
70
+
71
+ /** mkdir -p(目录存在即 resolveSkillPath 命中——检查的是目录而非 SKILL.md)。 */
72
+ function mkdirp(dir: string): void {
73
+ mkdirSync(dir, { recursive: true });
74
+ }
75
+
76
+ describe("resolveSkillPath — 搜索序(project > user > npm)", () => {
77
+ it("project 优先:同名 skill 同时存在于 project/user/npm 时返回 project 路径", () => {
78
+ const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(projRoot);
79
+ const name = "dup-skill";
80
+ mkdirp(join(projRoot, ".agents/skills", name));
81
+ mkdirp(join(homeRoot, ".pi/agent/skills", name));
82
+ mkdirp(join(homeRoot, ".pi/agent/npm/node_modules/some-pkg/skills", name));
83
+
84
+ const resolved = resolveSkillPath(name);
85
+
86
+ expect(resolved).toBe(join(projRoot, ".agents/skills", name));
87
+ expect(cwdSpy).toHaveBeenCalled();
88
+ });
89
+
90
+ it("user 次之:仅 user/npm 存在时返回 user 路径", () => {
91
+ vi.spyOn(process, "cwd").mockReturnValue(projRoot);
92
+ const name = "user-skill";
93
+ mkdirp(join(homeRoot, ".pi/agent/skills", name));
94
+ mkdirp(join(homeRoot, ".pi/agent/npm/node_modules/some-pkg/skills", name));
95
+
96
+ expect(resolveSkillPath(name)).toBe(join(homeRoot, ".pi/agent/skills", name));
97
+ });
98
+
99
+ it("npm 兜底:仅 npm 包内存在时返回包内 skills 路径(readdirSync 枚举多包候选)", () => {
100
+ vi.spyOn(process, "cwd").mockReturnValue(projRoot);
101
+ const name = "npm-skill";
102
+ // 两个包并存:断言实现枚举 npm/node_modules 下每个包(S-6 语义,防只查首个包的回退)
103
+ mkdirp(join(homeRoot, ".pi/agent/npm/node_modules/pkg-a/other", "x"));
104
+ mkdirp(join(homeRoot, ".pi/agent/npm/node_modules/some-pkg/skills", name));
105
+
106
+ expect(resolveSkillPath(name)).toBe(
107
+ join(homeRoot, ".pi/agent/npm/node_modules/some-pkg/skills", name),
108
+ );
109
+ });
110
+ });
111
+
112
+ describe("resolveSkillPath — 结果缓存(IF8/DM3)", () => {
113
+ it("hit 不再 existsSync:二次调用零 stat", () => {
114
+ vi.spyOn(process, "cwd").mockReturnValue(projRoot);
115
+ const name = "cache-hit";
116
+ const expected = join(projRoot, ".agents/skills", name);
117
+ mkdirp(expected);
118
+
119
+ const first = resolveSkillPath(name);
120
+ const callsAfterFirst = existsSyncCalls.count;
121
+ expect(first).toBe(expected);
122
+ expect(callsAfterFirst).toBeGreaterThan(0);
123
+
124
+ const second = resolveSkillPath(name);
125
+ expect(second).toBe(expected);
126
+ expect(existsSyncCalls.count).toBe(callsAfterFirst); // 零增量
127
+ });
128
+
129
+ it("未命中 undefined 也缓存:不存在的名字二次调用零 stat", () => {
130
+ vi.spyOn(process, "cwd").mockReturnValue(projRoot);
131
+ const name = "no-such-skill-xyz";
132
+
133
+ const first = resolveSkillPath(name);
134
+ expect(first).toBeUndefined();
135
+ const callsAfterFirst = existsSyncCalls.count;
136
+ expect(callsAfterFirst).toBeGreaterThan(0);
137
+
138
+ const second = resolveSkillPath(name);
139
+ expect(second).toBeUndefined();
140
+ expect(existsSyncCalls.count).toBe(callsAfterFirst); // 零增量(未命中也缓存)
141
+ });
142
+
143
+ it("clearSkillPathCache 后重扫:计数恢复增长且结果一致", () => {
144
+ vi.spyOn(process, "cwd").mockReturnValue(projRoot);
145
+ const name = "cache-clear";
146
+ const expected = join(projRoot, ".agents/skills", name);
147
+ mkdirp(expected);
148
+
149
+ expect(resolveSkillPath(name)).toBe(expected);
150
+ const callsAfterFirst = existsSyncCalls.count;
151
+ expect(callsAfterFirst).toBeGreaterThan(0);
152
+
153
+ clearSkillPathCache();
154
+ expect(resolveSkillPath(name)).toBe(expected);
155
+ expect(existsSyncCalls.count).toBeGreaterThan(callsAfterFirst); // 重扫发生
156
+ });
157
+ });
@@ -0,0 +1,191 @@
1
+ // src/orchestration/__tests__/test-mocks.ts
2
+ //
3
+ // Pi ExtensionAPI / ExtensionContext 测试 mock 工厂(PR #173 review S8)。
4
+ //
5
+ // 为什么需要工厂:此前测试用 `as never` 注入残缺 mock(如 `{ appendEntry: vi.fn() }`
6
+ // 直接塞进 `JsonlRunStoreOptions.pi`)。`as never` 是 bottom type 断言,mock 缺字段时
7
+ // 编译期零报错,比 `as unknown as T` 更危险。工厂返回**类型完整**的对象——被 mock
8
+ // 接口(真实 SDK 类型,tsc 按根 tsconfig 排除规则不检查 __tests__,但编辑器 LSP 与
9
+ // 显式 tsc 文件级检查会检查)新增成员时,这里补默认值即暴露,用例侧不再有断言后门。
10
+ //
11
+ // 语义约定:
12
+ // - 默认值贴近真实行为:sessionManager.getEntries 返回浅拷贝(真实 SessionManager
13
+ // 契约);appendEntry 捕获变体追加完整 CustomEntry(真实 pi.appendEntry 即向 session
14
+ // 追加 CustomEntry);isIdle → true、hasPendingMessages → false 等按真实语义取值。
15
+ // - 测试真正消费的成员(jsonl-run-store 只读 ctx.sessionManager.getEntries 与
16
+ // pi.appendEntry)行为真实;其余成员为 no-op spy(vi.fn()),调用不炸但返回
17
+ // undefined——本包测试不消费它们。
18
+ // - modelRegistry / ui.theme 是含 private 字段的 nominal class:真实构造需
19
+ // ModelRuntime.create()(async + 读用户级 models.json)/ loadTheme 读包文件,且
20
+ // vitest 把 @earendil-works/pi-coding-agent alias 到 mocks/(无这些值导出),结构
21
+ // 构造又被 private 字段阻断。二者经 fail-loud Proxy 提供:误用时立即抛错(而非
22
+ // 静默 undefined),把「缺 mock」变成显式失败。这是本文件仅有的两处断言收敛点。
23
+
24
+ import { vi } from "vitest";
25
+
26
+ import type {
27
+ CustomEntry,
28
+ ExtensionAPI,
29
+ ExtensionContext,
30
+ ExtensionUIContext,
31
+ ModelRegistry,
32
+ Theme,
33
+ } from "@earendil-works/pi-coding-agent";
34
+
35
+ /** 误用即抛错的占位成员:比静默返回 undefined 的 no-op 更早暴露「缺 mock」。 */
36
+ function failLoud<T>(memberPath: string): T {
37
+ return new Proxy(
38
+ {},
39
+ {
40
+ get(_target, prop) {
41
+ throw new Error(
42
+ `[test-mocks] ${memberPath}.${String(prop)} 未提供 mock 实现——` +
43
+ `本测试不应消费该成员;确需消费时经 overrides 显式提供`,
44
+ );
45
+ },
46
+ },
47
+ ) as unknown as T;
48
+ }
49
+
50
+ /**
51
+ * 构造类型完整的 ExtensionContext。
52
+ *
53
+ * @param entries sessionManager.getEntries 的返回源(浅拷贝返回)。与 mkPi(entries)
54
+ * 共享同一数组时可复现真实 pi 行为:appendEntry 写入 ↔ getEntries 读出。
55
+ * @param overrides 覆写任意 ctx 字段(浅合并,整体替换成员,最后应用)。
56
+ */
57
+ export function mkCtx(
58
+ entries: CustomEntry[] = [],
59
+ overrides: Partial<ExtensionContext> = {},
60
+ ): ExtensionContext {
61
+ const ctx: ExtensionContext = {
62
+ ui: mkUi(),
63
+ // pi 在 xyz-agent 下以 rpc 子进程模式运行;rpc 模式 hasUI 为 true(SDK 契约注释)
64
+ mode: "rpc",
65
+ hasUI: true,
66
+ cwd: "/tmp",
67
+ sessionManager: {
68
+ getCwd: () => "/tmp",
69
+ getSessionDir: () => "/tmp/sessions-test",
70
+ getSessionId: () => "session-test",
71
+ // 真实语义:首条 assistant 消息前 session 文件未落盘,返回 undefined
72
+ getSessionFile: () => undefined,
73
+ getLeafId: () => null,
74
+ getLeafEntry: () => undefined,
75
+ getEntry: () => undefined,
76
+ getLabel: () => undefined,
77
+ getBranch: () => [],
78
+ buildContextEntries: () => [],
79
+ getHeader: () => null,
80
+ // 真实 SessionManager.getEntries 返回浅拷贝
81
+ getEntries: () => [...entries],
82
+ getTree: () => [],
83
+ getSessionName: () => undefined,
84
+ },
85
+ modelRegistry: failLoud<ModelRegistry>("ctx.modelRegistry"),
86
+ // SDK 契约:model / signal 在 agent 未流式输出时合法为 undefined
87
+ model: undefined,
88
+ isIdle: vi.fn(() => true),
89
+ isProjectTrusted: vi.fn(() => true),
90
+ signal: undefined,
91
+ abort: vi.fn(),
92
+ hasPendingMessages: vi.fn(() => false),
93
+ shutdown: vi.fn(),
94
+ getContextUsage: vi.fn(() => undefined),
95
+ compact: vi.fn(),
96
+ getSystemPrompt: vi.fn(() => ""),
97
+ };
98
+ return Object.assign(ctx, overrides);
99
+ }
100
+
101
+ /** ExtensionUIContext 完整 no-op mock(theme 为 fail-loud 占位,见文件头说明)。 */
102
+ function mkUi(): ExtensionUIContext {
103
+ return {
104
+ select: vi.fn(async () => undefined),
105
+ confirm: vi.fn(async () => false),
106
+ input: vi.fn(async () => undefined),
107
+ notify: vi.fn(),
108
+ onTerminalInput: vi.fn(() => () => {}),
109
+ setStatus: vi.fn(),
110
+ setWorkingMessage: vi.fn(),
111
+ setWorkingVisible: vi.fn(),
112
+ setWorkingIndicator: vi.fn(),
113
+ setHiddenThinkingLabel: vi.fn(),
114
+ setWidget: vi.fn(),
115
+ setFooter: vi.fn(),
116
+ setHeader: vi.fn(),
117
+ setTitle: vi.fn(),
118
+ custom: vi.fn(),
119
+ pasteToEditor: vi.fn(),
120
+ setEditorText: vi.fn(),
121
+ getEditorText: vi.fn(() => ""),
122
+ editor: vi.fn(async () => undefined),
123
+ addAutocompleteProvider: vi.fn(),
124
+ setEditorComponent: vi.fn(),
125
+ getEditorComponent: vi.fn(() => undefined),
126
+ theme: failLoud<Theme>("ctx.ui.theme"),
127
+ getAllThemes: vi.fn(() => []),
128
+ getTheme: vi.fn(() => undefined),
129
+ setTheme: vi.fn(() => ({ success: true })),
130
+ getToolsExpanded: vi.fn(() => false),
131
+ setToolsExpanded: vi.fn(),
132
+ };
133
+ }
134
+
135
+ /**
136
+ * 构造类型完整的 ExtensionAPI。
137
+ *
138
+ * @param entries 给定时,appendEntry 追加完整 CustomEntry 到该数组(真实
139
+ * pi.appendEntry 语义:向 session 追加 custom entry);缺省为纯 spy(可断言
140
+ * 调用次数、无副作用)。
141
+ * @param overrides 覆写任意 pi 字段(浅合并,最后应用)。
142
+ */
143
+ export function mkPi(
144
+ entries?: CustomEntry[],
145
+ overrides: Partial<ExtensionAPI> = {},
146
+ ): ExtensionAPI {
147
+ const appendEntry =
148
+ entries === undefined
149
+ ? vi.fn()
150
+ : vi.fn((customType: string, data?: unknown) => {
151
+ entries.push({
152
+ type: "custom",
153
+ customType,
154
+ data,
155
+ id: `entry-${entries.length}`,
156
+ parentId: null,
157
+ timestamp: new Date().toISOString(),
158
+ });
159
+ });
160
+ const pi: ExtensionAPI = {
161
+ on: vi.fn(),
162
+ registerTool: vi.fn(),
163
+ registerCommand: vi.fn(),
164
+ registerShortcut: vi.fn(),
165
+ registerFlag: vi.fn(),
166
+ getFlag: vi.fn(() => undefined),
167
+ registerMessageRenderer: vi.fn(),
168
+ registerEntryRenderer: vi.fn(),
169
+ sendMessage: vi.fn(),
170
+ sendUserMessage: vi.fn(),
171
+ appendEntry,
172
+ setSessionName: vi.fn(),
173
+ getSessionName: vi.fn(() => undefined),
174
+ setLabel: vi.fn(),
175
+ exec: vi.fn(),
176
+ getActiveTools: vi.fn(() => []),
177
+ getAllTools: vi.fn(() => []),
178
+ setActiveTools: vi.fn(),
179
+ getCommands: vi.fn(() => []),
180
+ setModel: vi.fn(async () => true),
181
+ getThinkingLevel: vi.fn(),
182
+ setThinkingLevel: vi.fn(),
183
+ registerProvider: vi.fn(),
184
+ unregisterProvider: vi.fn(),
185
+ events: {
186
+ emit: vi.fn(),
187
+ on: vi.fn(() => () => {}),
188
+ },
189
+ };
190
+ return Object.assign(pi, overrides);
191
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * buildWorkerScript 模板 hoisting(IF5/IF6)— byte-identical 快照锚定。
3
+ *
4
+ * ES8:快照先行——fixture 由「改造前实现」生成落盘(本文件同 commit),
5
+ * 断言重构后输出与 fixture 逐字节一致,锁定 PRE/POST 边界换行与 userScript
6
+ * 缩进语义(AC-4「逐字保留」不变式)。IF6(_KNOWN_FIELDS 提升至生成源
7
+ * module scope)落地时基线已按设计在同一 commit 内更新为最终形态。
8
+ *
9
+ * 样例脚本覆盖 $ARGS / schema / parallel / pipeline / workflow / phase / log 特性。
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join } from "node:path";
14
+
15
+ import { describe, expect, it } from "vitest";
16
+
17
+ import { buildWorkerScript } from "../worker-script-builder.ts";
18
+
19
+ const sampleScript = [
20
+ "// sample exercising template features",
21
+ 'const name = $ARGS.name ?? "default";',
22
+ 'phase("build");',
23
+ 'const spec = await agent("build it", { label: "builder", schema: { type: "object" } });',
24
+ "const results = await parallel([",
25
+ ' agent({ task: "unit tests", agent: "./tester.md" }),',
26
+ ' agent({ task: "lint", agent: "./linter.md", skill: "lint-skill" }),',
27
+ "]);",
28
+ "await pipeline([(x) => x, (x) => x]);",
29
+ 'await workflow("deploy", { env: "prod" });',
30
+ 'log("done " + name + " " + $WORKSPACE + " " + $BUDGET.remaining());',
31
+ "module.exports = { execute: async (ctx) => ctx.agent(\"finalize\") };",
32
+ ].join("\n");
33
+
34
+ function loadFixture(): string {
35
+ const here = dirname(fileURLToPath(import.meta.url));
36
+ return readFileSync(join(here, "__fixtures__", "worker-template.snapshot.txt"), "utf8");
37
+ }
38
+
39
+ describe("buildWorkerScript — 模板 hoisting byte-identical 快照(IF5)", () => {
40
+ it("特性全量样例的输出与 fixture 逐字节一致", () => {
41
+ const out = buildWorkerScript(sampleScript);
42
+ expect(out).toBe(loadFixture());
43
+ });
44
+
45
+ it("首尾边界锚定:use strict 开头 + error handler 结尾 + userScript 缩进注入", () => {
46
+ const out = buildWorkerScript(sampleScript);
47
+ // PRE 边界:第一行 use strict(无前导换行)
48
+ expect(out.startsWith('"use strict";\n')).toBe(true);
49
+ // userScript 段:两空格缩进注入(AC-4 拼接语义)
50
+ expect(out).toContain("\n // sample exercising template features\n");
51
+ // userScript 段结束后一个空行再接 auto-invoke 段(POST 首部换行语义)
52
+ expect(out).toContain("ctx.agent(\"finalize\") };\n\n // ── Auto-invoke execute() for module.exports pattern ──");
53
+ // POST 边界:以 error handler 的 "});" 结尾(无尾随换行)
54
+ expect(out.endsWith('_safePost({ type: "error", runId, error: err.message || String(err), workerLogs: _workerLogs }, "error");\n});')).toBe(true);
55
+ });
56
+
57
+ it("空 userScript 与任意脚本的拼接语义稳定(PRE/POST 不含 userScript 状态)", () => {
58
+ const a = buildWorkerScript("x");
59
+ const b = buildWorkerScript("y");
60
+ // 除 userScript 段外完全一致:以 userScript 行为界切割比对
61
+ const [preA] = a.split("\n x\n");
62
+ const [preB] = b.split("\n y\n");
63
+ expect(preA).toBe(preB);
64
+ });
65
+ });
66
+
67
+ describe("buildWorkerScript — _KNOWN_FIELDS module scope 提升(IF6)", () => {
68
+ const script = buildWorkerScript("// noop user script");
69
+
70
+ it("生成源含 module 级 _KNOWN_FIELDS 声明(与 _workerLogs 同级,IIFE 外)", () => {
71
+ // 声明在 IIFE 开始之前(module scope):const _KNOWN_FIELDS 必须出现在 "(async () => {" 前
72
+ const iifePos = script.indexOf("(async () => {");
73
+ const knownPos = script.indexOf("const _KNOWN_FIELDS = new Set([");
74
+ expect(knownPos).toBeGreaterThan(-1);
75
+ expect(knownPos).toBeLessThan(iifePos);
76
+ // 紧随 _workerLogs 声明之后(design IF6:与 _workerLogs/:62 同级)
77
+ expect(script.indexOf("const _workerLogs = [];")).toBeLessThan(knownPos);
78
+ });
79
+
80
+ it("agent() 体内不再重建 Set(引用 _KNOWN_FIELDS,无 new Set)", () => {
81
+ // agent() 函数体(到 parallel 声明前)内不得有 new Set 构造
82
+ const agentPos = script.indexOf("async function agent(firstArg, secondArg)");
83
+ const parallelPos = script.indexOf("async function parallel(calls)");
84
+ expect(agentPos).toBeGreaterThan(-1);
85
+ expect(parallelPos).toBeGreaterThan(agentPos);
86
+ const agentBody = script.slice(agentPos, parallelPos);
87
+ expect(agentBody).not.toContain("new Set(");
88
+ expect(agentBody).toContain("_KNOWN_FIELDS.has(k)");
89
+ });
90
+
91
+ it("字段集合内容逐字段一致(16 known fields 不丢失)", () => {
92
+ expect(script).toContain(
93
+ 'const _KNOWN_FIELDS = new Set(["prompt", "description", "schema", "model", "scene", "label", "task", "agent", "phase", "skill", "timeoutMs", "cwd", "fork", "worktree", "returnMeta", "thinkingLevel"]);',
94
+ );
95
+ // unknown-fields 警告文案不变(known 列表仍全量)
96
+ expect(script).toMatch(/Known fields:.*returnMeta.*thinkingLevel/);
97
+ });
98
+ });
@@ -21,8 +21,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
21
21
  vi.mock("../lifecycle.ts", () => ({
22
22
  runWorkflow: vi.fn(),
23
23
  abortRun: vi.fn(async () => {}),
24
- pauseRun: vi.fn(async () => {}),
25
- resumeRun: vi.fn(async () => {}),
26
24
  scheduleTimeBudget: vi.fn(() => undefined),
27
25
  }));
28
26
 
@@ -0,0 +1,110 @@
1
+ /**
2
+ * WorkflowScript.validate lint memo(IF9/#15,TC9/DM2)— 引用相等键缓存测试。
3
+ *
4
+ * vi.mock script-lint 模块统计 lintScript 调用次数(design IF9 测试面):
5
+ * - 同 path + sourceCode 相等(含等值异字面量)→ 二次 validate 只 1 次 lint 调用
6
+ * - 【设计修正】design 原文「异引用等值 miss」在 JS 不可构造:字符串 === 是值相等
7
+ * (实测等值异字面量命中)。lintScript 是纯函数,值相等 ⟹ 结果相同,值键 memo
8
+ * 语义严格正确且是引用键意图的超集(registry 等值重建实例同样命中)。
9
+ * - 同 path 新内容(值不等)→ 重 lint 并更新条目
10
+ * - clearLintMemo → 重 lint(config-loader.invalidateCache 挂载点)
11
+ */
12
+ import { describe, it, expect, vi, beforeEach } from "vitest";
13
+
14
+ const lintCalls = vi.hoisted(() => ({ count: 0 }));
15
+
16
+ vi.mock("../script-lint.ts", async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import("../script-lint.ts")>();
18
+ const realLintScript = actual.lintScript;
19
+ const countingLintScript = ((source: string) => {
20
+ lintCalls.count++;
21
+ return realLintScript(source);
22
+ }) as typeof actual.lintScript;
23
+ return { ...actual, lintScript: countingLintScript };
24
+ });
25
+
26
+ import { WorkflowScript, clearLintMemo, type WorkflowMeta } from "../models/workflow-script.ts";
27
+
28
+ const meta: WorkflowMeta = {
29
+ kind: "workflow",
30
+ name: "w",
31
+ description: "",
32
+ phases: [],
33
+ };
34
+
35
+ function makeScript(path: string, sourceCode: string): WorkflowScript {
36
+ return new WorkflowScript({
37
+ name: "w",
38
+ source: "saved",
39
+ path,
40
+ sourceCode,
41
+ meta,
42
+ available: true,
43
+ });
44
+ }
45
+
46
+ const VALID = '/* @pi-meta name: w */\nagent({ prompt: "x" });\n';
47
+
48
+ beforeEach(() => {
49
+ clearLintMemo();
50
+ lintCalls.count = 0;
51
+ });
52
+
53
+ describe("WorkflowScript.validate — lintMemo(IF9)", () => {
54
+ it("同 sourceCode 引用:二次 validate 只 1 次 lint 调用", () => {
55
+ const src = VALID;
56
+ const a = makeScript("/ws/.pi/workflows/w.js", src);
57
+ const b = makeScript("/ws/.pi/workflows/w.js", src); // registry 重建实例场景
58
+
59
+ const ra = a.validate();
60
+ const rb = b.validate();
61
+
62
+ expect(lintCalls.count).toBe(1);
63
+ expect(rb).toEqual(ra); // 缓存返回同一 lint 结果
64
+ });
65
+
66
+ it("等值异字面量同样命中(JS === 值相等;lintScript 纯函数 ⟹ 值键 memo 正确)", () => {
67
+ const a = makeScript("/ws/.pi/workflows/w.js", VALID);
68
+ const b = makeScript("/ws/.pi/workflows/w.js", `/* @pi-meta name: w */\nagent({ prompt: "x" });\n`);
69
+
70
+ a.validate();
71
+ b.validate();
72
+
73
+ // 设计原文预期「异引用 miss → 2 次」,但字符串 === 是值相等,等值必命中;
74
+ // lintScript(source) 纯函数,同值同结果,缓存正确性不受影响(实测锚定)。
75
+ expect(lintCalls.count).toBe(1);
76
+ });
77
+
78
+ it("同 path 新内容(值不等):重 lint 并更新条目", () => {
79
+ const path = "/ws/.pi/workflows/w.js";
80
+ const old = makeScript(path, VALID);
81
+ old.validate();
82
+ expect(lintCalls.count).toBe(1);
83
+
84
+ const edited = makeScript(path, '/* @pi-meta name: w */\nagent({ prompt: "y", outputSchema: {} });\n');
85
+ edited.validate();
86
+ expect(lintCalls.count).toBe(2); // 新引用 → miss → 重 lint
87
+
88
+ // 新条目已覆写:再以 edited 同引用 validate 命中缓存
89
+ const replay = makeScript(path, edited.sourceCode);
90
+ replay.validate();
91
+ expect(lintCalls.count).toBe(2);
92
+ });
93
+
94
+ it("clearLintMemo 后重 lint(invalidateCache 挂载点)", () => {
95
+ const a = makeScript("/ws/.pi/workflows/w.js", VALID);
96
+ a.validate();
97
+ expect(lintCalls.count).toBe(1);
98
+
99
+ clearLintMemo();
100
+ a.validate();
101
+ expect(lintCalls.count).toBe(2);
102
+ });
103
+
104
+ it("不同 path 互不共享条目", () => {
105
+ const src = VALID;
106
+ makeScript("/ws/.pi/workflows/a.js", src).validate();
107
+ makeScript("/ws/.pi/workflows/b.js", src).validate();
108
+ expect(lintCalls.count).toBe(2);
109
+ });
110
+ });
@@ -279,7 +279,7 @@ interface ScriptOutcome {
279
279
  /**
280
280
  * 断言 runAndWait 返回成功完成的终态。
281
281
  *
282
- * - result.status === "done"(runAndWait 恒 done)
282
+ * - result.status === "closed"(runAndWait 恒 done)
283
283
  * - result.reason === "completed"(脚本正常 return,非 failed/aborted/time_limited)
284
284
  * - result.scriptResult.status 是 "ok" 或 "partial"(脚本层 outcome,非 "error")
285
285
  * - result.error 为 undefined
@@ -21,6 +21,7 @@
21
21
 
22
22
  import type { AgentCallOpts } from "./models/types.ts";
23
23
  import { resolveSkillPath } from "./skill-discovery.ts";
24
+ import { stringifySchemaCached } from "../shared/schema-jsonify.ts";
24
25
 
25
26
  export interface ResolveResult {
26
27
  opts: AgentCallOpts;
@@ -59,7 +60,9 @@ export function resolveAgentOpts(opts: AgentCallOpts): ResolveResult {
59
60
  // which got concatenated into the final append file as path garbage — the SO instruction
60
61
  // never reached the subprocess. Now the instruction content is pushed directly.
61
62
  if (opts.schema) {
62
- const schemaJson = JSON.stringify(opts.schema);
63
+ // IF7(#13):同 schema 对象引用的 compact stringify 走 WeakMap 缓存
64
+ // (与 session-runner formatSchemaInstruction 的 pretty 版共享缓存条目)
65
+ const schemaJson = stringifySchemaCached(opts.schema, "compact");
63
66
  const content = [
64
67
  "## MANDATORY: Structured Output Requirement",
65
68
  "",
@@ -62,8 +62,8 @@ function formatMessage(name: string, errors: readonly unknown[]): string {
62
62
  * 校验 spec.parameters(JSON Schema draft-07)对 spec.args 的约束。
63
63
  *
64
64
  * - spec.parameters === undefined → 跳过(安全退化:漏拷 parameters 退化是「不校验」非「校验错」)
65
- * - coerceTypes 原地规范化 spec.args(worker 启动 + pause/resume 重建共用同一对象,
66
- * run.spec === spec,保证恢复路径参数一致)
65
+ * - coerceTypes 原地规范化 spec.args(worker 启动 + 崩溃重建重跑共用同一对象,
66
+ * run.spec === spec,保证重跑路径参数一致)
67
67
  * - 失败 throw ArgsValidationError(非原始 ajv 错误)
68
68
  *
69
69
  * @throws ArgsValidationError 参数不合法或 schema 无效
@@ -16,6 +16,7 @@ import { resolve } from "node:path";
16
16
 
17
17
  // WorkflowMeta 规范来源是 shared/resource-meta.ts(m1 DM1);WorkflowSource 来自 workflow-script
18
18
  import type { WorkflowSource } from "./models/workflow-script.ts";
19
+ import { clearLintMemo } from "./models/workflow-script.ts";
19
20
  import type { WorkflowMeta } from "../shared/resource-meta.ts";
20
21
  import { getCachedFile, getCachedFileContent, clearFileCache } from "../shared/resource-discovery.ts";
21
22
  import { parseResourceMeta } from "../shared/meta-parser.ts";
@@ -93,6 +94,20 @@ function toWorkflowSource(source: ResourceSource): WorkflowSource {
93
94
  return source === "project-pi-tmp" ? "tmp" : "saved";
94
95
  }
95
96
 
97
+ /**
98
+ * 按路径推导 ResourceSource(IF4/外部 #5 残留)。
99
+ *
100
+ * getWorkflowByPath 可加载任意绝对路径(不限扫描源),此前硬编码 "user-pi"
101
+ * 使 workspaceRoot/.pi/workflows/.tmp/ 下的临时脚本按路径加载被错标 saved
102
+ * (discoverWorkflows 对同文件正确标 tmp,两路径不一致)。修正:路径落在
103
+ * tmp 目录前缀内 → "project-pi-tmp",其余 → "user-pi"(非 tmp 路径输出不变,DS4)。
104
+ */
105
+ function deriveResourceSource(filePath: string, workspaceRoot: string): ResourceSource {
106
+ const tmpDir = resolve(workspaceRoot, ".pi/workflows/.tmp");
107
+ const normalized = resolve(filePath);
108
+ return normalized === tmpDir || normalized.startsWith(`${tmpDir}/`) ? "project-pi-tmp" : "user-pi";
109
+ }
110
+
96
111
  // ── 单文件 → CachedWorkflowMeta(IF1 parseResourceMeta + 整对象透传)──
97
112
 
98
113
  /**
@@ -264,11 +279,23 @@ export async function getWorkflow(name: string): Promise<CachedWorkflowMeta | un
264
279
  * - ~/ 前缀展开;相对路径/非 .js 引用返回 undefined(引用唯一形态 = 绝对路径)
265
280
  * - 任意路径(不限扫描源):内置包内脚本、用户任意位置脚本均可执行
266
281
  * - meta 提取失败/文件不可读 → available=false(fail-safe,不抛)
282
+ * - [perf] 与 getWorkflow(name) 对称走 bucket 缓存(key=绝对路径,与 stem 名不冲突),
283
+ * 消除 workflow tool 主路径每次 run 的全文 regex + YAML.parse(mtime 判变失效)
267
284
  */
268
285
  export async function getWorkflowByPath(ref: string): Promise<CachedWorkflowMeta | undefined> {
269
286
  const filePath = normalizeRef(ref, WORKFLOW_REF_EXT);
270
287
  if (filePath === null) return undefined;
271
- return toCachedMeta(filePath, "user-pi");
288
+ const workspaceRoot = findWorkspaceRoot();
289
+ const bucket = getCacheBucket(workspaceRoot);
290
+ const cached = bucket.get(filePath);
291
+ if (cached && isCacheValid(cached)) {
292
+ return cached.meta;
293
+ }
294
+ // IF4:source 按路径推导——.tmp 前缀 → project-pi-tmp(→ "tmp"),其余 user-pi 维持现值
295
+ const meta = await toCachedMeta(filePath, deriveResourceSource(filePath, workspaceRoot));
296
+ const file = getCachedFile(filePath);
297
+ if (file) bucket.set(filePath, { meta, mtimeMs: file.mtimeMs });
298
+ return meta;
272
299
  }
273
300
 
274
301
  /**
@@ -277,6 +304,8 @@ export async function getWorkflowByPath(ref: string): Promise<CachedWorkflowMeta
277
304
  */
278
305
  export function invalidateCache(): void {
279
306
  // m5:清统一 mtime 缓存层 + bucket(测试隔离 + mtime 漏判场景手动刷新兜底)
307
+ // IF9:连带清 workflow-script validate 的 lint memo(同源文件缓存,测试隔离统一走它)
280
308
  clearFileCache();
281
309
  cache.clear();
310
+ clearLintMemo();
282
311
  }