@zhushanwen/pi-ask-user 0.2.0 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-ask-user",
3
- "version": "0.2.0",
3
+ "version": "1.0.0",
4
4
  "description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview, inline editor, and optional comments.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -37,11 +37,15 @@
37
37
  "peerDependencies": {
38
38
  "@mariozechner/pi-coding-agent": "*",
39
39
  "@mariozechner/pi-tui": "*",
40
- "@sinclair/typebox": "*"
40
+ "@sinclair/typebox": "*",
41
+ "@zhushanwen/pi-subagent-workflow": "*"
41
42
  },
42
43
  "peerDependenciesMeta": {
43
44
  "@mariozechner/pi-tui": {
44
45
  "optional": true
46
+ },
47
+ "@zhushanwen/pi-subagent-workflow": {
48
+ "optional": true
45
49
  }
46
50
  },
47
51
  "scripts": {
@@ -0,0 +1,286 @@
1
+ // src/__tests__/channel-handler.test.ts
2
+ //
3
+ // Tests createAskUserChannelHandler:把 subagent 子进程的 ask_user 请求透传到主进程 UI。
4
+ //
5
+ // 覆盖:
6
+ // - RPC 路径(ctx.mode === 'rpc'):转发器——handler 内部调 askUserInteract(select 通道),
7
+ // 把 proto answers JSON.stringify 成 {value} 返回,子进程 JSON.parse(value) 正确 decode。
8
+ // - TUI 路径(ctx.mode === 'tui'):handler 走 ctx.ui.custom(mock 成返回预设 Result),
9
+ // 验证内部 Result → proto AskUserAnswers 重新编码(single/multi/Other/comment 四种答案形态)。
10
+ // - 取消(askUserInteract/custom 返回 null 或 cancelled)→ {cancelled: true}
11
+ // - 输入校验(channelPayload 缺失/无 questions)→ {cancelled: true}
12
+ import type { AskUserQuestion } from "@xyz-agent/extension-protocol";
13
+ import { describe, expect, it } from "vitest";
14
+
15
+ import { createAskUserChannelHandler } from "../channel-handler";
16
+ import type { Result } from "../types";
17
+
18
+ // ── Mock ctx ───────────────────────────────────────────
19
+ // RPC:ctx.ui.select 模拟前端回传的 proto answers(JSON.stringify(AskUserAnswers))。
20
+ // TUI:ctx.ui.custom 模拟 AskUserComponent 产出的内部 Result。
21
+ type CtxMode = "tui" | "rpc";
22
+
23
+ interface MockCtxOpts {
24
+ mode: CtxMode;
25
+ /** RPC:select 返回的 value(JSON.stringify 后的 proto answers);undefined = 取消 */
26
+ selectResult?: string | undefined;
27
+ /** TUI:custom 返回的内部 Result;null = 用户取消 */
28
+ customResult?: Result | null;
29
+ }
30
+
31
+ function makeCtx(opts: MockCtxOpts): {
32
+ mode: CtxMode;
33
+ hasUI: boolean;
34
+ ui: {
35
+ select?: (title: string, options: string[], o?: { signal?: AbortSignal }) => Promise<string | undefined>;
36
+ custom: <T = void>(factory: unknown) => Promise<T>;
37
+ };
38
+ } {
39
+ const { mode, selectResult, customResult } = opts;
40
+ const hasUI = true;
41
+ if (mode === "rpc") {
42
+ return {
43
+ mode,
44
+ hasUI,
45
+ ui: {
46
+ select: async (): Promise<string | undefined> => selectResult,
47
+ custom: async <T = void>(): Promise<T> => undefined as T,
48
+ },
49
+ };
50
+ }
51
+ // TUI
52
+ return {
53
+ mode,
54
+ hasUI,
55
+ ui: {
56
+ custom: async <T = void>(): Promise<T> => customResult as T,
57
+ },
58
+ };
59
+ }
60
+
61
+ // ── 样例 proto questions(handler 收到的格式) ──────────
62
+ const singleProto: AskUserQuestion = {
63
+ question: "Which DB?",
64
+ options: [{ label: "Postgres", value: "Postgres" }, { label: "SQLite", value: "SQLite" }],
65
+ };
66
+
67
+ const multiProto: AskUserQuestion = {
68
+ question: "Which tools?",
69
+ header: "Tools",
70
+ multiSelect: true,
71
+ options: [
72
+ { label: "A", value: "A" },
73
+ { label: "B", value: "B" },
74
+ { label: "C", value: "C" },
75
+ ],
76
+ };
77
+
78
+ const commentProto: AskUserQuestion = {
79
+ question: "Which DB?",
80
+ allowComment: true,
81
+ options: [{ label: "Postgres", value: "Postgres" }, { label: "SQLite", value: "SQLite" }],
82
+ };
83
+
84
+ // ── Tests ───────────────────────────────────────────────
85
+
86
+ describe("createAskUserChannelHandler", () => {
87
+ it("RPC: single-select proto answers → {value: JSON.stringify(answers)}", async () => {
88
+ // 前端回传 proto answers:{[key]: value},key = question 全文(无 header)
89
+ const protoAnswers = { "Which DB?": "Postgres" };
90
+ const handler = createAskUserChannelHandler(
91
+ makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
92
+ );
93
+ const resp = await handler({ channelPayload: { questions: [singleProto] } });
94
+ expect(resp).toEqual({ value: JSON.stringify({ "Which DB?": "Postgres" }) });
95
+ });
96
+
97
+ it("RPC: multi-select proto answers (JSON array value) → 透传", async () => {
98
+ const protoAnswers = { Tools: JSON.stringify(["A", "C"]) };
99
+ const handler = createAskUserChannelHandler(
100
+ makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
101
+ );
102
+ const resp = await handler({ channelPayload: { questions: [multiProto] } });
103
+ expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
104
+ });
105
+
106
+ it("RPC: Other + comment proto answers → 透传", async () => {
107
+ const protoAnswers = {
108
+ "Which DB?": "Postgres",
109
+ "Which DB?__other": "Custom DB",
110
+ "Which DB?__comment": "prod constraint",
111
+ };
112
+ const handler = createAskUserChannelHandler(
113
+ makeCtx({ mode: "rpc", selectResult: JSON.stringify(protoAnswers) }) as never,
114
+ );
115
+ const resp = await handler({ channelPayload: { questions: [commentProto] } });
116
+ expect(resp).toEqual({ value: JSON.stringify(protoAnswers) });
117
+ });
118
+
119
+ it("RPC: user cancel (select undefined) → {cancelled: true}", async () => {
120
+ const handler = createAskUserChannelHandler(
121
+ makeCtx({ mode: "rpc", selectResult: undefined }) as never,
122
+ );
123
+ const resp = await handler({ channelPayload: { questions: [singleProto] } });
124
+ expect(resp).toEqual({ cancelled: true });
125
+ });
126
+
127
+ it("TUI: internal Result single-select → 重新编码为 proto answers", async () => {
128
+ // 内部 Result.answers:key = question 全文,value = 选中 label
129
+ const internalResult: Result = {
130
+ questions: [],
131
+ answers: { "Which DB?": "Postgres" },
132
+ cancelled: false,
133
+ };
134
+ const handler = createAskUserChannelHandler(
135
+ makeCtx({ mode: "tui", customResult: internalResult }) as never,
136
+ );
137
+ const resp = await handler({ channelPayload: { questions: [singleProto] } });
138
+ // 期望:proto answers { "Which DB?": "Postgres" }
139
+ expect(resp).toEqual({ value: JSON.stringify({ "Which DB?": "Postgres" }) });
140
+ });
141
+
142
+ it("TUI: multi-select internal Result → proto JSON array value", async () => {
143
+ const internalResult: Result = {
144
+ questions: [],
145
+ answers: { "Which tools?": "A, C" },
146
+ cancelled: false,
147
+ };
148
+ const handler = createAskUserChannelHandler(
149
+ makeCtx({ mode: "tui", customResult: internalResult }) as never,
150
+ );
151
+ const resp = await handler({ channelPayload: { questions: [multiProto] } });
152
+ // 期望:key=header "Tools",value = JSON.stringify(["A","C"])
153
+ expect(resp).toEqual({ value: JSON.stringify({ Tools: JSON.stringify(["A", "C"]) }) });
154
+ });
155
+
156
+ it("TUI: value≠label single-select → encodeTuiResultToProto 回查 proto option value(PR #85 #8 回归守护)", async () => {
157
+ // value≠label 是 #8 修复的核心场景:TUI 渲染用 label,但 proto 期望回传 option.value。
158
+ // 若 #8 修复回归(直接 push label),此测试会失败:返回 "显示名A" 而非 "val_a"。
159
+ const valueNeqLabelProto: AskUserQuestion = {
160
+ question: "选哪个?",
161
+ options: [
162
+ { label: "显示名A", value: "val_a" },
163
+ { label: "显示名B", value: "val_b" },
164
+ ],
165
+ };
166
+ // 内部 Result.answers:用户在 TUI 选了"显示名A"(label)
167
+ const internalResult: Result = {
168
+ questions: [],
169
+ answers: { "选哪个?": "显示名A" },
170
+ cancelled: false,
171
+ };
172
+ const handler = createAskUserChannelHandler(
173
+ makeCtx({ mode: "tui", customResult: internalResult }) as never,
174
+ );
175
+ const resp = await handler({ channelPayload: { questions: [valueNeqLabelProto] } });
176
+ // 期望:proto answers 回查 value,返回 "val_a"(不是 label "显示名A")
177
+ expect(resp).toEqual({ value: JSON.stringify({ "选哪个?": "val_a" }) });
178
+ });
179
+
180
+ it("TUI: value≠label multi-select → proto JSON 数组元素回查 value(PR #85 #8 回归守护)", async () => {
181
+ // 多选路径同样依赖 #8 修复:selected.push(opt?.value ?? t),多选会 JSON.stringify 数组。
182
+ const valueNeqLabelMultiProto: AskUserQuestion = {
183
+ question: "选哪些?",
184
+ header: "Opts",
185
+ multiSelect: true,
186
+ options: [
187
+ { label: "显示名A", value: "val_a" },
188
+ { label: "显示名B", value: "val_b" },
189
+ ],
190
+ };
191
+ const internalResult: Result = {
192
+ questions: [],
193
+ answers: { "选哪些?": "显示名A, 显示名B" },
194
+ cancelled: false,
195
+ };
196
+ const handler = createAskUserChannelHandler(
197
+ makeCtx({ mode: "tui", customResult: internalResult }) as never,
198
+ );
199
+ const resp = await handler({ channelPayload: { questions: [valueNeqLabelMultiProto] } });
200
+ // 期望:多选 JSON 数组,每个元素回查 value(["val_a","val_b"],不是 label)
201
+ expect(resp).toEqual({ value: JSON.stringify({ Opts: JSON.stringify(["val_a", "val_b"]) }) });
202
+ });
203
+
204
+ it("TUI: Other free text → ${key}__other", async () => {
205
+ // 内部 Result:selected label + Other 文本逗号拼接(与 getAnswerText 语义一致)
206
+ const internalResult: Result = {
207
+ questions: [],
208
+ answers: { "Which DB?": "Postgres, Custom DB" },
209
+ cancelled: false,
210
+ };
211
+ const handler = createAskUserChannelHandler(
212
+ makeCtx({ mode: "tui", customResult: internalResult }) as never,
213
+ );
214
+ const resp = await handler({ channelPayload: { questions: [singleProto] } });
215
+ expect(resp).toEqual({
216
+ value: JSON.stringify({ "Which DB?": "Postgres", "Which DB?__other": "Custom DB" }),
217
+ });
218
+ });
219
+
220
+ it("TUI: comment → ${key}__comment", async () => {
221
+ const internalResult: Result = {
222
+ questions: [],
223
+ answers: { "Which DB?": "Postgres — prod constraint" },
224
+ cancelled: false,
225
+ };
226
+ const handler = createAskUserChannelHandler(
227
+ makeCtx({ mode: "tui", customResult: internalResult }) as never,
228
+ );
229
+ const resp = await handler({ channelPayload: { questions: [commentProto] } });
230
+ expect(resp).toEqual({
231
+ value: JSON.stringify({ "Which DB?": "Postgres", "Which DB?__comment": "prod constraint" }),
232
+ });
233
+ });
234
+
235
+ it("TUI: user cancel (custom returns null) → {cancelled: true}", async () => {
236
+ const handler = createAskUserChannelHandler(
237
+ makeCtx({ mode: "tui", customResult: null }) as never,
238
+ );
239
+ const resp = await handler({ channelPayload: { questions: [singleProto] } });
240
+ expect(resp).toEqual({ cancelled: true });
241
+ });
242
+
243
+ it("TUI: custom returns cancelled Result → {cancelled: true}", async () => {
244
+ const handler = createAskUserChannelHandler(
245
+ makeCtx({
246
+ mode: "tui",
247
+ customResult: { questions: [], answers: {}, cancelled: true },
248
+ }) as never,
249
+ );
250
+ const resp = await handler({ channelPayload: { questions: [singleProto] } });
251
+ expect(resp).toEqual({ cancelled: true });
252
+ });
253
+
254
+ it("input: channelPayload missing → {cancelled: true}", async () => {
255
+ const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
256
+ const resp = await handler({});
257
+ expect(resp).toEqual({ cancelled: true });
258
+ });
259
+
260
+ it("input: questions empty array → {cancelled: true}", async () => {
261
+ const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
262
+ const resp = await handler({ channelPayload: { questions: [] } });
263
+ expect(resp).toEqual({ cancelled: true });
264
+ });
265
+
266
+ it("input: questions not an array → {cancelled: true}", async () => {
267
+ const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
268
+ const resp = await handler({ channelPayload: { questions: "not-array" } });
269
+ expect(resp).toEqual({ cancelled: true });
270
+ });
271
+
272
+ it("input: req is null/undefined → {cancelled: true}", async () => {
273
+ const handler = createAskUserChannelHandler(makeCtx({ mode: "rpc" }) as never);
274
+ expect(await handler(null)).toEqual({ cancelled: true });
275
+ expect(await handler(undefined)).toEqual({ cancelled: true });
276
+ });
277
+
278
+ it("RPC: allowCancel passed through to askUserInteract (default true)", async () => {
279
+ // allowCancel 默认 true:验证不抛错(select mock 返回 undefined=取消)
280
+ const handler = createAskUserChannelHandler(
281
+ makeCtx({ mode: "rpc", selectResult: undefined }) as never,
282
+ );
283
+ const resp = await handler({ channelPayload: { questions: [singleProto] } });
284
+ expect(resp).toEqual({ cancelled: true });
285
+ });
286
+ });
@@ -0,0 +1,180 @@
1
+ // src/__tests__/channel-registry-register.test.ts
2
+ //
3
+ // Tests registerAskUserChannelHandler:ask-user 侧的 globalThis Symbol 握手注册纯函数。
4
+ //
5
+ // 覆盖(PR #85 #M4 + #M5):
6
+ // - 空 globalThis → 建 slot(仅 pending),handler 入 pending;**slot.registry === undefined**
7
+ // (M4 核心断言:ask-user 永不创建 registry 实例)
8
+ // - slot 存在但 registry 未就绪 → push pending(registry 仍 undefined)
9
+ // - slot 存在且 registry 就绪 → 直接调 registry.register("ask_user", handler),pending 不增长
10
+ // - 重复调用:registry 就绪时 register 多次(同名覆盖幂等);未就绪时 pending.length 增长
11
+ // - version !== 1 → warn + 重建为新 slot,旧 pending 丢弃
12
+ //
13
+ // 隔离:每个用例前 Reflect.deleteProperty(globalThis, CHANNEL_HANDSHAKE_KEY)。
14
+ import { beforeEach,describe, expect, it, vi } from "vitest";
15
+
16
+ import type { ChannelHandler } from "../channel-handler";
17
+ import {
18
+ CHANNEL_HANDSHAKE_KEY,
19
+ type ChannelRegistryHandshake,
20
+ registerAskUserChannelHandler,
21
+ } from "../channel-registry-register";
22
+
23
+ // 拿到当前 slot(cast any 安全:测试控制 slot 写入,结构已知)
24
+ function readSlot(): ChannelRegistryHandshake | undefined {
25
+ return Reflect.get(globalThis, CHANNEL_HANDSHAKE_KEY) as
26
+ | ChannelRegistryHandshake
27
+ | undefined;
28
+ }
29
+
30
+ /** 塞一个指定 version 的 slot(手动构造,绕过 registerAskUserChannelHandler)。 */
31
+ function writeSlot(slot: ChannelRegistryHandshake): void {
32
+ Reflect.set(globalThis, CHANNEL_HANDSHAKE_KEY, slot);
33
+ }
34
+
35
+ /** 用 spy 构造 mock registry:register 是 vi.fn,pending flush 时可断言调用。 */
36
+ function makeMockRegistry(): {
37
+ register: ReturnType<typeof vi.fn>;
38
+ resolve: ReturnType<typeof vi.fn>;
39
+ list: ReturnType<typeof vi.fn>;
40
+ } {
41
+ return {
42
+ register: vi.fn(),
43
+ resolve: vi.fn().mockReturnValue(undefined),
44
+ list: vi.fn().mockReturnValue([]),
45
+ };
46
+ }
47
+
48
+ // 占位 handler——测试只关心调用计数和参数,handler 实体不重要
49
+ const noopHandler: ChannelHandler = async () => undefined;
50
+
51
+ describe("registerAskUserChannelHandler", () => {
52
+ beforeEach(() => {
53
+ Reflect.deleteProperty(globalThis, CHANNEL_HANDSHAKE_KEY);
54
+ });
55
+
56
+ it("空 globalThis → 建 slot,handler 入 pending,slot.registry === undefined(M4 核心)", () => {
57
+ expect(readSlot()).toBeUndefined();
58
+
59
+ registerAskUserChannelHandler(noopHandler);
60
+
61
+ const slot = readSlot();
62
+ expect(slot).toBeDefined();
63
+ expect(slot!.version).toBe(1);
64
+ // M4 核心断言:ask-user 永不创建 registry 实例
65
+ expect(slot!.registry).toBeUndefined();
66
+ expect(slot!.pending).toHaveLength(1);
67
+ expect(slot!.pending[0]).toEqual({ channel: "ask_user", handler: noopHandler });
68
+ });
69
+
70
+ it("slot 存在但 registry 未就绪 → push pending(registry 仍 undefined)", () => {
71
+ // 预置 slot:version=1,pending=[],registry 缺失(模拟 subagent-workflow 尚未 session_start)
72
+ const preSlot: ChannelRegistryHandshake = { version: 1, pending: [] };
73
+ writeSlot(preSlot);
74
+
75
+ registerAskUserChannelHandler(noopHandler);
76
+
77
+ const slot = readSlot();
78
+ expect(slot).toBe(preSlot); // 同一对象,未重建
79
+ expect(slot!.registry).toBeUndefined();
80
+ expect(slot!.pending).toHaveLength(1);
81
+ expect(slot!.pending[0]).toEqual({ channel: "ask_user", handler: noopHandler });
82
+ });
83
+
84
+ it("slot 存在且 registry 就绪 → 调 registry.register,pending 不增长", () => {
85
+ const mockRegistry = makeMockRegistry();
86
+ const preSlot: ChannelRegistryHandshake = {
87
+ version: 1,
88
+ registry: mockRegistry as unknown as ChannelRegistryHandshake["registry"],
89
+ pending: [],
90
+ };
91
+ writeSlot(preSlot);
92
+
93
+ registerAskUserChannelHandler(noopHandler);
94
+
95
+ expect(mockRegistry.register).toHaveBeenCalledTimes(1);
96
+ expect(mockRegistry.register).toHaveBeenCalledWith("ask_user", noopHandler);
97
+ // pending 不增长(直接 register,不进队列)
98
+ expect(preSlot.pending).toHaveLength(0);
99
+ });
100
+
101
+ it("registry 就绪时重复 register → register 被调多次(同名覆盖幂等,pending 始终 0)", () => {
102
+ const mockRegistry = makeMockRegistry();
103
+ const preSlot: ChannelRegistryHandshake = {
104
+ version: 1,
105
+ registry: mockRegistry as unknown as ChannelRegistryHandshake["registry"],
106
+ pending: [],
107
+ };
108
+ writeSlot(preSlot);
109
+
110
+ const h1: ChannelHandler = async () => "a";
111
+ const h2: ChannelHandler = async () => "b";
112
+ registerAskUserChannelHandler(h1);
113
+ registerAskUserChannelHandler(h2);
114
+
115
+ expect(mockRegistry.register).toHaveBeenCalledTimes(2);
116
+ expect(mockRegistry.register).toHaveBeenNthCalledWith(1, "ask_user", h1);
117
+ expect(mockRegistry.register).toHaveBeenNthCalledWith(2, "ask_user", h2);
118
+ expect(preSlot.pending).toHaveLength(0); // 幂等:不进 pending
119
+ });
120
+
121
+ it("registry 未就绪时多次 register → pending.length 增长(顺序保留)", () => {
122
+ // 第 1 次:空 globalThis → 建 slot + pending[0]
123
+ const h1: ChannelHandler = async () => "a";
124
+ registerAskUserChannelHandler(h1);
125
+ // 第 2 次:slot 已存在、registry 仍 undefined → pending[1]
126
+ const h2: ChannelHandler = async () => "b";
127
+ registerAskUserChannelHandler(h2);
128
+
129
+ const slot = readSlot();
130
+ expect(slot!.registry).toBeUndefined();
131
+ expect(slot!.pending).toHaveLength(2);
132
+ expect(slot!.pending[0]).toEqual({ channel: "ask_user", handler: h1 });
133
+ expect(slot!.pending[1]).toEqual({ channel: "ask_user", handler: h2 });
134
+ });
135
+
136
+ it("version !== 1 → warn + 重建为新 slot,旧 pending 丢弃", () => {
137
+ // 塞一个 version=2 的旧 slot(模拟未来协议升级 / 脏数据)
138
+ const spyWarn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
139
+ const legacyPending = [{ channel: "stale", handler: noopHandler }];
140
+ const legacySlot = {
141
+ version: 2 as const,
142
+ registry: makeMockRegistry() as unknown as ChannelRegistryHandshake["registry"],
143
+ pending: legacyPending,
144
+ };
145
+ writeSlot(legacySlot as unknown as ChannelRegistryHandshake);
146
+
147
+ registerAskUserChannelHandler(noopHandler);
148
+
149
+ const slot = readSlot();
150
+ // 旧 slot 被替换(version 退回 1,pending 重置为仅含本次注册)
151
+ expect(slot).not.toBe(legacySlot);
152
+ expect(slot!.version).toBe(1);
153
+ expect(slot!.pending).toEqual([{ channel: "ask_user", handler: noopHandler }]);
154
+ // warn 被调用(包含 version mismatch 提示)
155
+ expect(spyWarn).toHaveBeenCalledTimes(1);
156
+ expect(spyWarn.mock.calls[0]![0]).toContain("version mismatch");
157
+
158
+ spyWarn.mockRestore();
159
+ });
160
+
161
+ it("registry 就绪但 slot 已预置 pending → 仍走 register 路径(pending 不被本函数消费)", () => {
162
+ // 边界:subagent-workflow flush 后理论上 pending 应为空,但若 flush 漏了,
163
+ // 新 handler 来时仍应直接 register(不消费遗留 pending——那是 subagent-workflow 的职责)。
164
+ const mockRegistry = makeMockRegistry();
165
+ const preExistingPending = [{ channel: "ask_user", handler: noopHandler }];
166
+ const preSlot: ChannelRegistryHandshake = {
167
+ version: 1,
168
+ registry: mockRegistry as unknown as ChannelRegistryHandshake["registry"],
169
+ pending: preExistingPending,
170
+ };
171
+ writeSlot(preSlot);
172
+
173
+ registerAskUserChannelHandler(noopHandler);
174
+
175
+ expect(mockRegistry.register).toHaveBeenCalledTimes(1);
176
+ // 遗留 pending 未被消费(长度不变)
177
+ expect(preSlot.pending).toHaveLength(1);
178
+ expect(preSlot.pending).toBe(preExistingPending);
179
+ });
180
+ });
@@ -16,6 +16,8 @@ interface PiShape {
16
16
  registerTool(t: unknown): void;
17
17
  getAllTools(): { name: string }[];
18
18
  setActiveTools(names: string[]): void;
19
+ // session_start handler(factory 注册透传 channel 用);测试不覆盖透传,空实现。
20
+ on(event: string, handler: (...args: unknown[]) => unknown): void;
19
21
  }
20
22
 
21
23
  export interface E2EApi {
@@ -48,6 +50,9 @@ export function makeE2E(questions: Question[], opts: E2EOptions = {}): E2EApi {
48
50
  setActiveTools(names) {
49
51
  this.activeTools = names;
50
52
  },
53
+ on() {
54
+ // no-op:session_start handler(透传 channel 注册),测试不覆盖
55
+ },
51
56
  };
52
57
 
53
58
  factory(pi as never);
@@ -75,9 +75,9 @@ describe("E2E-3: single question + allowComment — Enter in comment skips", ()
75
75
  expect(details.cancelled).toBe(false);
76
76
  // 不含 " — " 分隔符
77
77
  expect(details.answers["Which DB?"]).toBe("Postgres");
78
- expect(details.answers["Which DB?"]).not.toContain("—");
79
- });
78
+ expect(details.answers["Which DB?"]).not.toContain("—");
80
79
  });
