@zhushanwen/pi-ask-user 0.1.0 → 0.2.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.
@@ -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>>;
@@ -57,16 +65,40 @@ const getTool = (overrides: Partial<MockPi> = {}): RegisteredTool => {
57
65
  return pi.tool;
58
66
  };
59
67
 
68
+ // 真 headless ctx:mode='print'(无 dialog 能力,hasUI=false),ui 上无 select。
69
+ // isGuiCapable(ctx)=false(mode≠'rpc')→ 不走 RPC 分支 → custom 也不可用 → catch 走禁用。
70
+ const makeHeadlessCtx = () => ({
71
+ mode: "print" as const,
72
+ hasUI: false,
73
+ signal: undefined as AbortSignal | undefined,
74
+ ui: {},
75
+ });
76
+
60
77
  // ── Mock ctx builder ────────────────────────────────────
78
+ // mode 区分三场景:'tui'(默认,走 custom)/ 'rpc'(走 select)/ 'print'(headless)。
79
+ // Pi 的 hasUI:TUI 和 RPC 都为 true(dialog-capable),print/json 为 false。
80
+ // RPC 模式才挂 select(与真实 Pi 一致:TUI 模式的 ctx.ui 不一定有 select)。
61
81
  const makeCtx = (
62
82
  overrides: Partial<{
63
- hasUI: boolean;
83
+ mode: TestMode;
64
84
  customResult: unknown;
65
85
  customThrows: Error | null;
86
+ selectResult: string | undefined;
87
+ selectThrows: Error | null;
66
88
  }> = {},
67
89
  ) => {
68
- const { hasUI = true, customResult = null, customThrows = null } = overrides;
90
+ const {
91
+ mode = "tui",
92
+ customResult = null,
93
+ customThrows = null,
94
+ selectResult = undefined,
95
+ selectThrows = null,
96
+ } = overrides;
97
+ const hasUI = mode === "tui" || mode === "rpc";
98
+ // RPC 模式才挂 select(与真实 Pi 一致:TUI 模式的 ctx.ui 不一定有 select)
99
+ const hasSelect = mode === "rpc";
69
100
  return {
101
+ mode,
70
102
  hasUI,
71
103
  signal: undefined as AbortSignal | undefined,
72
104
  ui: {
@@ -74,6 +106,18 @@ const makeCtx = (
74
106
  if (customThrows) throw customThrows;
75
107
  return customResult as T;
76
108
  },
109
+ ...(hasSelect
110
+ ? {
111
+ select: async (
112
+ _title: string,
113
+ _options: string[],
114
+ _opts?: { signal?: AbortSignal },
115
+ ): Promise<string | undefined> => {
116
+ if (selectThrows) throw selectThrows;
117
+ return selectResult;
118
+ },
119
+ }
120
+ : {}),
77
121
  },
78
122
  };
79
123
  };
@@ -156,45 +200,32 @@ describe("execute — validation (FR-2 / AC-8 / AC-13)", () => {
156
200
  });
157
201
 
158
202
  // ── I-5 ~ I-7: Headless(FR-8 / AC-7)──────────────────
