@zhushanwen/pi-subagent-workflow 0.2.0 → 0.3.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/README.md +56 -0
- package/agents/{scout.md → explorer.md} +1 -1
- package/agents/orchestrator.md +48 -0
- package/package.json +1 -1
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
- package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
- package/src/execution/__tests__/crash-recovery.test.ts +5 -1
- package/src/execution/__tests__/dialog-queue.test.ts +299 -0
- package/src/execution/__tests__/execute-nesting.test.ts +1 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +1 -1
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
- package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
- package/src/execution/__tests__/host-mode.test.ts +87 -0
- package/src/execution/__tests__/index-session-start.test.ts +342 -0
- package/src/execution/__tests__/list-component.test.ts +1 -1
- package/src/execution/__tests__/notifier-flush.test.ts +78 -0
- package/src/execution/__tests__/path-encoding.test.ts +30 -1
- package/src/execution/__tests__/record-store.test.ts +86 -2
- package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
- package/src/execution/__tests__/rpc-mode.test.ts +89 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
- package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
- package/src/execution/__tests__/spawn-args.test.ts +14 -19
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
- package/src/execution/__tests__/stdin-writer.test.ts +353 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/tool-action.test.ts +1 -1
- package/src/execution/__tests__/ui-channels.test.ts +187 -0
- package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
- package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
- package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
- package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
- package/src/execution/__tests__/worktree-manager.test.ts +1 -1
- package/src/execution/agent-registry.ts +1 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/finalize-record.ts +160 -0
- package/src/execution/get-state-handshake.ts +104 -0
- package/src/execution/host-mode.ts +52 -0
- package/src/execution/manifest-store.ts +206 -0
- package/src/execution/notifier.ts +5 -1
- package/src/execution/path-encoding.ts +18 -0
- package/src/execution/pi-invocation.ts +1 -1
- package/src/execution/record-store.ts +108 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-runner.ts +216 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/subagent-service.ts +167 -197
- package/src/execution/ui-channels.ts +216 -0
- package/src/execution/ui-interaction-model.ts +48 -0
- package/src/execution/ui-request-handler-factory.ts +175 -0
- package/src/execution/ui-request-observability.ts +77 -0
- package/src/execution/ui-request-queue.ts +168 -0
- package/src/index.ts +90 -6
- package/src/interface/format.ts +2 -0
- package/src/interface/subagent-actions.ts +9 -2
- package/src/interface/subagent-tool.ts +9 -8
|
@@ -14,36 +14,22 @@
|
|
|
14
14
|
// - fs.promises.* → 保留真实实现(temp-prompt 整体被 mock,不触发真实 I/O)。
|
|
15
15
|
// - temp-prompt → mock(writePromptToTempFile 返回固定路径,消除 fake-timers flaky)。
|
|
16
16
|
// - alive-store.writeAliveMarker → mock(避免写 .alive sidecar)。
|
|
17
|
+
//
|
|
18
|
+
// mock 工厂 + FakeChild class + 工具函数(lastSpawnedChild/waitForSpawn/emitStdoutLine/
|
|
19
|
+
// sessionHeader/makeRecord/makeOpts/makeCtx)抽到 helpers/spawn-mock.ts,与
|
|
20
|
+
// run-spawn-edges.test.ts / run-spawn-rpc-mode.test.ts 三文件共享。vi.mock 工厂内用
|
|
21
|
+
// `await import("./helpers/spawn-mock.ts")` 取回 FakeChild(绕开 vitest 的 hoisting 限制——
|
|
22
|
+
// 工厂函数体不能引用顶层 import 变量,但 async 工厂内的 await import 是运行时求值)。
|
|
17
23
|
|
|
18
|
-
import
|
|
24
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
25
|
+
import * as fs from "node:fs";
|
|
19
26
|
|
|
20
27
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
28
|
|
|
22
|
-
// ── mock modules
|
|
23
|
-
//
|
|
24
|
-
// vitest 会把 vi.mock 提升到文件顶部(早于其他 import / 声明)。mock 工厂若要引用
|
|
25
|
-
// FakeChild,需在工厂内部 import(async 工厂可用 await import),而非引用顶部
|
|
26
|
-
// 顶层 import(它们在 vi.mock 执行时尚未绑定)。
|
|
29
|
+
// ── mock modules(工厂体共享自 helpers/spawn-mock.ts;vi.mock 必须各文件独立声明)──
|
|
27
30
|
|
|
28
31
|
vi.mock("node:child_process", async () => {
|
|
29
|
-
const {
|
|
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
|
-
|
|
32
|
+
const { FakeChild } = await import("./helpers/spawn-mock.ts");
|
|
47
33
|
return {
|
|
48
34
|
spawn: vi.fn(() => new FakeChild()),
|
|
49
35
|
execFileSync: vi.fn(() => ""), // buildEnvBlock 的 git branch 调用,返回空避免副作用
|
|
@@ -62,13 +48,11 @@ vi.mock("node:fs", async () => {
|
|
|
62
48
|
writeFileSync: vi.fn(),
|
|
63
49
|
readdirSync: vi.fn(() => []),
|
|
64
50
|
},
|
|
65
|
-
// 具名导出与 default 保持一致
|
|
66
51
|
mkdirSync: vi.fn(),
|
|
67
52
|
existsSync: vi.fn(() => false),
|
|
68
53
|
appendFileSync: vi.fn(),
|
|
69
54
|
writeFileSync: vi.fn(),
|
|
70
55
|
readdirSync: vi.fn(() => []),
|
|
71
|
-
// promises 保留真实实现——temp-prompt 已被 mock(见下方 vi.mock),不再触发真实 I/O
|
|
72
56
|
promises: actual.promises,
|
|
73
57
|
};
|
|
74
58
|
});
|
|
@@ -80,9 +64,7 @@ vi.mock("../alive-store.ts", () => ({
|
|
|
80
64
|
// temp-prompt:mock 掉真实 fs.promises I/O(mkdtemp/writeFile/rm)。
|
|
81
65
|
// 原先保留真实实现导致 fake-timers 测试偶发 flaky——writePromptToTempFile 的真实异步
|
|
82
66
|
// I/O 在 CI 慢机器上无法在 advanceTimersByTimeAsync 的有限步数内 resolve,spawn 永不触发。
|
|
83
|
-
// runSpawn 只消费返回的 filePath 字符串(传给 --append-system-prompt),无需真实文件。
|
|
84
67
|
vi.mock("../temp-prompt.ts", () => ({
|
|
85
|
-
// 文件名规则与真实实现对齐(safeName:非 \w.- 替换为 _),保持 spawn args 断言稳定
|
|
86
68
|
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
87
69
|
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
88
70
|
return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
|
|
@@ -90,11 +72,18 @@ vi.mock("../temp-prompt.ts", () => ({
|
|
|
90
72
|
cleanupTempPrompt: vi.fn(async () => {}),
|
|
91
73
|
}));
|
|
92
74
|
|
|
93
|
-
import {
|
|
94
|
-
import
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
75
|
+
import { runSpawn } from "../session-runner.ts";
|
|
76
|
+
import {
|
|
77
|
+
emitStdoutLine,
|
|
78
|
+
type FakeChild,
|
|
79
|
+
lastSpawnedChild as lastSpawnedChildOf,
|
|
80
|
+
makeCtx,
|
|
81
|
+
makeOpts,
|
|
82
|
+
makeRecord,
|
|
83
|
+
mockSessionFileExists as mockSessionFileExistsOf,
|
|
84
|
+
sessionHeader,
|
|
85
|
+
waitForSpawn as waitForSpawnOf,
|
|
86
|
+
} from "./helpers/spawn-mock.ts";
|
|
98
87
|
|
|
99
88
|
const mockSpawn = vi.mocked(spawn);
|
|
100
89
|
const mockExec = vi.mocked(execFileSync);
|
|
@@ -102,124 +91,10 @@ const mockExistsSync = vi.mocked(fs.existsSync);
|
|
|
102
91
|
const mockAppendFileSync = vi.mocked(fs.appendFileSync);
|
|
103
92
|
const mockMkdirSync = vi.mocked(fs.mkdirSync);
|
|
104
93
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
}
|
|
94
|
+
// 绑定到本文件 mockSpawn 的 lastSpawnedChild/waitForSpawn(需读 mockSpawn.mock.results)
|
|
95
|
+
const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
|
|
96
|
+
const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
|
|
97
|
+
const mockSessionFileExists = (p: string): void => mockSessionFileExistsOf(mockExistsSync, p);
|
|
223
98
|
|
|
224
99
|
// ============================================================
|
|
225
100
|
// 测试
|
|
@@ -892,6 +767,65 @@ describe("runSpawn", () => {
|
|
|
892
767
|
});
|
|
893
768
|
});
|
|
894
769
|
|
|
770
|
+
// ── 15. stdin prompt 注入 ──
|
|
771
|
+
//
|
|
772
|
+
// [RPC prompt 修复] pi runRpcMode 只通过 stdin RpcCommand 驱动——positional task arg
|
|
773
|
+
// / -p flag 在 rpc mode 下被 resolveAppMode 无视。runSpawn 必须在 spawn 后主动写
|
|
774
|
+
// {type:"prompt",message:<task>} 到 child.stdin,否则子进程阻塞、totalTokens 恒 0。
|
|
775
|
+
// sendPromptCommand 在 spawn + setEncoding 后同步执行,waitForSpawn 拿到 child 时
|
|
776
|
+
// 命令已在 stdin 缓冲。PassThrough.write 无 reader 时缓冲全部数据,可事后读出断言。
|
|
777
|
+
describe("stdin prompt 注入", () => {
|
|
778
|
+
it("spawn 后向 stdin 写一行 {type:prompt} 且 message 含 task 文本", async () => {
|
|
779
|
+
const record = makeRecord();
|
|
780
|
+
const taskText = "Task: hello-prompt-injection";
|
|
781
|
+
const promise = runSpawn(record, taskText, makeOpts(), makeCtx());
|
|
782
|
+
|
|
783
|
+
await waitForSpawn();
|
|
784
|
+
const child = lastSpawnedChild();
|
|
785
|
+
|
|
786
|
+
// sendPromptCommand 已同步执行——PassThrough 缓冲了写入的命令,读出来断言。
|
|
787
|
+
// pause() 让 PassThrough 切到暂停模式(默认 flow 模式下数据缓冲在内部),
|
|
788
|
+
// 然后 read() 取出全部已缓冲内容。
|
|
789
|
+
child.stdin.pause();
|
|
790
|
+
const buffered = child.stdin.read()?.toString() ?? "";
|
|
791
|
+
|
|
792
|
+
// 收尾:让 runSpawn resolve(避免悬挂)
|
|
793
|
+
emitStdoutLine(child, sessionHeader());
|
|
794
|
+
child.stdout.end();
|
|
795
|
+
child.stderr.end();
|
|
796
|
+
child.emit("close", 0);
|
|
797
|
+
await promise;
|
|
798
|
+
|
|
799
|
+
// 断言:缓冲含合法 JSON,type=prompt,message 含 task 文本
|
|
800
|
+
const lines = buffered.trim().split("\n");
|
|
801
|
+
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
802
|
+
const cmd = JSON.parse(lines[0]!) as { type: string; message: string; id?: string };
|
|
803
|
+
expect(cmd.type).toBe("prompt");
|
|
804
|
+
expect(cmd.message).toBe(taskText);
|
|
805
|
+
expect(typeof cmd.id).toBe("string");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("child.stdin.destroyed → sendPromptCommand 不抛错(guard 生效)", async () => {
|
|
809
|
+
const record = makeRecord();
|
|
810
|
+
const promise = runSpawn(record, "Task: destroyed-stdin", makeOpts(), makeCtx());
|
|
811
|
+
|
|
812
|
+
await waitForSpawn();
|
|
813
|
+
const child = lastSpawnedChild();
|
|
814
|
+
|
|
815
|
+
// destroy stdin 模拟子进程已关闭输入通道;sendPromptCommand 在 spawn 时已执行过一次
|
|
816
|
+
//(stdin 当时未 destroyed),这里仅验证后续不抛。此用例主要保护 guard 逻辑——
|
|
817
|
+
// 收尾正常 close 即证明无异常抛出中断 runSpawn。
|
|
818
|
+
child.stdin.destroy();
|
|
819
|
+
emitStdoutLine(child, sessionHeader());
|
|
820
|
+
child.stdout.end();
|
|
821
|
+
child.stderr.end();
|
|
822
|
+
child.emit("close", 0);
|
|
823
|
+
|
|
824
|
+
const result = await promise;
|
|
825
|
+
expect(result.success).toBe(true);
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
895
829
|
// 注:C1(orphan 进程兜底)与 M8(stdout 边界)describe 块已移至 run-spawn-edges.test.ts,
|
|
896
830
|
// 拆分以保持本文件 < 1000 行(pre-commit hook 限制)。两文件各自独立声明文件级 mock。
|
|
897
831
|
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// src/__tests__/run-spawn-rpc-mode.test.ts
|
|
2
|
+
//
|
|
3
|
+
// runSpawn 的 RPC mode 集成测试(从 run-spawn-integration.test.ts 拆出,保持该文件 < 1000 行)。
|
|
4
|
+
//
|
|
5
|
+
// 本文件覆盖 FR-4: RPC mode(pi --mode rpc)无 header 场景——record.sessionFile 无法靠
|
|
6
|
+
// stdout header 推导,必须通过 get_state RPC 握手回填。验证修复后的握手逻辑。
|
|
7
|
+
//
|
|
8
|
+
// mock 工厂 + FakeChild + 工具函数共享自 helpers/spawn-mock.ts(详见该文件头注释)。
|
|
9
|
+
// vi.mock 必须各文件独立声明(文件作用域),工厂内用 `await import` 取回 FakeChild。
|
|
10
|
+
|
|
11
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
|
|
16
|
+
vi.mock("node:child_process", async () => {
|
|
17
|
+
const { FakeChild } = await import("./helpers/spawn-mock.ts");
|
|
18
|
+
return {
|
|
19
|
+
spawn: vi.fn(() => new FakeChild()),
|
|
20
|
+
execFileSync: vi.fn(() => ""),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock("node:fs", async () => {
|
|
25
|
+
const actual = await import("node:fs");
|
|
26
|
+
return {
|
|
27
|
+
default: {
|
|
28
|
+
...actual,
|
|
29
|
+
mkdirSync: vi.fn(),
|
|
30
|
+
existsSync: vi.fn(() => false),
|
|
31
|
+
appendFileSync: vi.fn(),
|
|
32
|
+
writeFileSync: vi.fn(),
|
|
33
|
+
readdirSync: vi.fn(() => []),
|
|
34
|
+
},
|
|
35
|
+
mkdirSync: vi.fn(),
|
|
36
|
+
existsSync: vi.fn(() => false),
|
|
37
|
+
appendFileSync: vi.fn(),
|
|
38
|
+
writeFileSync: vi.fn(),
|
|
39
|
+
readdirSync: vi.fn(() => []),
|
|
40
|
+
promises: actual.promises,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
vi.mock("../alive-store.ts", () => ({
|
|
45
|
+
writeAliveMarker: vi.fn(),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
vi.mock("../temp-prompt.ts", () => ({
|
|
49
|
+
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
50
|
+
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
51
|
+
return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
|
|
52
|
+
}),
|
|
53
|
+
cleanupTempPrompt: vi.fn(async () => {}),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
import { runSpawn } from "../session-runner.ts";
|
|
57
|
+
import {
|
|
58
|
+
emitStdoutLine,
|
|
59
|
+
type FakeChild,
|
|
60
|
+
lastSpawnedChild as lastSpawnedChildOf,
|
|
61
|
+
makeCtx,
|
|
62
|
+
makeOpts,
|
|
63
|
+
makeRecord,
|
|
64
|
+
mockSessionFileExists as mockSessionFileExistsOf,
|
|
65
|
+
waitForSpawn as waitForSpawnOf,
|
|
66
|
+
} from "./helpers/spawn-mock.ts";
|
|
67
|
+
|
|
68
|
+
const mockSpawn = vi.mocked(spawn);
|
|
69
|
+
const mockExec = vi.mocked(execFileSync);
|
|
70
|
+
const mockExistsSync = vi.mocked(fs.existsSync);
|
|
71
|
+
const mockAppendFileSync = vi.mocked(fs.appendFileSync);
|
|
72
|
+
|
|
73
|
+
// 绑定到本文件 mockSpawn/mockExistsSync 的 helper(需读 mock 状态)
|
|
74
|
+
const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
|
|
75
|
+
const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
|
|
76
|
+
const mockSessionFileExists = (p: string): void => mockSessionFileExistsOf(mockExistsSync, p);
|
|
77
|
+
|
|
78
|
+
// ============================================================
|
|
79
|
+
// 测试
|
|
80
|
+
// ============================================================
|
|
81
|
+
|
|
82
|
+
describe("runSpawn", () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
vi.clearAllMocks();
|
|
85
|
+
mockExec.mockReturnValue("");
|
|
86
|
+
mockExistsSync.mockReturnValue(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
vi.restoreAllMocks();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── FR-4: RPC mode 无 header(get_state 握手回填 sessionFile)──
|
|
94
|
+
//
|
|
95
|
+
// RPC mode(pi --mode rpc)不向 stdout 输出 header 行,record.sessionFile 无法靠
|
|
96
|
+
// header 推导,必须通过 get_state RPC 握手回填。json mode 测试 emit sessionHeader()
|
|
97
|
+
// 模拟 header;本组测试不 emit header,靠 get_state response 回填,验证修复后的握手逻辑
|
|
98
|
+
//(握手移出 header 块、spawn 后无条件启动、close handler 主动 settle 不阻塞)。
|
|
99
|
+
describe("RPC mode 无 header(FR-4 get_state 握手)", () => {
|
|
100
|
+
/**
|
|
101
|
+
* 捕获握手发出的 get_state 命令并 emit 对应 response。
|
|
102
|
+
*
|
|
103
|
+
* 握手在 spawn 后发 get_state 到 child.stdin(id 随机)。测试监听 stdin 捕获 id,
|
|
104
|
+
* emit get_state response 到 stdout,经 stdout pump 匹配 get_stateListeners 触发
|
|
105
|
+
* finishHandshake 回填 record.sessionFile。
|
|
106
|
+
*/
|
|
107
|
+
function captureAndRespondGetState(
|
|
108
|
+
child: FakeChild,
|
|
109
|
+
sessionFile: string,
|
|
110
|
+
sessionId = "rpc-sess",
|
|
111
|
+
): void {
|
|
112
|
+
child.stdin.on("data", (data: Buffer | string) => {
|
|
113
|
+
const text = typeof data === "string" ? data : data.toString();
|
|
114
|
+
for (const line of text.split("\n")) {
|
|
115
|
+
if (!line.trim()) continue;
|
|
116
|
+
try {
|
|
117
|
+
const cmd = JSON.parse(line) as { type?: string; id?: string };
|
|
118
|
+
if (cmd.type === "get_state" && cmd.id) {
|
|
119
|
+
emitStdoutLine(child, {
|
|
120
|
+
type: "response",
|
|
121
|
+
command: "get_state",
|
|
122
|
+
success: true,
|
|
123
|
+
id: cmd.id,
|
|
124
|
+
data: { sessionFile, sessionId },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// 非 JSON 行(prompt 命令等)忽略
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
it("无 header + get_state response 回填 sessionFile → identity 写入成功", async () => {
|
|
135
|
+
const record = makeRecord();
|
|
136
|
+
const promise = runSpawn(record, "Task: rpc-no-header", makeOpts(), makeCtx());
|
|
137
|
+
|
|
138
|
+
await waitForSpawn();
|
|
139
|
+
const child = lastSpawnedChild();
|
|
140
|
+
|
|
141
|
+
const expectedSessionFile =
|
|
142
|
+
"/tmp/test/agents/subagents/--tmp-test--/sessions/rpc-session.jsonl";
|
|
143
|
+
// 进程退出后 existsSync(record.sessionFile) 校验通过 → 补写 identity
|
|
144
|
+
mockSessionFileExists(expectedSessionFile);
|
|
145
|
+
captureAndRespondGetState(child, expectedSessionFile);
|
|
146
|
+
|
|
147
|
+
// 等待 stdin listener 触发 + response 经 stdout pump 处理 → finishHandshake 回填。
|
|
148
|
+
// PassThrough attach data listener 后在 nextTick flush 缓冲,setTimeout(20) 足够覆盖。
|
|
149
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
150
|
+
|
|
151
|
+
// RPC mode:只 emit 事件,不 emit header
|
|
152
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
153
|
+
child.stdout.end();
|
|
154
|
+
child.emit("close", 0);
|
|
155
|
+
|
|
156
|
+
const result = await promise;
|
|
157
|
+
|
|
158
|
+
expect(result.success).toBe(true);
|
|
159
|
+
expect(record.sessionFile).toBe(expectedSessionFile);
|
|
160
|
+
expect(result.sessionFile).toBe(expectedSessionFile);
|
|
161
|
+
// identity 经握手回填的 sessionFile 写入(不再依赖 sessionHeader 条件)
|
|
162
|
+
expect(mockAppendFileSync).toHaveBeenCalledWith(
|
|
163
|
+
expectedSessionFile,
|
|
164
|
+
expect.stringContaining('"customType":"subagent-identity"'),
|
|
165
|
+
"utf-8",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("无 header + get_state 无响应 → close 主动 settle 不阻塞,identity 不写入", async () => {
|
|
170
|
+
const record = makeRecord();
|
|
171
|
+
const promise = runSpawn(record, "Task: rpc-no-response", makeOpts(), makeCtx());
|
|
172
|
+
|
|
173
|
+
await waitForSpawn();
|
|
174
|
+
const child = lastSpawnedChild();
|
|
175
|
+
|
|
176
|
+
// 消费 stdin 避免背压;不 emit get_state response(模拟握手超时/失败)
|
|
177
|
+
child.stdin.on("data", () => {});
|
|
178
|
+
|
|
179
|
+
emitStdoutLine(child, { type: "turn_end" });
|
|
180
|
+
child.stdout.end();
|
|
181
|
+
child.emit("close", 0);
|
|
182
|
+
|
|
183
|
+
const result = await promise;
|
|
184
|
+
|
|
185
|
+
// close handler 主动 settle,不等握手内部 6s 超时 → 测试不超时(5s 默认上限)
|
|
186
|
+
expect(result.success).toBe(true);
|
|
187
|
+
// 握手未完成 → sessionFile 未回填
|
|
188
|
+
expect(record.sessionFile).toBeUndefined();
|
|
189
|
+
// identity 不写入
|
|
190
|
+
expect(mockAppendFileSync).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -244,4 +244,50 @@ describe("maybeCleanupExpiredSessionFiles", () => {
|
|
|
244
244
|
expect(fs.existsSync(finalized)).toBe(false);
|
|
245
245
|
expect(fs.existsSync(alive)).toBe(false);
|
|
246
246
|
});
|
|
247
|
+
|
|
248
|
+
// ---- [F2] manifest .json 清理(仅 records 子目录内)----
|
|
249
|
+
// records 目录布局:subagents/<enc>/records/<id>.json(D-004 cwd 物理隔离)。
|
|
250
|
+
// allowManifestJson 仅在名为 records 的子目录内打开,防止误删 enc 外层的
|
|
251
|
+
// worktrees.json(worktree reaper 依赖的状态文件)。
|
|
252
|
+
|
|
253
|
+
it("[F2] deletes expired manifest .json inside records/ subdir", () => {
|
|
254
|
+
forceCleanupTrigger();
|
|
255
|
+
const manifest = createSessionFile(
|
|
256
|
+
path.join("--Users-x-proj--", "records", "rec-1.json"),
|
|
257
|
+
31,
|
|
258
|
+
);
|
|
259
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
260
|
+
expect(fs.existsSync(manifest)).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("[F2] preserves manifest .json younger than TTL inside records/", () => {
|
|
264
|
+
forceCleanupTrigger();
|
|
265
|
+
const manifest = createSessionFile(
|
|
266
|
+
path.join("--Users-x-proj--", "records", "rec-young.json"),
|
|
267
|
+
5,
|
|
268
|
+
);
|
|
269
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
270
|
+
expect(fs.existsSync(manifest)).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("[F2] CRITICAL: does NOT delete worktrees.json at subagents/ root (regression)", () => {
|
|
274
|
+
// 破坏性风险点:worktrees.json 在 agentDir/subagents/worktrees.json(enc 外层第一层)。
|
|
275
|
+
// 暴力匹配所有 .json 会误删 → worktree reaper 失效。allowManifestJson 只在名为 records
|
|
276
|
+
// 的子目录内打开,根层的 worktrees.json 必须保留。
|
|
277
|
+
forceCleanupTrigger();
|
|
278
|
+
const worktrees = createSessionFile("worktrees.json", 31);
|
|
279
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
280
|
+
expect(fs.existsSync(worktrees)).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("[F2] preserves .json.tmp.* inside records/ (recoverTmpFiles owns them)", () => {
|
|
284
|
+
// 跳过 .tmp.:session_start 的 recoverTmpFiles 同步处理 tmp,GC 不重复。
|
|
285
|
+
forceCleanupTrigger();
|
|
286
|
+
const tmp = createSessionFile(
|
|
287
|
+
path.join("--Users-x-proj--", "records", "rec-2.json.tmp.123"),
|
|
288
|
+
31,
|
|
289
|
+
);
|
|
290
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
291
|
+
expect(fs.existsSync(tmp)).toBe(true);
|
|
292
|
+
});
|
|
247
293
|
});
|
|
@@ -58,10 +58,12 @@ vi.mock("../session-file-gc.ts", () => ({
|
|
|
58
58
|
}));
|
|
59
59
|
|
|
60
60
|
// mock subagent-service:避免真正构造 SubagentService(它依赖 ModelConfigService 等)
|
|
61
|
-
const { mockInitModel, mockInitSession, mockSetModelConfigService, mockSetSubagentService, capturedConstructorArg } =
|
|
61
|
+
const { mockInitModel, mockInitSession, mockSetUiRequestHandler, mockSetModelConfigService, mockSetSubagentService, capturedConstructorArg } =
|
|
62
62
|
vi.hoisted(() => ({
|
|
63
63
|
mockInitModel: vi.fn(),
|
|
64
64
|
mockInitSession: vi.fn(),
|
|
65
|
+
// W3: index.ts session_start 注入 UI handler 时调用
|
|
66
|
+
mockSetUiRequestHandler: vi.fn(),
|
|
65
67
|
mockSetModelConfigService: vi.fn(),
|
|
66
68
|
mockSetSubagentService: vi.fn(),
|
|
67
69
|
capturedConstructorArg: { current: undefined as unknown },
|
|
@@ -80,6 +82,8 @@ vi.mock("../model-config-service.ts", () => ({
|
|
|
80
82
|
vi.mock("../subagent-service.ts", () => ({
|
|
81
83
|
SubagentService: class {
|
|
82
84
|
initSession = mockInitSession;
|
|
85
|
+
// W3: index.ts session_start 注入 UI handler 时调用
|
|
86
|
+
setUiRequestHandler = mockSetUiRequestHandler;
|
|
83
87
|
constructor(init: unknown) {
|
|
84
88
|
capturedConstructorArg.current = init;
|
|
85
89
|
}
|
|
@@ -136,6 +140,8 @@ function createMockPi(overrides: Record<string, unknown> = {}): {
|
|
|
136
140
|
function createMockCtx(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
137
141
|
return {
|
|
138
142
|
cwd: "/home/user/project",
|
|
143
|
+
// [Wave1 #21] mode 必填(与 SDK ExtensionContext 契约一致);默认 tui。
|
|
144
|
+
mode: "tui",
|
|
139
145
|
modelRegistry: { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => false },
|
|
140
146
|
model: undefined,
|
|
141
147
|
sessionManager: {
|
|
@@ -20,18 +20,20 @@ describe("buildSpawnArgs", () => {
|
|
|
20
20
|
skillPaths: undefined as string[] | undefined,
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
it("基础参数:--mode
|
|
24
|
-
const args = buildSpawnArgs(baseParams
|
|
25
|
-
expect(args).toEqual([
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
it("基础参数:--mode rpc --session-dir,不含 -p 也不含 task(task 经 stdin 传)", () => {
|
|
24
|
+
const args = buildSpawnArgs(baseParams);
|
|
25
|
+
expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("不含 -p / --print(rpc mode 下 -p 被 resolveAppMode 无视,是死代码)", () => {
|
|
29
|
+
const args = buildSpawnArgs(baseParams);
|
|
30
|
+
expect(args).not.toContain("-p");
|
|
31
|
+
expect(args).not.toContain("--print");
|
|
29
32
|
});
|
|
30
33
|
|
|
31
34
|
it("有 model → 追加 --model provider/id", () => {
|
|
32
35
|
const args = buildSpawnArgs(
|
|
33
36
|
{ ...baseParams, model: "openai/gpt-4o" },
|
|
34
|
-
"Task: x",
|
|
35
37
|
);
|
|
36
38
|
expect(args).toContain("--model");
|
|
37
39
|
const idx = args.indexOf("--model");
|
|
@@ -41,7 +43,6 @@ describe("buildSpawnArgs", () => {
|
|
|
41
43
|
it("model + thinkingLevel → model 后缀 :level", () => {
|
|
42
44
|
const args = buildSpawnArgs(
|
|
43
45
|
{ ...baseParams, model: "anthropic/claude", thinkingLevel: "high" },
|
|
44
|
-
"Task: x",
|
|
45
46
|
);
|
|
46
47
|
const idx = args.indexOf("--model");
|
|
47
48
|
expect(args[idx + 1]).toBe("anthropic/claude:high");
|
|
@@ -50,7 +51,6 @@ describe("buildSpawnArgs", () => {
|
|
|
50
51
|
it("thinkingLevel 无 model → 不追加(thinking 依赖 model 后缀)", () => {
|
|
51
52
|
const args = buildSpawnArgs(
|
|
52
53
|
{ ...baseParams, model: undefined, thinkingLevel: "high" },
|
|
53
|
-
"Task: x",
|
|
54
54
|
);
|
|
55
55
|
expect(args).not.toContain("--model");
|
|
56
56
|
});
|
|
@@ -58,7 +58,6 @@ describe("buildSpawnArgs", () => {
|
|
|
58
58
|
it("agentTools → --tools 逗号分隔", () => {
|
|
59
59
|
const args = buildSpawnArgs(
|
|
60
60
|
{ ...baseParams, agentTools: ["read", "bash", "edit"] },
|
|
61
|
-
"Task: x",
|
|
62
61
|
);
|
|
63
62
|
const idx = args.indexOf("--tools");
|
|
64
63
|
expect(args[idx + 1]).toBe("read,bash,edit");
|
|
@@ -67,7 +66,6 @@ describe("buildSpawnArgs", () => {
|
|
|
67
66
|
it("appendSystemPromptPath → --append-system-prompt <path>", () => {
|
|
68
67
|
const args = buildSpawnArgs(
|
|
69
68
|
{ ...baseParams, appendSystemPromptPath: "/tmp/prompt.md" },
|
|
70
|
-
"Task: x",
|
|
71
69
|
);
|
|
72
70
|
const idx = args.indexOf("--append-system-prompt");
|
|
73
71
|
expect(args[idx + 1]).toBe("/tmp/prompt.md");
|
|
@@ -76,7 +74,6 @@ describe("buildSpawnArgs", () => {
|
|
|
76
74
|
it("forkSource → --fork <path>", () => {
|
|
77
75
|
const args = buildSpawnArgs(
|
|
78
76
|
{ ...baseParams, forkSource: "/sessions/parent.jsonl" },
|
|
79
|
-
"Task: x",
|
|
80
77
|
);
|
|
81
78
|
const idx = args.indexOf("--fork");
|
|
82
79
|
expect(args[idx + 1]).toBe("/sessions/parent.jsonl");
|
|
@@ -85,7 +82,6 @@ describe("buildSpawnArgs", () => {
|
|
|
85
82
|
it("skillPaths 多个 → 每个 push --skill <path>", () => {
|
|
86
83
|
const args = buildSpawnArgs(
|
|
87
84
|
{ ...baseParams, skillPaths: ["/skills/a", "/skills/b", "/skills/c"] },
|
|
88
|
-
"Task: x",
|
|
89
85
|
);
|
|
90
86
|
// 三个 --skill token,后跟各自路径,顺序保留
|
|
91
87
|
const skillIdxs = args
|
|
@@ -100,17 +96,16 @@ describe("buildSpawnArgs", () => {
|
|
|
100
96
|
it("skillPaths 空数组 → 不含 --skill", () => {
|
|
101
97
|
const args = buildSpawnArgs(
|
|
102
98
|
{ ...baseParams, skillPaths: [] },
|
|
103
|
-
"Task: x",
|
|
104
99
|
);
|
|
105
100
|
expect(args).not.toContain("--skill");
|
|
106
101
|
});
|
|
107
102
|
|
|
108
103
|
it("skillPaths undefined → 不含 --skill", () => {
|
|
109
|
-
const args = buildSpawnArgs(baseParams
|
|
104
|
+
const args = buildSpawnArgs(baseParams);
|
|
110
105
|
expect(args).not.toContain("--skill");
|
|
111
106
|
});
|
|
112
107
|
|
|
113
|
-
it("
|
|
108
|
+
it("全参数组合:所有 flag 存在,不含 -p 也不含 positional task", () => {
|
|
114
109
|
const args = buildSpawnArgs(
|
|
115
110
|
{
|
|
116
111
|
model: "openai/gpt-4o",
|
|
@@ -121,18 +116,18 @@ describe("buildSpawnArgs", () => {
|
|
|
121
116
|
forkSource: "/parent.jsonl",
|
|
122
117
|
skillPaths: ["/skills/x"],
|
|
123
118
|
},
|
|
124
|
-
"final task",
|
|
125
119
|
);
|
|
126
|
-
|
|
120
|
+
// 末尾应是最后一个 --skill 的路径(task 不再作为 positional arg 出现)
|
|
121
|
+
expect(args[args.length - 1]).toBe("/skills/x");
|
|
127
122
|
expect(args).toContain("--fork");
|
|
128
123
|
expect(args).toContain("--tools");
|
|
129
124
|
expect(args).toContain("--skill");
|
|
125
|
+
expect(args).not.toContain("-p");
|
|
130
126
|
});
|
|
131
127
|
|
|
132
128
|
it("空 tools 数组不追加 --tools", () => {
|
|
133
129
|
const args = buildSpawnArgs(
|
|
134
130
|
{ ...baseParams, agentTools: [] },
|
|
135
|
-
"Task: x",
|
|
136
131
|
);
|
|
137
132
|
expect(args).not.toContain("--tools");
|
|
138
133
|
});
|