@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.
Files changed (64) 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 +1 -1
  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 +1 -1
  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/finalize-record.ts +160 -0
  44. package/src/execution/get-state-handshake.ts +104 -0
  45. package/src/execution/host-mode.ts +52 -0
  46. package/src/execution/manifest-store.ts +206 -0
  47. package/src/execution/notifier.ts +5 -1
  48. package/src/execution/path-encoding.ts +18 -0
  49. package/src/execution/pi-invocation.ts +1 -1
  50. package/src/execution/record-store.ts +108 -2
  51. package/src/execution/session-file-gc.ts +25 -3
  52. package/src/execution/session-runner.ts +216 -32
  53. package/src/execution/spawn-event-adapter.ts +219 -6
  54. package/src/execution/stdin-writer.ts +106 -0
  55. package/src/execution/subagent-service.ts +167 -197
  56. package/src/execution/ui-channels.ts +216 -0
  57. package/src/execution/ui-interaction-model.ts +48 -0
  58. package/src/execution/ui-request-handler-factory.ts +175 -0
  59. package/src/execution/ui-request-observability.ts +77 -0
  60. package/src/execution/ui-request-queue.ts +168 -0
  61. package/src/index.ts +90 -6
  62. package/src/interface/format.ts +2 -0
  63. package/src/interface/subagent-actions.ts +9 -2
  64. package/src/interface/subagent-tool.ts +9 -8
