@zhushanwen/pi-ask-user 0.1.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.
@@ -0,0 +1,179 @@
1
+ // src/__tests__/editor-ops.test.ts
2
+ //
3
+ // editor-ops.ts 独立单元测试。
4
+ // 覆盖审查 S5 发现的 surrogate pair 边界盲区:
5
+ // - moveCursorLeft/Right 在 emoji(surrogate pair)边界正确按 code point 移动
6
+ // - deleteCharBeforeCursor 删整个 emoji code point
7
+ //
8
+ // 之前仅靠 component.test.ts 黑盒间接覆盖,无法直接锁定 surrogate 行为。
9
+
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import {
13
+ deleteCharBeforeCursor,
14
+ handleEditorPaste,
15
+ insertAtCursor,
16
+ moveCursorEnd,
17
+ moveCursorHome,
18
+ moveCursorLeft,
19
+ moveCursorRight,
20
+ } from "../editor-ops.js";
21
+ import { createQuestionState, type QuestionState } from "../types.js";
22
+
23
+ function stateWith(text: string, cursor = text.length): QuestionState {
24
+ const s = createQuestionState();
25
+ s.draftText = text;
26
+ s.cursorIndex = cursor;
27
+ return s;
28
+ }
29
+
30
+ describe("moveCursorLeft", () => {
31
+ it("moves left by 1 for ASCII", () => {
32
+ const s = stateWith("abc", 3);
33
+ moveCursorLeft(s);
34
+ expect(s.cursorIndex).toBe(2);
35
+ });
36
+
37
+ it("moves left by 1 code point (2 UTF-16 units) for emoji", () => {
38
+ // "a😀b" → length=4 (a=1, 😀=2, b=1),光标在末尾(4)
39
+ const s = stateWith("a😀b", 4);
40
+ moveCursorLeft(s); // 从 4 → 跳过 b 到 3
41
+ expect(s.cursorIndex).toBe(3);
42
+ moveCursorLeft(s); // 从 3 → 跳过 emoji 到 1(不是 2)
43
+ expect(s.cursorIndex).toBe(1);
44
+ });
45
+
46
+ it("does not go below 0", () => {
47
+ const s = stateWith("abc", 0);
48
+ moveCursorLeft(s);
49
+ expect(s.cursorIndex).toBe(0);
50
+ });
51
+
52
+ it("stops at 0 after emoji", () => {
53
+ const s = stateWith("😀", 2);
54
+ moveCursorLeft(s);
55
+ expect(s.cursorIndex).toBe(0);
56
+ });
57
+ });
58
+
59
+ describe("moveCursorRight", () => {
60
+ it("moves right by 1 for ASCII", () => {
61
+ const s = stateWith("abc", 0);
62
+ moveCursorRight(s);
63
+ expect(s.cursorIndex).toBe(1);
64
+ });
65
+
66
+ it("moves right by 1 code point (2 UTF-16 units) for emoji", () => {
67
+ // "a😀b" → 光标在 0,右移应到 1(a),再到 3(跳过 emoji 的 2 个 unit)
68
+ const s = stateWith("a😀b", 0);
69
+ moveCursorRight(s); // 0 → 1
70
+ expect(s.cursorIndex).toBe(1);
71
+ moveCursorRight(s); // 1 → 3(跳过 😀 的 2 个 unit)
72
+ expect(s.cursorIndex).toBe(3);
73
+ });
74
+
75
+ it("does not exceed draftText.length", () => {
76
+ const s = stateWith("ab", 2);
77
+ moveCursorRight(s);
78
+ expect(s.cursorIndex).toBe(2);
79
+ });
80
+
81
+ it("stops at end after emoji", () => {
82
+ const s = stateWith("x😀", 1);
83
+ moveCursorRight(s);
84
+ expect(s.cursorIndex).toBe(3); // 跳到末尾
85
+ moveCursorRight(s); // 不超出
86
+ expect(s.cursorIndex).toBe(3);
87
+ });
88
+ });
89
+
90
+ describe("deleteCharBeforeCursor", () => {
91
+ it("deletes single ASCII char", () => {
92
+ const s = stateWith("abc", 2);
93
+ const changed = deleteCharBeforeCursor(s);
94
+ expect(changed).toBe(true);
95
+ expect(s.draftText).toBe("ac");
96
+ expect(s.cursorIndex).toBe(1);
97
+ });
98
+
99
+ it("deletes entire emoji code point (2 UTF-16 units)", () => {
100
+ // "a😀b" 光标在 4(末尾),删除应删掉 b → "a😀"
101
+ const s = stateWith("a😀b", 4);
102
+ deleteCharBeforeCursor(s);
103
+ expect(s.draftText).toBe("a😀");
104
+ expect(s.cursorIndex).toBe(3);
105
+ // 再删一次应删掉整个 emoji(2 units),不是只删 1
106
+ deleteCharBeforeCursor(s);
107
+ expect(s.draftText).toBe("a");
108
+ expect(s.cursorIndex).toBe(1);
109
+ });
110
+
111
+ it("returns false at cursor 0 (nothing to delete)", () => {
112
+ const s = stateWith("abc", 0);
113
+ const changed = deleteCharBeforeCursor(s);
114
+ expect(changed).toBe(false);
115
+ expect(s.draftText).toBe("abc");
116
+ expect(s.cursorIndex).toBe(0);
117
+ });
118
+
119
+ it("deletes emoji at start of text", () => {
120
+ const s = stateWith("😀hello", 2);
121
+ deleteCharBeforeCursor(s);
122
+ expect(s.draftText).toBe("hello");
123
+ expect(s.cursorIndex).toBe(0);
124
+ });
125
+ });
126
+
127
+ describe("insertAtCursor", () => {
128
+ it("inserts text at cursor position", () => {
129
+ const s = stateWith("abc", 1);
130
+ insertAtCursor(s, "X");
131
+ expect(s.draftText).toBe("aXbc");
132
+ expect(s.cursorIndex).toBe(2);
133
+ });
134
+ });
135
+
136
+ describe("moveCursorHome / moveCursorEnd", () => {
137
+ it("home sets cursor to 0", () => {
138
+ const s = stateWith("abc", 2);
139
+ moveCursorHome(s);
140
+ expect(s.cursorIndex).toBe(0);
141
+ });
142
+
143
+ it("end sets cursor to draftText.length", () => {
144
+ const s = stateWith("a😀b", 0);
145
+ moveCursorEnd(s);
146
+ expect(s.cursorIndex).toBe(4);
147
+ });
148
+ });
149
+
150
+ describe("handleEditorPaste", () => {
151
+ it("inserts printable text", () => {
152
+ const s = stateWith("ab", 1);
153
+ const changed = handleEditorPaste(s, "XY");
154
+ expect(changed).toBe(true);
155
+ expect(s.draftText).toBe("aXYb");
156
+ expect(s.cursorIndex).toBe(3);
157
+ });
158
+
159
+ it("inserts emoji correctly (surrogate pair via Array.from)", () => {
160
+ const s = stateWith("", 0);
161
+ handleEditorPaste(s, "😀");
162
+ expect(s.draftText).toBe("😀");
163
+ expect(s.cursorIndex).toBe(2);
164
+ });
165
+
166
+ it("rejects unknown escape sequences (non-bracketed)", () => {
167
+ const s = stateWith("ab", 1);
168
+ const changed = handleEditorPaste(s, "\x1b[6n");
169
+ expect(changed).toBe(false);
170
+ expect(s.draftText).toBe("ab");
171
+ });
172
+
173
+ it("strips bracketed paste markers", () => {
174
+ const s = stateWith("", 0);
175
+ const changed = handleEditorPaste(s, "\x1b[200~hello\x1b[201~");
176
+ expect(changed).toBe(true);
177
+ expect(s.draftText).toBe("hello");
178
+ });
179
+ });
@@ -6,6 +6,8 @@ import factory from "../index";
6
6
  import { mockTui, stubTheme } from "./fixtures";
