@zhushanwen/pi-subagent-workflow 0.1.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/context-builder.md +1 -3
- package/agents/explorer.md +27 -0
- package/agents/oracle.md +2 -2
- package/agents/orchestrator.md +48 -0
- package/agents/planner.md +1 -3
- package/agents/researcher.md +0 -2
- package/agents/reviewer.md +2 -2
- package/agents/worker.md +0 -2
- package/package.json +5 -3
- package/skills/workflow-script-format/SKILL.md +6 -6
- package/src/execution/__tests__/agent-registry.test.ts +3 -3
- package/src/execution/__tests__/agent-result-mapper.test.ts +24 -2
- 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__/concurrency-pool.test.ts +33 -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 +41 -9
- package/src/execution/__tests__/finalize-record.test.ts +173 -0
- package/src/execution/__tests__/gui-mode-dispatch.test.ts +59 -0
- 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__/sdk-contract.test.ts +5 -2
- package/src/execution/__tests__/session-file-gc.test.ts +46 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +20 -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-abort.test.ts +60 -0
- package/src/execution/__tests__/subagent-service.test.ts +73 -3
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +72 -3
- package/src/execution/__tests__/tool-action.test.ts +27 -5
- 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/agent-result-mapper.ts +4 -1
- package/src/execution/channel-registry-access.ts +138 -0
- package/src/execution/concurrency-pool.ts +38 -6
- package/src/execution/dialog-queue.ts +329 -0
- package/src/execution/execute-options-mapper.ts +21 -4
- package/src/execution/execution-record.ts +5 -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 +110 -2
- package/src/execution/session-file-gc.ts +25 -3
- package/src/execution/session-reconstructor.ts +11 -0
- package/src/execution/session-runner.ts +228 -32
- package/src/execution/spawn-event-adapter.ts +219 -6
- package/src/execution/stdin-writer.ts +106 -0
- package/src/execution/stream-sink.ts +83 -0
- package/src/execution/subagent-service.ts +230 -235
- package/src/execution/subprocess-agent-runner.ts +16 -4
- package/src/execution/types.ts +23 -3
- 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 +101 -4
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +84 -0
- package/src/interface/__tests__/workflow-state-file-exposure.test.ts +38 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +50 -0
- package/src/interface/command-actions.ts +77 -0
- package/src/interface/commands.ts +40 -4
- package/src/interface/format.ts +2 -0
- package/src/interface/gui-mappers.ts +83 -0
- package/src/interface/helpers.ts +52 -9
- package/src/interface/list-component.ts +3 -1
- package/src/interface/subagent-actions.ts +44 -24
- package/src/interface/subagent-tool.ts +56 -24
- package/src/interface/subagents.ts +45 -5
- package/src/interface/tool-render.ts +16 -5
- package/src/interface/tool-workflow-script.ts +113 -15
- package/src/interface/tool-workflow.ts +92 -34
- package/src/interface/views/WorkflowsView.ts +13 -4
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +70 -0
- package/src/interface/views/detail-content.ts +20 -0
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +208 -0
- package/src/orchestration/__tests__/agent-call-stream.test.ts +157 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +2 -0
- package/src/orchestration/__tests__/execute-agent-call.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +177 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +15 -0
- package/src/orchestration/agent-opts-resolver.ts +11 -2
- package/src/orchestration/error-recovery.ts +131 -23
- package/src/orchestration/execute-agent-call.ts +12 -3
- package/src/orchestration/jsonl-run-store.ts +10 -0
- package/src/orchestration/lifecycle.ts +1 -1
- package/src/orchestration/models/agent-call.ts +7 -0
- package/src/orchestration/models/ports.ts +15 -2
- package/src/orchestration/models/run-spec.ts +6 -0
- package/src/orchestration/models/trace.ts +1 -0
- package/src/orchestration/models/types.ts +19 -0
- package/src/orchestration/node-ops.ts +2 -0
- package/src/orchestration/worker-script-builder.ts +1 -0
- package/workflows/README.md +58 -0
- package/workflows/chain.js +107 -0
- package/workflows/map-reduce.js +142 -0
- package/workflows/parallel.js +131 -0
- package/workflows/scatter-gather.js +146 -0
- package/agents/scout.md +0 -17
- package/examples/README.md +0 -43
- package/examples/chain.example.js +0 -92
- package/examples/map-reduce.example.js +0 -99
- package/examples/parallel.example.js +0 -82
- package/examples/scatter-gather.example.js +0 -106
- package/src/interface/gui-adapter.ts +0 -136
|
@@ -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
|
+
});
|