@zhushanwen/pi-subagent-workflow 0.2.0 → 0.3.1

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 (70) hide show
  1. package/README.md +56 -0
  2. package/agents/{scout.md → explorer.md} +1 -1
  3. package/agents/orchestrator.md +48 -0
  4. package/package.json +1 -1
  5. package/src/execution/__tests__/agent-registry.test.ts +3 -3
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
  7. package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
  8. package/src/execution/__tests__/crash-recovery.test.ts +5 -1
  9. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  10. package/src/execution/__tests__/execute-nesting.test.ts +1 -1
  11. package/src/execution/__tests__/execute-options-mapper.test.ts +22 -3
  12. package/src/execution/__tests__/finalize-record.test.ts +173 -0
  13. package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
  14. package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
  15. package/src/execution/__tests__/host-mode.test.ts +87 -0
  16. package/src/execution/__tests__/index-session-start.test.ts +342 -0
  17. package/src/execution/__tests__/list-component.test.ts +1 -1
  18. package/src/execution/__tests__/notifier-flush.test.ts +78 -0
  19. package/src/execution/__tests__/path-encoding.test.ts +30 -1
  20. package/src/execution/__tests__/record-store.test.ts +86 -2
  21. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  22. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  23. package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
  24. package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
  25. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
  26. package/src/execution/__tests__/session-file-gc.test.ts +46 -0
  27. package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
  28. package/src/execution/__tests__/spawn-args.test.ts +14 -19
  29. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  30. package/src/execution/__tests__/stdin-writer.test.ts +353 -0
  31. package/src/execution/__tests__/subagent-service.test.ts +73 -3
  32. package/src/execution/__tests__/tool-action.test.ts +4 -4
  33. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  34. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  35. package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
  36. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  37. package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
  38. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  39. package/src/execution/__tests__/worktree-manager.test.ts +1 -1
  40. package/src/execution/agent-registry.ts +1 -1
  41. package/src/execution/channel-registry-access.ts +138 -0
  42. package/src/execution/dialog-queue.ts +329 -0
  43. package/src/execution/execute-options-mapper.ts +5 -3
  44. package/src/execution/finalize-record.ts +160 -0
  45. package/src/execution/get-state-handshake.ts +104 -0
  46. package/src/execution/host-mode.ts +52 -0
  47. package/src/execution/manifest-store.ts +206 -0
  48. package/src/execution/notifier.ts +5 -1
  49. package/src/execution/path-encoding.ts +18 -0
  50. package/src/execution/pi-invocation.ts +1 -1
  51. package/src/execution/record-store.ts +108 -2
  52. package/src/execution/session-file-gc.ts +25 -3
  53. package/src/execution/session-runner.ts +216 -32
  54. package/src/execution/spawn-event-adapter.ts +219 -6
  55. package/src/execution/stdin-writer.ts +106 -0
  56. package/src/execution/subagent-service.ts +167 -197
  57. package/src/execution/types.ts +6 -6
  58. package/src/execution/ui-channels.ts +216 -0
  59. package/src/execution/ui-interaction-model.ts +48 -0
  60. package/src/execution/ui-request-handler-factory.ts +175 -0
  61. package/src/execution/ui-request-observability.ts +77 -0
  62. package/src/execution/ui-request-queue.ts +168 -0
  63. package/src/index.ts +90 -6
  64. package/src/interface/__tests__/detectors.test.ts +76 -0
  65. package/src/interface/__tests__/subagent-tool-prompt.test.ts +27 -4
  66. package/src/interface/__tests__/workflow-tool-prompt.test.ts +24 -2
  67. package/src/interface/format.ts +2 -0
  68. package/src/interface/subagent-actions.ts +21 -11
  69. package/src/interface/subagent-tool.ts +43 -10
  70. package/src/interface/tool-workflow.ts +58 -16
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @zhushanwen/pi-subagent-workflow
2
+
3
+ Pi 的 subagent + workflow 合并包:任务委派 + 多 agent 编排(chain / parallel / scatter-gather / map-reduce),单包统一执行链 + 分层配额(ADR-030)。
4
+
5
+ ## 内置 Agents
6
+
7
+ | Agent | 角色 | 是否执行 |
8
+ |-------|------|---------|
9
+ | `context-builder` | 模糊需求 → 可执行规格(meta-prompt) | 只写 what |
10
+ | `planner` | 已明确需求 → 有序实施步骤 | 只写 how |
11
+ | `explorer` | 代码库结构摸底(只读) | 不改文件 |
12
+ | `researcher` | 外部资料 / 竞品 / 文档调研 | 不改文件 |
13
+ | `worker` | 编码 / 修复 / 文件操作 | 可改文件 |
14
+ | `reviewer` | 代码质量审查、找 bug | 只读 |
15
+ | `oracle` | 需求对齐核验 | 只读 |
16
+ | `orchestrator` | **纯协调器**:拆解 + 委派,不直接执行 | 只协调 |
17
+ | `general-purpose` | 兜底,无角色假设 | 按需 |
18
+
19
+ ## Orchestrator 协调器模式
20
+
21
+ 主 agent 禁用 bash / read / write / edit 等执行工具,只保留协调类工具,被迫作为纯协调器:拆解任务 → 委派 subagent → 汇总结果。orchestrator agent 自身也可递归委派子 orchestrator,实现分层任务拆解(深度受 `Depth: N/10` 护栏保护)。
22
+
23
+ 可用工具仅 4 个:`todo`、`goal_control`、`workflow`、`subagent`。
24
+
25
+ ### 启动命令
26
+
27
+ ```bash
28
+ # 方式一:CLI 工具白名单(临时验证最快)
29
+ pi --tools todo,goal_control,workflow,subagent
30
+
31
+ # 方式二:白名单 + 注入 orchestrator 的 system prompt(推荐,主进程也具备协调器视角)
32
+ pi --tools todo,goal_control,workflow,subagent \
33
+ --append-system-prompt "$(cat ~/.pi/agent/npm/node_modules/@zhushanwen/pi-subagent-workflow/agents/orchestrator.md)"
34
+ ```
35
+
36
+ > **依赖**:需先安装本包及相关扩展
37
+ > ```bash
38
+ > pi install npm:@zhushanwen/pi-subagent-workflow
39
+ > pi install npm:@zhushanwen/pi-todo
40
+ > pi install npm:@zhushanwen/pi-goal
41
+ > ```
42
+ > `--tools` 白名单按 tool 注册名匹配。注意 goal 扩展注册的 tool 名是 `goal_control`(非 `goal`)。
43
+
44
+ ### 递归深度
45
+
46
+ 系统内置 `n = 10` 深度护栏(fork 链 + 嵌套取 max),超过抛 `ForkDepthExceededError`。实测建议控制在 **3-4 层**以内——更深层会因上下文逐层压缩导致信息失真。
47
+
48
+ ## 安装
49
+
50
+ ```bash
51
+ pi install npm:@zhushanwen/pi-subagent-workflow
52
+ ```
53
+
54
+ ## License
55
+
56
+ MIT
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: scout
2
+ name: explorer
3
3
  description: 快速代码库侦查