203
+ // 真 headless:hasUI=false 且 ui 上无 select(print 模式),askUserInteract 抛错 → 禁用工具。
159
204
  describe("execute — headless (FR-8 / AC-7)", () => {
160
- it("I-5: hasUI=false → isError with interactive-session message", async () => {
205
+ it("I-5: headless (no select) → isError with disabled message", async () => {
161
206
  const tool = getTool();
162
- const result = await tool.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
207
+ const result = await tool.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
163
208
  expect(result.isError).toBe(true);
164
- expect(result.content[0].text).toContain("interactive");
209
+ expect(result.content[0].text).toContain("disabled");
165
210
  });
166
211
 
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.
212
+ it("I-6: headless disables ask_user tool via setActiveTools", async () => {
173
213
  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
214
  const tool2 = getTool({
185
215
  getAllTools: () => [{ name: "ask_user" }, { name: "other" }],
186
216
  setActiveTools: (names: string[]) => {
187
217
  captured = names;
188
218
  },
189
219
  });
190
- await tool2.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
220
+ await tool2.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
191
221
  expect(captured).not.toContain("ask_user");
192
222
  expect(captured).toContain("other");
193
223
  });
194
224
 
195
- it("I-7: hasUI=false details.cancelled = true", async () => {
225
+ it("I-7: headless details.cancelled = true", async () => {
196
226
  const tool = getTool();
197
- const result = await tool.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
227
+ const result = await tool.execute("id", validSingle, undefined, undefined, makeHeadlessCtx());
228
+ // headless 走 step 2 提前返回:cancelled Result(禁用工具,不进交互分支)
198
229
  expect(result.details.cancelled).toBe(true);
199
230
  });
200
231
  });
@@ -221,6 +252,7 @@ describe("execute — signal abort (FR-10 / AC-14)", () => {
221
252
  // 此前 mock 直接返回 customResult、从不调用 factory,该 abort 监听器是 dead path。
222
253
  // 现在中断后监听器调用 done(null),custom 解析为 null → cancelled。
223
254
  const ctx = {
255
+ mode: "tui" as const,
224
256
  hasUI: true,
225
257
  signal: controller.signal,
226
258
  ui: {
@@ -486,6 +518,7 @@ describe("execute — inline render (FR-3)", () => {
486
518
  const tool = getTool();
487
519
  let customArgCount = -1;
488
520
  const ctx = {
521
+ mode: "tui" as const,
489
522
  hasUI: true,
490
523
  signal: undefined as AbortSignal | undefined,
491
524
  ui: {
@@ -500,3 +533,251 @@ describe("execute — inline render (FR-3)", () => {
500
533
  expect(customArgCount).toBe(1);
501
534
  });
502
535
  });
536
+
537
+ // ── RPC 模式(xyz-agent GUI 富交互协议)──────────────────
538
+ // hasUI=false + ui.select 存在 → 走 askUserInteract(select 通道 + ASK_USER_MARKER)。
539
+ // select 的返回值是前端 JSON.stringify 的 AskUserAnswers,index.ts 做格式转换。
540
+ describe("execute — RPC mode (askUserInteract via select channel)", () => {
541
+ it("R-1: single-select answer → converted to Result.answers (key=question)", async () => {
542
+ const tool = getTool();
543
+ // 协议 answers:key=header(单问题无 header → question 全文),value=选中 label
544
+ const protoAnswers = JSON.stringify({ "Which DB?": "Postgres" });
545
+ const result = await tool.execute(
546
+ "id",
547
+ validSingle,
548
+ undefined,
549
+ undefined,
550
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
551
+ );
552
+ expect(result.details.cancelled).toBe(false);
553
+ expect(result.details.answers["Which DB?"]).toBe("Postgres");
554
+ expect(result.content[0].text).toContain("Postgres");
555
+ });
556
+
557
+ it("R-2: multi-select answer (JSON array) → comma-joined labels", async () => {
558
+ const tool = getTool();
559
+ const multi = {
560
+ questions: [
561
+ {
562
+ question: "Which tools?",
563
+ header: "Tools",
564
+ options: [{ label: "A" }, { label: "B" }, { label: "C" }],
565
+ multiSelect: true,
566
+ },
567
+ ],
568
+ };
569
+ // 协议多选:value = JSON.stringify(["A","C"])
570
+ const protoAnswers = JSON.stringify({ Tools: JSON.stringify(["A", "C"]) });
571
+ const result = await tool.execute(
572
+ "id",
573
+ multi,
574
+ undefined,
575
+ undefined,
576
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
577
+ );
578
+ expect(result.details.answers["Which tools?"]).toBe("A, C");
579
+ });
580
+
581
+ it("R-2b: multi-select 乱序回传 → 按 options 定义顺序排序(S#3)", async () => {
582
+ const tool = getTool();
583
+ const multi = {
584
+ questions: [
585
+ {
586
+ question: "Which tools?",
587
+ header: "Tools",
588
+ options: [{ label: "A" }, { label: "B" }, { label: "C" }],
589
+ multiSelect: true,
590
+ },
591
+ ],
592
+ };
593
+ // 前端回传顺序 ["C", "A"] —— 应按 options 索引重排为 "A, C"
594
+ const protoAnswers = JSON.stringify({ Tools: JSON.stringify(["C", "A"]) });
595
+ const result = await tool.execute(
596
+ "id",
597
+ multi,
598
+ undefined,
599
+ undefined,
600
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
601
+ );
602
+ expect(result.details.answers["Which tools?"]).toBe("A, C");
603
+ });
604
+
605
+ it("R-3: Other free text → appended to answer parts", async () => {
606
+ const tool = getTool();
607
+ // 单选 Postgres + Other "Custom DB"
608
+ const protoAnswers = JSON.stringify({
609
+ "Which DB?": "Postgres",
610
+ "Which DB?__other": "Custom DB",
611
+ });
612
+ const result = await tool.execute(
613
+ "id",
614
+ validSingle,
615
+ undefined,
616
+ undefined,
617
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
618
+ );
619
+ // TUI 语义:parts = [selected, other].join(", ")
620
+ expect(result.details.answers["Which DB?"]).toBe("Postgres, Custom DB");
621
+ });
622
+
623
+ it("R-4: comment → inlined with ' — ' separator", async () => {
624
+ const tool = getTool();
625
+ const withComment = {
626
+ questions: [
627
+ {
628
+ question: "Which DB?",
629
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
630
+ allowComment: true,
631
+ },
632
+ ],
633
+ };
634
+ const protoAnswers = JSON.stringify({
635
+ "Which DB?": "Postgres",
636
+ "Which DB?__comment": "prod constraint",
637
+ });
638
+ const result = await tool.execute(
639
+ "id",
640
+ withComment,
641
+ undefined,
642
+ undefined,
643
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
644
+ );
645
+ expect(result.details.answers["Which DB?"]).toBe("Postgres — prod constraint");
646
+ });
647
+
648
+ it("R-5: user cancel (select returns undefined) → cancelled details", async () => {
649
+ const tool = getTool();
650
+ const result = await tool.execute(
651
+ "id",
652
+ validSingle,
653
+ undefined,
654
+ undefined,
655
+ makeCtx({ mode: "rpc", selectResult: undefined }),
656
+ );
657
+ expect(result.content[0].text).toContain("User cancelled");
658
+ expect(result.details.cancelled).toBe(true);
659
+ });
660
+
661
+ it("R-6: select throws → isError + disabled (not retriable)", async () => {
662
+ const tool = getTool();
663
+ const result = await tool.execute(
664
+ "id",
665
+ validSingle,
666
+ undefined,
667
+ undefined,
668
+ makeCtx({ mode: "rpc", selectThrows: new Error("channel broken") }),
669
+ );
670
+ expect(result.isError).toBe(true);
671
+ expect(result.content[0].text).toContain("disabled");
672
+ expect(result.details.error).toBe("channel broken");
673
+ });
674
+
675
+ it("R-7: header used as answers key when provided", async () => {
676
+ const tool = getTool();
677
+ const multiQ = {
678
+ questions: [
679
+ {
680
+ question: "Which database?",
681
+ header: "DB",
682
+ options: [{ label: "Postgres" }, { label: "MySQL" }],
683
+ },
684
+ ],
685
+ };
686
+ // 协议 answers key = header("DB"),但 Result.answers key = question 全文
687
+ const protoAnswers = JSON.stringify({ DB: "Postgres" });
688
+ const result = await tool.execute(
689
+ "id",
690
+ multiQ,
691
+ undefined,
692
+ undefined,
693
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
694
+ );
695
+ // 转换后 key 必须是 question 全文(与 TUI 版 buildResult 一致)
696
+ expect(result.details.answers["Which database?"]).toBe("Postgres");
697
+ });
698
+
699
+ it("R-8: multi-question mixed (single-select + multi-select + Other + comment)", async () => {
700
+ const tool = getTool();
701
+ const mixed = {
702
+ questions: [
703
+ {
704
+ question: "Which database?",
705
+ header: "DB",
706
+ options: [{ label: "Postgres" }, { label: "MySQL" }],
707
+ },
708
+ {
709
+ question: "Which tools?",
710
+ header: "Tools",
711
+ options: [{ label: "A" }, { label: "B" }, { label: "C" }],
712
+ multiSelect: true,
713
+ },
714
+ {
715
+ question: "Which region?",
716
+ header: "Region",
717
+ options: [{ label: "US" }, { label: "EU" }],
718
+ allowComment: true,
719
+ },
720
+ ],
721
+ };
722
+ // Q1: single-select Postgres
723
+ // Q2: multi-select [C, A] (乱序 → 应重排为 A, C) + Other "Custom"
724
+ // Q3: 无选中 (parts.length === 0 → skip, 不出现在 answers 中)
725
+ const protoAnswers = JSON.stringify({
726
+ DB: "Postgres",
727
+ Tools: JSON.stringify(["C", "A"]),
728
+ "Tools__other": "Custom",
729
+ // Region 无选中 → protoAnswersToResult 的 `if (parts.length === 0) continue` 跳过
730
+ });
731
+ const result = await tool.execute(
732
+ "id",
733
+ mixed,
734
+ undefined,
735
+ undefined,
736
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
737
+ );
738
+
739
+ expect(result.details.cancelled).toBe(false);
740
+ // Q1: single-select
741
+ expect(result.details.answers["Which database?"]).toBe("Postgres");
742
+ // Q2: multi-select 重排 + Other
743
+ expect(result.details.answers["Which tools?"]).toBe("A, C, Custom");
744
+ // Q3: 无选中 → 跳过(不在 answers map 中)
745
+ expect(result.details.answers["Which region?"]).toBeUndefined();
746
+ });
747
+
748
+ it("R-9: multi-question with comment on one question", async () => {
749
+ const tool = getTool();
750
+ const multiQ = {
751
+ questions: [
752
+ {
753
+ question: "Which DB?",
754
+ header: "DB",
755
+ options: [{ label: "Postgres" }],
756
+ },
757
+ {
758
+ question: "Why?",
759
+ header: "Reason",
760
+ options: [{ label: "Performance" }],
761
+ allowComment: true,
762
+ },
763
+ ],
764
+ };
765
+ const protoAnswers = JSON.stringify({
766
+ DB: "Postgres",
767
+ Reason: "Performance",
768
+ "Reason__comment": "benchmarked",
769
+ });
770
+ const result = await tool.execute(
771
+ "id",
772
+ multiQ,
773
+ undefined,
774
+ undefined,
775
+ makeCtx({ mode: "rpc", selectResult: protoAnswers }),
776
+ );
777
+
778
+ // Q1: 无 comment
779
+ expect(result.details.answers["Which DB?"]).toBe("Postgres");
780
+ // Q2: 有 comment → 内联
781
+ expect(result.details.answers["Why?"]).toBe("Performance — benchmarked");
782
+ });
783
+ });