@zhushanwen/pi-ask-user 0.2.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1",
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);
@@ -197,6 +204,24 @@ describe("execute — validation (FR-2 / AC-8 / AC-13)", () => {
197
204
  );
198
205
  expect(result.details.cancelled).toBe(true);
199
206
  });
207
+
208
+ it("I-4b: string options (schema-relaxed) → execute → validateInput catches → isError + Correct hint", async () => {
209
+ // 端到端证明:schema 放宽(Union([OptionSchema, string]))后 string options
210
+ // 能穿过 TypeCompiler.Check、抵达 execute → validateInput 友好拦截。
211
+ // test-coverage reviewer 点名的“execute wiring 未测”缺口。
212
+ const tool = getTool();
213
+ const result = await tool.execute(
214
+ "id",
215
+ { questions: [{ question: "Q", options: ["A", "B"] }] },
216
+ undefined,
217
+ undefined,
218
+ makeCtx(),
219
+ );
220
+ expect(result.isError).toBe(true);
221
+ expect(result.details.cancelled).toBe(true);
222
+ expect(result.content[0].text).toContain("not strings");
223
+ expect(result.content[0].text).toContain("Correct");
224
+ });
200
225
  });
201
226
 
202
227
  // ── I-5 ~ I-7: Headless(FR-8 / AC-7)──────────────────