4
4
  tools: read, bash, grep
5
5
  ---
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: orchestrator
3
+ description: "纯协调器 agent,只做任务拆解与委派,不直接执行读写或命令操作"
4
+ tools: todo, goal_control, workflow, subagent
5
+ ---
6
+
7
+ 你是一个纯协调器(orchestrator)。你的职责是理解目标、拆解任务、分配给合适的执行 agent、汇总结果、对齐决策。你不亲自读写文件、不亲自跑命令——这些由子 agent 完成。
8
+
9
+ ## 可用工具
10
+
11
+ 你只有以下 4 个工具,其余全部不可用:
12
+
13
+ - **todo** — 追踪任务清单(拆解后的子任务状态)
14
+ - **goal_control** — 目标驱动循环 + 预算控制(长任务用目标封装)
15
+ - **workflow** — 多 agent 编排(chain / parallel / scatter-gather / map-reduce)
16
+ - **subagent** — 委派单个子任务给执行 agent
17
+
18
+ 没有 bash / read / write / edit / grep。不要尝试调用它们。
19
+
20
+ ## 执行 agent 选择
21
+
22
+ 通过 `subagent` 工具的 `agent` 字段指定角色:
23
+
24
+ | Agent | 适用场景 |
25
+ |-------|---------|
26
+ | `explorer` | 摸清代码库结构、找入口点、理解模块关系 |
27
+ | `researcher` | 外部资料、竞品、文档调研 |
28
+ | `planner` | 已明确需求的有序实施步骤 |
29
+ | `context-builder` | 模糊需求转成可执行规格 |
30
+ | `worker` | 编码、修复、文件操作 |
31
+ | `reviewer` | 代码质量审查、找 bug |
32
+ | `oracle` | 需求对齐核验 |
33
+ | `orchestrator` | 子任务仍过复杂时递归拆解(见下) |
34
+
35
+ ## 派发原则
36
+
37
+ 1. **无依赖则并发**:独立子任务用并发 subagent(同一消息多个 start),不要串行
38
+ 2. **有依赖则串行**:后置任务依赖前置产出时,等前置完成再派
39
+ 3. **禁止空泛委托**:每个子任务必须包含目标、输入文件路径(绝对路径)、预期产出、约束
40
+ 4. **综合而非转述**:汇总子 agent 结果时做跨任务对齐与决策,不原样转发
41
+
42
+ ## 递归与深度控制
43
+
44
+ 你可以把过复杂的子任务委派给子 `orchestrator`。嵌套深度受系统护栏保护(环境块 `Depth: N/10`)。实测建议控制在 **3-4 层以内**——超过后上下文逐层压缩,原始信息(文件内容、命令输出)到不了顶层,出现"电话传话"式失真。接近上限时主动收敛,改用 worker 直接执行。
45
+
46
+ ## 输出
47
+
48
+ 汇报每个子任务的派发决策与汇总结论。不叙述推导过程。受阻要明说,不要静默跳过。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
@@ -147,13 +147,13 @@ describe("AgentRegistry.discoverAll", () => {
147
147
  // ============================================================
148
148
 
149
149
  describe("createPackageBuiltinRegistry", () => {
150
- it("discovers packaged agents/*.md (worker, reviewer, scout, etc.)", () => {
150
+ it("discovers packaged agents/*.md (worker, reviewer, explorer, etc.)", () => {
151
151
  // [HISTORICAL] S6: 包内 agents/ 此前未被接通——discoverAll 从未调用,
152
152
  // 导致 pi install 后包内 agent 定义开箱不可用。
153
153
  const builtin = createPackageBuiltinRegistry();
154
154
  const names = builtin.list();
155
- // 包内至少有 worker/reviewer/scout 等核心 agent
156
- expect(names).toEqual(expect.arrayContaining(["worker", "reviewer", "scout", "researcher", "planner", "oracle", "context-builder"]));
155
+ // 包内至少有 worker/reviewer/explorer 等核心 agent
156
+ expect(names).toEqual(expect.arrayContaining(["worker", "reviewer", "explorer", "researcher", "planner", "oracle", "context-builder"]));
157
157
  // 每个 agent 都有 systemPrompt
158
158
  for (const name of names) {
159
159
  const cfg = builtin.get(name);
@@ -0,0 +1,484 @@
1
+ // src/execution/__tests__/ask-user-transit-e2e.test.ts
2
+ //
3
+ // #34 跨进程 ask_user transit 完整 e2e 测试。
4
+ //
5
+ // 验证完整链路:
6
+ // FakeChild.stdout emit extension_ui_request
7
+ // → runSpawn stdout pump(parseSpawnLine 解析)
8
+ // → createUiRequestQueue 入队 + handleUiRequest
9
+ // → ctx.uiRequestHandler(由 createUiRequestHandlerForMode 构造)
10
+ // → channel registry 命中 'ask_user' → channel handler 返回固定答案
11
+ // → respond 回写 child.stdin(extension_ui_response)
12
+ //
13
+ // 与单元测试的差异:ui-request-handler.test.ts 只测 parseSpawnLine + parseChannel +
14
+ // createUiRequestQueue 各层独立;本文件串起**所有层**(adapter → queue → factory →
15
+ // channel handler → respond),覆盖跨进程协议透传的真实链路,是 ask_user 功能的
16
+ // 端到端契约验证。
17
+ //
18
+ // 关键被测对象:
19
+ // 1. spawn-event-adapter.parseSpawnLine:识别 extension_ui_request 行
20
+ // 2. session-runner.runSpawn 的 stdout pump:调 enqueueUiRequest
21
+ // 3. ui-request-queue.createUiRequestQueue:FIFO + handleUiRequest
22
+ // 4. ui-request-handler-factory.createUiRequestHandlerForMode:channel 路由 + 默认转发
23
+ // 5. stdin-writer.respond:回写 extension_ui_response
24
+ //
25
+ // mock 策略:与 run-spawn-integration.test.ts 一致(共享 helpers/spawn-mock.ts),
26
+ // 额外注入 ctx.uiRequestHandler + ctx.dialogQueue + channel registry。
27
+
28
+ import { execFileSync, spawn } from "node:child_process";
29
+ import * as fs from "node:fs";
30
+
31
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
32
+
33
+ // ── mock modules(与 run-spawn-*.test.ts 一致)──
34
+
35
+ vi.mock("node:child_process", async () => {
36
+ const { FakeChild } = await import("./helpers/spawn-mock.ts");
37
+ return {
38
+ spawn: vi.fn(() => new FakeChild()),
39
+ execFileSync: vi.fn(() => ""),
40
+ };
41
+ });
42
+
43
+ vi.mock("node:fs", async () => {
44
+ const actual = await import("node:fs");
45
+ return {
46
+ default: {
47
+ ...actual,
48
+ mkdirSync: vi.fn(),
49
+ existsSync: vi.fn(() => false),
50
+ appendFileSync: vi.fn(),
51
+ writeFileSync: vi.fn(),
52
+ readdirSync: vi.fn(() => []),
53
+ },
54
+ mkdirSync: vi.fn(),
55
+ existsSync: vi.fn(() => false),
56
+ appendFileSync: vi.fn(),
57
+ writeFileSync: vi.fn(),
58
+ readdirSync: vi.fn(() => []),
59
+ promises: actual.promises,
60
+ };
61
+ });
62
+
63
+ vi.mock("../alive-store.ts", () => ({
64
+ writeAliveMarker: vi.fn(),
65
+ }));
66
+
67
+ vi.mock("../temp-prompt.ts", () => ({
68
+ writePromptToTempFile: vi.fn(async (agent: string) => {
69
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
70
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
71
+ }),
72
+ cleanupTempPrompt: vi.fn(async () => {}),
73
+ }));
74
+
75
+ import { DialogGlobalQueue } from "../dialog-queue.ts";
76
+ import { runSpawn, type SessionRunnerContext } from "../session-runner.ts";
77
+ import { type ChannelHandler,createUiChannelRegistry } from "../ui-channels.ts";
78
+ import { createUiRequestHandlerForMode } from "../ui-request-handler-factory.ts";
79
+ import {
80
+ emitStdoutLine,
81
+ type FakeChild,
82
+ lastSpawnedChild as lastSpawnedChildOf,
83
+ makeCtx as makeCtxBase,
84
+ makeOpts,
85
+ makeRecord,
86
+ sessionHeader,
87
+ waitForSpawn as waitForSpawnOf,
88
+ } from "./helpers/spawn-mock.ts";
89
+
90
+ const mockSpawn = vi.mocked(spawn);
91
+ const mockExec = vi.mocked(execFileSync);
92
+ const mockExistsSync = vi.mocked(fs.existsSync);
93
+
94
+ const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
95
+ const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
96
+
97
+ // ── ask_user 协议样本(真实 Pi 格式)──
98
+
99
+ const ASK_USER_MARKER = "\0XYZ_ASK_USER";
100
+
101
+ interface AskUserPayload {
102
+ questions: Array<{
103
+ question: string;
104
+ options: Array<{ label: string }>;
105
+ }>;
106
+ allowCancel: boolean;
107
+ }
108
+
109
+ const askUserPayload: AskUserPayload = {
110
+ questions: [
111
+ {
112
+ question: "What is your preference?",
113
+ options: [{ label: "Option A" }, { label: "Option B" }],
114
+ },
115
+ ],
116
+ allowCancel: true,
117
+ };
118
+
119
+ /** 构造 Pi 原生 extension_ui_request stdout 行(ask_user 借道 select 通道)。 */
120
+ function askUserLine(id: string): string {
121
+ return JSON.stringify({
122
+ type: "extension_ui_request",
123
+ id,
124
+ method: "select",
125
+ title: ASK_USER_MARKER,
126
+ options: [JSON.stringify(askUserPayload)],
127
+ });
128
+ }
129
+
130
+ /** 读出 child.stdin 已缓冲的全部字节。 */
131
+ function readStdin(child: FakeChild): string {
132
+ child.stdin.pause();
133
+ return child.stdin.read()?.toString() ?? "";
134
+ }
135
+
136
+ /** 从 child.stdin 按行拆分(去空行 + JSON.parse)。 */
137
+ function readStdinLines(child: FakeChild): unknown[] {
138
+ return readStdin(child)
139
+ .split("\n")
140
+ .map((l) => l.trim())
141
+ .filter((l) => l.length > 0)
142
+ .map((l) => JSON.parse(l));
143
+ }
144
+
145
+ /** 构造 ctx,注入 uiRequestHandler(由 createUiRequestHandlerForMode 构建)。 */
146
+ function makeAskUserCtx(
147
+ registry: ReturnType<typeof createUiChannelRegistry>,
148
+ dialogQueue: DialogGlobalQueue,
149
+ overrides: Partial<SessionRunnerContext> = {},
150
+ ): SessionRunnerContext {
151
+ // ctx.ui / ctx.mode 是 createUiRequestHandlerForMode 的入参。
152
+ // mode='rpc' → hostMode='gui'(全透传),dialog 进 dialogQueue 串行。
153
+ // ctx.ui 在 channel 命中时不被调用(channel handler 直接返回),故 ui 用 stub 即可。
154
+ const ctx = {
155
+ cwd: "/tmp/test",
156
+ mode: "rpc" as const,
157
+ sessionManager: {
158
+ getSessionId: () => "s1",
159
+ getSessionFile: () => undefined,
160
+ getSessionDir: () => "/tmp/test/sessions",
161
+ },
162
+ modelRegistry: undefined,
163
+ model: undefined,
164
+ } as SessionRunnerContext;
165
+ const handler = createUiRequestHandlerForMode(ctx as never, registry, dialogQueue);
166
+ return makeCtxBase({
167
+ ...overrides,
168
+ uiRequestHandler: handler,
169
+ dialogQueue,
170
+ });
171
+ }
172
+
173
+ beforeEach(() => {
174
+ vi.clearAllMocks();
175
+ mockExec.mockReturnValue("");
176
+ mockExistsSync.mockReturnValue(false);
177
+ // 静默 stdin-writer / factory 的 warn(序列化失败降级等场景)
178
+ vi.spyOn(console, "warn").mockImplementation(() => {});
179
+ vi.spyOn(console, "error").mockImplementation(() => {});
180
+ });
181
+
182
+ afterEach(() => {
183
+ vi.restoreAllMocks();
184
+ });
185
+
186
+ // ============================================================
187
+ // E2E:完整 transit 链路(adapter → queue → factory → channel → respond)
188
+ // ============================================================
189
+
190
+ describe("ask_user 跨进程 transit e2e (#34)", () => {
191
+ it("channel handler 命中 ask_user → 返回的 value 经 respond 回写 child.stdin", async () => {
192
+ // 1. 注册 ask_user channel handler,返回固定答案(模拟用户选了 Option A)
193
+ const registry = createUiChannelRegistry();
194
+ const answer = JSON.stringify({ q0: "Option A" });
195
+ const channelHandler: ChannelHandler = vi.fn(async () => ({ value: answer }));
196
+ registry.register("ask_user", channelHandler);
197
+
198
+ const dialogQueue = new DialogGlobalQueue();
199
+ const ctx = makeAskUserCtx(registry, dialogQueue);
200
+
201
+ // 2. 启动 runSpawn(不 await,spawn 后异步 emit 请求)
202
+ const record = makeRecord();
203
+ const promise = runSpawn(record, "Task: ask-user-e2e", makeOpts(), ctx);
204
+
205
+ await waitForSpawn();
206
+ const child = lastSpawnedChild();
207
+
208
+ // 3. emit session header(runSpawn 需要 header 才能完成 close 路径)+ ask_user 请求
209
+ emitStdoutLine(child, sessionHeader("sess-ask-1"));
210
+ child.stdout.write(askUserLine("req-ask-1") + "\n");
211
+
212
+ // 4. 给 stdout pump + channel handler (async) + respond 时间执行
213
+ // PassThrough data listener flush + microtask + handler promise resolve 需若干 tick。
214
+ await new Promise((r) => setTimeout(r, 30));
215
+
216
+ // 5. 收尾:让 runSpawn resolve
217
+ child.stdout.end();
218
+ child.emit("close", 0);
219
+ await promise;
220
+
221
+ // 6. 断言 child.stdin 收到 extension_ui_response(value 分支)
222
+ const responseLines = readStdinLines(child).filter(
223
+ (l): l is { type: string; id: string; value?: string } =>
224
+ typeof l === "object" && l !== null && (l as { type?: string }).type === "extension_ui_response",
225
+ );
226
+ expect(responseLines.length).toBe(1);
227
+ expect(responseLines[0]!.id).toBe("req-ask-1");
228
+ expect(responseLines[0]!.value).toBe(answer);
229
+
230
+ // 7. channel handler 被调用一次,入参是 UiRequest(含 channel='ask_user' + payload)
231
+ expect(channelHandler).toHaveBeenCalledTimes(1);
232
+ const handlerArg = (channelHandler as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as {
233
+ method: string;
234
+ channel: string;
235
+ channelPayload: AskUserPayload;
236
+ id: string;
237
+ };
238
+ expect(handlerArg.method).toBe("select");
239
+ expect(handlerArg.channel).toBe("ask_user");
240
+ expect(handlerArg.id).toBe("req-ask-1");
241
+ expect(handlerArg.channelPayload).toEqual(askUserPayload);
242
+ });
243
+
244
+ it("channel handler 返回 {confirmed:true} → respond 写 confirmed 分支", async () => {
245
+ const registry = createUiChannelRegistry();
246
+ const channelHandler: ChannelHandler = vi.fn(async () => ({ confirmed: true }));
247
+ registry.register("ask_user", channelHandler);
248
+
249
+ const dialogQueue = new DialogGlobalQueue();
250
+ const ctx = makeAskUserCtx(registry, dialogQueue);
251
+
252
+ const record = makeRecord();
253
+ const promise = runSpawn(record, "Task: confirm-e2e", makeOpts(), ctx);
254
+
255
+ await waitForSpawn();
256
+ const child = lastSpawnedChild();
257
+
258
+ emitStdoutLine(child, sessionHeader("sess-confirm"));
259
+ child.stdout.write(askUserLine("req-confirm") + "\n");
260
+ await new Promise((r) => setTimeout(r, 30));
261
+
262
+ child.stdout.end();
263
+ child.emit("close", 0);
264
+ await promise;
265
+
266
+ const responseLines = readStdinLines(child).filter(
267
+ (l): l is { type: string; id: string; confirmed?: boolean } =>
268
+ typeof l === "object" && l !== null && (l as { type?: string }).type === "extension_ui_response",
269
+ );
270
+ expect(responseLines).toHaveLength(1);
271
+ expect(responseLines[0]!.id).toBe("req-confirm");
272
+ expect(responseLines[0]!.confirmed).toBe(true);
273
+ });
274
+
275
+ it("channel handler 返回 {cancelled:true} → respond 写 cancelled 分支", async () => {
276
+ const registry = createUiChannelRegistry();
277
+ // 模拟用户取消(allowCancel 场景)
278
+ const channelHandler: ChannelHandler = vi.fn(async () => ({ cancelled: true }));
279
+ registry.register("ask_user", channelHandler);
280
+
281
+ const dialogQueue = new DialogGlobalQueue();
282
+ const ctx = makeAskUserCtx(registry, dialogQueue);
283
+
284
+ const record = makeRecord();
285
+ const promise = runSpawn(record, "Task: cancel-e2e", makeOpts(), ctx);
286
+
287
+ await waitForSpawn();
288
+ const child = lastSpawnedChild();
289
+
290
+ emitStdoutLine(child, sessionHeader("sess-cancel"));
291
+ child.stdout.write(askUserLine("req-cancel") + "\n");
292
+ await new Promise((r) => setTimeout(r, 30));
293
+
294
+ child.stdout.end();
295
+ child.emit("close", 0);
296
+ await promise;
297
+
298
+ const responseLines = readStdinLines(child).filter(
299
+ (l): l is { type: string; id: string; cancelled?: boolean } =>
300
+ typeof l === "object" && l !== null && (l as { type?: string }).type === "extension_ui_response",
301
+ );
302
+ expect(responseLines).toHaveLength(1);
303
+ expect(responseLines[0]!.id).toBe("req-cancel");
304
+ expect(responseLines[0]!.cancelled).toBe(true);
305
+ });
306
+
307
+ it("多个 ask_user 请求 FIFO 串行 → 按到达顺序回写,channel handler 不并发", async () => {
308
+ // L1 per-child 队列(createUiRequestQueue)保证同子进程内 FIFO 串行。
309
+ // 验证:连续 emit 两个 ask_user,channel handler 按顺序被调,response 按顺序写回。
310
+ const registry = createUiChannelRegistry();
311
+ const callOrder: string[] = [];
312
+ const channelHandler: ChannelHandler = vi.fn(async (req: { id: string }) => {
313
+ callOrder.push(req.id);
314
+ // 加延迟让两个请求有机会并发(若队列没串行)
315
+ await new Promise((r) => setTimeout(r, 15));
316
+ return { value: `ans-${req.id}` };
317
+ });
318
+ registry.register("ask_user", channelHandler);
319
+
320
+ const dialogQueue = new DialogGlobalQueue();
321
+ const ctx = makeAskUserCtx(registry, dialogQueue);
322
+
323
+ const record = makeRecord();
324
+ const promise = runSpawn(record, "Task: fifo-e2e", makeOpts(), ctx);
325
+
326
+ await waitForSpawn();
327
+ const child = lastSpawnedChild();
328
+
329
+ emitStdoutLine(child, sessionHeader("sess-fifo"));
330
+ child.stdout.write(askUserLine("req-fifo-1") + "\n");
331
+ child.stdout.write(askUserLine("req-fifo-2") + "\n");
332
+ // 等两个 handler 都执行完(每个 ~15ms + overhead)
333
+ await new Promise((r) => setTimeout(r, 80));
334
+
335
+ child.stdout.end();
336
+ child.emit("close", 0);
337
+ await promise;
338
+
339
+ // 串行:handler 按到达顺序被调,无并发
340
+ expect(callOrder).toEqual(["req-fifo-1", "req-fifo-2"]);
341
+ expect(channelHandler).toHaveBeenCalledTimes(2);
342
+
343
+ // 两个 response 都写回,按 id 可找到
344
+ const responses = readStdinLines(child).filter(
345
+ (l): l is { type: string; id: string; value?: string } =>
346
+ typeof l === "object" && l !== null && (l as { type?: string }).type === "extension_ui_response",
347
+ );
348
+ const ids = responses.map((r) => r.id).sort();
349
+ expect(ids).toEqual(["req-fifo-1", "req-fifo-2"]);
350
+ const r1 = responses.find((r) => r.id === "req-fifo-1");
351
+ const r2 = responses.find((r) => r.id === "req-fifo-2");
352
+ expect(r1?.value).toBe("ans-req-fifo-1");
353
+ expect(r2?.value).toBe("ans-req-fifo-2");
354
+ });
355
+
356
+ it("channel 未注册 → defaultDialogForward 回 cancelled(ask_user 扩展未安装兜底)", async () => {
357
+ // 不注册 ask_user channel → channelHandler 为 undefined → defaultDialogForward
358
+ // defaultDialogForward 调 ctx.ui.select(这里 ctx.ui 是 stub,select 返 undefined → cancelled)
359
+ const registry = createUiChannelRegistry();
360
+ const dialogQueue = new DialogGlobalQueue();
361
+
362
+ // ctx.ui.select 返回 undefined(模拟用户取消 / stub)
363
+ const selectSpy = vi.fn(async () => undefined);
364
+ const ctxBase = makeCtxBase();
365
+ const fakeCtx = {
366
+ cwd: "/tmp/test",
367
+ mode: "rpc" as const,
368
+ sessionManager: {
369
+ getSessionId: () => "s1",
370
+ getSessionFile: () => undefined,
371
+ getSessionDir: () => "/tmp/test/sessions",
372
+ },
373
+ modelRegistry: undefined,
374
+ model: undefined,
375
+ ui: { select: selectSpy },
376
+ } as never;
377
+ const handler = createUiRequestHandlerForMode(fakeCtx, registry, dialogQueue);
378
+ const ctx = { ...ctxBase, uiRequestHandler: handler, dialogQueue };
379
+
380
+ const record = makeRecord();
381
+ const promise = runSpawn(record, "Task: no-channel", makeOpts(), ctx);
382
+
383
+ await waitForSpawn();
384
+ const child = lastSpawnedChild();
385
+
386
+ emitStdoutLine(child, sessionHeader("sess-nochannel"));
387
+ child.stdout.write(askUserLine("req-nochannel") + "\n");
388
+ await new Promise((r) => setTimeout(r, 30));
389
+
390
+ child.stdout.end();
391
+ child.emit("close", 0);
392
+ await promise;
393
+
394
+ // ctx.ui.select 被调(defaultDialogForward 兜底)
395
+ expect(selectSpy).toHaveBeenCalledTimes(1);
396
+ // response 是 cancelled(select 返 undefined → cancelled)
397
+ const responses = readStdinLines(child).filter(
398
+ (l): l is { type: string; id: string; cancelled?: boolean } =>
399
+ typeof l === "object" && l !== null && (l as { type?: string }).type === "extension_ui_response",
400
+ );
401
+ expect(responses).toHaveLength(1);
402
+ expect(responses[0]!.id).toBe("req-nochannel");
403
+ expect(responses[0]!.cancelled).toBe(true);
404
+ });
405
+
406
+ it("channel handler 抛错 → respond 写 cancelled(队列不卡死)", async () => {
407
+ const registry = createUiChannelRegistry();
408
+ const channelHandler: ChannelHandler = vi.fn(async () => {
409
+ throw new Error("handler boom");
410
+ });
411
+ registry.register("ask_user", channelHandler);
412
+
413
+ const dialogQueue = new DialogGlobalQueue();
414
+ const ctx = makeAskUserCtx(registry, dialogQueue);
415
+
416
+ const record = makeRecord();
417
+ const promise = runSpawn(record, "Task: handler-throws", makeOpts(), ctx);
418
+
419
+ await waitForSpawn();
420
+ const child = lastSpawnedChild();
421
+
422
+ emitStdoutLine(child, sessionHeader("sess-throws"));
423
+ child.stdout.write(askUserLine("req-throws") + "\n");
424
+ await new Promise((r) => setTimeout(r, 30));
425
+
426
+ child.stdout.end();
427
+ child.emit("close", 0);
428
+ await promise;
429
+
430
+ // ui-request-queue 的 handleUiRequest catch handler 抛错 → respond cancelled
431
+ const responses = readStdinLines(child).filter(
432
+ (l): l is { type: string; id: string; cancelled?: boolean } =>
433
+ typeof l === "object" && l !== null && (l as { type?: string }).type === "extension_ui_response",
434
+ );
435
+ expect(responses).toHaveLength(1);
436
+ expect(responses[0]!.id).toBe("req-throws");
437
+ expect(responses[0]!.cancelled).toBe(true);
438
+ });
439
+
440
+ it("child close 后 pending ask_user 的 handler 完成 → 不再写 stdin(signal aborted)", async () => {
441
+ // 验证 R3:子进程退出时 AbortController.abort,handler 完成后 respond 跳过写入。
442
+ // 构造:channel handler 在 child close 后才 resolve(模拟慢用户响应 + 子进程先退出)。
443
+ const registry = createUiChannelRegistry();
444
+ let resolveHandler: ((v: { value: string }) => void) | undefined;
445
+ const channelHandler: ChannelHandler = vi.fn(
446
+ () => new Promise<{ value: string }>((resolve) => {
447
+ resolveHandler = resolve;
448
+ }),
449
+ );
450
+ registry.register("ask_user", channelHandler);
451
+
452
+ const dialogQueue = new DialogGlobalQueue();
453
+ const ctx = makeAskUserCtx(registry, dialogQueue);
454
+
455
+ const record = makeRecord();
456
+ const promise = runSpawn(record, "Task: abort-pending", makeOpts(), ctx);
457
+
458
+ await waitForSpawn();
459
+ const child = lastSpawnedChild();
460
+
461
+ emitStdoutLine(child, sessionHeader("sess-abort"));
462
+ child.stdout.write(askUserLine("req-abort") + "\n");
463
+ // 让 handler 入队(但未 resolve)
464
+ await new Promise((r) => setTimeout(r, 20));
465
+
466
+ // 子进程先退出(handler 仍 pending)→ onClose abort
467
+ child.stdout.end();
468
+ child.emit("close", 0);
469
+ await promise;
470
+
471
+ // 现在 handler 仍 pending;resolve 它(模拟延迟响应)
472
+ expect(resolveHandler).toBeDefined();
473
+ resolveHandler!({ value: "late-answer" });
474
+ // 给 finally 块执行时间
475
+ await new Promise((r) => setTimeout(r, 20));
476
+
477
+ // child.stdin 不应收到 extension_ui_response(signal.aborted → respond 跳过)
478
+ const responses = readStdinLines(child).filter(
479
+ (l): l is { type: string; id: string } =>
480
+ typeof l === "object" && l !== null && (l as { type?: string }).type === "extension_ui_response",
481
+ );
482
+ expect(responses).toHaveLength(0);
483
+ });
484
+ });