@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.
- package/package.json +4 -1
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +0 -31
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/index.test.ts +305 -24
- package/src/__tests__/question-view.test.ts +50 -35
- package/src/__tests__/validate.test.ts +5 -3
- package/src/__tests__/w2-draft-hint.test.ts +0 -89
- package/src/__tests__/w3-regression.test.ts +5 -187
- package/src/answer-format.ts +51 -0
- package/src/component.ts +42 -101
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +193 -69
- package/src/question-view.ts +32 -47
- package/src/submit-view.ts +2 -4
- package/src/validate.ts +11 -0
|
@@ -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
|
-
|
|
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 {
|
|
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:
|
|
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,
|
|
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("
|
|
209
|
+
expect(result.content[0].text).toContain("disabled");
|
|
165
210
|
});
|
|
166
211
|
|
|
167
|
-
it("I-6:
|
|
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,
|
|
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:
|
|
225
|
+
it("I-7: headless details.cancelled = true", async () => {
|
|
196
226
|
const tool = getTool();
|
|
197
|
-
const result = await tool.execute("id", validSingle, undefined, undefined,
|
|
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
|
+
});
|