@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,227 @@
1
+ // src/__tests__/session-start-reaper.test.ts
2
+ //
3
+ // 验证 session_start 的两个新行为:
4
+ // 1. WTM.scan reaper 被调用(best-effort)
5
+ // 2. mainSessionFile 被缓存
6
+ // 3. scan 抛错不阻断启动
7
+
8
+ import { beforeEach,describe, expect, it, vi } from "vitest";
9
+
10
+ // ── mock modules(在 import 前声明)──
11
+
12
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
13
+ getAgentDir: () => "/home/user/.pi/agent",
14
+ }));
15
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
16
+ getAgentDir: () => "/home/user/.pi/agent",
17
+ }));
18
+ vi.mock("@mariozechner/pi-ai", () => ({
19
+ StringEnum: (values: string[]) => ({ type: "string", enum: values }),
20
+ }));
21
+ vi.mock("@earendil-works/pi-ai", () => ({
22
+ StringEnum: (values: string[]) => ({ type: "string", enum: values }),
23
+ }));
24
+ vi.mock("@sinclair/typebox", () => ({
25
+ Type: {
26
+ Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
27
+ Optional: (schema: unknown) => ({ ...schema as object, optional: true }),
28
+ String: () => ({ type: "string" }),
29
+ Boolean: () => ({ type: "boolean" }),
30
+ Number: () => ({ type: "number" }),
31
+ Array: (items: unknown) => ({ type: "array", items }),
32
+ Record: (key: unknown, value: unknown) => ({ type: "object", additionalProperties: value, key }),
33
+ Unknown: () => ({ type: "unknown" }),
34
+ Union: (members: unknown[]) => ({ type: "union", members }),
35
+ Literal: (value: unknown) => ({ type: "literal", value }),
36
+ },
37
+ }));
38
+
39
+ // hoisted mock 实例
40
+ const { mockScan, mockCleanup } = vi.hoisted(() => ({
41
+ mockScan: vi.fn(),
42
+ mockCleanup: vi.fn(),
43
+ }));
44
+
45
+ vi.mock("../worktree-manager.ts", () => ({
46
+ WorktreeManager: class {
47
+ constructor(_agentDir: string) { /* mock */ }
48
+ scan = mockScan;
49
+ cleanup = mockCleanup;
50
+ create = vi.fn();
51
+ collectPatch = vi.fn();
52
+ registerPid = vi.fn();
53
+ },
54
+ }));
55
+
56
+ vi.mock("../session-file-gc.ts", () => ({
57
+ maybeCleanupExpiredSessionFiles: vi.fn(),
58
+ }));
59
+
60
+ // mock subagent-service:避免真正构造 SubagentService(它依赖 ModelConfigService 等)
61
+ const { mockInitModel, mockInitSession, mockSetModelConfigService, mockSetSubagentService, capturedConstructorArg } =
62
+ vi.hoisted(() => ({
63
+ mockInitModel: vi.fn(),
64
+ mockInitSession: vi.fn(),
65
+ mockSetModelConfigService: vi.fn(),
66
+ mockSetSubagentService: vi.fn(),
67
+ capturedConstructorArg: { current: undefined as unknown },
68
+ }));
69
+
70
+ vi.mock("../model-config-service.ts", () => ({
71
+ ModelConfigService: class {
72
+ initModel = mockInitModel;
73
+ // F-4/D-003: index.ts 复用 modelService.getAgentRegistry(),stub 返回最小结构
74
+ getAgentRegistry = () => ({ get: () => undefined, list: () => [] });
75
+ },
76
+ getModelConfigService: () => null,
77
+ setModelConfigService: mockSetModelConfigService,
78
+ }));
79
+
80
+ vi.mock("../subagent-service.ts", () => ({
81
+ SubagentService: class {
82
+ initSession = mockInitSession;
83
+ constructor(init: unknown) {
84
+ capturedConstructorArg.current = init;
85
+ }
86
+ },
87
+ getSubagentService: () => null,
88
+ setSubagentService: mockSetSubagentService,
89
+ }));
90
+
91
+ // mock commands/tools(避免触发真实注册)
92
+ vi.mock("../commands/subagents.ts", () => ({
93
+ registerSubagentsCommand: vi.fn(),
94
+ }));
95
+ vi.mock("../tools/subagent-tool.ts", () => ({
96
+ registerSubagentTool: vi.fn(),
97
+ }));
98
+ vi.mock("../tui/bg-notify-render.ts", () => ({
99
+ renderBgNotifyMessage: vi.fn(),
100
+ }));
101
+
102
+ // ── import 被测工厂 ──
103
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
104
+
105
+ import subagentsExtension from "../../index.ts";
106
+
107
+ // ── helpers ──
108
+
109
+ /** 创建最小 mock ExtensionAPI,捕获 session_start handler。 */
110
+ function createMockPi(overrides: Record<string, unknown> = {}): {
111
+ pi: ExtensionAPI;
112
+ getSessionStartHandler: () => ((event: unknown, ctx: unknown) => void) | undefined;
113
+ } {
114
+ let sessionStartHandler: ((event: unknown, ctx: unknown) => void) | undefined;
115
+ const noop = (): void => { /* mock */ };
116
+ const pi = new Proxy<ExtensionAPI>(overrides as ExtensionAPI, {
117
+ get(target, prop: string | symbol): unknown {
118
+ if (prop === "on") {
119
+ return (event: string, handler: (...args: unknown[]) => unknown) => {
120
+ if (event === "session_start") {
121
+ sessionStartHandler = handler as (event: unknown, ctx: unknown) => void;
122
+ }
123
+ };
124
+ }
125
+ if (prop in target) return target[prop as keyof ExtensionAPI];
126
+ return noop;
127
+ },
128
+ });
129
+ return {
130
+ pi,
131
+ getSessionStartHandler: () => sessionStartHandler,
132
+ };
133
+ }
134
+
135
+ /** 最小 ExtensionContext mock。 */
136
+ function createMockCtx(overrides: Record<string, unknown> = {}): Record<string, unknown> {
137
+ return {
138
+ cwd: "/home/user/project",
139
+ modelRegistry: { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => false },
140
+ model: undefined,
141
+ sessionManager: {
142
+ getSessionId: () => "session-123",
143
+ getSessionFile: () => "/home/user/.pi/agent/sessions/session-123.jsonl",
144
+ getSessionDir: () => "/home/user/.pi/agent/sessions",
145
+ getCwd: () => "/home/user/project",
146
+ getEntries: () => [],
147
+ getBranch: () => [],
148
+ getLeafId: () => null,
149
+ getLeafEntry: () => undefined,
150
+ getEntry: () => undefined,
151
+ getHeader: () => null,
152
+ getTree: () => [],
153
+ getSessionName: () => undefined,
154
+ },
155
+ ...overrides,
156
+ };
157
+ }
158
+
159
+ // ── tests ──
160
+
161
+ describe("session_start worktree reaper", () => {
162
+ beforeEach(() => {
163
+ vi.clearAllMocks();
164
+ });
165
+
166
+ it("session_start 触发 WTM.scan 调用", () => {
167
+ const { pi, getSessionStartHandler } = createMockPi();
168
+ subagentsExtension(pi);
169
+
170
+ const handler = getSessionStartHandler();
171
+ expect(handler).toBeDefined();
172
+
173
+ handler!(
174
+ { type: "session_start", reason: "startup" },
175
+ createMockCtx(),
176
+ );
177
+
178
+ expect(mockScan).toHaveBeenCalledTimes(1);
179
+ // scan 无参(全局注册表,不依赖 cwd)
180
+ expect(mockScan).toHaveBeenCalledWith();
181
+ });
182
+
183
+ it("scan 抛错不阻断 session_start", () => {
184
+ mockScan.mockImplementation(() => {
185
+ throw new Error("git not found");
186
+ });
187
+
188
+ const { pi, getSessionStartHandler } = createMockPi();
189
+ subagentsExtension(pi);
190
+
191
+ const handler = getSessionStartHandler();
192
+ expect(handler).toBeDefined();
193
+
194
+ // 不应抛错
195
+ expect(() => {
196
+ handler!(
197
+ { type: "session_start", reason: "startup" },
198
+ createMockCtx(),
199
+ );
200
+ }).not.toThrow();
201
+
202
+ // service 仍然被注册(启动未被阻断)
203
+ expect(mockSetSubagentService).toHaveBeenCalled();
204
+ });
205
+
206
+ it("mainSessionFile 被缓存并传给 SubagentService", () => {
207
+ const { pi, getSessionStartHandler } = createMockPi();
208
+ subagentsExtension(pi);
209
+
210
+ const handler = getSessionStartHandler();
211
+ handler!(
212
+ { type: "session_start", reason: "startup" },
213
+ createMockCtx(),
214
+ );
215
+
216
+ // SubagentService 构造参数含 getMainSessionFile getter
217
+ const init = capturedConstructorArg.current as {
218
+ getMainSessionFile?: () => string | undefined;
219
+ } | undefined;
220
+ expect(init).toBeDefined();
221
+ expect(init?.getMainSessionFile).toBeDefined();
222
+ // 返回 session_start 时缓存的 sessionFile
223
+ expect(init?.getMainSessionFile?.()).toBe(
224
+ "/home/user/.pi/agent/sessions/session-123.jsonl",
225
+ );
226
+ });
227
+ });
@@ -0,0 +1,244 @@
1
+ // src/__tests__/spawn-args.test.ts
2
+ import { execFileSync } from "node:child_process";
3
+ import * as fs from "node:fs";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+
9
+ import { MAX_FORK_DEPTH } from "../session-context-resolver.ts";
10
+ import { buildEnvBlock, buildSpawnArgs } from "../session-runner.ts";
11
+
12
+ describe("buildSpawnArgs", () => {
13
+ const baseParams = {
14
+ model: undefined as string | undefined,
15
+ thinkingLevel: undefined as string | undefined,
16
+ agentTools: undefined as string[] | undefined,
17
+ appendSystemPromptPath: undefined as string | undefined,
18
+ sessionDir: "/sessions/dir",
19
+ forkSource: undefined as string | undefined,
20
+ skillPaths: undefined as string[] | undefined,
21
+ };
22
+
23
+ it("基础参数:--mode json -p --session-dir + task", () => {
24
+ const args = buildSpawnArgs(baseParams, "Task: do something");
25
+ expect(args).toEqual([
26
+ "--mode", "json", "-p", "--session-dir", "/sessions/dir",
27
+ "Task: do something",
28
+ ]);
29
+ });
30
+
31
+ it("有 model → 追加 --model provider/id", () => {
32
+ const args = buildSpawnArgs(
33
+ { ...baseParams, model: "openai/gpt-4o" },
34
+ "Task: x",
35
+ );
36
+ expect(args).toContain("--model");
37
+ const idx = args.indexOf("--model");
38
+ expect(args[idx + 1]).toBe("openai/gpt-4o");
39
+ });
40
+
41
+ it("model + thinkingLevel → model 后缀 :level", () => {
42
+ const args = buildSpawnArgs(
43
+ { ...baseParams, model: "anthropic/claude", thinkingLevel: "high" },
44
+ "Task: x",
45
+ );
46
+ const idx = args.indexOf("--model");
47
+ expect(args[idx + 1]).toBe("anthropic/claude:high");
48
+ });
49
+
50
+ it("thinkingLevel 无 model → 不追加(thinking 依赖 model 后缀)", () => {
51
+ const args = buildSpawnArgs(
52
+ { ...baseParams, model: undefined, thinkingLevel: "high" },
53
+ "Task: x",
54
+ );
55
+ expect(args).not.toContain("--model");
56
+ });
57
+
58
+ it("agentTools → --tools 逗号分隔", () => {
59
+ const args = buildSpawnArgs(
60
+ { ...baseParams, agentTools: ["read", "bash", "edit"] },
61
+ "Task: x",
62
+ );
63
+ const idx = args.indexOf("--tools");
64
+ expect(args[idx + 1]).toBe("read,bash,edit");
65
+ });
66
+
67
+ it("appendSystemPromptPath → --append-system-prompt <path>", () => {
68
+ const args = buildSpawnArgs(
69
+ { ...baseParams, appendSystemPromptPath: "/tmp/prompt.md" },
70
+ "Task: x",
71
+ );
72
+ const idx = args.indexOf("--append-system-prompt");
73
+ expect(args[idx + 1]).toBe("/tmp/prompt.md");
74
+ });
75
+
76
+ it("forkSource → --fork <path>", () => {
77
+ const args = buildSpawnArgs(
78
+ { ...baseParams, forkSource: "/sessions/parent.jsonl" },
79
+ "Task: x",
80
+ );
81
+ const idx = args.indexOf("--fork");
82
+ expect(args[idx + 1]).toBe("/sessions/parent.jsonl");
83
+ });
84
+
85
+ it("skillPaths 多个 → 每个 push --skill <path>", () => {
86
+ const args = buildSpawnArgs(
87
+ { ...baseParams, skillPaths: ["/skills/a", "/skills/b", "/skills/c"] },
88
+ "Task: x",
89
+ );
90
+ // 三个 --skill token,后跟各自路径,顺序保留
91
+ const skillIdxs = args
92
+ .map((a, i) => (a === "--skill" ? i : -1))
93
+ .filter((i) => i >= 0);
94
+ expect(skillIdxs).toHaveLength(3);
95
+ expect(args[skillIdxs[0] + 1]).toBe("/skills/a");
96
+ expect(args[skillIdxs[1] + 1]).toBe("/skills/b");
97
+ expect(args[skillIdxs[2] + 1]).toBe("/skills/c");
98
+ });
99
+
100
+ it("skillPaths 空数组 → 不含 --skill", () => {
101
+ const args = buildSpawnArgs(
102
+ { ...baseParams, skillPaths: [] },
103
+ "Task: x",
104
+ );
105
+ expect(args).not.toContain("--skill");
106
+ });
107
+
108
+ it("skillPaths undefined → 不含 --skill", () => {
109
+ const args = buildSpawnArgs(baseParams, "Task: x");
110
+ expect(args).not.toContain("--skill");
111
+ });
112
+
113
+ it("全参数组合顺序正确,task 始终最后", () => {
114
+ const args = buildSpawnArgs(
115
+ {
116
+ model: "openai/gpt-4o",
117
+ thinkingLevel: "low",
118
+ agentTools: ["read"],
119
+ appendSystemPromptPath: "/tmp/p.md",
120
+ sessionDir: "/s",
121
+ forkSource: "/parent.jsonl",
122
+ skillPaths: ["/skills/x"],
123
+ },
124
+ "final task",
125
+ );
126
+ expect(args[args.length - 1]).toBe("final task");
127
+ expect(args).toContain("--fork");
128
+ expect(args).toContain("--tools");
129
+ expect(args).toContain("--skill");
130
+ });
131
+
132
+ it("空 tools 数组不追加 --tools", () => {
133
+ const args = buildSpawnArgs(
134
+ { ...baseParams, agentTools: [] },
135
+ "Task: x",
136
+ );
137
+ expect(args).not.toContain("--tools");
138
+ });
139
+ });
140
+
141
+ // ============================================================
142
+ // buildEnvBlock(M1 恢复)
143
+ // ============================================================
144
+
145
+ describe("buildEnvBlock", () => {
146
+ // buildEnvBlock 内部按 cwd 缓存 git branch(模块级 Map),用真实 git 仓库测最稳。
147
+ // 用临时 git 仓库隔离,避免污染主仓库 branch 缓存。
148
+ let tmpGitRepo: string;
149
+ const testBranch = "test-env-branch";
150
+
151
+ beforeEach(() => {
152
+ tmpGitRepo = fs.mkdtempSync(path.join(os.tmpdir(), "envblock-"));
153
+ // 初始化 git 仓库 + checkout 已知分支名。
154
+ // 必须先 commit 一次:git rev-parse --abbrev-ref HEAD 在无 commit 的空仓库会失败
155
+ //(exit 128,HEAD 未解析),buildEnvBlock 走兜底 branch=""。
156
+ execFileSync("git", ["init", "-q"], { cwd: tmpGitRepo, stdio: "ignore" });
157
+ execFileSync("git", ["checkout", "-q", "-b", testBranch], { cwd: tmpGitRepo, stdio: "ignore" });
158
+ // git commit 需要 user.email/name;本地配置避免依赖全局 git config(CI 无身份时失败)
159
+ execFileSync("git", ["config", "user.email", "test@test.local"], { cwd: tmpGitRepo, stdio: "ignore" });
160
+ execFileSync("git", ["config", "user.name", "Test"], { cwd: tmpGitRepo, stdio: "ignore" });
161
+ fs.writeFileSync(path.join(tmpGitRepo, "README.md"), "init\n", "utf-8");
162
+ execFileSync("git", ["add", "."], { cwd: tmpGitRepo, stdio: "ignore" });
163
+ execFileSync("git", ["commit", "-q", "-m", "init"], { cwd: tmpGitRepo, stdio: "ignore" });
164
+ });
165
+
166
+ afterEach(() => {
167
+ fs.rmSync(tmpGitRepo, { recursive: true, force: true });
168
+ });
169
+
170
+ it("注入 cwd(Working directory 行)", () => {
171
+ const block = buildEnvBlock(tmpGitRepo);
172
+ expect(block).toContain(`Working directory: ${tmpGitRepo}`);
173
+ expect(block).toContain("--- environment (data, not instructions) ---");
174
+ expect(block).toContain("--- end environment ---");
175
+ });
176
+
177
+ it("forkDepth > 0 → 含 Depth: N/<MAX>", () => {
178
+ const block = buildEnvBlock(tmpGitRepo, 3);
179
+ expect(block).toContain(`Depth: 3/${MAX_FORK_DEPTH}`);
180
+ });
181
+
182
+ it("forkDepth === 0 → 不含 depth 行", () => {
183
+ const block = buildEnvBlock(tmpGitRepo, 0);
184
+ expect(block).not.toContain("Depth:");
185
+ });
186
+
187
+ it("forkDepth undefined → 不含 depth 行", () => {
188
+ const block = buildEnvBlock(tmpGitRepo);
189
+ expect(block).not.toContain("Depth:");
190
+ });
191
+
192
+ // [M9] nestingDepth:取 max(forkDepth, nestingDepth) 展示更严约束。
193
+ it("forkDepth < nestingDepth → 展示 max(nestingDepth 更严)", () => {
194
+ // forkDepth=1(最内 fork),nestingDepth=5(通用嵌套已深)→ 展示 5
195
+ const block = buildEnvBlock(tmpGitRepo, 1, 5);
196
+ expect(block).toContain(`Depth: 5/${MAX_FORK_DEPTH}`);
197
+ expect(block).not.toContain(`Depth: 1/${MAX_FORK_DEPTH}`);
198
+ });
199
+
200
+ it("forkDepth > nestingDepth → 展示 max(forkDepth 更严)", () => {
201
+ const block = buildEnvBlock(tmpGitRepo, 7, 2);
202
+ expect(block).toContain(`Depth: 7/${MAX_FORK_DEPTH}`);
203
+ });
204
+
205
+ it("forkDepth=0 + nestingDepth>0 → 展示 nestingDepth(非 fork 嵌套也计入)", () => {
206
+ // 非 fork 但有嵌套(如顶层 → 子 → 孙),nestingDepth=2 应展示
207
+ const block = buildEnvBlock(tmpGitRepo, undefined, 2);
208
+ expect(block).toContain(`Depth: 2/${MAX_FORK_DEPTH}`);
209
+ });
210
+
211
+ it("git branch 存在 → 含 Git branch 行", () => {
212
+ const block = buildEnvBlock(tmpGitRepo);
213
+ expect(block).toContain(`Git branch: ${testBranch}`);
214
+ });
215
+
216
+ it("非 git 目录 → 不含 Git branch 行(git 失败兜底空串)", () => {
217
+ const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), "envblock-nogit-"));
218
+ try {
219
+ const block = buildEnvBlock(nonGitDir);
220
+ expect(block).not.toContain("Git branch:");
221
+ // 但仍含 working directory(环境块始终输出)
222
+ expect(block).toContain(`Working directory: ${nonGitDir}`);
223
+ } finally {
224
+ fs.rmSync(nonGitDir, { recursive: true, force: true });
225
+ }
226
+ });
227
+
228
+ it("git 失败(execFileSync throw)→ 不崩,静默省略 branch", () => {
229
+ const mockExec = vi.spyOn(
230
+ { execFileSync },
231
+ "execFileSync",
232
+ );
233
+ mockExec.mockImplementation(() => {
234
+ throw new Error("git not found");
235
+ });
236
+ try {
237
+ const block = buildEnvBlock("/some/cwd");
238
+ expect(block).not.toContain("Git branch:");
239
+ expect(block).toContain("Working directory: /some/cwd");
240
+ } finally {
241
+ mockExec.mockRestore();
242
+ }
243
+ });
244
+ });
@@ -0,0 +1,167 @@
1
+ // src/__tests__/spawn-event-adapter.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 { describe, expect, it } from "vitest";
7
+
8
+ import {
9
+ deriveSessionFilePath,
10
+ findSessionFileByHeaderId,
11
+ parseSpawnLine,
12
+ } from "../spawn-event-adapter.ts";
13
+
14
+ describe("parseSpawnLine", () => {
15
+ describe("空白行", () => {
16
+ it("空字符串返回 null", () => {
17
+ expect(parseSpawnLine("")).toBeNull();
18
+ });
19
+
20
+ it("纯空白返回 null", () => {
21
+ expect(parseSpawnLine(" ")).toBeNull();
22
+ expect(parseSpawnLine("\t\t")).toBeNull();
23
+ });
24
+ });
25
+
26
+ describe("header 行", () => {
27
+ it("type=session + id 识别为 header", () => {
28
+ const line = JSON.stringify({
29
+ type: "session",
30
+ id: "abc-123",
31
+ timestamp: "2026-07-03T12:00:00.000Z",
32
+ cwd: "/home/user/project",
33
+ });
34
+ const result = parseSpawnLine(line);
35
+ expect(result?.kind).toBe("header");
36
+ if (result?.kind === "header") {
37
+ expect(result.header.id).toBe("abc-123");
38
+ expect(result.header.cwd).toBe("/home/user/project");
39
+ }
40
+ });
41
+
42
+ it("header 含 parentSession + version", () => {
43
+ const line = JSON.stringify({
44
+ type: "session",
45
+ id: "child-456",
46
+ timestamp: "2026-07-03T12:00:00.000Z",
47
+ cwd: "/home/user/project",
48
+ parentSession: "parent-789",
49
+ version: 2,
50
+ });
51
+ const result = parseSpawnLine(line);
52
+ expect(result?.kind).toBe("header");
53
+ if (result?.kind === "header") {
54
+ expect(result.header.parentSession).toBe("parent-789");
55
+ expect(result.header.version).toBe(2);
56
+ }
57
+ });
58
+ });
59
+
60
+ describe("事件行", () => {
61
+ it("tool_execution_start 识别为 event", () => {
62
+ const line = JSON.stringify({
63
+ type: "tool_execution_start",
64
+ toolName: "bash",
65
+ toolCallId: "call-1",
66
+ args: { command: "ls" },
67
+ });
68
+ const result = parseSpawnLine(line);
69
+ expect(result?.kind).toBe("event");
70
+ if (result?.kind === "event") {
71
+ expect(result.event.type).toBe("tool_execution_start");
72
+ expect(result.event.toolName).toBe("bash");
73
+ }
74
+ });
75
+
76
+ it("message_end 识别为 event", () => {
77
+ const line = JSON.stringify({
78
+ type: "message_end",
79
+ message: {
80
+ role: "assistant",
81
+ usage: { input: 100, output: 50 },
82
+ },
83
+ });
84
+ const result = parseSpawnLine(line);
85
+ expect(result?.kind).toBe("event");
86
+ if (result?.kind === "event") {
87
+ expect(result.event.type).toBe("message_end");
88
+ }
89
+ });
90
+
91
+ it("turn_end 识别为 event", () => {
92
+ const result = parseSpawnLine(JSON.stringify({ type: "turn_end" }));
93
+ expect(result?.kind).toBe("event");
94
+ });
95
+
96
+ it("未知 type 仍识别为 event(schema 校验由调用方)", () => {
97
+ const result = parseSpawnLine(JSON.stringify({ type: "some_future_event" }));
98
+ expect(result?.kind).toBe("event");
99
+ });
100
+ });
101
+
102
+ describe("invalid 行", () => {
103
+ it("非 JSON 返回 invalid", () => {
104
+ const result = parseSpawnLine("not json at all");
105
+ expect(result?.kind).toBe("invalid");
106
+ if (result?.kind === "invalid") {
107
+ expect(result.raw).toBe("not json at all");
108
+ expect(result.error).toBeTruthy();
109
+ }
110
+ });
111
+
112
+ it("JSON 但无 type 字段返回 invalid", () => {
113
+ const result = parseSpawnLine(JSON.stringify({ foo: "bar" }));
114
+ expect(result?.kind).toBe("invalid");
115
+ if (result?.kind === "invalid") {
116
+ expect(result.error).toContain("type");
117
+ }
118
+ });
119
+
120
+ it("JSON 但 type 非 string 返回 invalid", () => {
121
+ const result = parseSpawnLine(JSON.stringify({ type: 123 }));
122
+ expect(result?.kind).toBe("invalid");
123
+ });
124
+
125
+ it("JSON null 返回 invalid", () => {
126
+ const result = parseSpawnLine("null");
127
+ expect(result?.kind).toBe("invalid");
128
+ });
129
+ });
130
+ });
131
+
132
+ describe("deriveSessionFilePath", () => {
133
+ it("拼接 sessionDir + fileTimestamp(冒号点转连字符) + id", () => {
134
+ const header = {
135
+ type: "session" as const,
136
+ id: "abc-123",
137
+ timestamp: "2026-07-03T12:00:00.000Z",
138
+ cwd: "/proj",
139
+ };
140
+ const path = deriveSessionFilePath(header, "/sessions/dir");
141
+ // fileTimestamp = timestamp.replace(/[:.]/g, "-") = "2026-07-03T12-00-00-000Z"
142
+ expect(path).toBe("/sessions/dir/2026-07-03T12-00-00-000Z_abc-123.jsonl");
143
+ });
144
+ });
145
+
146
+ describe("findSessionFileByHeaderId", () => {
147
+ it("sessionId 后缀匹配返回实际文件路径", () => {
148
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "spawn-adapter-test-"));
149
+ const expectedFile = path.join(tmpDir, "2026-07-03T12-00-00-000Z_sid-456.jsonl");
150
+ fs.writeFileSync(expectedFile, "{}");
151
+ const result = findSessionFileByHeaderId(tmpDir, "sid-456");
152
+ expect(result).toBe(expectedFile);
153
+ fs.rmSync(tmpDir, { recursive: true });
154
+ });
155
+
156
+ it("无匹配返回 undefined", () => {
157
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "spawn-adapter-test-"));
158
+ const result = findSessionFileByHeaderId(tmpDir, "nonexistent");
159
+ expect(result).toBeUndefined();
160
+ fs.rmSync(tmpDir, { recursive: true });
161
+ });
162
+
163
+ it("目录不存在返回 undefined(不抛错)", () => {
164
+ const result = findSessionFileByHeaderId("/nonexistent/path/xyz", "sid");
165
+ expect(result).toBeUndefined();
166
+ });
167
+ });