@@ -0,0 +1,187 @@
1
+ // src/__tests__/ui-channels.test.ts
2
+ //
3
+ // W1 红灯测试:ui-channels.ts — channel 提取(marker 解析)+ channel 注册表。
4
+ //
5
+ // 测试对象:extensions/subagent-workflow/src/execution/ui-channels.ts(新建)
6
+ // 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 2:channel 注册表」
7
+ //
8
+ // parseChannel(req): ExtensionUiRequest → { channel?, channelPayload? }
9
+ // - select → 从 title 解析 NUL 前缀(parseFromMarkerString)
10
+ // - setWidget → 从 widgetLines[0] 解析 NUL 前缀(parseFromMarkerArray)
11
+ // - 其他 method → {}(无 channel)
12
+ //
13
+ // marker 格式:\0<UPPER_CASE_ID>[:]<JSON-payload?>
14
+ // - ASK_USER_MARKER = "\0XYZ_ASK_USER",payload 在 options[0]
15
+ // - GUI_WIDGET_MARKER = "\0XYZ_GUI_WIDGET:" + JSON,payload 在同行
16
+ // channel 名规范化:去 "XYZ_" 前缀,小写化(XYZ_ASK_USER → ask_user)
17
+ //
18
+ // UiChannelRegistry:
19
+ // - register(channel, handler) / resolve(channel) / list()
20
+ //
21
+ // 边界:无 NUL 前缀 → {};JSON parse 失败 → {}(不抛);字段缺失 → {}
22
+ //
23
+ // 红灯原因:ui-channels.ts 尚未创建,import 失败。
24
+
25
+ import { describe, expect, it, vi } from "vitest";
26
+
27
+ import {
28
+ createUiChannelRegistry,
29
+ type ExtensionUiRequestLike,
30
+ parseChannel,
31
+ } from "../ui-channels.ts";
32
+
33
+ // ── 测试 fixture 构造助手 ────────────────────────────────────
34
+ // ExtensionUiRequestLike 是 parseChannel 入参的最小形状(method + 对应字段)。
35
+
36
+ function selectReq(title: string, options?: string[]): ExtensionUiRequestLike {
37
+ return { method: "select", title, options };
38
+ }
39
+
40
+ function setWidgetReq(widgetKey: string, widgetLines: string[] | undefined): ExtensionUiRequestLike {
41
+ return { method: "setWidget", widgetKey, widgetLines };
42
+ }
43
+
44
+ // ── ASK_USER_MARKER 样本(真实 Pi 协议格式) ─────────────────
45
+ // title = "\0XYZ_ASK_USER",options[0] = JSON.stringify({questions, allowCancel})
46
+ const ASK_USER_MARKER = "\0XYZ_ASK_USER";
47
+ const askUserPayload = {
48
+ questions: [{ question: "Pick one", options: [{ label: "A" }, { label: "B" }] }],
49
+ allowCancel: true,
50
+ };
51
+
52
+ // ── GUI_WIDGET_MARKER 样本(真实 Pi 协议格式) ───────────────
53
+ // widgetLines[0] = "\0XYZ_GUI_WIDGET:" + JSON.stringify({component:{...}})
54
+ const GUI_WIDGET_MARKER_PREFIX = "\0XYZ_GUI_WIDGET:";
55
+ const guiWidgetPayload = { component: { name: "StatusCard", props: { ok: true } } };
56
+
57
+ describe("parseChannel — select method(从 title 解析 NUL 前缀)", () => {
58
+ it("title 含 ASK_USER_MARKER → channel='ask_user' + payload 从 options[0] 解析", () => {
59
+ const req = selectReq(ASK_USER_MARKER, [JSON.stringify(askUserPayload)]);
60
+ const result = parseChannel(req);
61
+ expect(result.channel).toBe("ask_user");
62
+ expect(result.channelPayload).toEqual(askUserPayload);
63
+ });
64
+
65
+ it("title 无 NUL 前缀 → {}(普通 select,无 channel)", () => {
66
+ const req = selectReq("Choose a plan", ["basic", "pro"]);
67
+ const result = parseChannel(req);
68
+ expect(result.channel).toBeUndefined();
69
+ expect(result.channelPayload).toBeUndefined();
70
+ });
71
+
72
+ it("title 含 marker 但 options 缺失 → channel 解析但 payload undefined", () => {
73
+ // marker 在 title,但 options 为空 → channel 名仍可提取,payload 无来源
74
+ const req = selectReq(ASK_USER_MARKER);
75
+ const result = parseChannel(req);
76
+ expect(result.channel).toBe("ask_user");
77
+ });
78
+ });
79
+
80
+ describe("parseChannel — setWidget method(从 widgetLines[0] 解析 NUL 前缀)", () => {
81
+ it("widgetLines[0] 含 GUI_WIDGET_MARKER → channel='gui_widget' + payload", () => {
82
+ const req = setWidgetReq("w1", [GUI_WIDGET_MARKER_PREFIX + JSON.stringify(guiWidgetPayload)]);
83
+ const result = parseChannel(req);
84
+ expect(result.channel).toBe("gui_widget");
85
+ expect(result.channelPayload).toEqual(guiWidgetPayload);
86
+ });
87
+
88
+ it("widgetLines 无 NUL 前缀 → {}(普通 setWidget)", () => {
89
+ const req = setWidgetReq("w1", ["plain content line"]);
90
+ const result = parseChannel(req);
91
+ expect(result.channel).toBeUndefined();
92
+ });
93
+
94
+ it("widgetLines 为 undefined → {}", () => {
95
+ const req = setWidgetReq("w1", undefined);
96
+ const result = parseChannel(req);
97
+ expect(result.channel).toBeUndefined();
98
+ });
99
+
100
+ it("widgetLines 为空数组 → {}", () => {
101
+ const req = setWidgetReq("w1", []);
102
+ const result = parseChannel(req);
103
+ expect(result.channel).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe("parseChannel — 其他 method(无 channel 提取位置)", () => {
108
+ it("confirm → {}", () => {
109
+ const result = parseChannel({ method: "confirm", title: "t", message: "m" });
110
+ expect(result.channel).toBeUndefined();
111
+ });
112
+
113
+ it("input → {}", () => {
114
+ const result = parseChannel({ method: "input", title: "t" });
115
+ expect(result.channel).toBeUndefined();
116
+ });
117
+
118
+ it("notify → {}", () => {
119
+ const result = parseChannel({ method: "notify", message: "hi" });
120
+ expect(result.channel).toBeUndefined();
121
+ });
122
+ });
123
+
124
+ describe("parseChannel — channel 名规范化规则", () => {
125
+ it("去 XYZ_ 命名空间前缀 + 小写化(XYZ_ASK_USER → ask_user)", () => {
126
+ const req = selectReq("\0XYZ_ASK_USER", [JSON.stringify({ x: 1 })]);
127
+ expect(parseChannel(req).channel).toBe("ask_user");
128
+ });
129
+
130
+ it("XYZ_GUI_WIDGET: 带冒号后缀 → gui_widget(去前缀 + 去冒号 + 小写)", () => {
131
+ const req = setWidgetReq("k", ["\0XYZ_GUI_WIDGET:" + JSON.stringify({ y: 2 })]);
132
+ expect(parseChannel(req).channel).toBe("gui_widget");
133
+ });
134
+ });
135
+
136
+ describe("parseChannel — 边界(不抛错)", () => {
137
+ it("marker 后 payload 非法 JSON → 不抛错,channel 仍解析(payload undefined)", () => {
138
+ const req = selectReq(ASK_USER_MARKER, ["not-valid-json{"]);
139
+ expect(() => parseChannel(req)).not.toThrow();
140
+ const result = parseChannel(req);
141
+ expect(result.channel).toBe("ask_user");
142
+ });
143
+
144
+ it("GUI_WIDGET_MARKER 后非法 JSON → 不抛错", () => {
145
+ const req = setWidgetReq("k", [GUI_WIDGET_MARKER_PREFIX + "broken{json"]);
146
+ expect(() => parseChannel(req)).not.toThrow();
147
+ });
148
+ });
149
+
150
+ // ── UiChannelRegistry ────────────────────────────────────────
151
+
152
+ describe("UiChannelRegistry — 注册 + 解析 + 列举", () => {
153
+ it("register 后 resolve 返回注册的 handler", () => {
154
+ const registry = createUiChannelRegistry();
155
+ const handler = vi.fn();
156
+ registry.register("ask_user", handler);
157
+ expect(registry.resolve("ask_user")).toBe(handler);
158
+ });
159
+
160
+ it("未注册的 channel resolve 返回 undefined", () => {
161
+ const registry = createUiChannelRegistry();
162
+ expect(registry.resolve("unknown")).toBeUndefined();
163
+ });
164
+
165
+ it("list 返回所有已注册 channel 名", () => {
166
+ const registry = createUiChannelRegistry();
167
+ registry.register("ask_user", vi.fn());
168
+ registry.register("gui_widget", vi.fn());
169
+ const names = registry.list().sort();
170
+ expect(names).toEqual(["ask_user", "gui_widget"]);
171
+ });
172
+
173
+ it("list 空注册表返回 []", () => {
174
+ const registry = createUiChannelRegistry();
175
+ expect(registry.list()).toEqual([]);
176
+ });
177
+
178
+ it("重复 register 同名 channel 覆盖旧 handler", () => {
179
+ const registry = createUiChannelRegistry();
180
+ const old = vi.fn();
181
+ const fresh = vi.fn();
182
+ registry.register("ask_user", old);
183
+ registry.register("ask_user", fresh);
184
+ expect(registry.resolve("ask_user")).toBe(fresh);
185
+ expect(registry.list()).toEqual(["ask_user"]);
186
+ });
187
+ });
@@ -0,0 +1,67 @@
1
+ // src/__tests__/ui-interaction-model.test.ts
2
+ //
3
+ // W1 红灯测试:ui-interaction-model.ts — method 交互模型分类。
4
+ //
5
+ // 测试对象:extensions/subagent-workflow/src/execution/ui-interaction-model.ts(新建)
6
+ // 契约来源:.fix-plans/00-master-summary.md §一 冲突 2「维度 1:透传判定规则」
7
+ //
8
+ // isDialogMethod(method):
9
+ // - dialog(占输入焦点,等响应,需透传+排队):select / confirm / input / editor → true
10
+ // - fire-and-forget(纯展示/写入,不等响应):notify / setStatus / setWidget /
11
+ // setTitle / set_editor_text / 未知 method → false
12
+ //
13
+ // 红灯原因:ui-interaction-model.ts 尚未创建,import 失败。
14
+
15
+ import { describe, expect, it } from "vitest";
16
+
17
+ import { isDialogMethod } from "../ui-interaction-model.ts";
18
+
19
+ describe("isDialogMethod — dialog 类 method(占输入焦点)", () => {
20
+ it("select → true(含 ask_user channel 借道 select)", () => {
21
+ expect(isDialogMethod("select")).toBe(true);
22
+ });
23
+
24
+ it("confirm → true", () => {
25
+ expect(isDialogMethod("confirm")).toBe(true);
26
+ });
27
+
28
+ it("input → true", () => {
29
+ expect(isDialogMethod("input")).toBe(true);
30
+ });
31
+
32
+ it("editor → true", () => {
33
+ expect(isDialogMethod("editor")).toBe(true);
34
+ });
35
+ });
36
+
37
+ describe("isDialogMethod — fire-and-forget 类 method(纯展示/写入)", () => {
38
+ it("notify → false", () => {
39
+ expect(isDialogMethod("notify")).toBe(false);
40
+ });
41
+
42
+ it("setStatus → false", () => {
43
+ expect(isDialogMethod("setStatus")).toBe(false);
44
+ });
45
+
46
+ it("setWidget → false(含 gui_widget channel 借道 setWidget)", () => {
47
+ expect(isDialogMethod("setWidget")).toBe(false);
48
+ });
49
+
50
+ it("setTitle → false", () => {
51
+ expect(isDialogMethod("setTitle")).toBe(false);
52
+ });
53
+
54
+ it("set_editor_text → false", () => {
55
+ expect(isDialogMethod("set_editor_text")).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe("isDialogMethod — 未知 method 默认 fire-and-forget", () => {
60
+ it("未知 method 名 → false(未来 Pi 新增 method 不误判为 dialog)", () => {
61
+ expect(isDialogMethod("some_future_method")).toBe(false);
62
+ });
63
+
64
+ it("空字符串 → false", () => {
65
+ expect(isDialogMethod("")).toBe(false);
66
+ });
67
+ });
@@ -0,0 +1,166 @@
1
+ // src/__tests__/ui-request-handler-factory.test.ts
2
+ //
3
+ // C1 测试:ui-request-handler-factory.ts — createUiRequestHandlerForMode 透传矩阵。
4
+ //
5
+ // 透传矩阵(createUiRequestHandlerForMode 返回的 handler 行为):
6
+ // - headless(json/print/undefined):返回 undefined(不注入 handler)
7
+ // - TUI:fire-and-forget 回 ack 不透传;dialog 进 dialogQueue 串行
8
+ // - GUI(rpc):fire-and-forget 直接调 realHandler;dialog 进 dialogQueue 串行
9
+ // realHandler 路由:channel 命中 → channelHandler(经 coerceUiResponse 形变);未命中 → defaultDialogForward(cancelled)。
10
+ // 测接口契约,不测实现细节。
11
+
12
+ import type { ExtensionContext, ExtensionMode } from "@mariozechner/pi-coding-agent";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+
15
+ import { DialogGlobalQueue, type UiRequest } from "../dialog-queue.ts";
16
+ import { type ChannelHandler,createUiChannelRegistry } from "../ui-channels.ts";
17
+ import { createUiRequestHandlerForMode } from "../ui-request-handler-factory.ts";
18
+
19
+ // mock ExtensionContext 已补 mode 字段(host-mode.ts 读它分流)。最小形状构造。
20
+ function makeCtx(mode: ExtensionMode): ExtensionContext {
21
+ return {
22
+ cwd: "/tmp/test",
23
+ mode,
24
+ sessionManager: {
25
+ getSessionId: () => "s1",
26
+ getSessionFile: () => undefined,
27
+ getSessionDir: () => "/tmp/test/sessions",
28
+ },
29
+ modelRegistry: undefined,
30
+ model: undefined,
31
+ } as ExtensionContext;
32
+ }
33
+
34
+ function dialogReq(id: string, channel?: string): UiRequest {
35
+ return { method: "select", id, title: `q-${id}`, ...(channel ? { channel } : {}) };
36
+ }
37
+
38
+ function fireAndForgetReq(id: string): UiRequest {
39
+ return { method: "notify", id, message: `n-${id}` };
40
+ }
41
+
42
+ // dialog 路径用 fake timers 推进 processNext;静默 console.warn/error(stub 故意 warn)。
43
+ beforeEach(() => {
44
+ vi.useFakeTimers();
45
+ vi.spyOn(console, "warn").mockImplementation(() => {});
46
+ vi.spyOn(console, "error").mockImplementation(() => {});
47
+ });
48
+
49
+ afterEach(() => {
50
+ vi.useRealTimers();
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ describe("createUiRequestHandlerForMode — headless 返回 undefined", () => {
55
+ it("mode='json' → undefined(不注入 handler)", () => {
56
+ const queue = new DialogGlobalQueue();
57
+ expect(createUiRequestHandlerForMode(makeCtx("json"), createUiChannelRegistry(), queue))
58
+ .toBeUndefined();
59
+ });
60
+
61
+ it("mode='print' → undefined", () => {
62
+ const queue = new DialogGlobalQueue();
63
+ expect(createUiRequestHandlerForMode(makeCtx("print"), createUiChannelRegistry(), queue))
64
+ .toBeUndefined();
65
+ });
66
+ });
67
+
68
+ describe("createUiRequestHandlerForMode — TUI 模式透传", () => {
69
+ it("fire-and-forget(notify)→ {ack:true},不调 realHandler / 不入队", async () => {
70
+ const registry = createUiChannelRegistry();
71
+ const queue = new DialogGlobalQueue();
72
+ const enqueueSpy = vi.spyOn(queue, "enqueue");
73
+
74
+ const handler = createUiRequestHandlerForMode(makeCtx("tui"), registry, queue)!;
75
+ const resp = await handler(fireAndForgetReq("f1"));
76
+
77
+ expect(resp).toEqual({ ack: true });
78
+ expect(console.warn).not.toHaveBeenCalled(); // realHandler(defaultDialogForward)未走
79
+ expect(enqueueSpy).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("dialog(select 无 channel)→ 进 dialogQueue(enqueue 被调,defaultDialogForward stub cancelled)", async () => {
83
+ const registry = createUiChannelRegistry();
84
+ const queue = new DialogGlobalQueue();
85
+ const enqueueSpy = vi.spyOn(queue, "enqueue");
86
+
87
+ const handler = createUiRequestHandlerForMode(makeCtx("tui"), registry, queue)!;
88
+ const pending = handler(dialogReq("d1"));
89
+
90
+ expect(enqueueSpy).toHaveBeenCalledTimes(1);
91
+ await vi.advanceTimersByTimeAsync(0);
92
+ await expect(pending).resolves.toEqual({ cancelled: true });
93
+ });
94
+ });
95
+
96
+ describe("createUiRequestHandlerForMode — GUI(rpc)模式透传", () => {
97
+ it("fire-and-forget(notify)→ 直接调 realHandler,不入队", async () => {
98
+ const registry = createUiChannelRegistry();
99
+ const queue = new DialogGlobalQueue();
100
+ const enqueueSpy = vi.spyOn(queue, "enqueue");
101
+
102
+ const handler = createUiRequestHandlerForMode(makeCtx("rpc"), registry, queue)!;
103
+ // notify 无 channel → realHandler → defaultDialogForward(cancelled)
104
+ const resp = await handler(fireAndForgetReq("f1"));
105
+
106
+ expect(enqueueSpy).not.toHaveBeenCalled();
107
+ expect(resp).toEqual({ cancelled: true });
108
+ });
109
+
110
+ it("dialog(select 无 channel)→ 进 dialogQueue", async () => {
111
+ const registry = createUiChannelRegistry();
112
+ const queue = new DialogGlobalQueue();
113
+ const enqueueSpy = vi.spyOn(queue, "enqueue");
114
+
115
+ const handler = createUiRequestHandlerForMode(makeCtx("rpc"), registry, queue)!;
116
+ const pending = handler(dialogReq("d1"));
117
+
118
+ expect(enqueueSpy).toHaveBeenCalledTimes(1);
119
+ await vi.advanceTimersByTimeAsync(0);
120
+ await expect(pending).resolves.toEqual({ cancelled: true });
121
+ });
122
+ });
123
+
124
+ describe("createUiRequestHandlerForMode — channel 业务路由", () => {
125
+ it("channel 命中 registry → 调注册的 channelHandler(不走 defaultDialogForward)", async () => {
126
+ const registry = createUiChannelRegistry();
127
+ const channelHandler: ChannelHandler = vi.fn(async () => ({ value: "from-channel" }));
128
+ registry.register("ask_user", channelHandler);
129
+
130
+ // GUI fire-and-forget 直接调 realHandler,绕过队列;channel 命中立即生效
131
+ const handler = createUiRequestHandlerForMode(makeCtx("rpc"), registry, new DialogGlobalQueue())!;
132
+ const resp = await handler({ method: "notify", id: "f1", message: "m", channel: "ask_user" });
133
+
134
+ expect(channelHandler).toHaveBeenCalledTimes(1);
135
+ expect(resp).toEqual({ value: "from-channel" });
136
+ });
137
+
138
+ it("channel 未命中 → defaultDialogForward(stub cancelled)", async () => {
139
+ const handler = createUiRequestHandlerForMode(
140
+ makeCtx("rpc"), createUiChannelRegistry(), new DialogGlobalQueue())!;
141
+ const resp = await handler({ method: "notify", id: "f1", message: "m", channel: "unknown" });
142
+ expect(resp).toEqual({ cancelled: true });
143
+ });
144
+ });
145
+
146
+ // coerceUiResponse 形变(通过 channelHandler 返回不同 shape 间接测)
147
+ describe("createUiRequestHandlerForMode — coerceUiResponse 形变", () => {
148
+ async function callWithChannel(raw: unknown) {
149
+ const registry = createUiChannelRegistry();
150
+ registry.register("ask_user", (async () => raw) as ChannelHandler);
151
+ const handler = createUiRequestHandlerForMode(makeCtx("rpc"), registry, new DialogGlobalQueue())!;
152
+ return handler({ method: "notify", id: "f1", message: "m", channel: "ask_user" });
153
+ }
154
+
155
+ it("channelHandler 返回 {value:'x'} → {value:'x'}", async () => {
156
+ expect(await callWithChannel({ value: "x" })).toEqual({ value: "x" });
157
+ });
158
+
159
+ it("channelHandler 返回 {confirmed:true} → {confirmed:true}", async () => {
160
+ expect(await callWithChannel({ confirmed: true })).toEqual({ confirmed: true });
161
+ });
162
+
163
+ it("channelHandler 返回 null(非法)→ 降级 {cancelled:true}", async () => {
164
+ expect(await callWithChannel(null)).toEqual({ cancelled: true });
165
+ });
166
+ });
@@ -0,0 +1,204 @@
1
+ // src/__tests__/ui-request-handler.test.ts
2
+ //
3
+ // W2 测试(TC-W2 / SR-2):UI 请求处理 —— Pi 原生协议格式。
4
+ //
5
+ // 测试对象:session-runner.ts 的 handleUiRequest + spawn-event-adapter.ts 的 parseSpawnLine
6
+ // 契约来源:.fix-plans/00-master-summary.md §二 2.1(ExtensionUiRequest)+ 2.2(UiRequest/channel)
7
+ // + 2.3(stdin 回写 extension_ui_response 格式)+ §一冲突 2(channel 提取在 session-runner 层)
8
+ //
9
+ // 修复原因(FR-12/SR-2):
10
+ // 旧测试用错误的 JSON-RPC 2.0 格式 mock(jsonrpc:"2.0" + params.marker:"ASK_USER"
11
+ // + params.questions),测试绿但生产红(Pi 实际发 {type, method, title, options} 平铺)。
12
+ // 改为 Pi 真实格式 + channel/channelPayload 断言 + extension_ui_response 回写格式。
13
+ //
14
+ // 红灯原因:handleUiRequest 签名仍是旧的 (child, id, params, ctx, signal)
15
+ // + parseChannel 未接入 session-runner,编译失败。W2 改完签名后转绿。
16
+
17
+ import { PassThrough } from "node:stream";
18
+
19
+ import { describe, expect, it, vi } from "vitest";
20
+
21
+ import { ASK_USER_RPC_PROMPT } from "../session-runner.ts";
22
+ import { parseSpawnLine } from "../spawn-event-adapter.ts";
23
+ import { parseChannel } from "../ui-channels.ts";
24
+ import { createUiRequestQueue, type UiRequest, type UiRequestHandler } from "../ui-request-queue.ts";
25
+
26
+ // ── Pi 原生协议样本构造 ────────────────────────────────────
27
+ // 真实格式:{type:"extension_ui_request", id, method:"select",
28
+ // title:"\0XYZ_ASK_USER", options:[JSON.stringify({questions, allowCancel})]}
29
+ // 无 jsonrpc 字段、无 params 包裹、method 在顶层(非 method:"extension_ui_request")。
30
+
31
+ const ASK_USER_MARKER = "\0XYZ_ASK_USER";
32
+ const askUserPayload = {
33
+ questions: [
34
+ {
35
+ question: "What is your preference?",
36
+ options: [{ label: "Option A" }, { label: "Option B" }],
37
+ },
38
+ ],
39
+ allowCancel: true,
40
+ };
41
+
42
+ function askUserLine(id: string): string {
43
+ return JSON.stringify({
44
+ type: "extension_ui_request",
45
+ id,
46
+ method: "select",
47
+ title: ASK_USER_MARKER,
48
+ options: [JSON.stringify(askUserPayload)],
49
+ });
50
+ }
51
+
52
+ // ── 解析:Pi 原生格式 → ExtensionUiRequest(method 平铺,无 params) ─────
53
+
54
+ describe("parseSpawnLine — ask_user 请求解析(Pi 原生格式)", () => {
55
+ it("ask_user 行被识别为 extension_ui_request kind", () => {
56
+ const result = parseSpawnLine(askUserLine("ui-req-001"));
57
+ expect(result?.kind).toBe("extension_ui_request");
58
+ });
59
+
60
+ it("request.method === 'select'(ask_user 借道 select dialog 通道)", () => {
61
+ const result = parseSpawnLine(askUserLine("ui-req-001"));
62
+ if (result?.kind !== "extension_ui_request") {
63
+ expect.fail("expected extension_ui_request kind");
64
+ return;
65
+ }
66
+ expect(result.request.method).toBe("select");
67
+ });
68
+
69
+ it("request.title === ASK_USER_MARKER(NUL 前缀 marker 原样保留,由 parseChannel 提取)", () => {
70
+ const result = parseSpawnLine(askUserLine("ui-req-001"));
71
+ if (result?.kind !== "extension_ui_request") return;
72
+ expect(result.request.title).toBe(ASK_USER_MARKER);
73
+ });
74
+
75
+ it("id 被正确提取(用于 response 关联)", () => {
76
+ const result = parseSpawnLine(askUserLine("ui-req-001"));
77
+ if (result?.kind !== "extension_ui_request") return;
78
+ expect(result.id).toBe("ui-req-001");
79
+ });
80
+ });
81
+
82
+ // ── channel 提取(session-runner 层消费 parseChannel) ──────────────
83
+ // adapter 层只做协议解析(method + 字段平铺),channel 提取在 session-runner 层。
84
+ // 对 select.title 的 NUL 前缀解析出 channel='ask_user' + payload={questions, allowCancel}。
85
+
86
+ describe("parseChannel — ask_user channel 提取(session-runner 层)", () => {
87
+ it("select.title 含 ASK_USER_MARKER → channel='ask_user'", () => {
88
+ const result = parseSpawnLine(askUserLine("ui-req-001"));
89
+ if (result?.kind !== "extension_ui_request") return;
90
+ const channel = parseChannel(result.request);
91
+ expect(channel.channel).toBe("ask_user");
92
+ });
93
+
94
+ it("channelPayload 已 parse 为 {questions, allowCancel}", () => {
95
+ const result = parseSpawnLine(askUserLine("ui-req-001"));
96
+ if (result?.kind !== "extension_ui_request") return;
97
+ const channel = parseChannel(result.request);
98
+ expect(channel.channelPayload).toEqual(askUserPayload);
99
+ });
100
+ });
101
+
102
+ // ── stdin 回写格式:extension_ui_response(非 JSON-RPC 2.0 result) ─────
103
+
104
+ describe("handleUiRequest — stdin 回写 extension_ui_response(Pi 原生格式)", () => {
105
+ it("handler 返回 {value} → stdin 写入 {type:extension_ui_response, id, value}", async () => {
106
+ const stdin = new PassThrough();
107
+ const written: string[] = [];
108
+ stdin.on("data", (chunk: Buffer) => written.push(chunk.toString()));
109
+
110
+ const child = { stdin, on: vi.fn(), removeListener: vi.fn() } as unknown as Parameters<
111
+ typeof createUiRequestQueue
112
+ >[0];
113
+
114
+ const handler: UiRequestHandler = vi.fn(
115
+ async (req: UiRequest) => ({ value: `answer-for-${req.id}` }),
116
+ );
117
+ const ctx = { uiRequestHandler: handler } as unknown as Parameters<
118
+ typeof createUiRequestQueue
119
+ >[1];
120
+
121
+ const enqueue = createUiRequestQueue(child, ctx);
122
+ // W2 新签名:enqueue(id, request) —— request 是 ExtensionUiRequest(method 平铺)
123
+ enqueue("ui-req-002", {
124
+ method: "select",
125
+ title: ASK_USER_MARKER,
126
+ options: [JSON.stringify(askUserPayload)],
127
+ });
128
+
129
+ // 等微任务 + stdin flush
130
+ await new Promise((r) => setImmediate(r));
131
+
132
+ const raw = written.join("");
133
+ expect(raw).toContain('"type":"extension_ui_response"');
134
+ expect(raw).toContain('"id":"ui-req-002"');
135
+ expect(raw).toContain('"value":"answer-for-ui-req-002"');
136
+ // 旧 JSON-RPC 2.0 格式不应出现
137
+ expect(raw).not.toContain('"jsonrpc"');
138
+ expect(raw).not.toContain('"result"');
139
+ });
140
+ });
141
+
142
+ // ── handler 抛错兜底(M5):catch → 回 cancelled → 写 stdin ─────
143
+ // session-runner.ts:489-494 的 catch 分支:handler reject 时兜底写 cancelled。
144
+
145
+ describe("handleUiRequest — handler 抛错兜底回 cancelled", () => {
146
+ it("handler reject(new Error('boom')) → stdin 写入 cancelled:true", async () => {
147
+ const stdin = new PassThrough();
148
+ const written: string[] = [];
149
+ stdin.on("data", (chunk: Buffer) => written.push(chunk.toString()));
150
+
151
+ const child = { stdin, on: vi.fn(), removeListener: vi.fn() } as unknown as Parameters<
152
+ typeof createUiRequestQueue
153
+ >[0];
154
+
155
+ // handler 抛错(reject)
156
+ const handler: UiRequestHandler = vi.fn(
157
+ async () => Promise.reject(new Error("boom")),
158
+ );
159
+ const ctx = { uiRequestHandler: handler } as unknown as Parameters<
160
+ typeof createUiRequestQueue
161
+ >[1];
162
+
163
+ const enqueue = createUiRequestQueue(child, ctx);
164
+ enqueue("ui-req-err", {
165
+ method: "select",
166
+ title: ASK_USER_MARKER,
167
+ options: [JSON.stringify(askUserPayload)],
168
+ });
169
+
170
+ // 等微任务 + stdin flush
171
+ await new Promise((r) => setImmediate(r));
172
+
173
+ const raw = written.join("");
174
+ expect(raw).toContain('"type":"extension_ui_response"');
175
+ expect(raw).toContain('"id":"ui-req-err"');
176
+ expect(raw).toContain('"cancelled":true');
177
+ });
178
+ });
179
+
180
+ // ── W4 提示词(保留,不受格式修复影响) ──────────────────────────
181
+
182
+ describe("W4: ask_user RPC 系统提示词注入", () => {
183
+ it("ASK_USER_RPC_PROMPT 常量已导出且非空", () => {
184
+ expect(ASK_USER_RPC_PROMPT).toBeDefined();
185
+ expect(typeof ASK_USER_RPC_PROMPT).toBe("string");
186
+ expect(ASK_USER_RPC_PROMPT.length).toBeGreaterThan(0);
187
+ });
188
+
189
+ it("提示词包含 ask_user 工具说明", () => {
190
+ expect(ASK_USER_RPC_PROMPT).toContain("ask_user");
191
+ expect(ASK_USER_RPC_PROMPT).toContain("Tool Availability");
192
+ });
193
+
194
+ it("提示词告知 LLM ask_user 走 RPC 转发", () => {
195
+ expect(ASK_USER_RPC_PROMPT).toContain("RPC");
196
+ expect(ASK_USER_RPC_PROMPT).toContain("main agent");
197
+ expect(ASK_USER_RPC_PROMPT).toContain("forwarded");
198
+ });
199
+
200
+ it("提示词说明用户在主 agent 界面回答", () => {
201
+ expect(ASK_USER_RPC_PROMPT).toContain("user");
202
+ expect(ASK_USER_RPC_PROMPT).toContain("answers");
203
+ });
204
+ });