@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.
- package/package.json +9 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/channel-handler.test.ts +286 -0
- package/src/__tests__/channel-registry-register.test.ts +180 -0
- package/src/__tests__/component-keymap.test.ts +0 -31
- package/src/__tests__/e2e-harness.ts +6 -0
- package/src/__tests__/e2e.test.ts +2 -2
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/index.test.ts +312 -24
- package/src/__tests__/question-view.test.ts +50 -35
- package/src/__tests__/sdk-contract.test.ts +1 -1
- 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/channel-handler.ts +215 -0
- package/src/channel-registry-register.ts +108 -0
- package/src/component.ts +42 -101
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +211 -69
- package/src/question-view.ts +32 -47
- package/src/submit-view.ts +2 -4
- package/src/validate.ts +11 -0
|
@@ -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
|
-
|
|
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 {
|
|
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:
|
|
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,
|
|
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("
|
|
216
|
+
expect(result.content[0].text).toContain("disabled");
|
|
165
217
|
});
|
|
166
218
|
|
|
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.
|
|
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,
|
|
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:
|
|
232
|
+
it("I-7: headless details.cancelled = true", async () => {
|
|
196
233
|
const tool = getTool();
|
|
197
|
-
const result = await tool.execute("id", validSingle, undefined, undefined,
|
|
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
|
+
});
|