@@ -0,0 +1,95 @@
1
+ // src/__tests__/prompt-quality.test.ts
2
+ //
3
+ // 提示词质量回归:ask_user tool 的 description 与 validate.ts 文案必须能让弱模型
4
+ // 首次调用就用对参数形状,用错了也能拿到带 Correct 正例的纠正。
5
+ //
6
+ // 背景(系统性债务):
7
+ // - description 缺 JSON 正例:弱模型最高频错误是把 options 当字符串数组传
8
+ // ("options":["A","B"] 而非 [{"label","description"}])。
9
+ // - 条件必填(header)用 Type.Optional 表达,弱模型批量时漏 header。
10
+ // - schema 层 ajv 干报错先于 validate.ts 友好文案——故 InputSchema 故意放宽 options
11
+ // 元素到 string,让误用能抵达 validateInput 的带正例纠正。
12
+ //
13
+ // 本测试用源码断言(读 .ts 文件文本)锁定这些约束,防止后续重构把正例/反例/调用信号
14
+ // 删掉或弱化。读源码而非 import,避免 mock 链(index.ts 依赖 pi-tui/ExtensionAPI 等)。
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { dirname,join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { describe, expect, it } from "vitest";
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+
24
+ const INDEX_SRC = readFileSync(join(__dirname, "../index.ts"), "utf-8");
25
+ const VALIDATE_SRC = readFileSync(join(__dirname, "../validate.ts"), "utf-8");
26
+
27
+ /** 提取 description: `...` 模板字符串的原始内容(模板内无反引号,匹配到闭合 `,)。 */
28
+ function extractDescription(src: string): string {
29
+ const m = src.match(/description:\s*`([\s\S]*?)`,/);
30
+ if (!m) throw new Error("description template literal not found in index.ts");
31
+ return m[1];
32
+ }
33
+
34
+ const DESCRIPTION = extractDescription(INDEX_SRC);
35
+
36
+ describe("ask_user description — 参数形状正例(B. 补正例)", () => {
37
+ it("含单问题 JSON 正例,options 为 {label, description} 对象数组", () => {
38
+ // 弱模型最高频错误是把 options 当字符串数组传;正例必须显式给出对象形态。
39
+ expect(DESCRIPTION).toContain('"options":[{"label"');
40
+ expect(DESCRIPTION).toContain('"question"');
41
+ });
42
+
43
+ it("含批量 JSON 正例,每个 question 带 header", () => {
44
+ // 批量模式下 header 是条件必填,正例必须展示 header 字段。
45
+ expect(DESCRIPTION).toContain('"header"');
46
+ // 批量正例含多个 question(questions 数组里至少两个 header)
47
+ const headerMatches = DESCRIPTION.match(/"header"/g) || [];
48
+ expect(headerMatches.length).toBeGreaterThanOrEqual(2);
49
+ });
50
+
51
+ it("正例里的 label 可带 (Recommended) 前缀(调用信号未被破坏)", () => {
52
+ expect(DESCRIPTION).toContain("(Recommended)");
53
+ });
54
+ });
55
+
56
+ describe("ask_user description — 参数结构反例(B. 补反例)", () => {
57
+ it("含 ≥2 条参数结构反例(string array / flatten / Other 等)", () => {
58
+ // 锁定关键反例措辞,防止后续精简掉。
59
+ const antiPatterns = [
60
+ "string array", // options 不能当字符串数组传
61
+ "Flatten", // 不能把字段铺到顶层(匹配 Flattening/Flatten)
62
+ "Other", // 不能手动加 Other
63
+ ];
64
+ const hits = antiPatterns.filter((p) => DESCRIPTION.includes(p));
65
+ expect(hits.length).toBeGreaterThanOrEqual(2);
66
+ });
67
+ });
68
+
69
+ describe("ask_user description — 调用信号保留(不要破坏亮点)", () => {
70
+ it("保留 'Use ONLY when ALL hold' 调用门槛", () => {
71
+ // 这是 ask_user 写得比 subagent 原版好的调用信号引导,必须保留。
72
+ expect(DESCRIPTION).toContain("Use ONLY when");
73
+ });
74
+
75
+ it("保留 'Do NOT use' 边界段", () => {
76
+ expect(DESCRIPTION).toContain("Do NOT use");
77
+ });
78
+ });
79
+
80
+ describe("validate.ts — runtime 友好纠错(A. 带正例)", () => {
81
+ it("含 options 字符串元素检测('objects, not strings')", () => {
82
+ // schema 层已放宽让 string options 进到这里;validate 必须友好拦截。
83
+ expect(VALIDATE_SRC).toContain("objects, not strings");
84
+ });
85
+
86
+ it("options 字符串错误带 Correct 正例", () => {
87
+ // 友好文案必须给出最小可用形状,让弱模型直接抄。
88
+ expect(VALIDATE_SRC).toContain('Correct: "options":[{"label"');
89
+ });
90
+
91
+ it("header 缺失错误带 Correct 正例", () => {
92
+ // 多问题模式漏 header 是弱模型批量时的常见错误,纠正文案要带正例。
93
+ expect(VALIDATE_SRC).toContain("Correct: {\"header\"");
94
+ });
95
+ });
@@ -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", () => {
@@ -1,7 +1,8 @@
1
1
  // src/__tests__/validate.test.ts
2
+ import { Value } from "@sinclair/typebox/value";
2
3
  import { describe, expect, it } from "vitest";
3
4
 
4
- import { HEADER_MAX_CHARS, type Question } from "../types";
5
+ import { HEADER_MAX_CHARS, InputSchema, type Question } from "../types";
5
6
  import { validateInput } from "../validate";
6
7
 
7
8
  const q = (overrides: Partial<Question> = {}): Question => ({
@@ -136,4 +137,67 @@ describe("validateInput", () => {
136
137
  it("accepts header at exactly HEADER_MAX_CHARS (12)", () => {
137
138
  expect(validateInput([q({ header: "123456789012" })])).toBeNull();
138
139
  });
140
+
141
+ // V-17: options 元素是 string(弱模型最高频误用 "options":["A","B"])→ 友好错误带 Correct 正例。
142
+ // schema 层已放宽让 string options 能进到这里(见 types.ts InputSchema),故可直接传 string[]。
143
+ it("rejects string option elements with a Correct example (weak-model misuse)", () => {
144
+ const result = validateInput([
145
+ { question: "Which DB?", options: ["Postgres", "SQLite"] },
146
+ ]);
147
+ expect(result).not.toBeNull();
148
+ expect(result).toContain("objects, not strings");
149
+ expect(result).toContain('Correct: "options":[{"label"');
150
+ });
151
+
152
+ // V-18: 混合 [string, object] → 仍友好拦截第一个 string 元素
153
+ it("rejects mixed string/object options", () => {
154
+ const result = validateInput([
155
+ { question: "Q", options: ["A", { label: "B" }] },
156
+ ]);
157
+ expect(result).toContain("objects, not strings");
158
+ });
159
+
160
+ // V-19: header 缺失错误带 Correct 正例(A. runtime 友好纠错)
161
+ it("header-missing error includes a Correct example", () => {
162
+ const result = validateInput([
163
+ q({ question: "Q1", header: "H1" }),
164
+ q({ question: "Q2" }), // no header
165
+ ]);
166
+ expect(result).toContain("Correct:");
167
+ expect(result).toContain('"header"');
168
+ });
169
+ });
170
+
171
+ // ── options 字符串「下沉」机制集成证明 ──────────────────
172
+ // 目标(任务核心):弱模型误用 "options":["A","B"] 不能被 schema 层干报错拦死,
173
+ // 必须能进 validateInput 拿到带 Correct 正例的友好文案。Value.Check 是 Pi 运行时
174
+ // TypeCompiler.Check 的等价校验(同一引擎),这里直接断言 schema 行为。
175
+ describe("schema-vs-validate integration (options 字符串下沉)", () => {
176
+ it("string options PASS the schema layer (reach execute, not raw ajv error)", () => {
177
+ const malformed = {
178
+ questions: [{ question: "Which DB?", options: ["Postgres", "SQLite"] }],
179
+ };
180
+ expect(Value.Check(InputSchema, malformed)).toBe(true);
181
+ });
182
+
183
+ it("string options then caught by validateInput with a friendly Correct example", () => {
184
+ const result = validateInput([
185
+ { question: "Which DB?", options: ["Postgres", "SQLite"] },
186
+ ]);
187
+ expect(result).not.toBeNull();
188
+ expect(result).toContain("objects, not strings");
189
+ expect(result).toContain('Correct: "options":[{"label"');
190
+ });
191
+
192
+ it("well-formed object options still pass schema AND validateInput", () => {
193
+ const wellFormed = {
194
+ questions: [
195
+ { question: "Which DB?", options: [{ label: "A" }, { label: "B" }] },
196
+ ],
197
+ };
198
+ expect(Value.Check(InputSchema, wellFormed)).toBe(true);
199
+ expect(
200
+ validateInput([{ question: "Which DB?", options: [{ label: "A" }, { label: "B" }] }]),
201
+ ).toBeNull();
202
+ });
139
203
  });
@@ -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",
@@ -215,7 +233,18 @@ export default function (pi: ExtensionAPI): void {
215
233
 
216
234
  Do NOT use this tool to outsource judgment you should make — if you can form a defensible recommendation from the codebase, proceed and state your choice. Do NOT use for trivia answerable by reading code/docs, or for simple confirmations ("I'll delete X") where plain text suffices. You cannot use this tool to collect free-form requirements, long-form feedback, or multi-paragraph input — it returns short selections only.
217
235
 
218
- If you recommend an option, prefix its label with "(Recommended)" and list it first. For structured multi-option decisions, prefer this tool over plain-text questions; for everything else, reply in plain text.`,
236
+ If you recommend an option, prefix its label with "(Recommended)" and list it first. For structured multi-option decisions, prefer this tool over plain-text questions; for everything else, reply in plain text.
237
+
238
+ Examples:
239
+ {"questions":[{"question":"Which DB?","context":"Need ACID + JSON columns.","options":[{"label":"(Recommended) Postgres","description":"Mature, strong consistency."},{"label":"SQLite","description":"Zero-ops, embedded."}]}]}
240
+
241
+ {"questions":[{"header":"DB","question":"Which database?","options":[{"label":"Postgres","description":"..."},{"label":"SQLite","description":"..."}]},{"header":"Region","question":"Which region?","options":[{"label":"us-east-1","description":"..."},{"label":"eu-west-1","description":"..."}]}]}
242
+
243
+ Don't:
244
+ - Passing options as a string array ("options":["A","B"]) — each option must be {"label","description"}.
245
+ - Forgetting header in multi-question mode (questions.length > 1).
246
+ - Flattening question/header/options to the top level — wrap them in questions:[...].
247
+ - Including an "Other" option — it is added automatically.`,
219
248
  promptSnippet:
220
249
  "Ask the user structured clarifying questions with options — only when you cannot resolve the ambiguity yourself",
221
250
  promptGuidelines: [
@@ -235,9 +264,11 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
235
264
  _onUpdate: AgentToolUpdateCallback<AskUserDetails> | undefined,
236
265
  ctx: ExtensionContext,
237
266
  ): Promise<ExecuteResult> {
238
- const questions = params.questions;
267
+ const questions = params.questions as Question[];
239
268
 
240
- // 1. 参数校验(spec FR-2
269
+ // 1. 参数校验(spec FR-2)。validateInput 接收宽松 InputQuestion[]:
270
+ // options 可能含 string 误用(schema 已故意放宽以抵达这里的友好文案),
271
+ // validateInput 会先拦截 string options 再跑其余校验。通过后 questions 已是干净 Question[]。
241
272
  const validationError = validateInput(questions);
242
273
  if (validationError) {
243
274
  return cancelledResult(questions, `Error: ${validationError}`, true);
@@ -308,7 +339,8 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
308
339
  },
309
340
 
310
341
  renderCall(args: Static<typeof InputSchema>, theme: ThemeLike) {
311
- const questions: Question[] = args.questions ?? [];
342
+ // args 来自 LLM 原始入参(options 可能是 string),只读 header/question 不碰 options。
343
+ const questions = (args.questions ?? []) as Question[];
312
344
  const topics = questions.map((q) => q.header ?? truncateToWidth(q.question, HEADER_MAX_CHARS)).join(", ");
313
345
  return new TruncatedText(
314
346
  theme.fg("toolTitle", theme.bold("ask_user ")) + theme.fg("muted", topics),
package/src/types.ts CHANGED
@@ -52,17 +52,47 @@ export const QuestionSchema = Type.Object({
52
52
  ),
53
53
  });
54
54
 
55
+ /**
56
+ * LLM-facing input schema (宽松版 options 元素)。
57
+ *
58
+ * options 元素故意放宽为 `OptionSchema | string`:弱模型最高频误用是把 options
59
+ * 当字符串数组传(`"options":["A","B"]`)。严格 schema 会让 Pi 运行时的 typebox
60
+ * TypeCompiler.Check 直接拦截(干报错 "must be object"),根本进不了 validateInput
61
+ * 的友好文案。这里放宽让 string 元素通过 schema 层、抵达 validateInput,由它返回带
62
+ * Correct 正例的纠正错误(runtime 友好纠错)。
63
+ *
64
+ * 字段描述复用 QuestionSchema.properties(只覆盖 options 数组元素类型),避免描述
65
+ * 双份维护。Static 派生的 InputQuestion.options 是 `(Option | string)[]`,让
66
+ * validateInput 里的 `typeof opt === "string"` 检查在 TS 层 sound(而非死分支)。
67
+ * 通过 validateInput 后,运行时已保证无 string,index.ts 以 `as Question[]` 收窄使用。
68
+ */
69
+ const inputOptionElement = Type.Union([OptionSchema, Type.String()]);
70
+
55
71
  export const InputSchema = Type.Object({
56
- questions: Type.Array(QuestionSchema, {
57
- minItems: 1,
58
- maxItems: 4,
59
- description: "1-4 questions, each a single decision. Batch only related decisions that the user should resolve together; otherwise ask the most important one alone.",
60
- }),
72
+ questions: Type.Array(
73
+ Type.Object({
74
+ ...QuestionSchema.properties,
75
+ options: Type.Array(inputOptionElement, {
76
+ minItems: 2,
77
+ maxItems: 4,
78
+ description:
79
+ "2-4 mutually exclusive options. Each must be a {label, description} OBJECT, never a bare string; do NOT include an 'Other' option — it is added automatically.",
80
+ }),
81
+ }),
82
+ {
83
+ minItems: 1,
84
+ maxItems: 4,
85
+ description: "1-4 questions, each a single decision. Batch only related decisions that the user should resolve together; otherwise ask the most important one alone.",
86
+ },
87
+ ),
61
88
  });
62
89
 
63
90
  // ── 派生类型 ─────────────────────────────────────────
64
91
  export type Option = Static<typeof OptionSchema>;
92
+ /** 内部使用的严格 question 形状(options 为干净 Option[])。validateInput 通过后使用。 */
65
93
  export type Question = Static<typeof QuestionSchema>;
94
+ /** LLM 入参 question 形状:options 可能含 string 误用,validateInput 负责友好拦截。 */
95
+ export type InputQuestion = Static<typeof InputSchema>["questions"][number];
66
96
 
67
97
  // ── Result schema(details,renderResult 数据源) ─────
68
98
  export const ResultSchema = Type.Object({
package/src/validate.ts CHANGED
@@ -1,20 +1,29 @@
1
1
  // src/validate.ts
2
- import { HEADER_MAX_CHARS, type Question, QUESTION_MAX_CHARS } from "./types";
2
+ import { HEADER_MAX_CHARS, type InputQuestion, QUESTION_MAX_CHARS } from "./types";
3
3
 
4
4
  /** 控制字符(含 \n \r \t 等):question 文本禁止包含,避免 answers key 含不可见字符(spec FR-2) */
5
5
  const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
6
6
 
7
+ /** 错误消息里 question/header 文本预览的截断长度(避免长文本撑爆错误消息)。 */
8
+ const ERROR_PREVIEW_CHARS = 20;
9
+
7
10
  /**
8
11
  * 校验输入参数。通过返回 null,失败返回错误消息字符串。
12
+ *
13
+ * 入参类型是宽松的 InputQuestion[](options 元素可能是 string 误用)——见 types.ts
14
+ * InputSchema 注释。Pi 运行时只放宽到让 string options 能进到这里被友好拦截;这里先
15
+ * 预检 string options,再跑原有结构/语义校验。
16
+ *
9
17
  * 校验项(spec FR-2):
18
+ * - options 元素必须是 {label, description} 对象,不能是 string(弱模型高频误用)
10
19
  * - question 文本长度上限与无控制字符(保证 answers key 有界、可预测)
11
20
  * - question 文本在数组内唯一
12
21
  * - 同问题内 option label 唯一
13
22
  * - 多问题(questions.length > 1)时每个 question 必须有非空 header
14
23
  *
15
- * 错误消息面向 LLM:除描述违规外,附带一句修复指引(如何改)。
24
+ * 错误消息面向 LLM:除描述违规外,附带一句修复指引(如何改),对结构误用附 Correct 正例。
16
25
  */
17
- export function validateInput(questions: Question[]): string | null {
26
+ export function validateInput(questions: InputQuestion[]): string | null {
18
27
  const seenQuestions = new Set<string>();
19
28
 
20
29
  for (const q of questions) {
@@ -22,11 +31,11 @@ export function validateInput(questions: Question[]): string | null {
22
31
 
23
32
  // 1a. question 文本长度上限(key 有界)
24
33
  if (qt.length > QUESTION_MAX_CHARS) {
25
- return `Question text exceeds ${QUESTION_MAX_CHARS} chars: "${qt.slice(0, 20)}...". Shorten it to a single concise decision; move extra context into the context field.`;
34
+ return `Question text exceeds ${QUESTION_MAX_CHARS} chars: "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Shorten it to a single concise decision; move extra context into the context field.`;
26
35
  }
27
36
  // 1b. question 文本无控制字符(key 可预测,不影响下游渲染/解析)
28
37
  if (CONTROL_CHAR_RE.test(qt)) {
29
- return `Question text must not contain control characters (incl. newlines): "${qt.slice(0, 20)}...". Use plain single-line text; split multi-part questions into separate entries.`;
38
+ return `Question text must not contain control characters (incl. newlines): "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Use plain single-line text; split multi-part questions into separate entries.`;
30
39
  }
31
40
 
32
41
  // 1c. question 文本唯一
@@ -35,14 +44,20 @@ export function validateInput(questions: Question[]): string | null {
35
44
  }
36
45
  seenQuestions.add(qt);
37
46
 
38
- // 2. option label 唯一且非空(空 label 会污染 details.answers 的值)
47
+ // 2. option 元素必须是 {label, description} 对象,不能是 string。
48
+ // 弱模型最高频误用:"options":["A","B"]。schema 层已放宽让 string 进来,这里友好拦截
49
+ // (InputQuestion.options 是 (Option | string)[],typeof 收窄后 opt 为 Option)。
39
50
  const seenLabels = new Set<string>();
40
51
  for (const opt of q.options) {
52
+ if (typeof opt === "string") {
53
+ return `Options for question "${qt}" must be an array of {label, description} objects, not strings. Correct: "options":[{"label":"A","description":"..."},{"label":"B","description":"..."}]`;
54
+ }
55
+ // opt 已收窄为 Option
41
56
  if (opt.label.trim() === "") {
42
- return `Option label must not be empty in question "${q.question}". Give every option a distinct, descriptive label.`;
57
+ return `Option label must not be empty in question "${qt}". Give every option a distinct, descriptive label.`;
43
58
  }
44
59
  if (seenLabels.has(opt.label)) {
45
- return `Duplicate option label "${opt.label}" in question "${q.question}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
60
+ return `Duplicate option label "${opt.label}" in question "${qt}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
46
61
  }
47
62
  seenLabels.add(opt.label);
48
63
  }
@@ -52,7 +67,7 @@ export function validateInput(questions: Question[]): string | null {
52
67
  if (questions.length > 1) {
53
68
  for (const q of questions) {
54
69
  if (!q.header || q.header.trim() === "") {
55
- return `Question "${q.question}" requires a non-empty header in multi-question mode (it labels the tab). Provide a header of <=12 chars.`;
70
+ return `Question "${q.question}" requires a non-empty header in multi-question mode (it labels the tab). Provide a header of <=12 chars. Correct: {"header":"DB","question":"...","options":[{"label":"...","description":"..."}]}`;
56
71
  }
57
72
  }
58
73
 
@@ -72,7 +87,7 @@ export function validateInput(questions: Question[]): string | null {
72
87
  // 这里提前拒绝,让 LLM 拿到可修复错误而非残缺 UI(兑现 schema description 的 ≤12 契约)。
73
88
  for (const q of questions) {
74
89
  if (q.header !== undefined && q.header.length > HEADER_MAX_CHARS) {
75
- return `Header exceeds ${HEADER_MAX_CHARS} chars: "${q.header.slice(0, 20)}..." in question "${q.question}". Shorten it; longer headers are truncated in the tab bar.`;
90
+ return `Header exceeds ${HEADER_MAX_CHARS} chars: "${q.header.slice(0, ERROR_PREVIEW_CHARS)}..." in question "${q.question}". Shorten it; longer headers are truncated in the tab bar.`;
76
91
  }
77
92
  }
78
93