@zhushanwen/pi-subagent-workflow 0.1.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 (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,356 @@
1
+ // src/__tests__/model-resolver.test.ts
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
+
4
+ import {
5
+ availableThinkingLevels,
6
+ type ModelInfo,
7
+ type ModelRegistryLike,
8
+ resolveModel,
9
+ } from "../model-resolver.ts";
10
+
11
+ // ============================================================
12
+ // helpers
13
+ // ============================================================
14
+
15
+ function makeModel(over: Partial<ModelInfo> = {}): ModelInfo {
16
+ return {
17
+ id: over.id ?? "sonnet-4-5",
18
+ name: over.name ?? "Claude Sonnet 4.5",
19
+ provider: over.provider ?? "anthropic",
20
+ reasoning: over.reasoning ?? false,
21
+ thinkingLevelMap: over.thinkingLevelMap,
22
+ contextWindow: over.contextWindow,
23
+ };
24
+ }
25
+
26
+ /** 构造 mock registry:registered 表 (provider/modelId) → ModelInfo;authed 集合控制鉴权。 */
27
+ function makeRegistry(models: ModelInfo[], authed: string[] = models.map((m) => `${m.provider}/${m.id}`)): ModelRegistryLike {
28
+ const authSet = new Set(authed);
29
+ return {
30
+ getAvailable: () => models,
31
+ find: (provider, modelId) => models.find((m) => m.provider === provider && m.id === modelId),
32
+ hasConfiguredAuth: (m) => {
33
+ if (!m || typeof m !== "object") return false;
34
+ const mm = m as ModelInfo;
35
+ return authSet.has(`${mm.provider}/${mm.id}`);
36
+ },
37
+ };
38
+ }
39
+
40
+ /** 主 agent model(第三层兼底)。 */
41
+ const ctxModel = makeModel({ id: "main-model", provider: "main" });
42
+
43
+ // ============================================================
44
+ // resolveModel — 三层优先级
45
+ // ============================================================
46
+
47
+ describe("resolveModel — three-layer priority", () => {
48
+ it("L1: paramOverride.model wins over agentConfig and ctxModel", () => {
49
+ const m1 = makeModel({ id: "explicit", provider: "p1" });
50
+ const reg = makeRegistry([m1]);
51
+ const r = resolveModel(
52
+ { name: "worker", systemPrompt: "", model: "main/agent-md" },
53
+ reg,
54
+ { model: "p1/explicit" },
55
+ ctxModel,
56
+ );
57
+ expect(r.model.id).toBe("explicit");
58
+ });
59
+
60
+ it("L2: agentConfig.model used when no paramOverride", () => {
61
+ const m = makeModel({ id: "agent-md-model", provider: "ap" });
62
+ const reg = makeRegistry([m]);
63
+ const r = resolveModel(
64
+ { name: "worker", systemPrompt: "", model: "ap/agent-md-model" },
65
+ reg,
66
+ undefined,
67
+ ctxModel,
68
+ );
69
+ expect(r.model.id).toBe("agent-md-model");
70
+ });
71
+
72
+ it("L3: ctxModel (main agent model) used when no override and no agentConfig.model", () => {
73
+ const reg = makeRegistry([]);
74
+ const r = resolveModel(
75
+ { name: "worker", systemPrompt: "" },
76
+ reg,
77
+ undefined,
78
+ ctxModel,
79
+ );
80
+ expect(r.model).toBe(ctxModel);
81
+ expect(r.thinkingLevel).toBeUndefined();
82
+ });
83
+
84
+ it("L3: ctxModel used even when registry is empty (no lookup needed)", () => {
85
+ const reg = makeRegistry([]);
86
+ const r = resolveModel(undefined, reg, undefined, ctxModel);
87
+ expect(r.model).toBe(ctxModel);
88
+ });
89
+ });
90
+
91
+ // ============================================================
92
+ // 显式指定失败的错误行为
93
+ // ============================================================
94
+
95
+ describe("resolveModel — explicit override failures throw (no silent fallback)", () => {
96
+ it("paramOverride.model not in registry → throws (does NOT fall back to ctxModel)", () => {
97
+ const reg = makeRegistry([]);
98
+ expect(() =>
99
+ resolveModel(undefined, reg, { model: "x/nonexistent" }, ctxModel),
100
+ ).toThrow(/not found in registry/);
101
+ });
102
+
103
+ it("paramOverride.model found but auth missing → throws auth-specific message", () => {
104
+ const m = makeModel({ id: "unauthed", provider: "u" });
105
+ const reg = makeRegistry([m], []); // 无鉴权
106
+ expect(() =>
107
+ resolveModel(undefined, reg, { model: "u/unauthed" }, ctxModel),
108
+ ).toThrow(/exists but auth is not configured/);
109
+ });
110
+
111
+ it("agentConfig.model not in registry → throws", () => {
112
+ const reg = makeRegistry([]);
113
+ expect(() =>
114
+ resolveModel(
115
+ { name: "worker", systemPrompt: "", model: "x/missing" },
116
+ reg,
117
+ undefined,
118
+ ctxModel,
119
+ ),
120
+ ).toThrow(/not found in registry/);
121
+ });
122
+
123
+ it("no override, no agentConfig.model, no ctxModel → throws listing available", () => {
124
+ const m = makeModel({ id: "visible", provider: "v" });
125
+ const reg = makeRegistry([m]);
126
+ expect(() => resolveModel(undefined, reg, undefined, undefined)).toThrow(
127
+ /No available model.*Available models/s,
128
+ );
129
+ });
130
+
131
+ it("invalid model string (no slash) → throws", () => {
132
+ const reg = makeRegistry([]);
133
+ expect(() =>
134
+ resolveModel(undefined, reg, { model: "no-slash" }, ctxModel),
135
+ ).toThrow(/not found in registry/);
136
+ });
137
+ });
138
+
139
+ // ============================================================
140
+ // thinkingLevel 解析
141
+ // ============================================================
142
+
143
+ describe("resolveModel — thinkingLevel resolution", () => {
144
+ it("override path: returns requested thinkingLevel when model supports it", () => {
145
+ const m = makeModel({
146
+ id: "reasoning-model",
147
+ provider: "rp",
148
+ reasoning: true,
149
+ thinkingLevelMap: { low: 1, high: 2, xhigh: 3 },
150
+ });
151
+ const reg = makeRegistry([m]);
152
+ const r = resolveModel(undefined, reg, { model: "rp/reasoning-model", thinkingLevel: "high" });
153
+ expect(r.thinkingLevel).toBe("high");
154
+ });
155
+
156
+ it("override path: clamps down to highest available when requested unsupported", () => {
157
+ const m = makeModel({
158
+ id: "limited-model",
159
+ provider: "lp",
160
+ reasoning: true,
161
+ thinkingLevelMap: { low: 1, medium: 2 }, // 不含 xhigh
162
+ });
163
+ const reg = makeRegistry([m]);
164
+ const r = resolveModel(undefined, reg, { model: "lp/limited-model", thinkingLevel: "xhigh" });
165
+ expect(r.thinkingLevel).toBe("medium");
166
+ });
167
+
168
+ it("override path: returns undefined when model.reasoning === false", () => {
169
+ const m = makeModel({ id: "non-reasoning", provider: "nr", reasoning: false });
170
+ const reg = makeRegistry([m]);
171
+ const r = resolveModel(undefined, reg, { model: "nr/non-reasoning", thinkingLevel: "high" });
172
+ expect(r.thinkingLevel).toBeUndefined();
173
+ });
174
+
175
+ it("ctxModel path: thinkingLevel from paramOverride.thinkingLevel (pass-through, no clamp)", () => {
176
+ const reg = makeRegistry([]);
177
+ const r = resolveModel(undefined, reg, { thinkingLevel: "high" }, ctxModel);
178
+ // ctxModel reasoning=false,但 ctxModel 路径不 clamp(主 agent model 直接透传)
179
+ expect(r.thinkingLevel).toBe("high");
180
+ });
181
+
182
+ it("ctxModel path: thinkingLevel from agentConfig.thinkingLevel when no paramOverride", () => {
183
+ const reg = makeRegistry([]);
184
+ const r = resolveModel(
185
+ { name: "worker", systemPrompt: "", thinkingLevel: "medium" },
186
+ reg,
187
+ undefined,
188
+ ctxModel,
189
+ );
190
+ expect(r.thinkingLevel).toBe("medium");
191
+ });
192
+
193
+ it("ctxModel path: thinkingLevel undefined when no override anywhere", () => {
194
+ const reg = makeRegistry([]);
195
+ const r = resolveModel(undefined, reg, undefined, ctxModel);
196
+ expect(r.thinkingLevel).toBeUndefined();
197
+ });
198
+ });
199
+
200
+ // ============================================================
201
+ // lookupModel 容错:剥离 ":thinkingLevel" 后缀(A)
202
+ // ============================================================
203
+
204
+ describe('resolveModel — strips ":thinkingLevel" suffix from model string (A)', () => {
205
+ it('resolves model passed with ":xhigh" suffix', () => {
206
+ const m = makeModel({ id: "ds-pro", provider: "deepseek-router", reasoning: true, thinkingLevelMap: { xhigh: 3 } });
207
+ const reg = makeRegistry([m]);
208
+ const r = resolveModel(undefined, reg, { model: "deepseek-router/ds-pro:xhigh" }, ctxModel);
209
+ expect(r.model.id).toBe("ds-pro");
210
+ });
211
+
212
+ it('resolves model passed with ":high" suffix (registry has no suffix)', () => {
213
+ const m = makeModel({ id: "sonnet", provider: "anthropic", reasoning: true, thinkingLevelMap: { high: 2 } });
214
+ const reg = makeRegistry([m]);
215
+ const r = resolveModel(undefined, reg, { model: "anthropic/sonnet:high" });
216
+ expect(r.model.id).toBe("sonnet");
217
+ });
218
+
219
+ it('strips ":off" suffix (off is a valid thinking level)', () => {
220
+ const m = makeModel({ id: "m1", provider: "p", reasoning: false });
221
+ const reg = makeRegistry([m]);
222
+ const r = resolveModel(undefined, reg, { model: "p/m1:off" });
223
+ expect(r.model.id).toBe("m1");
224
+ });
225
+
226
+ it('does NOT strip unrelated colon suffix (e.g. ":foo")', () => {
227
+ // ":foo" 不是合法 thinking level,不剥离 → 查不到 → 抛 not found
228
+ const m = makeModel({ id: "m1", provider: "p", reasoning: false });
229
+ const reg = makeRegistry([m]);
230
+ expect(() => resolveModel(undefined, reg, { model: "p/m1:foo" })).toThrow(/not found in registry/);
231
+ });
232
+
233
+ it('suffix-stripped resolve still respects explicit thinkingLevel param', () => {
234
+ const m = makeModel({ id: "ds-pro", provider: "deepseek-router", reasoning: true, thinkingLevelMap: { high: 2, xhigh: 3 } });
235
+ const reg = makeRegistry([m]);
236
+ // model 带 ":xhigh" 但 thinkingLevel param 指定 high -> thinking 取 high
237
+ const r = resolveModel(undefined, reg, { model: "deepseek-router/ds-pro:xhigh", thinkingLevel: "high" });
238
+ expect(r.model.id).toBe("ds-pro");
239
+ expect(r.thinkingLevel).toBe("high");
240
+ });
241
+ });
242
+
243
+ // ============================================================
244
+ // not-found 错误信息:列出相近可用 model(B)
245
+ // ============================================================
246
+
247
+ describe("resolveModel — not-found error suggests similar models (B)", () => {
248
+ it("not-found with available registry lists similar models", () => {
249
+ const m = makeModel({ id: "ds-pro", provider: "deepseek-router" });
250
+ const reg = makeRegistry([m]);
251
+ let msg = "";
252
+ try {
253
+ resolveModel(undefined, reg, { model: "deepseek-router/ds-por" }, ctxModel); // 拼写错误
254
+ } catch (e) {
255
+ msg = (e as Error).message;
256
+ }
257
+ expect(msg).toMatch(/not found in registry/);
258
+ expect(msg).toMatch(/deepseek-router\/ds-pro/); // 建议列表含正确拼写
259
+ });
260
+
261
+ it("not-found with empty registry reports no available models", () => {
262
+ const reg = makeRegistry([]);
263
+ expect(() => resolveModel(undefined, reg, { model: "x/none" }, ctxModel)).toThrow(
264
+ /Registry has no available models/,
265
+ );
266
+ });
267
+
268
+ it("auth-missing error does NOT list models, points to models.json", () => {
269
+ const m = makeModel({ id: "unauthed", provider: "u" });
270
+ const reg = makeRegistry([m], []);
271
+ let msg = "";
272
+ try {
273
+ resolveModel(undefined, reg, { model: "u/unauthed" }, ctxModel);
274
+ } catch (e) {
275
+ msg = (e as Error).message;
276
+ }
277
+ expect(msg).toMatch(/auth is not configured/);
278
+ expect(msg).toMatch(/models\.json/);
279
+ expect(msg).not.toMatch(/Similar available models/); // auth 错误不列 model
280
+ });
281
+ });
282
+
283
+ describe("availableThinkingLevels", () => {
284
+ it("returns [] when reasoning false", () => {
285
+ expect(availableThinkingLevels({ reasoning: false })).toEqual([]);
286
+ });
287
+ it("returns [] when no thinkingLevelMap", () => {
288
+ expect(availableThinkingLevels({ reasoning: true })).toEqual([]);
289
+ });
290
+ it("filters THINKING_ORDER to non-null map entries, ascending", () => {
291
+ expect(
292
+ availableThinkingLevels({ reasoning: true, thinkingLevelMap: { off: 0, high: 2, low: 1, xhigh: 3 } }),
293
+ ).toEqual(["off", "low", "high", "xhigh"]);
294
+ });
295
+ });
296
+
297
+ // ============================================================
298
+ // ModelConfigService: initModel(ctxModel) → resolveModel L3 plumb-through
299
+ // changeset 主打路径的 runtime 契约保护(EA-4)。
300
+ // 链路:session_start initModel({ctxModel}) → _ctxModel 缓存 → resolveModel 第三层命中
301
+ // ============================================================
302
+
303
+ import * as fs from "node:fs";
304
+ import * as os from "node:os";
305
+ import * as path from "node:path";
306
+
307
+ import { ModelConfigService } from "../model-config-service.ts";
308
+
309
+ describe("ModelConfigService: ctx.model plumb-through (EA-4)", () => {
310
+ let tmpDir: string;
311
+
312
+ beforeEach(() => {
313
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subagents-resolver-test-"));
314
+ });
315
+ afterEach(() => {
316
+ fs.rmSync(tmpDir, { recursive: true, force: true });
317
+ });
318
+
319
+ /** 构造已 initModel 的 ModelConfigService,ctxModel 注入缓存。 */
320
+ function makeService(ctxModel: ModelInfo | undefined, registry: ModelRegistryLike = makeRegistry([])): ModelConfigService {
321
+ const svc = new ModelConfigService({ agentDir: tmpDir });
322
+ svc.initModel({
323
+ modelRegistry: registry,
324
+ sessionId: "test-session",
325
+ ctxModel,
326
+ });
327
+ return svc;
328
+ }
329
+
330
+ it("initModel caches ctxModel; resolveModel L3 returns it (no override, no agentConfig.model)", () => {
331
+ const main = makeModel({ id: "main-model", provider: "main" });
332
+ const svc = makeService(main);
333
+
334
+ // 无 override、无 agentConfig.model → 第三层命中缓存的 ctxModel
335
+ const r = svc.resolveModel("general-purpose", undefined);
336
+ expect(r.model).toBe(main);
337
+ });
338
+
339
+ it("resolveModel L3 hits cached ctxModel even with agent that has no model in frontmatter", () => {
340
+ const main = makeModel({ id: "inherited", provider: "parent" });
341
+ const svc = makeService(main);
342
+
343
+ // worker.md 无 model frontmatter → 跳过 L2,命中 L3 ctxModel
344
+ const r = svc.resolveModel("worker", undefined);
345
+ expect(r.model).toBe(main);
346
+ });
347
+
348
+ it("resolveModel still prefers explicit paramOverride over cached ctxModel (L1 > L3)", () => {
349
+ const main = makeModel({ id: "main", provider: "main" });
350
+ const explicit = makeModel({ id: "explicit", provider: "p1" });
351
+ const svc = makeService(main, makeRegistry([explicit]));
352
+
353
+ const r = svc.resolveModel("worker", { model: "p1/explicit" });
354
+ expect(r.model.id).toBe("explicit");
355
+ });
356
+ });
@@ -0,0 +1,61 @@
1
+ // src/__tests__/output-collector.test.ts
2
+ //
3
+ // 锁定 extractParsedOutput 纯函数契约——它被 collectResult 直接消费,决定
4
+ // AgentResult.parsedOutput 字段。toUsageTotal / collectResponseText 已删除
5
+ // (usage 收口进 getTotalUsage,text 收口进 getFullText,均在 execution-record.test 测)。
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { extractParsedOutput } from "../output-collector.ts";
9
+ import type { ToolCall } from "../types.ts";
10
+
11
+ // ============================================================
12
+ // extractParsedOutput
13
+ // ============================================================
14
+
15
+ describe("extractParsedOutput", () => {
16
+ it("returns undefined for empty toolCalls", () => {
17
+ expect(extractParsedOutput([])).toBeUndefined();
18
+ });
19
+
20
+ it("returns undefined when no structured-output call exists", () => {
21
+ const calls: ToolCall[] = [
22
+ { toolName: "bash", result: { details: "x" } },
23
+ { toolName: "read", result: { details: "y" } },
24
+ ];
25
+ expect(extractParsedOutput(calls)).toBeUndefined();
26
+ });
27
+
28
+ it("returns undefined when structured-output has no result.details", () => {
29
+ const calls: ToolCall[] = [
30
+ { toolName: "structured-output", result: { content: [] } },
31
+ { toolName: "structured-output", result: {} },
32
+ { toolName: "structured-output" },
33
+ ];
34
+ expect(extractParsedOutput(calls)).toBeUndefined();
35
+ });
36
+
37
+ it("returns details when exactly one structured-output call has details", () => {
38
+ const calls: ToolCall[] = [
39
+ { toolName: "bash" },
40
+ { toolName: "structured-output", result: { details: { answer: 42 } } },
41
+ ];
42
+ expect(extractParsedOutput(calls)).toEqual({ answer: 42 });
43
+ });
44
+
45
+ it("returns the LAST structured-output details (reverse iteration)", () => {
46
+ const calls: ToolCall[] = [
47
+ { toolName: "structured-output", result: { details: "first" } },
48
+ { toolName: "bash" },
49
+ { toolName: "structured-output", result: { details: "second" } },
50
+ ];
51
+ expect(extractParsedOutput(calls)).toBe("second");
52
+ });
53
+
54
+ it("ignores isError structured-output calls without details, picks one with details", () => {
55
+ const calls: ToolCall[] = [
56
+ { toolName: "structured-output", isError: true, result: { content: [{ type: "text", text: "bad" }] } },
57
+ { toolName: "structured-output", result: { details: { ok: true } } },
58
+ ];
59
+ expect(extractParsedOutput(calls)).toEqual({ ok: true });
60
+ });
61
+ });
@@ -0,0 +1,75 @@
1
+ // src/__tests__/path-encoding.test.ts
2
+ //
3
+ // 锁定 encodeCwd 契约:session-runner 与 session-file-gc 共用此编码,
4
+ // 漂移会导致同一 cwd 落到两个不同目录(见 path-encoding.ts 顶部注释)。
5
+ import * as path from "node:path";
6
+
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import { encodeCwd, getSubagentSessionDir } from "../path-encoding.ts";
10
+
11
+ describe("encodeCwd", () => {
12
+ it("encodes a normal unix absolute path", () => {
13
+ expect(encodeCwd("/Users/x/proj")).toBe("--Users-x-proj--");
14
+ });
15
+
16
+ it("strips a single leading backslash", () => {
17
+ expect(encodeCwd("\\foo")).toBe("--foo--");
18
+ });
19
+
20
+ it("encodes Windows drive letter (colon + backslash)", () => {
21
+ // C:\proj → 去掉无前导分隔符 → C:\proj → : 和 \ 都替换为 - → C--proj
22
+ expect(encodeCwd("C:\\proj")).toBe("--C--proj--");
23
+ });
24
+
25
+ it("encodes empty string to bare delimiter pair", () => {
26
+ expect(encodeCwd("")).toBe("----");
27
+ });
28
+
29
+ it("encodes relative path with no leading separator", () => {
30
+ expect(encodeCwd("relative/path")).toBe("--relative-path--");
31
+ });
32
+
33
+ it("collapses consecutive separators (forward slash)", () => {
34
+ expect(encodeCwd("/a//b")).toBe("--a--b--");
35
+ });
36
+
37
+ it("collapses consecutive separators (backslash)", () => {
38
+ expect(encodeCwd("a\\b\\c")).toBe("--a-b-c--");
39
+ });
40
+
41
+ it("encodes mixed separators and colon", () => {
42
+ // /x:y\\z → 去前导 / → x:y\z → : \ → - - → x-y-z
43
+ expect(encodeCwd("/x:y\\z")).toBe("--x-y-z--");
44
+ });
45
+ });
46
+
47
+ describe("getSubagentSessionDir", () => {
48
+ it("returns agentDir/subagents/<encodedCwd>/sessions", () => {
49
+ // [MF#1] 既有布局:subagents/<enc>/sessions/(不改,避免升级用户既有数据 orphan)
50
+ const result = getSubagentSessionDir("/home/user/.pi/agent", "/home/user/project");
51
+ expect(result).toBe(
52
+ path.join("/home/user/.pi/agent", "subagents", "--home-user-project--", "sessions")
53
+ );
54
+ });
55
+
56
+ it("uses mainCwd encoding (not effectiveCwd)", () => {
57
+ // D-004: 用主 cwd 编码,保证同一主 cwd 下所有 subagent 存同一目录
58
+ const mainCwd = "/Users/zhushanwen/Code/my-project";
59
+ const result = getSubagentSessionDir("~/.pi/agent", mainCwd);
60
+ const encoded = encodeCwd(mainCwd);
61
+ expect(result).toBe(path.join("~/.pi/agent", "subagents", encoded, "sessions"));
62
+ });
63
+
64
+ it("produces consistent path for same inputs", () => {
65
+ const a = getSubagentSessionDir("/agent", "/cwd");
66
+ const b = getSubagentSessionDir("/agent", "/cwd");
67
+ expect(a).toBe(b);
68
+ });
69
+
70
+ it("produces different path for different mainCwd", () => {
71
+ const a = getSubagentSessionDir("/agent", "/cwd1");
72
+ const b = getSubagentSessionDir("/agent", "/cwd2");
73
+ expect(a).not.toBe(b);
74
+ });
75
+ });
@@ -0,0 +1,73 @@
1
+ // src/__tests__/pi-invocation.test.ts
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ import { afterEach,describe, expect, it } from "vitest";
7
+
8
+ import { getPiInvocation } from "../pi-invocation.ts";
9
+
10
+ describe("getPiInvocation", () => {
11
+ const originalArgv = process.argv;
12
+ const originalExecPath = process.execPath;
13
+ let tmpScript: string;
14
+
15
+ afterEach(() => {
16
+ Object.defineProperty(process, "argv", { value: originalArgv, configurable: true });
17
+ Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
18
+ if (tmpScript && fs.existsSync(tmpScript)) fs.unlinkSync(tmpScript);
19
+ });
20
+
21
+ it("真实脚本路径存在 → node <script> <userArgs>", () => {
22
+ // 创建真实临时脚本文件(避免 ESM spy 限制)
23
+ tmpScript = path.join(os.tmpdir(), `pi-inv-test-${Date.now()}.mjs`);
24
+ fs.writeFileSync(tmpScript, "// test");
25
+ Object.defineProperty(process, "argv", { value: ["node", tmpScript], configurable: true });
26
+ Object.defineProperty(process, "execPath", { value: "/usr/bin/node", configurable: true });
27
+
28
+ const result = getPiInvocation(["--mode", "json", "Task: x"]);
29
+ expect(result.command).toBe("/usr/bin/node");
30
+ expect(result.args).toEqual([tmpScript, "--mode", "json", "Task: x"]);
31
+ });
32
+
33
+ it("bun 虚拟脚本(/$bunfs/root/)→ 退化到 pi-in-PATH", () => {
34
+ tmpScript = "";
35
+ const virtualScript = "/$bunfs/root/pi";
36
+ Object.defineProperty(process, "argv", { value: ["bun", virtualScript], configurable: true });
37
+ Object.defineProperty(process, "execPath", { value: "/usr/bin/bun", configurable: true });
38
+
39
+ const result = getPiInvocation(["--mode", "json"]);
40
+ expect(result.command).toBe("pi");
41
+ expect(result.args).toEqual(["--mode", "json"]);
42
+ });
43
+
44
+ it("非通用 runtime(pi standalone binary)→ 直接 execPath", () => {
45
+ tmpScript = "";
46
+ Object.defineProperty(process, "argv", { value: ["/usr/bin/pi", "/nonexistent"], configurable: true });
47
+ Object.defineProperty(process, "execPath", { value: "/usr/local/bin/pi-binary", configurable: true });
48
+
49
+ const result = getPiInvocation(["--mode", "json"]);
50
+ expect(result.command).toBe("/usr/local/bin/pi-binary");
51
+ expect(result.args).toEqual(["--mode", "json"]);
52
+ });
53
+
54
+ it("node 通用 runtime + 脚本不存在 → pi-in-PATH", () => {
55
+ tmpScript = "";
56
+ Object.defineProperty(process, "argv", { value: ["node", "/nonexistent"], configurable: true });
57
+ Object.defineProperty(process, "execPath", { value: "/usr/bin/node", configurable: true });
58
+
59
+ const result = getPiInvocation(["--mode", "json"]);
60
+ expect(result.command).toBe("pi");
61
+ expect(result.args).toEqual(["--mode", "json"]);
62
+ });
63
+
64
+ it("空 userArgs 合法(仅 command + 空 args)", () => {
65
+ tmpScript = "";
66
+ Object.defineProperty(process, "argv", { value: ["node"], configurable: true });
67
+ Object.defineProperty(process, "execPath", { value: "/usr/bin/node", configurable: true });
68
+
69
+ const result = getPiInvocation([]);
70
+ expect(result.command).toBe("pi");
71
+ expect(result.args).toEqual([]);
72
+ });
73
+ });