80
+ });
81
81
 
82
82
  // ── E2E-4: 多问题提交 — 逐题选择后 Submit tab 提交(S-11)──────
83
83
  describe("E2E-4: multi-question submit — answer each then Submit tab", () => {
@@ -44,6 +44,9 @@ interface MockPi {
44
44
  getAllTools(): { name: string }[];
45
45
  activeTools?: string[] | null;
46
46
  setActiveTools(names: string[]): void;
47
+ // session_start handler:factory 注册 ask_user channel handler 时调用(透传功能)。
48
+ // 测试不验证透传,提供空 on 让 factory 不抛错。
49
+ on(event: string, handler: (...args: unknown[]) => unknown): void;
47
50
  }
48
51
 
49
52
  /** Runs the factory, returns the captured registered tool. */
@@ -58,6 +61,10 @@ const getTool = (overrides: Partial<MockPi> = {}): RegisteredTool => {
58
61
  setActiveTools(names) {
59
62
  this.activeTools = names;
60
63
  },
64
+ on() {
65
+ // no-op:session_start handler 注册透传 channel(subagent-workflow 可选),
66
+ // 测试不覆盖透传路径
67
+ },
61
68
  ...overrides,
62
69
  };
63
70
  factory(pi as never);
@@ -1,7 +1,7 @@
1
1
  // src/__tests__/question-view.test.ts
2
2
  import { describe, expect, it } from "vitest";
3
3
 
4
- import { getSplitPaneWidths, renderQuestionView, type RenderContext } from "../question-view";
4
+ import { getSplitPaneWidths, type RenderContext,renderQuestionView } from "../question-view";
5
5
  import { createQuestionState, type Question, type QuestionState } from "../types";
6
6
  import { stubTheme } from "./fixtures";
7
7
 
@@ -10,8 +10,8 @@
10
10
 
11
11
  import { describe, expect, it } from "vitest";
12
12
 
13
- import { validateInput } from "../validate";
14
13
  import askUserExtension from "../index";
14
+ import { validateInput } from "../validate";
15
15
 
16
16
  describe("ask-user SDK contract", () => {
17
17
  it("default export is a function accepting ExtensionAPI", () => {
@@ -0,0 +1,215 @@
1
+ // src/channel-handler.ts
2
+ //
3
+ // ask_user channel handler:把 subagent 子进程的 ask_user 请求透传到主进程 UI 渲染。
4
+ //
5
+ // 设计(关键决策):askUserInteract(@xyz-agent/extension-protocol)只在 RPC 模式可用
6
+ // (内部 isGuiCapable 检查 mode==='rpc',TUI 下抛错)。所以 handler 按 ctx.mode 分流:
7
+ // - RPC:转发器——调 askUserInteract(guiCtx, protoQuestions),复用 select 通道 +
8
+ // ASK_USER_MARKER 契约,主进程 ctx.ui.select 经 GUI sidecar 渲染(不进 parseSpawnLine,
9
+ // 不循环)。返回 {value: JSON.stringify(answers)} 让子进程 JSON.parse(value) decode。
10
+ // - TUI:走 ctx.ui.custom + AskUserComponent。三步:(1) protoQuestions → 内部 Question[],
11
+ // (2) ctx.ui.custom 渲染拿内部 Result,(3) 内部 Result.answers(key=question 全文,
12
+ // value="label1, label2 — comment")→ 重新编码为 proto AskUserAnswers(key=header/question,
13
+ // 单选=value,多选=JSON 数组,Other→__other,comment→__comment),让子进程 decode 一致。
14
+ //
15
+ // handler 收到的 req.channelPayload = {questions: AskUserQuestion[], allowCancel}(proto 格式,
16
+ // 由子进程 askUserInteract 编码、subagent-workflow parseChannel 解析 options[0] JSON 得到)。
17
+
18
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
19
+ import {
20
+ type AskUserAnswers,
21
+ askUserInteract,
22
+ type AskUserQuestion,
23
+ } from "@xyz-agent/extension-protocol";
24
+
25
+ import { AskUserComponent } from "./component";
26
+ import { ANSWER_COMMENT_SEPARATOR, type Option, type Question, type Result, type ThemeLike } from "./types";
27
+
28
+ /**
29
+ * channel handler 签名——与 subagent-workflow 的 UiChannelRegistry.ChannelHandler 一致
30
+ *((req: unknown) => Promise<unknown>)。本文件不静态 import subagent-workflow(它是可选
31
+ * peerDep,未安装时静态 import 会导致整个 ask-user 加载失败);注册时通过动态 import 拿
32
+ * registry,handler 签名用本地等价类型,运行时结构兼容。
33
+ */
34
+ export type ChannelHandler = (req: unknown) => Promise<unknown>;
35
+
36
+ /** handler 返回给 subagent-workflow 的 UiResponse 形状(dialog-queue.ts 定义)。
37
+ * - {value}: select 的回传值(子进程 JSON.parse(value) 得 answers)
38
+ * - {cancelled}: 用户取消 / 子进程 close / handler 抛错 */
39
+ type ChannelResponse = { value: string } | { cancelled: true };
40
+
41
+ /** handler 收到的 req 形状收窄(ChannelHandler 签名是 unknown,按形状 as 收窄)。
42
+ * channelPayload 由 subagent-workflow parseChannel 填充。 */
43
+ interface ChannelRequest {
44
+ channelPayload?: { questions?: AskUserQuestion[]; allowCancel?: boolean };
45
+ }
46
+
47
+ /** proto AskUserQuestion → 内部 Question(AskUserComponent 接受内部格式)。
48
+ * proto options 可选(无 options=纯自由文本),内部 options 必填——无 options 的 protoQuestion
49
+ * 这里仍映射出 options(从子进程 ask-user 调用方保证 protoQuestions 总带 options;若缺则返 [] 由调用方判)。 */
50
+ function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[] {
51
+ return protoQuestions.map((pq: AskUserQuestion): Question => {
52
+ const opts: Option[] = (pq.options ?? []).map((o: { label: string; description?: string }): Option => ({
53
+ label: o.label,
54
+ ...(o.description !== undefined ? { description: o.description } : {}),
55
+ }));
56
+ return {
57
+ question: pq.question,
58
+ ...(pq.header !== undefined ? { header: pq.header } : {}),
59
+ ...(pq.context !== undefined ? { context: pq.context } : {}),
60
+ options: opts,
61
+ ...(pq.multiSelect !== undefined ? { multiSelect: pq.multiSelect } : {}),
62
+ ...(pq.allowComment !== undefined ? { allowComment: pq.allowComment } : {}),
63
+ };
64
+ });
65
+ }
66
+
67
+ /**
68
+ * 把 TUI 路径产出的内部 Result.answers 重新编码为 proto AskUserAnswers。
69
+ *
70
+ * 内部 Result.answers:key = question 全文,value = "label1, label2 — comment"
71
+ * (Other 自由文本与 selected 标签逗号拼接,comment 用 ANSWER_COMMENT_SEPARATOR 分隔)。
72
+ *
73
+ * proto AskUserAnswers 契约(@xyz-agent/extension-protocol):
74
+ * - key = question.header ?? question 全文
75
+ * - 单选:value = 选中项 value string
76
+ * - 多选:value = JSON.stringify(选中项 value 数组)
77
+ * - Other 自由文本:单独 key `${header}__other`
78
+ * - comment:单独 key `${header}__comment`
79
+ *
80
+ * 解码(无信息丢失):用 protoQuestion.options 的 label 集合精确匹配 selected;
81
+ * 不匹配的 token = Other 自由文本;comment 由 ANSWER_COMMENT_SEPARATOR 切出。
82
+ */
83
+ function encodeTuiResultToProto(
84
+ protoQuestions: AskUserQuestion[],
85
+ result: Result,
86
+ ): AskUserAnswers {
87
+ const answers: AskUserAnswers = {};
88
+ for (const pq of protoQuestions) {
89
+ const key = pq.header ?? pq.question;
90
+ const internalText = result.answers[pq.question];
91
+ if (internalText === undefined) continue; // 该问题未答(protoAnswersToResult 也跳过未答)
92
+
93
+ // 已知选项 label 集合(protoQuestion.options 的 label/value 都作候选——value 缺失时用 label)
94
+ const knownLabels = new Set<string>();
95
+ for (const o of pq.options ?? []) {
96
+ knownLabels.add(o.label);
97
+ if (o.value !== undefined) knownLabels.add(o.value);
98
+ }
99
+
100
+ // 切 body / comment(comment 在 ANSWER_COMMENT_SEPARATOR 之后)
101
+ const sepIdx = internalText.indexOf(ANSWER_COMMENT_SEPARATOR);
102
+ const body = sepIdx >= 0 ? internalText.slice(0, sepIdx) : internalText;
103
+ const comment = sepIdx >= 0
104
+ ? internalText.slice(sepIdx + ANSWER_COMMENT_SEPARATOR.length).trim() || undefined
105
+ : undefined;
106
+
107
+ // body tokens:匹配 knownLabels 的为 selected,其余为 Other 自由文本
108
+ const tokens = body.split(/[,,]/).map((t: string) => t.trim()).filter((t: string) => t !== "");
109
+ const selected: string[] = [];
110
+ const otherTokens: string[] = [];
111
+ for (const t of tokens) {
112
+ if (knownLabels.has(t)) {
113
+ // 回查 proto option 的 value(PR #85 #8):TUI 渲染用 label,但 RPC 路径
114
+ // (askUserInteract)回传的是 option.value。value≠label 时若直接 push label,
115
+ // TUI/RPC 两条路径产出分裂。value 缺失时 fallback label(保持 ask-user 自身
116
+ // toProtoQuestions 的 value=label 语义,以及历史行为)。
117
+ const opt = pq.options?.find(o => o.label === t || o.value === t);
118
+ selected.push(opt?.value ?? t);
119
+ } else {
120
+ otherTokens.push(t);
121
+ }
122
+ }
123
+ const otherText = otherTokens.join(", ") || undefined;
124
+
125
+ // 主 key:单选 = 首个选中 value;多选 = JSON 数组(即便为空也写入,与 RPC 契约一致)
126
+ if (pq.multiSelect) {
127
+ answers[key] = JSON.stringify(selected);
128
+ } else if (selected.length > 0) {
129
+ answers[key] = selected[0]!;
130
+ }
131
+
132
+ if (otherText) answers[`${key}__other`] = otherText;
133
+ if (comment) answers[`${key}__comment`] = comment;
134
+ }
135
+ return answers;
136
+ }
137
+
138
+ /** TUI 路径:ctx.ui.custom + AskUserComponent 渲染,返回 proto answers 或 null(取消)。
139
+ *
140
+ * allowCancel 透传预留(PR #85 #12):AskUserComponent 构造函数暂未接收 allowCancel,
141
+ * Esc 取消始终可用(component.ts 的 escBackOrConfirm / cancel 无条件生效)。待组件升级
142
+ * 支持禁用 Esc 后,应把 allowCancel 下传给 AskUserComponent 构造函数。当前 allowCancel=false
143
+ * 时 TUI 与 RPC 路径仍有分裂,但 handler 层已不再吞掉 allowCancel(修复分裂的第一步)。 */
144
+ async function runTuiProtoInteraction(
145
+ protoQuestions: AskUserQuestion[],
146
+ ctx: ExtensionContext,
147
+ allowCancel: boolean,
148
+ ): Promise<AskUserAnswers | null> {
149
+ const questions = protoToInternalQuestions(protoQuestions);
150
+ // 预留:组件升级后此处改为 new AskUserComponent(questions, tui, theme, done, allowCancel)
151
+ void allowCancel;
152
+ const result = await ctx.ui.custom<Result | null>(
153
+ (tui: unknown, theme: unknown, _kb: unknown, done: (r: Result | null) => void) => {
154
+ const comp = new AskUserComponent(
155
+ questions,
156
+ tui as { requestRender(): void },
157
+ theme as ThemeLike,
158
+ done,
159
+ );
160
+ return comp;
161
+ },
162
+ );
163
+ if (result === null || result.cancelled) return null;
164
+ return encodeTuiResultToProto(protoQuestions, result);
165
+ }
166
+
167
+ /**
168
+ * 创建 ask_user channel handler。
169
+ *
170
+ * @param ctx 主进程 ExtensionContext(session_start 时注入)
171
+ * @returns ChannelHandler——req.channelPayload = {questions, allowCancel}(proto 格式),
172
+ * 返回 {value: JSON.stringify(answers)} 或 {cancelled: true}
173
+ */
174
+ export function createAskUserChannelHandler(ctx: ExtensionContext): ChannelHandler {
175
+ return async (req: unknown): Promise<unknown> => {
176
+ // req 正常是 subagent-workflow 构造的 UiRequest 对象;防御性收窄 null/undefined/
177
+ // 非 object(handler 抛错会被 dialog-queue 兜底为 {cancelled:true},但这里直接返回更干净)
178
+ if (req === null || typeof req !== "object") {
179
+ return { cancelled: true } satisfies ChannelResponse;
180
+ }
181
+ const r = req as ChannelRequest;
182
+ const payload = r.channelPayload;
183
+ if (!payload || !Array.isArray(payload.questions) || payload.questions.length === 0) {
184
+ return { cancelled: true } satisfies ChannelResponse;
185
+ }
186
+ const { questions, allowCancel } = payload;
187
+
188
+ // 按 ctx.mode 分流(PR #85 #13 / #M6):rpc 走 askUserInteract(select 通道+sidecar),
189
+ // 其余(tui/json/print/undefined)走 ctx.ui.custom+AskUserComponent。
190
+ // 用 ctx.mode === "rpc" 二值判定(与 index.ts execute 的 useRpc 判定一致);
191
+ // 三值分类不需要——handler 只关心「rpc 转发」vs「TUI 内部渲染」两条路径。
192
+ const answers =
193
+ ctx.mode === "rpc"
194
+ ? await runRpcForward(questions, ctx, allowCancel ?? true)
195
+ : await runTuiProtoInteraction(questions, ctx, allowCancel ?? true);
196
+
197
+ if (answers === null) return { cancelled: true } satisfies ChannelResponse;
198
+ return { value: JSON.stringify(answers) } satisfies ChannelResponse;
199
+ };
200
+ }
201
+
202
+ /** RPC 转发器:主进程 ctx.ui.select 经 GUI sidecar 渲染(不进 parseSpawnLine,不循环)。
203
+ * 完整复用 askUserInteract 的 encode/decode 契约。 */
204
+ async function runRpcForward(
205
+ questions: AskUserQuestion[],
206
+ ctx: ExtensionContext,
207
+ allowCancel: boolean,
208
+ ): Promise<AskUserAnswers | null> {
209
+ const guiCtx = {
210
+ mode: ctx.mode,
211
+ hasUI: ctx.hasUI,
212
+ ui: { select: ctx.ui.select.bind(ctx.ui) },
213
+ };
214
+ return askUserInteract(guiCtx, questions, { allowCancel });
215
+ }
@@ -0,0 +1,108 @@
1
+ // src/channel-registry-register.ts
2
+ //
3
+ // ask-user 侧的 channel handler 握手注册纯函数。
4
+ //
5
+ // 设计动机(PR #85 #M4 修复):原实现 ask-user 先 session_start 时会自建简化 Map-based
6
+ // registry 占据 canonical 槽位,劫持 subagent-workflow 后续 getOrCreateChannelRegistry 拿到的
7
+ // 实例(subagent-workflow 的 createUiChannelRegistry 才是 canonical——它带排队、dialog 队列
8
+ // 等完整能力)。修复后的握手协议改为「带 version 的 slot」:
9
+ // - subagent-workflow session_start 时往 slot 写 {version, registry, pending:[]}
10
+ // - ask-user session_start 时只读 slot,registry 就绪则调 registry.register,未就绪则 push pending
11
+ // - ask-user **永不**创建 registry 实例,**永不**写 slot.registry
12
+ //
13
+ // 这是 ask-user 侧的注册入口;canonical registry 由 subagent-workflow 创建。本模块永不创建
14
+ // registry 实例——仅往 slot 写 pending 或调 slot.registry.register。
15
+
16
+ import type { ChannelHandler } from "./channel-handler";
17
+
18
+ /**
19
+ * 进程级 channel registry 握手的 globalThis key(Symbol.for 跨模块共享)。
20
+ *
21
+ * ⚠️ 必须与 extensions/subagent-workflow/src/execution/channel-registry-access.ts 的字面量
22
+ * 完全一致——两边用同一字符串确保拿到同一 slot 实例。改名必须两侧同步。
23
+ */
24
+ export const CHANNEL_HANDSHAKE_KEY = Symbol.for(
25
+ "@zhushanwen/pi-subagents.channelHandshake",
26
+ );
27
+
28
+ /** 握手协议版本号。读写 slot 时校验 version !== 1 视为不兼容(warn + 重建 slot)。 */
29
+ const HANDSHAKE_VERSION = 1;
30
+
31
+ /** channel 名称(ask-user 固定注册 "ask_user")。 */
32
+ const ASK_USER_CHANNEL = "ask_user";
33
+
34
+ /**
35
+ * channel registry 的本地等价接口(与 subagent-workflow UiChannelRegistry 形状一致)。
36
+ * 本模块不静态 import subagent-workflow(它是可选 peerDep,未安装时静态 import 会致整个
37
+ * ask-user 加载失败);运行时结构兼容即可。
38
+ */
39
+ interface ChannelRegistry {
40
+ register(channel: string, handler: ChannelHandler): void;
41
+ resolve(channel: string): ChannelHandler | undefined;
42
+ list(): string[];
43
+ }
44
+
45
+ /** pending 队列元素:channel + handler。subagent-workflow flush 时遍历调用 registry.register。 */
46
+ interface PendingEntry {
47
+ channel: string;
48
+ handler: ChannelHandler;
49
+ }
50
+
51
+ /**
52
+ * globalThis slot 的形状。
53
+ *
54
+ * - `version`:握手协议版本(运行时校验,不兼容则丢弃重建)
55
+ * - `registry`:canonical 实例,**仅 subagent-workflow 创建**;缺失表示 registry 未就绪,
56
+ * ask-user 把 handler 入 pending 队列等待 flush
57
+ * - `pending`:未消费的注册请求(registry 就绪后由 subagent-workflow 一次性 flush)
58
+ */
59
+ export interface ChannelRegistryHandshake {
60
+ version: 1;
61
+ registry?: ChannelRegistry;
62
+ pending: PendingEntry[];
63
+ }
64
+
65
+ /** 从 globalThis 读 slot;version !== 1 视为无 slot(返回 undefined)。 */
66
+ function readSlot(): ChannelRegistryHandshake | undefined {
67
+ const slot = Reflect.get(globalThis, CHANNEL_HANDSHAKE_KEY) as
68
+ | ChannelRegistryHandshake
69
+ | undefined;
70
+ if (slot === undefined) return undefined;
71
+ if (slot.version !== HANDSHAKE_VERSION) {
72
+ console.warn(
73
+ `[ask-user] channel handshake slot version mismatch: expected ${HANDSHAKE_VERSION}, got ${slot.version}; discarding and rebuilding slot`,
74
+ );
75
+ return undefined;
76
+ }
77
+ return slot;
78
+ }
79
+
80
+ /** 在 globalThis 上建一个空 slot(仅 pending,无 registry——ask-user 永不建 registry)。 */
81
+ function ensureSlot(): ChannelRegistryHandshake {
82
+ const slot: ChannelRegistryHandshake = { version: HANDSHAKE_VERSION, pending: [] };
83
+ Reflect.set(globalThis, CHANNEL_HANDSHAKE_KEY, slot);
84
+ return slot;
85
+ }
86
+
87
+ /**
88
+ * 注册 ask_user channel handler 到 globalThis 握手 slot。
89
+ *
90
+ * 行为:
91
+ * 1. slot 不存在或 version 不兼容 → 建 slot(仅 pending),handler 入 pending;
92
+ * **slot.registry 保持 undefined**(M4 核心:ask-user 不建 registry)
93
+ * 2. slot 存在但 registry 未就绪 → handler 入 pending
94
+ * 3. slot 存在且 registry 就绪 → 直接调 registry.register("ask_user", handler)
95
+ *
96
+ * 多次调用幂等:registry 就绪时 register 同名覆盖;未就绪时 pending.length 增长
97
+ * (subagent-workflow flush 时一次性消费所有 pending)。
98
+ *
99
+ * @param handler ask_user channel handler(createAskUserChannelHandler 产出)
100
+ */
101
+ export function registerAskUserChannelHandler(handler: ChannelHandler): void {
102
+ const slot = readSlot() ?? ensureSlot();
103
+ if (slot.registry !== undefined) {
104
+ slot.registry.register(ASK_USER_CHANNEL, handler);
105
+ return;
106
+ }
107
+ slot.pending.push({ channel: ASK_USER_CHANNEL, handler });
108
+ }
package/src/index.ts CHANGED
@@ -11,8 +11,10 @@ import {
11
11
  getAskUserOther,
12
12
  } from "@xyz-agent/extension-protocol";
13
13
 
14
- import { AskUserComponent } from "./component";
15
14
  import { formatAnswer, parseAnswerParts } from "./answer-format";
15
+ import { createAskUserChannelHandler } from "./channel-handler";
16
+ import { registerAskUserChannelHandler } from "./channel-registry-register";
17
+ import { AskUserComponent } from "./component";
16
18
  import {
17
19
  type AskUserDetails,
18
20
  type ErrorDetails,
@@ -208,6 +210,22 @@ async function runRpcInteraction(
208
210
  }
209
211
 
210
212
  export default function (pi: ExtensionAPI): void {
213
+ // 注册 ask_user channel handler:把 subagent 子进程的 ask_user 请求透传到主进程 UI。
214
+ //
215
+ // 跨扩展握手协议(PR #85 #M4):通过 globalThis Symbol.for 约定 slot 形状
216
+ //(CHANNEL_HANDSHAKE_KEY,与 subagent-workflow/src/execution/channel-registry-access.ts
217
+ // 用同一字符串 key),不依赖 dynamic import npm 包名(两个扩展都通过
218
+ // ~/.pi/agent/extensions/ symlink 加载,互相之间无法用 npm 包名 import)。
219
+ //
220
+ // 握手流程(registerAskUserChannelHandler 内部完成):
221
+ // 1. 读 slot;不存在或 version 不兼容 → 建 slot(仅 pending,**永不建 registry**)
222
+ // 2. slot.registry 就绪(subagent-workflow 先到)→ 直接调 registry.register
223
+ // 3. slot.registry 未就绪 → handler 入 pending,等 subagent-workflow flush
224
+ // ask-user 永不创建 registry 实例——canonical registry 仅 subagent-workflow 创建。
225
+ pi.on("session_start", (_event, ctx) => {
226
+ registerAskUserChannelHandler(createAskUserChannelHandler(ctx));
227
+ });
228
+
211
229
  pi.registerTool({
212
230
  name: "ask_user",
213
231
  label: "Ask User",