@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.
- package/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
// src/__tests__/run-spawn-integration.test.ts
|
|
2
|
+
//
|
|
3
|
+
// runSpawn 集成测试(spawn 改造核心函数)。
|
|
4
|
+
//
|
|
5
|
+
// runSpawn 负责 spawn pi 子进程、pump stdout JSON 事件流、signal/maxTurns 终止、
|
|
6
|
+
// identity 补写、exitCode 判定。此前完全无集成测试——本文件覆盖审查发现的关键路径。
|
|
7
|
+
//
|
|
8
|
+
// mock 策略(参考 worktree-manager.test.ts 的 mock 模式):
|
|
9
|
+
// - node:child_process.spawn → 返回 FakeChild(EventEmitter + PassThrough),
|
|
10
|
+
// 测试用控制器 emit data/close/error 控制时序。
|
|
11
|
+
// - node:child_process.execFileSync → 返回空串(buildEnvBlock 的 git branch 调用避免副作用)。
|
|
12
|
+
// - node:fs 同步方法 → mock(mkdirSync/existsSync/appendFileSync/writeFileSync 等),
|
|
13
|
+
// 避免 sessionDir/sessionFile 触碰真实文件系统。
|
|
14
|
+
// - fs.promises.* → 保留真实实现(temp-prompt 整体被 mock,不触发真实 I/O)。
|
|
15
|
+
// - temp-prompt → mock(writePromptToTempFile 返回固定路径,消除 fake-timers flaky)。
|
|
16
|
+
// - alive-store.writeAliveMarker → mock(避免写 .alive sidecar)。
|
|
17
|
+
|
|
18
|
+
import type { PassThrough } from "node:stream";
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
|
|
22
|
+
// ── mock modules ──
|
|
23
|
+
//
|
|
24
|
+
// vitest 会把 vi.mock 提升到文件顶部(早于其他 import / 声明)。mock 工厂若要引用
|
|
25
|
+
// FakeChild,需在工厂内部 import(async 工厂可用 await import),而非引用顶部
|
|
26
|
+
// 顶层 import(它们在 vi.mock 执行时尚未绑定)。
|
|
27
|
+
|
|
28
|
+
vi.mock("node:child_process", async () => {
|
|
29
|
+
const { EventEmitter } = await import("node:events");
|
|
30
|
+
const { PassThrough } = await import("node:stream");
|
|
31
|
+
|
|
32
|
+
// FakeChild:模拟 ChildProcess(EventEmitter + PassThrough streams)。
|
|
33
|
+
// 测试通过 mockSpawn.mock.results.at(-1).value 取回实例,控制 emit data/close/error 时序。
|
|
34
|
+
class FakeChild extends EventEmitter {
|
|
35
|
+
pid = 12345;
|
|
36
|
+
stdout = new PassThrough();
|
|
37
|
+
stderr = new PassThrough();
|
|
38
|
+
killed = false;
|
|
39
|
+
killSignal: string | undefined;
|
|
40
|
+
kill(sig?: string): boolean {
|
|
41
|
+
this.killed = true;
|
|
42
|
+
this.killSignal = sig;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
spawn: vi.fn(() => new FakeChild()),
|
|
49
|
+
execFileSync: vi.fn(() => ""), // buildEnvBlock 的 git branch 调用,返回空避免副作用
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// node:fs:同步方法 mock(runSpawn 用到的全部),promises 保留真实实现(temp-prompt 用)。
|
|
54
|
+
vi.mock("node:fs", async () => {
|
|
55
|
+
const actual = await import("node:fs");
|
|
56
|
+
return {
|
|
57
|
+
default: {
|
|
58
|
+
...actual,
|
|
59
|
+
mkdirSync: vi.fn(),
|
|
60
|
+
existsSync: vi.fn(() => false),
|
|
61
|
+
appendFileSync: vi.fn(),
|
|
62
|
+
writeFileSync: vi.fn(),
|
|
63
|
+
readdirSync: vi.fn(() => []),
|
|
64
|
+
},
|
|
65
|
+
// 具名导出与 default 保持一致
|
|
66
|
+
mkdirSync: vi.fn(),
|
|
67
|
+
existsSync: vi.fn(() => false),
|
|
68
|
+
appendFileSync: vi.fn(),
|
|
69
|
+
writeFileSync: vi.fn(),
|
|
70
|
+
readdirSync: vi.fn(() => []),
|
|
71
|
+
// promises 保留真实实现——temp-prompt 已被 mock(见下方 vi.mock),不再触发真实 I/O
|
|
72
|
+
promises: actual.promises,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
vi.mock("../alive-store.ts", () => ({
|
|
77
|
+
writeAliveMarker: vi.fn(),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
// temp-prompt:mock 掉真实 fs.promises I/O(mkdtemp/writeFile/rm)。
|
|
81
|
+
// 原先保留真实实现导致 fake-timers 测试偶发 flaky——writePromptToTempFile 的真实异步
|
|
82
|
+
// I/O 在 CI 慢机器上无法在 advanceTimersByTimeAsync 的有限步数内 resolve,spawn 永不触发。
|
|
83
|
+
// runSpawn 只消费返回的 filePath 字符串(传给 --append-system-prompt),无需真实文件。
|
|
84
|
+
vi.mock("../temp-prompt.ts", () => ({
|
|
85
|
+
// 文件名规则与真实实现对齐(safeName:非 \w.- 替换为 _),保持 spawn args 断言稳定
|
|
86
|
+
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
87
|
+
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
88
|
+
return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
|
|
89
|
+
}),
|
|
90
|
+
cleanupTempPrompt: vi.fn(async () => {}),
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
94
|
+
import * as fs from "node:fs";
|
|
95
|
+
|
|
96
|
+
import { createRecord } from "../execution-record.ts";
|
|
97
|
+
import { type RunOptions, runSpawn, type SessionRunnerContext } from "../session-runner.ts";
|
|
98
|
+
|
|
99
|
+
const mockSpawn = vi.mocked(spawn);
|
|
100
|
+
const mockExec = vi.mocked(execFileSync);
|
|
101
|
+
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
102
|
+
const mockAppendFileSync = vi.mocked(fs.appendFileSync);
|
|
103
|
+
const mockMkdirSync = vi.mocked(fs.mkdirSync);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* spawn mock 返回的 fake child 类型。
|
|
107
|
+
* 由于 FakeChild 定义在 vi.mock 工厂内部(作用域隔离),此处用结构子集类型描述,
|
|
108
|
+
* 测试代码通过此类型访问 stdout/stderr/kill 等成员。
|
|
109
|
+
*/
|
|
110
|
+
interface FakeChild {
|
|
111
|
+
pid: number;
|
|
112
|
+
stdout: PassThrough;
|
|
113
|
+
stderr: PassThrough;
|
|
114
|
+
killed: boolean;
|
|
115
|
+
killSignal: string | undefined;
|
|
116
|
+
kill(sig?: string): boolean;
|
|
117
|
+
emit(event: string, ...args: unknown[]): boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 从最近一次 spawn 调用取回返回的 FakeChild(测试控制器)。 */
|
|
121
|
+
function lastSpawnedChild(): FakeChild {
|
|
122
|
+
const result = mockSpawn.mock.results.at(-1);
|
|
123
|
+
if (!result) throw new Error("spawn was not called yet");
|
|
124
|
+
return result.value as FakeChild;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 等待 runSpawn 内部调到 spawn(拿到 child 控制器)。
|
|
129
|
+
*
|
|
130
|
+
* runSpawn 是 async,spawn 在 mkdirSync + writePromptToTempFile 之后才调(均有微任务/
|
|
131
|
+
* I/O 延迟)。用 setInterval 轮询 mockSpawn.mock.results,比 vi.waitFor 在该 vitest 版本
|
|
132
|
+
* 下更可靠(vi.waitFor 偶发过早 resolve 导致后续读取竞态)。
|
|
133
|
+
*/
|
|
134
|
+
async function waitForSpawn(timeoutMs = 1000): Promise<void> {
|
|
135
|
+
const start = Date.now();
|
|
136
|
+
while (mockSpawn.mock.results.length === 0) {
|
|
137
|
+
if (Date.now() - start > timeoutMs) {
|
|
138
|
+
throw new Error(`spawn was not called within ${timeoutMs}ms`);
|
|
139
|
+
}
|
|
140
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================
|
|
145
|
+
// 辅助:向 stdout 写一行(自动补换行,runSpawn 按 \n split 行)
|
|
146
|
+
// ============================================================
|
|
147
|
+
|
|
148
|
+
function emitStdoutLine(child: FakeChild, obj: Record<string, unknown>): void {
|
|
149
|
+
child.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 构造 session header 行(stdout 首行)。 */
|
|
153
|
+
function sessionHeader(id = "sess-abc"): Record<string, unknown> {
|
|
154
|
+
return {
|
|
155
|
+
type: "session",
|
|
156
|
+
id,
|
|
157
|
+
timestamp: "2026-07-03T12-00-00-000Z",
|
|
158
|
+
cwd: "/tmp/test",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================
|
|
163
|
+
// 辅助:构造最小合法的 record / opts / ctx
|
|
164
|
+
// ============================================================
|
|
165
|
+
|
|
166
|
+
function makeRecord() {
|
|
167
|
+
return createRecord("run-1", {
|
|
168
|
+
agent: "general-purpose",
|
|
169
|
+
model: "test-model",
|
|
170
|
+
mode: "sync",
|
|
171
|
+
task: "do something",
|
|
172
|
+
startedAt: 1_000_000,
|
|
173
|
+
rootSessionId: "root-session",
|
|
174
|
+
parentRecordId: undefined,
|
|
175
|
+
depth: 0,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function makeOpts(overrides: Partial<RunOptions> = {}): RunOptions {
|
|
180
|
+
return {
|
|
181
|
+
resolved: {
|
|
182
|
+
model: {
|
|
183
|
+
id: "test-model",
|
|
184
|
+
name: "Test Model",
|
|
185
|
+
provider: "test",
|
|
186
|
+
reasoning: false,
|
|
187
|
+
},
|
|
188
|
+
thinkingLevel: undefined,
|
|
189
|
+
},
|
|
190
|
+
agentConfig: undefined,
|
|
191
|
+
appendSystemPrompt: undefined,
|
|
192
|
+
skillPath: undefined,
|
|
193
|
+
schema: undefined,
|
|
194
|
+
maxTurns: undefined,
|
|
195
|
+
graceTurns: undefined,
|
|
196
|
+
signal: undefined,
|
|
197
|
+
onEvent: undefined,
|
|
198
|
+
...overrides,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function makeCtx(overrides: Partial<SessionRunnerContext> = {}): SessionRunnerContext {
|
|
203
|
+
return {
|
|
204
|
+
cwd: "/tmp/test",
|
|
205
|
+
agentDir: "/tmp/test/agents",
|
|
206
|
+
skillDirs: [],
|
|
207
|
+
mainCwd: "/tmp/test",
|
|
208
|
+
mainSessionFile: undefined,
|
|
209
|
+
...overrides,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 让 sessionFile 存在校验通过——
|
|
215
|
+
* runSpawn 在进程退出后用 existsSync(record.sessionFile) 判断是否补写 identity。
|
|
216
|
+
* 默认 mock existsSync 返回 false(兜底查找),此 helper 在指定路径返回 true。
|
|
217
|
+
*/
|
|
218
|
+
function mockSessionFileExists(sessionFilePath: string): void {
|
|
219
|
+
mockExistsSync.mockImplementation((p: unknown) => {
|
|
220
|
+
return String(p) === sessionFilePath;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ============================================================
|
|
225
|
+
// 测试
|
|
226
|
+
// ============================================================
|
|
227
|
+
|
|
228
|
+
describe("runSpawn", () => {
|
|
229
|
+
beforeEach(() => {
|
|
230
|
+
vi.clearAllMocks();
|
|
231
|
+
// execFileSync 默认返回空串(git branch 兜底)
|
|
232
|
+
mockExec.mockReturnValue("");
|
|
233
|
+
// existsSync 默认 false(sessionFile 不存在兜底路径)
|
|
234
|
+
mockExistsSync.mockReturnValue(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
vi.restoreAllMocks();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── 1. 正常路径(happy path)──
|
|
242
|
+
describe("正常路径", () => {
|
|
243
|
+
it("header + turn_end 事件 + close(0) → success=true,record.sessionFile 被设置,turnCount 正确", async () => {
|
|
244
|
+
const record = makeRecord();
|
|
245
|
+
const promise = runSpawn(record, "Task: hello", makeOpts(), makeCtx());
|
|
246
|
+
|
|
247
|
+
// 等待 spawn 被调用拿到 child
|
|
248
|
+
// 等待 spawn 被调用拿到 child
|
|
249
|
+
await waitForSpawn();
|
|
250
|
+
const child = lastSpawnedChild();
|
|
251
|
+
|
|
252
|
+
const expectedSessionFile =
|
|
253
|
+
"/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Z_sess-abc.jsonl";
|
|
254
|
+
// 进程退出后 existsSync(record.sessionFile) 校验通过 → 补写 identity
|
|
255
|
+
mockSessionFileExists(expectedSessionFile);
|
|
256
|
+
|
|
257
|
+
// emit stdout:header + 2 个 turn_end
|
|
258
|
+
emitStdoutLine(child, sessionHeader("sess-abc"));
|
|
259
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
260
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
261
|
+
child.stdout.end();
|
|
262
|
+
child.emit("close", 0);
|
|
263
|
+
|
|
264
|
+
const result = await promise;
|
|
265
|
+
|
|
266
|
+
expect(result.success).toBe(true);
|
|
267
|
+
expect(result.turns).toBe(2); // 2 个 turn_end
|
|
268
|
+
expect(record.turnCount).toBe(2);
|
|
269
|
+
// record.sessionFile 由 header 推导路径回填
|
|
270
|
+
expect(record.sessionFile).toBe(expectedSessionFile);
|
|
271
|
+
expect(result.sessionFile).toBe(expectedSessionFile);
|
|
272
|
+
// sessionId 来自 header.id
|
|
273
|
+
expect(result.sessionId).toBe("sess-abc");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("collectResult 文本/toolCalls 从 record 派生(text_delta + tool 调用累积进 turns)", async () => {
|
|
277
|
+
const record = makeRecord();
|
|
278
|
+
const promise = runSpawn(record, "Task: write", makeOpts(), makeCtx());
|
|
279
|
+
|
|
280
|
+
// 等待 spawn 被调用拿到 child
|
|
281
|
+
await waitForSpawn();
|
|
282
|
+
const child = lastSpawnedChild();
|
|
283
|
+
mockSessionFileExists(
|
|
284
|
+
"/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Z_sess-x.jsonl",
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
emitStdoutLine(child, sessionHeader("sess-x"));
|
|
288
|
+
emitStdoutLine(child, { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Hello " } });
|
|
289
|
+
emitStdoutLine(child, { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "world" } });
|
|
290
|
+
emitStdoutLine(child, { type: "tool_execution_start", toolCallId: "t1", toolName: "bash", args: { command: "ls" } });
|
|
291
|
+
emitStdoutLine(child, { type: "tool_execution_end", toolCallId: "t1", toolName: "bash", result: { details: "file.txt" } });
|
|
292
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
293
|
+
child.stdout.end();
|
|
294
|
+
child.emit("close", 0);
|
|
295
|
+
|
|
296
|
+
const result = await promise;
|
|
297
|
+
|
|
298
|
+
expect(result.success).toBe(true);
|
|
299
|
+
expect(result.text).toBe("Hello world");
|
|
300
|
+
expect(result.toolCalls).toHaveLength(1);
|
|
301
|
+
expect(result.toolCalls[0]!.toolName).toBe("bash");
|
|
302
|
+
expect(result.toolCalls[0]!.result?.details).toBe("file.txt");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// ── 2. signal abort → child.kill(cancel 路径)──
|
|
307
|
+
describe("signal abort", () => {
|
|
308
|
+
it("abort signal 触发后 child.kill(SIGTERM) 被调用,success=false", async () => {
|
|
309
|
+
const controller = new AbortController();
|
|
310
|
+
const record = makeRecord();
|
|
311
|
+
const promise = runSpawn(
|
|
312
|
+
record,
|
|
313
|
+
"Task: long",
|
|
314
|
+
makeOpts({ signal: controller.signal }),
|
|
315
|
+
makeCtx(),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// 等待 spawn 被调用拿到 child
|
|
319
|
+
await waitForSpawn();
|
|
320
|
+
const child = lastSpawnedChild();
|
|
321
|
+
|
|
322
|
+
// abort 必须在 spawn 之后(addEventListener 已注册)。
|
|
323
|
+
// 用 queueMicrotask 延迟到当前微任务清空后触发,确保 listener 已挂载。
|
|
324
|
+
queueMicrotask(() => controller.abort());
|
|
325
|
+
|
|
326
|
+
// emit header + close(被 kill 后子进程退出,signal 终止 exitCode>=128 或 null)
|
|
327
|
+
emitStdoutLine(child, sessionHeader());
|
|
328
|
+
child.stdout.end();
|
|
329
|
+
child.emit("close", 143); // SIGTERM = 128+15
|
|
330
|
+
|
|
331
|
+
const result = await promise;
|
|
332
|
+
|
|
333
|
+
expect(child.killed).toBe(true);
|
|
334
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
335
|
+
// signal.aborted 路径:success=false,但 error 为 undefined(取消不算 error)
|
|
336
|
+
expect(result.success).toBe(false);
|
|
337
|
+
expect(result.error).toBeUndefined();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ── 3. maxTurns → limiter.abort → proc.kill ──
|
|
342
|
+
describe("maxTurns 限制", () => {
|
|
343
|
+
it("maxTurns=1, graceTurns=0 → 超出 turn 后 child.kill 被调用,success=true(达限视为正常完成)", async () => {
|
|
344
|
+
const record = makeRecord();
|
|
345
|
+
const promise = runSpawn(
|
|
346
|
+
record,
|
|
347
|
+
"Task: bounded",
|
|
348
|
+
makeOpts({ maxTurns: 1, graceTurns: 0 }),
|
|
349
|
+
makeCtx(),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
// 等待 spawn 被调用拿到 child
|
|
353
|
+
await waitForSpawn();
|
|
354
|
+
const child = lastSpawnedChild();
|
|
355
|
+
|
|
356
|
+
emitStdoutLine(child, sessionHeader());
|
|
357
|
+
// 第 1 个 turn_end:turn=1 >= maxTurns=1 → steer(noop)
|
|
358
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
359
|
+
// 第 2 个 turn_end:turn=2 >= maxTurns(1)+graceTurns(0)=1 → abort → proc.kill
|
|
360
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
361
|
+
child.stdout.end();
|
|
362
|
+
// 被 kill 后信号终止(exitCode>=128),视为正常完成
|
|
363
|
+
child.emit("close", 143);
|
|
364
|
+
|
|
365
|
+
const result = await promise;
|
|
366
|
+
|
|
367
|
+
expect(child.killed).toBe(true);
|
|
368
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
369
|
+
// maxTurns kill 视为正常完成(exitCode>=128 走信号终止分支 → success=true)
|
|
370
|
+
expect(result.success).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("maxTurns=2, graceTurns=2 → steer 后需 graceTurns 到达才 kill(第 4 turn 才 abort)", async () => {
|
|
374
|
+
const record = makeRecord();
|
|
375
|
+
const promise = runSpawn(
|
|
376
|
+
record,
|
|
377
|
+
"Task: grace",
|
|
378
|
+
makeOpts({ maxTurns: 2, graceTurns: 2 }),
|
|
379
|
+
makeCtx(),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// 等待 spawn 被调用拿到 child
|
|
383
|
+
await waitForSpawn();
|
|
384
|
+
const child = lastSpawnedChild();
|
|
385
|
+
|
|
386
|
+
// emit header + 4 个 turn_end。limiter 逻辑(maxTurns=2, graceTurns=2):
|
|
387
|
+
// turn=1: 1<2,无动作
|
|
388
|
+
// turn=2: 2>=limit → steer(仅一次)
|
|
389
|
+
// turn=3: 3<limit+grace(4),仍在宽限内
|
|
390
|
+
// turn=4: 4>=4 → abort → proc.kill(SIGTERM)
|
|
391
|
+
// 注:PassThrough 流加 data 监听器后,缓冲块逐个在后续微任务中 flush,close 前 await
|
|
392
|
+
// 较长时间窗(20ms)确保全部 turn_end 已被 data 处理器消费(含 limiter.abort → kill)。
|
|
393
|
+
emitStdoutLine(child, sessionHeader());
|
|
394
|
+
emitStdoutLine(child, { type: "turn_end" }); // turn=1
|
|
395
|
+
emitStdoutLine(child, { type: "turn_end" }); // turn=2: steer
|
|
396
|
+
emitStdoutLine(child, { type: "turn_end" }); // turn=3: 宽限内
|
|
397
|
+
emitStdoutLine(child, { type: "turn_end" }); // turn=4: abort → kill
|
|
398
|
+
child.stdout.end();
|
|
399
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
400
|
+
child.emit("close", 143);
|
|
401
|
+
|
|
402
|
+
const result = await promise;
|
|
403
|
+
|
|
404
|
+
// 第 4 个 turn_end 触发 abort → child.kill(SIGTERM)
|
|
405
|
+
expect(child.killed).toBe(true);
|
|
406
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
407
|
+
expect(record.turnCount).toBe(4);
|
|
408
|
+
// exitCode>=128(信号终止)→ success=true(达限视为正常完成)
|
|
409
|
+
expect(result.success).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// ── 4. 子进程非零退出(exitCode < 128)──
|
|
414
|
+
describe("非零退出码", () => {
|
|
415
|
+
it("close(1) + stderr 内容 → success=false,error 含 stderr", async () => {
|
|
416
|
+
const record = makeRecord();
|
|
417
|
+
const promise = runSpawn(record, "Task: fail", makeOpts(), makeCtx());
|
|
418
|
+
|
|
419
|
+
// 等待 spawn 被调用拿到 child
|
|
420
|
+
await waitForSpawn();
|
|
421
|
+
const child = lastSpawnedChild();
|
|
422
|
+
|
|
423
|
+
emitStdoutLine(child, sessionHeader());
|
|
424
|
+
child.stderr.write("Error: something went wrong\n");
|
|
425
|
+
child.stderr.end();
|
|
426
|
+
child.stdout.end();
|
|
427
|
+
child.emit("close", 1);
|
|
428
|
+
|
|
429
|
+
const result = await promise;
|
|
430
|
+
|
|
431
|
+
expect(result.success).toBe(false);
|
|
432
|
+
expect(result.error).toContain("something went wrong");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("close(1) 无 stderr → error 含 exit code 信息", async () => {
|
|
436
|
+
const record = makeRecord();
|
|
437
|
+
const promise = runSpawn(record, "Task: fail", makeOpts(), makeCtx());
|
|
438
|
+
|
|
439
|
+
// 等待 spawn 被调用拿到 child
|
|
440
|
+
await waitForSpawn();
|
|
441
|
+
const child = lastSpawnedChild();
|
|
442
|
+
|
|
443
|
+
child.stdout.end();
|
|
444
|
+
child.stderr.end();
|
|
445
|
+
child.emit("close", 1);
|
|
446
|
+
|
|
447
|
+
const result = await promise;
|
|
448
|
+
|
|
449
|
+
expect(result.success).toBe(false);
|
|
450
|
+
expect(result.error).toContain("exited with code 1");
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// ── 5. 子进程被信号终止(exitCode = null 或 >= 128)──
|
|
455
|
+
describe("信号终止", () => {
|
|
456
|
+
it("close(null) → success=true(信号终止视为正常,非 aborted)", async () => {
|
|
457
|
+
const record = makeRecord();
|
|
458
|
+
const promise = runSpawn(record, "Task: sig", makeOpts(), makeCtx());
|
|
459
|
+
|
|
460
|
+
// 等待 spawn 被调用拿到 child
|
|
461
|
+
await waitForSpawn();
|
|
462
|
+
const child = lastSpawnedChild();
|
|
463
|
+
|
|
464
|
+
emitStdoutLine(child, sessionHeader());
|
|
465
|
+
child.stdout.end();
|
|
466
|
+
child.stderr.end();
|
|
467
|
+
child.emit("close", null);
|
|
468
|
+
|
|
469
|
+
const result = await promise;
|
|
470
|
+
|
|
471
|
+
expect(result.success).toBe(true);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("close(143)(SIGTERM,>=128)无 signal → success=true", async () => {
|
|
475
|
+
const record = makeRecord();
|
|
476
|
+
const promise = runSpawn(record, "Task: sig143", makeOpts(), makeCtx());
|
|
477
|
+
|
|
478
|
+
// 等待 spawn 被调用拿到 child
|
|
479
|
+
await waitForSpawn();
|
|
480
|
+
const child = lastSpawnedChild();
|
|
481
|
+
|
|
482
|
+
emitStdoutLine(child, sessionHeader());
|
|
483
|
+
child.stdout.end();
|
|
484
|
+
child.stderr.end();
|
|
485
|
+
child.emit("close", 143);
|
|
486
|
+
|
|
487
|
+
const result = await promise;
|
|
488
|
+
|
|
489
|
+
expect(result.success).toBe(true);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// ── 6. spawn 本身失败(error 事件,如 ENOENT)──
|
|
494
|
+
describe("spawn error 事件", () => {
|
|
495
|
+
it("child.emit('error') → success=false,error 含错误信息,record.lastError 被设置", async () => {
|
|
496
|
+
const record = makeRecord();
|
|
497
|
+
const promise = runSpawn(record, "Task: enoent", makeOpts(), makeCtx());
|
|
498
|
+
|
|
499
|
+
// 等待 spawn 被调用拿到 child
|
|
500
|
+
await waitForSpawn();
|
|
501
|
+
const child = lastSpawnedChild();
|
|
502
|
+
|
|
503
|
+
// spawn error 事件(command not found 等)——runSpawn 的 error handler resolve(128)
|
|
504
|
+
child.emit("error", new Error("spawn ENOENT"));
|
|
505
|
+
|
|
506
|
+
const result = await promise;
|
|
507
|
+
|
|
508
|
+
expect(result.success).toBe(false);
|
|
509
|
+
expect(result.error).toContain("spawn ENOENT");
|
|
510
|
+
expect(record.lastError).toContain("spawn ENOENT");
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ── 7. identity 补写 ──
|
|
515
|
+
describe("identity 补写", () => {
|
|
516
|
+
it("正常 close 后 appendFileSync 写入 IDENTITY_CUSTOM_TYPE custom entry", async () => {
|
|
517
|
+
const record = makeRecord();
|
|
518
|
+
const promise = runSpawn(record, "Task: identity", makeOpts(), makeCtx());
|
|
519
|
+
|
|
520
|
+
// 等待 spawn 被调用拿到 child
|
|
521
|
+
await waitForSpawn();
|
|
522
|
+
const child = lastSpawnedChild();
|
|
523
|
+
|
|
524
|
+
const sessionFile =
|
|
525
|
+
"/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Z_sess-id.jsonl";
|
|
526
|
+
mockSessionFileExists(sessionFile);
|
|
527
|
+
|
|
528
|
+
emitStdoutLine(child, sessionHeader("sess-id"));
|
|
529
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
530
|
+
child.stdout.end();
|
|
531
|
+
child.emit("close", 0);
|
|
532
|
+
|
|
533
|
+
await promise;
|
|
534
|
+
|
|
535
|
+
// appendFileSync 被调用写 identity custom entry
|
|
536
|
+
expect(mockAppendFileSync).toHaveBeenCalledWith(
|
|
537
|
+
sessionFile,
|
|
538
|
+
expect.stringContaining('"customType":"subagent-identity"'),
|
|
539
|
+
"utf-8",
|
|
540
|
+
);
|
|
541
|
+
// 写入内容含 record.id / agent
|
|
542
|
+
const written = mockAppendFileSync.mock.calls[0]?.[1] as string;
|
|
543
|
+
expect(written).toContain(record.id);
|
|
544
|
+
expect(written).toContain(record.agent);
|
|
545
|
+
expect(written).toContain('"type":"custom"');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("sessionFile 不存在(existsSync=false)→ 不补写 identity(不调 appendFileSync)", async () => {
|
|
549
|
+
const record = makeRecord();
|
|
550
|
+
const promise = runSpawn(record, "Task: nofile", makeOpts(), makeCtx());
|
|
551
|
+
|
|
552
|
+
// 等待 spawn 被调用拿到 child
|
|
553
|
+
await waitForSpawn();
|
|
554
|
+
const child = lastSpawnedChild();
|
|
555
|
+
|
|
556
|
+
// existsSync 全返回 false(默认)→ record.sessionFile 经兜底查找仍不存在
|
|
557
|
+
emitStdoutLine(child, sessionHeader("sess-none"));
|
|
558
|
+
child.stdout.end();
|
|
559
|
+
child.emit("close", 0);
|
|
560
|
+
|
|
561
|
+
await promise;
|
|
562
|
+
|
|
563
|
+
expect(mockAppendFileSync).not.toHaveBeenCalled();
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// ── 8. fork depth 环境变量传递 ──
|
|
568
|
+
describe("fork depth 环境变量", () => {
|
|
569
|
+
it("fork=true, parentForkDepth=2 → spawn env 含 PI_SUBAGENT_FORK_DEPTH=3", async () => {
|
|
570
|
+
const record = makeRecord();
|
|
571
|
+
const promise = runSpawn(
|
|
572
|
+
record,
|
|
573
|
+
"Task: fork",
|
|
574
|
+
makeOpts({ fork: true, parentForkDepth: 2 }),
|
|
575
|
+
makeCtx({ mainSessionFile: "/tmp/main.jsonl" }),
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
// 等待 spawn 被调用拿到 child
|
|
579
|
+
await waitForSpawn();
|
|
580
|
+
const child = lastSpawnedChild();
|
|
581
|
+
|
|
582
|
+
emitStdoutLine(child, sessionHeader());
|
|
583
|
+
child.stdout.end();
|
|
584
|
+
child.emit("close", 0);
|
|
585
|
+
|
|
586
|
+
await promise;
|
|
587
|
+
|
|
588
|
+
// spawn 第 3 参数是 options(含 env)
|
|
589
|
+
const spawnOpts = mockSpawn.mock.calls[0]?.[2] as { env: Record<string, string> };
|
|
590
|
+
expect(spawnOpts.env.PI_SUBAGENT_FORK_DEPTH).toBe("3");
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("fork=false → spawn env 不含 PI_SUBAGENT_FORK_DEPTH", async () => {
|
|
594
|
+
const record = makeRecord();
|
|
595
|
+
const promise = runSpawn(record, "Task: nofork", makeOpts(), makeCtx());
|
|
596
|
+
|
|
597
|
+
// 等待 spawn 被调用拿到 child
|
|
598
|
+
await waitForSpawn();
|
|
599
|
+
const child = lastSpawnedChild();
|
|
600
|
+
|
|
601
|
+
emitStdoutLine(child, sessionHeader());
|
|
602
|
+
child.stdout.end();
|
|
603
|
+
child.emit("close", 0);
|
|
604
|
+
|
|
605
|
+
await promise;
|
|
606
|
+
|
|
607
|
+
const spawnOpts = mockSpawn.mock.calls[0]?.[2] as { env: Record<string, string | undefined> };
|
|
608
|
+
expect(spawnOpts.env.PI_SUBAGENT_FORK_DEPTH).toBeUndefined();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("spawn 参数含 --session-dir 且 cwd 来自 ctx.cwd", async () => {
|
|
612
|
+
const record = makeRecord();
|
|
613
|
+
const promise = runSpawn(record, "Task: args", makeOpts(), makeCtx());
|
|
614
|
+
|
|
615
|
+
// 等待 spawn 被调用拿到 child
|
|
616
|
+
await waitForSpawn();
|
|
617
|
+
const child = lastSpawnedChild();
|
|
618
|
+
|
|
619
|
+
emitStdoutLine(child, sessionHeader());
|
|
620
|
+
child.stdout.end();
|
|
621
|
+
child.emit("close", 0);
|
|
622
|
+
|
|
623
|
+
await promise;
|
|
624
|
+
|
|
625
|
+
// spawn(command, args, opts):args 是数组,应含 --session-dir
|
|
626
|
+
const args = mockSpawn.mock.calls[0]?.[1] as string[];
|
|
627
|
+
const sessionDirIdx = args.indexOf("--session-dir");
|
|
628
|
+
expect(sessionDirIdx).toBeGreaterThanOrEqual(0);
|
|
629
|
+
expect(args[sessionDirIdx + 1]).toContain("--tmp-test--");
|
|
630
|
+
// spawn opts.cwd 来自 ctx.cwd(无 worktree 时)
|
|
631
|
+
const spawnOpts = mockSpawn.mock.calls[0]?.[2] as { cwd: string };
|
|
632
|
+
expect(spawnOpts.cwd).toBe("/tmp/test");
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// ── 9. onEvent 回调 ──
|
|
637
|
+
describe("onEvent 回调", () => {
|
|
638
|
+
it("stdout 事件经 handleSdkEvent 翻译后回调 opts.onEvent", async () => {
|
|
639
|
+
const onEvent = vi.fn();
|
|
640
|
+
const record = makeRecord();
|
|
641
|
+
const promise = runSpawn(record, "Task: events", makeOpts({ onEvent }), makeCtx());
|
|
642
|
+
|
|
643
|
+
// 等待 spawn 被调用拿到 child
|
|
644
|
+
await waitForSpawn();
|
|
645
|
+
const child = lastSpawnedChild();
|
|
646
|
+
|
|
647
|
+
emitStdoutLine(child, sessionHeader());
|
|
648
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
649
|
+
child.stdout.end();
|
|
650
|
+
child.emit("close", 0);
|
|
651
|
+
|
|
652
|
+
await promise;
|
|
653
|
+
|
|
654
|
+
// onEvent 至少被 turn_end 调过一次(onEvent 签名是单参数 event)
|
|
655
|
+
const turnEndCalls = onEvent.mock.calls.filter(([ev]) => (ev as { type: string }).type === "turn_end");
|
|
656
|
+
expect(turnEndCalls.length).toBeGreaterThanOrEqual(1);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// ── 10. sessionDir 创建 ──
|
|
661
|
+
describe("sessionDir 创建", () => {
|
|
662
|
+
it("mkdirSync 以 recursive 创建 sessionDir", async () => {
|
|
663
|
+
const record = makeRecord();
|
|
664
|
+
const promise = runSpawn(record, "Task: mkdir", makeOpts(), makeCtx());
|
|
665
|
+
|
|
666
|
+
// 等待 spawn 被调用拿到 child
|
|
667
|
+
await waitForSpawn();
|
|
668
|
+
const child = lastSpawnedChild();
|
|
669
|
+
|
|
670
|
+
emitStdoutLine(child, sessionHeader());
|
|
671
|
+
child.stdout.end();
|
|
672
|
+
child.emit("close", 0);
|
|
673
|
+
|
|
674
|
+
await promise;
|
|
675
|
+
|
|
676
|
+
expect(mockMkdirSync).toHaveBeenCalledWith(
|
|
677
|
+
"/tmp/test/agents/subagents/--tmp-test--/sessions",
|
|
678
|
+
{ recursive: true },
|
|
679
|
+
);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// ── 11. 临时 prompt 文件清理 ──
|
|
684
|
+
describe("临时 prompt 文件清理", () => {
|
|
685
|
+
it("有 appendSystemPrompt → 创建临时文件并在 finally 清理(spawn 退出后目录消失)", async () => {
|
|
686
|
+
const record = makeRecord();
|
|
687
|
+
const promise = runSpawn(
|
|
688
|
+
record,
|
|
689
|
+
"Task: cleanup",
|
|
690
|
+
makeOpts({ appendSystemPrompt: ["extra instructions"] }),
|
|
691
|
+
makeCtx(),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// 等待 spawn 被调用拿到 child
|
|
695
|
+
await waitForSpawn();
|
|
696
|
+
const child = lastSpawnedChild();
|
|
697
|
+
|
|
698
|
+
emitStdoutLine(child, sessionHeader());
|
|
699
|
+
child.stdout.end();
|
|
700
|
+
child.emit("close", 0);
|
|
701
|
+
|
|
702
|
+
await promise;
|
|
703
|
+
|
|
704
|
+
// temp-prompt 已 mock(见文件顶部 vi.mock),返回固定文件名;
|
|
705
|
+
// 验证 spawn args 含 --append-system-prompt(说明临时文件路径被传给子进程)
|
|
706
|
+
const args = mockSpawn.mock.calls[0]?.[1] as string[];
|
|
707
|
+
const appendIdx = args.indexOf("--append-system-prompt");
|
|
708
|
+
expect(appendIdx).toBeGreaterThanOrEqual(0);
|
|
709
|
+
expect(args[appendIdx + 1]).toMatch(/prompt-general-purpose\.md$/);
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// ── 12. watchdog 超时兜底(R1)──
|
|
714
|
+
//
|
|
715
|
+
// [R0/R1] SPAWN_WATCHDOG_MS = 30 * 60 * 1000(session-runner.ts 顶部)。
|
|
716
|
+
// 子进程卡死在单 tool 内(turn_end 永不触发)时 limiter 失效,watchdog timer 兜底 SIGTERM,
|
|
717
|
+
// 防止 background 槽位/资源泄漏。timer 加了 .unref()(不阻止 Node 退出)。
|
|
718
|
+
//
|
|
719
|
+
// fake timers 方案 A:describe 块内 beforeEach useFakeTimers、afterEach useRealTimers。
|
|
720
|
+
// 难点:waitForSpawn 内部用 `await new Promise(r => setTimeout(r, 5))` 轮询,fake timers 下
|
|
721
|
+
// 该 setTimeout 不会自动触发——改用 vi.advanceTimersByTimeAsync 推进时间,它会同步 flush
|
|
722
|
+
// 已到期的 timer + 让挂起的真实 I/O(writePromptToTempFile 的 fs.promises)resolve。
|
|
723
|
+
// 关键验证:unref 的 timer 在 fake timers 下仍能被 advanceTimersByTime 触发(vitest fake
|
|
724
|
+
// timers 忽略 unref)。
|
|
725
|
+
describe("watchdog 超时兜底 (R1)", () => {
|
|
726
|
+
beforeEach(() => {
|
|
727
|
+
vi.useFakeTimers();
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
afterEach(() => {
|
|
731
|
+
vi.useRealTimers();
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* fake timers 下推进时间直到 spawn 被调用(替代 waitForSpawn)。
|
|
736
|
+
*
|
|
737
|
+
* runSpawn 在 mkdirSync + writePromptToTempFile(真实 fs.promises I/O)之后才调 spawn。
|
|
738
|
+
* 每次推进 10ms 让 waitForSpawn 轮询的 setTimeout(r,5) 触发;同时 advanceTimersByTimeAsync
|
|
739
|
+
* flush 已 resolve 的 I/O promise,使 runSpawn 继续走到 spawn。
|
|
740
|
+
*/
|
|
741
|
+
async function waitForSpawnFake(timeoutSteps = 200): Promise<FakeChild> {
|
|
742
|
+
for (let i = 0; i < timeoutSteps; i++) {
|
|
743
|
+
if (mockSpawn.mock.results.length > 0) break;
|
|
744
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
745
|
+
}
|
|
746
|
+
if (mockSpawn.mock.results.length === 0) {
|
|
747
|
+
throw new Error("spawn was not called (fake timers did not progress to spawn)");
|
|
748
|
+
}
|
|
749
|
+
return lastSpawnedChild();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
it("watchdog 超时(>30min)→ child.kill(SIGTERM) 被调用", async () => {
|
|
753
|
+
const record = makeRecord();
|
|
754
|
+
// 不 await——runSpawn 内部 await 子进程 close,watchdog 触发 kill 后还需 emit close 才 resolve
|
|
755
|
+
// [M-1] maxTurns=6 使 computeWatchdogMs 返回下限 30min(max(30min, 6*5min) = 30min)
|
|
756
|
+
const promise = runSpawn(record, "Task: hang", makeOpts({ maxTurns: 6 }), makeCtx());
|
|
757
|
+
|
|
758
|
+
const child = await waitForSpawnFake();
|
|
759
|
+
|
|
760
|
+
// spawn 后 child.killed 应为 false(watchdog 尚未触发)
|
|
761
|
+
expect(child.killed).toBe(false);
|
|
762
|
+
|
|
763
|
+
// 推进时间越过 watchdog 阈值(30 * 60 * 1000 + 100ms 余量)
|
|
764
|
+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100);
|
|
765
|
+
|
|
766
|
+
// watchdog 触发 child.kill("SIGTERM")
|
|
767
|
+
expect(child.killed).toBe(true);
|
|
768
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
769
|
+
|
|
770
|
+
// 收尾:emit close 让 runSpawn 的 await promise resolve(避免悬挂)
|
|
771
|
+
emitStdoutLine(child, sessionHeader());
|
|
772
|
+
child.stdout.end();
|
|
773
|
+
child.emit("close", 143); // SIGTERM = 128+15
|
|
774
|
+
|
|
775
|
+
const result = await promise;
|
|
776
|
+
// 信号终止(>=128)视为正常完成
|
|
777
|
+
expect(result.success).toBe(true);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("正常 close(0) → clearTimeout 生效,watchdog 不触发 kill", async () => {
|
|
781
|
+
const record = makeRecord();
|
|
782
|
+
// [M-1] maxTurns=6 使 watchdog=30min(与上面 watchdog 触发测试同基准)
|
|
783
|
+
const promise = runSpawn(record, "Task: quick", makeOpts({ maxTurns: 6 }), makeCtx());
|
|
784
|
+
|
|
785
|
+
const child = await waitForSpawnFake();
|
|
786
|
+
|
|
787
|
+
// 正常完成:emit header + close(0)
|
|
788
|
+
emitStdoutLine(child, sessionHeader());
|
|
789
|
+
child.stdout.end();
|
|
790
|
+
child.emit("close", 0);
|
|
791
|
+
|
|
792
|
+
const result = await promise;
|
|
793
|
+
expect(result.success).toBe(true);
|
|
794
|
+
|
|
795
|
+
// close 后 runSpawn 已 clearTimeout(watchdog);推进 30+ 分钟验证 watchdog 未触发 kill
|
|
796
|
+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 100);
|
|
797
|
+
|
|
798
|
+
expect(child.killed).toBe(false);
|
|
799
|
+
expect(child.killSignal).toBeUndefined();
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// ── 13. stderr 截断防 OOM(R2)──
|
|
804
|
+
//
|
|
805
|
+
// [R2] stderrBuffer = (stderrBuffer + data).slice(-STDERR_MAX_CHARS)(STDERR_MAX_CHARS=64*1024)。
|
|
806
|
+
// 失控子进程打满 stderr 会耗尽父进程内存;保留尾部 64KB 用于 error 诊断。
|
|
807
|
+
describe("stderr 截断防 OOM (R2)", () => {
|
|
808
|
+
it("stderr > 64KB → error 中的 stderr 内容是尾部 64KB(尾部标记保留,头部标记被截断)", async () => {
|
|
809
|
+
const record = makeRecord();
|
|
810
|
+
const promise = runSpawn(record, "Task: noisy stderr", makeOpts(), makeCtx());
|
|
811
|
+
|
|
812
|
+
await waitForSpawn();
|
|
813
|
+
const child = lastSpawnedChild();
|
|
814
|
+
|
|
815
|
+
// 头部标记(应被截断):足够长的前缀让 64KB 窗口推过它
|
|
816
|
+
const HEAD_MARKER = "HEAD_MARKER_HEAD_MARKER";
|
|
817
|
+
child.stderr.write(HEAD_MARKER);
|
|
818
|
+
// 填充 > 64KB 的噪声(用 "x" 重复),把头部标记推出 64KB 尾部窗口
|
|
819
|
+
child.stderr.write("x".repeat(70 * 1024));
|
|
820
|
+
// 尾部标记(应在截断后仍保留)
|
|
821
|
+
child.stderr.write("TAIL_MARKER_TAIL_MARKER");
|
|
822
|
+
|
|
823
|
+
emitStdoutLine(child, sessionHeader());
|
|
824
|
+
child.stderr.end();
|
|
825
|
+
child.stdout.end();
|
|
826
|
+
child.emit("close", 1); // 非零退出 → error 含 stderr
|
|
827
|
+
|
|
828
|
+
const result = await promise;
|
|
829
|
+
|
|
830
|
+
// 非零退出码 → success=false
|
|
831
|
+
expect(result.success).toBe(false);
|
|
832
|
+
// error 含尾部标记(证明保留了尾部 64KB)
|
|
833
|
+
expect(result.error).toContain("TAIL_MARKER_TAIL_MARKER");
|
|
834
|
+
// error 不含头部标记(证明头部被截断丢弃)
|
|
835
|
+
expect(result.error).not.toContain(HEAD_MARKER);
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// ── 14. UTF-8 编码跨 chunk 安全(R3)──
|
|
840
|
+
//
|
|
841
|
+
// [R3] child.stdout.setEncoding("utf8") 让流按字符边界切分,避免多字节 UTF-8
|
|
842
|
+
//(CJK/emoji)跨 chunk 时 toString() 产生 U+FFFD 替换符导致 JSON.parse 失败。
|
|
843
|
+
//
|
|
844
|
+
// 测法选择:用流状态(readableEncoding)验证 setEncoding("utf8") 被 runSpawn 调用过。
|
|
845
|
+
// 为什么不用 spy:runSpawn 在 spawn() 返回后同步调用 setEncoding,而 waitForSpawn 拿到
|
|
846
|
+
// child 时调用已完成,无法事后挂 spy 捕获。readableEncoding 是 PassThrough(Readable) 暴露的
|
|
847
|
+
// 只读属性,setEncoding("utf8") 后值变为 "utf8",是"编码确实被设置"的可靠证据。
|
|
848
|
+
// 端到端分片写多字节字符无法真正模拟"字节边界切断"(PassThrough + setEncoding 已在更高层
|
|
849
|
+
// 合并),故补一个"分片写入仍能解析"的用例佐证行缓冲健壮性。
|
|
850
|
+
describe("UTF-8 编码跨 chunk 安全 (R3)", () => {
|
|
851
|
+
it("runSpawn 对 child.stdout 和 child.stderr 调用 setEncoding('utf8')(readableEncoding 验证)", async () => {
|
|
852
|
+
const record = makeRecord();
|
|
853
|
+
const promise = runSpawn(record, "Task: utf8", makeOpts(), makeCtx());
|
|
854
|
+
|
|
855
|
+
await waitForSpawn();
|
|
856
|
+
const child = lastSpawnedChild();
|
|
857
|
+
|
|
858
|
+
// spawn 后 runSpawn 已同步调用 setEncoding("utf8"):readableEncoding 反映该调用结果
|
|
859
|
+
expect(child.stdout.readableEncoding).toBe("utf8");
|
|
860
|
+
expect(child.stderr.readableEncoding).toBe("utf8");
|
|
861
|
+
|
|
862
|
+
emitStdoutLine(child, sessionHeader());
|
|
863
|
+
child.stdout.end();
|
|
864
|
+
child.stderr.end();
|
|
865
|
+
child.emit("close", 0);
|
|
866
|
+
|
|
867
|
+
const result = await promise;
|
|
868
|
+
expect(result.success).toBe(true);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("stdout 多次分片写入同一 JSON 行仍能正确解析 turn_end(行缓冲合并)", async () => {
|
|
872
|
+
// 端到端验证:数据分两次 write,runSpawn 的行缓冲(stdoutBuffer += data)合并后解析。
|
|
873
|
+
// 此用例验证"分片写入仍能解析",setEncoding("utf8") 已由上一用例覆盖。
|
|
874
|
+
const record = makeRecord();
|
|
875
|
+
const promise = runSpawn(record, "Task: split", makeOpts(), makeCtx());
|
|
876
|
+
|
|
877
|
+
await waitForSpawn();
|
|
878
|
+
const child = lastSpawnedChild();
|
|
879
|
+
|
|
880
|
+
// header 完整一行
|
|
881
|
+
emitStdoutLine(child, sessionHeader());
|
|
882
|
+
// turn_end 拆成两半写入(跨 chunk 模拟)
|
|
883
|
+
child.stdout.write('{"type":"turn_e');
|
|
884
|
+
child.stdout.write('nd"}\n');
|
|
885
|
+
child.stdout.end();
|
|
886
|
+
child.emit("close", 0);
|
|
887
|
+
|
|
888
|
+
const result = await promise;
|
|
889
|
+
|
|
890
|
+
expect(result.success).toBe(true);
|
|
891
|
+
expect(record.turnCount).toBe(1); // 拆开的 turn_end 仍被解析为 1 次
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// 注:C1(orphan 进程兜底)与 M8(stdout 边界)describe 块已移至 run-spawn-edges.test.ts,
|
|
896
|
+
// 拆分以保持本文件 < 1000 行(pre-commit hook 限制)。两文件各自独立声明文件级 mock。
|
|
897
|
+
});
|