7
7
 
8
8
  // ── Types for the registered tool ───────────────────────
9
+ type TestMode = "tui" | "rpc" | "json" | "print";
10
+
9
11
  interface RegisteredTool {
10
12
  name: string;
11
13
  label: string;
@@ -16,6 +18,7 @@ interface RegisteredTool {
16
18
  signal: AbortSignal | undefined,
17
19
  onUpdate: unknown,
18
20
  ctx: {
21
+ mode: TestMode;
19
22
  hasUI: boolean;
20
23
  signal?: AbortSignal;
21
24
  ui: {
@@ -23,6 +26,11 @@ interface RegisteredTool {
23
26
  factory: (...args: unknown[]) => unknown,
24
27
  options?: { overlay?: boolean },
25
28
  ): Promise<T>;
29
+ select?: (
30
+ title: string,
31
+ options: string[],
32
+ opts?: { signal?: AbortSignal },
33
+ ) => Promise<string | undefined>;
26
34
  };
27
35
  },
28
36
  ) => Promise<Record<string, unknown>>;
@@ -36,6 +44,9 @@ interface MockPi {
36
44
  getAllTools(): { name: string }[];
37
45
  activeTools?: string[] | null;
38
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;
39
50
  }
40
51
 
41
52
  /** Runs the factory, returns the captured registered tool. */
@@ -50,6 +61,10 @@ const getTool = (overrides: Partial<MockPi> = {}): RegisteredTool => {
50
61
  setActiveTools(names) {
51
62
  this.activeTools = names;
52
63
  },
64
+ on() {
65
+ // no-op:session_start handler 注册透传 channel(subagent-workflow 可选),
66
+ // 测试不覆盖透传路径
67
+ },
53
68
  ...overrides,
54
69
  };
55
70
  factory(pi as never);
@@ -57,16 +72,40 @@ const getTool = (overrides: Partial<MockPi> = {}): RegisteredTool => {
57
72
  return pi.tool;
58
73
  };
59
74
 
75
+ // 真 headless ctx:mode='print'(无 dialog 能力,hasUI=false),ui 上无 select。
76
+ // isGuiCapable(ctx)=false(mode≠'rpc')→ 不走 RPC 分支 → custom 也不可用 → catch 走禁用。
77
+ const makeHeadlessCtx = () => ({
78
+ mode: "print" as const,
79
+ hasUI: false,
80
+ signal: undefined as AbortSignal | undefined,
81
+ ui: {},
82
+ });
83
+
60
84
  // ── Mock ctx builder ────────────────────────────────────
85
+ // mode 区分三场景:'tui'(默认,走 custom)/ 'rpc'(走 select)/ 'print'(headless)。
86
+ // Pi 的 hasUI:TUI 和 RPC 都为 true(dialog-capable),print/json 为 false。
87
+ // RPC 模式才挂 select(与真实 Pi 一致:TUI 模式的 ctx.ui 不一定有 select)。
61
88
  const makeCtx = (
62
89
  overrides: Partial<{
63
- hasUI: boolean;
90
+ mode: TestMode;
64
91
  customResult: unknown;
65
92
  customThrows: Error | null;
93
+ selectResult: string | undefined;
94
+ selectThrows: Error | null;
66
95
  }> = {},
67
96
  ) => {
68
- const { hasUI = true, customResult = null, customThrows = null } = overrides;
97
+ const {
98
+ mode = "tui",
99
+ customResult = null,
100
+ customThrows = null,
101
+ selectResult = undefined,
102
+ selectThrows = null,
103
+ } = overrides;
104
+ const hasUI = mode === "tui" || mode === "rpc";
105
+ // RPC 模式才挂 select(与真实 Pi 一致:TUI 模式的 ctx.ui 不一定有 select)
106
+ const hasSelect = mode === "rpc";
69
107
  return {
108
+ mode,
70
109
  hasUI,
71
110
  signal: undefined as AbortSignal | undefined,
72
111
  ui: {
@@ -74,6 +113,18 @@ const makeCtx = (
74
113
  if (customThrows) throw customThrows;
75
114
  return customResult as T;
76
115
  },
116
+ ...(hasSelect
117
+ ? {
118
+ select: async (
119
+ _title: string,
120
+ _options: string[],
121
+ _opts?: { signal?: AbortSignal },
122
+ ): Promise<string | undefined> => {
123
+ if (selectThrows) throw selectThrows;
124
+ return selectResult;
125
+ },
126
+ }
127
+ : {}),
77
128
  },
78
129
  };
79
130
  };
@@ -156,45 +207,32 @@ describe("execute — validation (FR-2 / AC-8 / AC-13)", () => {
156
207
  });
157
208
 
158
209
  // ── I-5 ~ I-7: Headless(FR-8 / AC-7)──────────────────
210
+ // 真 headless:hasUI=false 且 ui 上无 select(print 模式),askUserInteract 抛错 → 禁用工具。
159
211
  describe("execute — headless (FR-8 / AC-7)", () => {
160
- it("I-5: hasUI=false → isError with interactive-session message", async () => {
212
+ it("I-5: headless (no select) → isError with disabled message", async () => {
161
213
  const tool = getTool();
162
- const result = await tool.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
214
+ const result = await tool.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
163
215
  expect(result.isError).toBe(true);
164
- expect(result.content[0].text).toContain("interactive");
216
+ expect(result.content[0].text).toContain("disabled");
165
217
  });
166
218
 
167
- it("I-6: hasUI=false disables ask_user tool via setActiveTools", async () => {
168
- const tool = getTool();
169
- await tool.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
170
- // The mock setActiveTools stores into activeTools; getAllTools returns ask_user + other_tool.
171
- // We verify by checking the pi mock captured a filtered list.
172
- // Re-run with a pi that records the call.
219
+ it("I-6: headless disables ask_user tool via setActiveTools", async () => {
173
220
  let captured: string[] | null = null;
174
- const pi = {
175
- registerTool() {},
176
- getAllTools: () => [{ name: "ask_user" }, { name: "other" }],
177
- setActiveTools: (names: string[]) => {
178
- captured = names;
179
- },
180
- };
181
- factory(pi as never);
182
- // Re-extract tool — factory already registered, but registerTool is no-op above.
183
- // Use the getTool approach with override instead:
184
221
  const tool2 = getTool({
185
222
  getAllTools: () => [{ name: "ask_user" }, { name: "other" }],
186
223
  setActiveTools: (names: string[]) => {
187
224
  captured = names;
188
225
  },
189
226
  });
190
- await tool2.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
227
+ await tool2.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
191
228
  expect(captured).not.toContain("ask_user");
192
229
  expect(captured).toContain("other");
193
230
  });
194
231
 
195
- it("I-7: hasUI=false details.cancelled = true", async () => {
232
+ it("I-7: headless details.cancelled = true", async () => {
196
233
  const tool = getTool();
197
- const result = await tool.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
234
+ const result = await tool.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
235
+ // headless 走 step 2 提前返回:cancelled Result(禁用工具,不进交互分支)
198
236
  expect(result.details.cancelled).toBe(true);
199
237
  });
200
238
  });
@@ -221,6 +259,7 @@ describe("execute — signal abort (FR-10 / AC-14)", () => {
221
259
  // 此前 mock 直接返回 customResult、从不调用 factory,该 abort 监听器是 dead path。
222
260
  // 现在中断后监听器调用 done(null),custom 解析为 null → cancelled。
223
261
  const ctx = {
262
+ mode: "tui" as const,
224
263
  hasUI: true,
225
264
  signal: controller.signal,
226
265
  ui: {
@@ -486,6 +525,7 @@ describe("execute — inline render (FR-3)", () => {
486
525
  const tool = getTool();
487
526
  let customArgCount = -1;
488
527
  const ctx = {
528
+ mode: "tui" as const,
489
529
  hasUI: true,
490
530
  signal: undefined as AbortSignal | undefined,
491
531
  ui: {
@@ -500,3 +540,251 @@ describe("execute — inline render (FR-3)", () => {
500
540
  expect(customArgCount).toBe(1);
501
541
  });
502
542
  });
543
+
544
+ // ── RPC 模式(xyz-agent GUI 富交互协议)──────────────────
545
+ // hasUI=false + ui.select 存在 → 走 askUserInteract(select 通道 + ASK_USER_MARKER)。
546
+ // select 的返回值是前端 JSON.stringify 的 AskUserAnswers,index.ts 做格式转换。
547
+ describe("execute — RPC mode (askUserInteract via select channel)", () => {
548
+ it("R-1: single-select answer → converted to Result.answers (key=question)", async () => {
549
+ const tool = getTool();
550
+ // 协议 answers:key=header(单问题无 header → question 全文),value=选中 label
551
+ const protoAnswers = JSON.stringify({ "Which DB?": "Postgres" });
552
+ const result = await tool.execute(
553
+ "id",
554
+ validSingle,
555
+ undefined,
556
+ undefined,
557
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
558
+ );
559
+ expect(result.details.cancelled).toBe(false);
560
+ expect(result.details.answers["Which DB?"]).toBe("Postgres");
561
+ expect(result.content[0].text).toContain("Postgres");
562
+ });
563
+
564
+ it("R-2: multi-select answer (JSON array) → comma-joined labels", async () => {
565
+ const tool = getTool();
566
+ const multi = {
567
+ questions: [
568
+ {
569
+ question: "Which tools?",
570
+ header: "Tools",
571
+ options: [{ label: "A" }, { label: "B" }, { label: "C" }],
572
+ multiSelect: true,
573
+ },
574
+ ],
575
+ };
576
+ // 协议多选:value = JSON.stringify(["A","C"])
577
+ const protoAnswers = JSON.stringify({ Tools: JSON.stringify(["A", "C"]) });
578
+ const result = await tool.execute(
579
+ "id",
580
+ multi,
581
+ undefined,
582
+ undefined,
583
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
584
+ );
585
+ expect(result.details.answers["Which tools?"]).toBe("A, C");
586
+ });
587
+
588
+ it("R-2b: multi-select 乱序回传 → 按 options 定义顺序排序(S#3)", async () => {
589
+ const tool = getTool();
590
+ const multi = {
591
+ questions: [
592
+ {
593
+ question: "Which tools?",
594
+ header: "Tools",
595
+ options: [{ label: "A" }, { label: "B" }, { label: "C" }],
596
+ multiSelect: true,
597
+ },
598
+ ],
599
+ };
600
+ // 前端回传顺序 ["C", "A"] —— 应按 options 索引重排为 "A, C"
601
+ const protoAnswers = JSON.stringify({ Tools: JSON.stringify(["C", "A"]) });
602
+ const result = await tool.execute(
603
+ "id",
604
+ multi,
605
+ undefined,
606
+ undefined,
607
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
608
+ );
609
+ expect(result.details.answers["Which tools?"]).toBe("A, C");
610
+ });
611
+
612
+ it("R-3: Other free text → appended to answer parts", async () => {
613
+ const tool = getTool();
614
+ // 单选 Postgres + Other "Custom DB"
615
+ const protoAnswers = JSON.stringify({
616
+ "Which DB?": "Postgres",
617
+ "Which DB?__other": "Custom DB",
618
+ });
619
+ const result = await tool.execute(
620
+ "id",
621
+ validSingle,
622
+ undefined,
623
+ undefined,
624
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
625
+ );
626
+ // TUI 语义:parts = [selected, other].join(", ")
627
+ expect(result.details.answers["Which DB?"]).toBe("Postgres, Custom DB");
628
+ });
629
+
630
+ it("R-4: comment → inlined with ' — ' separator", async () => {
631
+ const tool = getTool();
632
+ const withComment = {
633
+ questions: [
634
+ {
635
+ question: "Which DB?",
636
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
637
+ allowComment: true,
638
+ },
639
+ ],
640
+ };
641
+ const protoAnswers = JSON.stringify({
642
+ "Which DB?": "Postgres",
643
+ "Which DB?__comment": "prod constraint",
644
+ });
645
+ const result = await tool.execute(
646
+ "id",
647
+ withComment,
648
+ undefined,
649
+ undefined,
650
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
651
+ );
652
+ expect(result.details.answers["Which DB?"]).toBe("Postgres — prod constraint");
653
+ });
654
+
655
+ it("R-5: user cancel (select returns undefined) → cancelled details", async () => {
656
+ const tool = getTool();
657
+ const result = await tool.execute(
658
+ "id",
659
+ validSingle,
660
+ undefined,
661
+ undefined,
662
+ makeCtx({ mode: "rpc", selectResult: undefined }),
663
+ );
664
+ expect(result.content[0].text).toContain("User cancelled");
665
+ expect(result.details.cancelled).toBe(true);
666
+ });
667
+
668
+ it("R-6: select throws → isError + disabled (not retriable)", async () => {
669
+ const tool = getTool();
670
+ const result = await tool.execute(
671
+ "id",
672
+ validSingle,
673
+ undefined,
674
+ undefined,
675
+ makeCtx({ mode: "rpc", selectThrows: new Error("channel broken") }),
676
+ );
677
+ expect(result.isError).toBe(true);
678
+ expect(result.content[0].text).toContain("disabled");
679
+ expect(result.details.error).toBe("channel broken");
680
+ });
681
+
682
+ it("R-7: header used as answers key when provided", async () => {
683
+ const tool = getTool();
684
+ const multiQ = {
685
+ questions: [
686
+ {
687
+ question: "Which database?",
688
+ header: "DB",
689
+ options: [{ label: "Postgres" }, { label: "MySQL" }],
690
+ },
691
+ ],
692
+ };
693
+ // 协议 answers key = header("DB"),但 Result.answers key = question 全文
694
+ const protoAnswers = JSON.stringify({ DB: "Postgres" });
695
+ const result = await tool.execute(
696
+ "id",
697
+ multiQ,
698
+ undefined,
699
+ undefined,
700
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
701
+ );
702
+ // 转换后 key 必须是 question 全文(与 TUI 版 buildResult 一致)
703
+ expect(result.details.answers["Which database?"]).toBe("Postgres");
704
+ });
705
+
706
+ it("R-8: multi-question mixed (single-select + multi-select + Other + comment)", async () => {
707
+ const tool = getTool();
708
+ const mixed = {
709
+ questions: [
710
+ {
711
+ question: "Which database?",
712
+ header: "DB",
713
+ options: [{ label: "Postgres" }, { label: "MySQL" }],
714
+ },
715
+ {
716
+ question: "Which tools?",
717
+ header: "Tools",
718
+ options: [{ label: "A" }, { label: "B" }, { label: "C" }],
719
+ multiSelect: true,
720
+ },
721
+ {
722
+ question: "Which region?",
723
+ header: "Region",
724
+ options: [{ label: "US" }, { label: "EU" }],
725
+ allowComment: true,
726
+ },
727
+ ],
728
+ };
729
+ // Q1: single-select Postgres
730
+ // Q2: multi-select [C, A] (乱序 → 应重排为 A, C) + Other "Custom"
731
+ // Q3: 无选中 (parts.length === 0 → skip, 不出现在 answers 中)
732
+ const protoAnswers = JSON.stringify({
733
+ DB: "Postgres",
734
+ Tools: JSON.stringify(["C", "A"]),
735
+ "Tools__other": "Custom",
736
+ // Region 无选中 → protoAnswersToResult 的 `if (parts.length === 0) continue` 跳过
737
+ });
738
+ const result = await tool.execute(
739
+ "id",
740
+ mixed,
741
+ undefined,
742
+ undefined,
743
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
744
+ );
745
+
746
+ expect(result.details.cancelled).toBe(false);
747
+ // Q1: single-select
748
+ expect(result.details.answers["Which database?"]).toBe("Postgres");
749
+ // Q2: multi-select 重排 + Other
750
+ expect(result.details.answers["Which tools?"]).toBe("A, C, Custom");
751
+ // Q3: 无选中 → 跳过(不在 answers map 中)
752
+ expect(result.details.answers["Which region?"]).toBeUndefined();
753
+ });
754
+
755
+ it("R-9: multi-question with comment on one question", async () => {
756
+ const tool = getTool();
757
+ const multiQ = {
758
+ questions: [
759
+ {
760
+ question: "Which DB?",
761
+ header: "DB",
762
+ options: [{ label: "Postgres" }],
763
+ },
764
+ {
765
+ question: "Why?",
766
+ header: "Reason",
767
+ options: [{ label: "Performance" }],
768
+ allowComment: true,
769
+ },
770
+ ],
771
+ };
772
+ const protoAnswers = JSON.stringify({
773
+ DB: "Postgres",
774
+ Reason: "Performance",
775
+ "Reason__comment": "benchmarked",
776
+ });
777
+ const result = await tool.execute(
778
+ "id",
779
+ multiQ,
780
+ undefined,
781
+ undefined,
782
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
783
+ );
784
+
785
+ // Q1: 无 comment
786
+ expect(result.details.answers["Which DB?"]).toBe("Postgres");
787
+ // Q2: 有 comment → 内联
788
+ expect(result.details.answers["Why?"]).toBe("Performance — benchmarked");
789
+ });
790
+ });