@zhushanwen/pi-ask-user 0.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.
@@ -0,0 +1,479 @@
1
+ // src/__tests__/index.test.ts
2
+ // Tests the factory + execute orchestration (FR-1/7/8/9/10/13) with mocked ctx/pi.
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import factory from "../index";
6
+ import { mockTui, stubTheme } from "./fixtures";
7
+
8
+ // ── Types for the registered tool ───────────────────────
9
+ interface RegisteredTool {
10
+ name: string;
11
+ label: string;
12
+ parameters: unknown;
13
+ execute: (
14
+ toolCallId: string,
15
+ params: Record<string, unknown>,
16
+ signal: AbortSignal | undefined,
17
+ onUpdate: unknown,
18
+ ctx: {
19
+ hasUI: boolean;
20
+ signal?: AbortSignal;
21
+ ui: {
22
+ custom<T = void>(
23
+ factory: (...args: unknown[]) => unknown,
24
+ options?: { overlay?: boolean },
25
+ ): Promise<T>;
26
+ };
27
+ },
28
+ ) => Promise<Record<string, unknown>>;
29
+ renderCall: (args: Record<string, unknown>, theme: unknown) => unknown;
30
+ renderResult: (result: { details: unknown }, options: { expanded: boolean }, theme: unknown) => unknown;
31
+ }
32
+
33
+ interface MockPi {
34
+ tool?: RegisteredTool;
35
+ registerTool(tool: RegisteredTool): void;
36
+ getAllTools(): { name: string }[];
37
+ activeTools?: string[] | null;
38
+ setActiveTools(names: string[]): void;
39
+ }
40
+
41
+ /** Runs the factory, returns the captured registered tool. */
42
+ const getTool = (overrides: Partial<MockPi> = {}): RegisteredTool => {
43
+ const pi: MockPi = {
44
+ registerTool(tool) {
45
+ this.tool = tool;
46
+ },
47
+ getAllTools() {
48
+ return [{ name: "ask_user" }, { name: "other_tool" }];
49
+ },
50
+ setActiveTools(names) {
51
+ this.activeTools = names;
52
+ },
53
+ ...overrides,
54
+ };
55
+ factory(pi as never);
56
+ if (!pi.tool) throw new Error("factory did not register a tool");
57
+ return pi.tool;
58
+ };
59
+
60
+ // ── Mock ctx builder ────────────────────────────────────
61
+ const makeCtx = (
62
+ overrides: Partial<{
63
+ hasUI: boolean;
64
+ customResult: unknown;
65
+ customThrows: Error | null;
66
+ }> = {},
67
+ ) => {
68
+ const { hasUI = true, customResult = null, customThrows = null } = overrides;
69
+ return {
70
+ hasUI,
71
+ signal: undefined as AbortSignal | undefined,
72
+ ui: {
73
+ custom: async <T = void>(..._args: unknown[]): Promise<T> => {
74
+ if (customThrows) throw customThrows;
75
+ return customResult as T;
76
+ },
77
+ },
78
+ };
79
+ };
80
+
81
+ const validSingle = {
82
+ questions: [
83
+ {
84
+ question: "Which DB?",
85
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
86
+ },
87
+ ],
88
+ };
89
+
90
+ // ── I-1 ~ I-4: 参数校验(AC-8/13)──────────────────────
91
+ describe("execute — validation (FR-2 / AC-8 / AC-13)", () => {
92
+ it("I-1: duplicate question → isError", async () => {
93
+ const tool = getTool();
94
+ const result = await tool.execute(
95
+ "id",
96
+ {
97
+ questions: [
98
+ { question: "Same", options: [{ label: "A" }, { label: "B" }] },
99
+ { question: "Same", options: [{ label: "C" }, { label: "D" }] },
100
+ ],
101
+ },
102
+ undefined,
103
+ undefined,
104
+ makeCtx(),
105
+ );
106
+ expect(result.isError).toBe(true);
107
+ expect(result.content[0].text).toContain("Duplicate");
108
+ });
109
+
110
+ it("I-2: duplicate option label → isError", async () => {
111
+ const tool = getTool();
112
+ const result = await tool.execute(
113
+ "id",
114
+ {
115
+ questions: [
116
+ { question: "Q", options: [{ label: "A" }, { label: "A" }] },
117
+ ],
118
+ },
119
+ undefined,
120
+ undefined,
121
+ makeCtx(),
122
+ );
123
+ expect(result.isError).toBe(true);
124
+ expect(result.content[0].text).toContain("Duplicate option");
125
+ });
126
+
127
+ it("I-3: multi-question missing header → isError", async () => {
128
+ const tool = getTool();
129
+ const result = await tool.execute(
130
+ "id",
131
+ {
132
+ questions: [
133
+ { question: "Q1", header: "H1", options: [{ label: "A" }, { label: "B" }] },
134
+ { question: "Q2", options: [{ label: "C" }, { label: "D" }] },
135
+ ],
136
+ },
137
+ undefined,
138
+ undefined,
139
+ makeCtx(),
140
+ );
141
+ expect(result.isError).toBe(true);
142
+ expect(result.content[0].text).toContain("header");
143
+ });
144
+
145
+ it("I-4: validation error details.cancelled = true", async () => {
146
+ const tool = getTool();
147
+ const result = await tool.execute(
148
+ "id",
149
+ { questions: [{ question: "Q", options: [{ label: "A" }, { label: "A" }] }] },
150
+ undefined,
151
+ undefined,
152
+ makeCtx(),
153
+ );
154
+ expect(result.details.cancelled).toBe(true);
155
+ });
156
+ });
157
+
158
+ // ── I-5 ~ I-7: Headless(FR-8 / AC-7)──────────────────
159
+ describe("execute — headless (FR-8 / AC-7)", () => {
160
+ it("I-5: hasUI=false → isError with interactive-session message", async () => {
161
+ const tool = getTool();
162
+ const result = await tool.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
163
+ expect(result.isError).toBe(true);
164
+ expect(result.content[0].text).toContain("interactive");
165
+ });
166
+
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.
173
+ 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
+ const tool2 = getTool({
185
+ getAllTools: () => [{ name: "ask_user" }, { name: "other" }],
186
+ setActiveTools: (names: string[]) => {
187
+ captured = names;
188
+ },
189
+ });
190
+ await tool2.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
191
+ expect(captured).not.toContain("ask_user");
192
+ expect(captured).toContain("other");
193
+ });
194
+
195
+ it("I-7: hasUI=false details.cancelled = true", async () => {
196
+ const tool = getTool();
197
+ const result = await tool.execute("id", validSingle, undefined, undefined, makeCtx({ hasUI: false }));
198
+ expect(result.details.cancelled).toBe(true);
199
+ });
200
+ });
201
+
202
+ // ── I-8 ~ I-9: Signal abort(FR-10 / AC-14)────────────
203
+ describe("execute — signal abort (FR-10 / AC-14)", () => {
204
+ it("I-8: pre-aborted signal → returns cancelled immediately", async () => {
205
+ const tool = getTool();
206
+ const controller = new AbortController();
207
+ controller.abort();
208
+ const ctx = makeCtx();
209
+ ctx.signal = controller.signal;
210
+ const result = await tool.execute("id", validSingle, controller.signal, undefined, ctx);
211
+ expect(result.content[0].text).toContain("cancelled");
212
+ expect(result.details.cancelled).toBe(true);
213
+ });
214
+
215
+ it("I-9: abort during custom → factory registers listener → done(null) → cancelled", async () => {
216
+ const tool = getTool();
217
+ const controller = new AbortController();
218
+ // 真正调用 factory,使源码中的 signal.addEventListener("abort", () => done(null)) 被注册。
219
+ // 此前 mock 直接返回 customResult、从不调用 factory,该 abort 监听器是 dead path。
220
+ // 现在中断后监听器调用 done(null),custom 解析为 null → cancelled。
221
+ const ctx = {
222
+ hasUI: true,
223
+ signal: controller.signal,
224
+ ui: {
225
+ custom: <T = void>(factory: (...args: unknown[]) => unknown): Promise<T> =>
226
+ new Promise((resolve) => {
227
+ const done = (r: T): void => resolve(r);
228
+ factory(mockTui, stubTheme, {}, done);
229
+ setTimeout(() => controller.abort(), 0);
230
+ }),
231
+ },
232
+ };
233
+ const result = await tool.execute("id", validSingle, controller.signal, undefined, ctx);
234
+ expect(result.details.cancelled).toBe(true);
235
+ });
236
+ });
237
+
238
+ // ── I-10 ~ I-11: 错误兜底(FR-13 / AC-15)─────────────
239
+ describe("execute — error fallback (FR-13 / AC-15)", () => {
240
+ it("I-10: ui.custom throws → isError with 'ask_user failed'", async () => {
241
+ const tool = getTool();
242
+ const result = await tool.execute(
243
+ "id",
244
+ validSingle,
245
+ undefined,
246
+ undefined,
247
+ makeCtx({ customThrows: new Error("boom") }),
248
+ );
249
+ expect(result.isError).toBe(true);
250
+ expect(result.content[0].text).toContain("ask_user failed");
251
+ expect(result.content[0].text).toContain("boom");
252
+ });
253
+
254
+ it("I-11: error details contains error message", async () => {
255
+ const tool = getTool();
256
+ const result = await tool.execute(
257
+ "id",
258
+ validSingle,
259
+ undefined,
260
+ undefined,
261
+ makeCtx({ customThrows: new Error("crash") }),
262
+ );
263
+ expect(result.details.error).toBe("crash");
264
+ });
265
+ });
266
+
267
+ // ── I-12 ~ I-15: 正常返回与取消(FR-7)─────────────────
268
+ describe("execute — result handling (FR-7)", () => {
269
+ it("I-12: normal result returns answer summary", async () => {
270
+ const tool = getTool();
271
+ const fakeResult = {
272
+ questions: [{ question: "Which DB?", options: [{ label: "Postgres" }] }],
273
+ answers: { "Which DB?": "Postgres" },
274
+ cancelled: false,
275
+ };
276
+ const result = await tool.execute(
277
+ "id",
278
+ validSingle,
279
+ undefined,
280
+ undefined,
281
+ makeCtx({ customResult: fakeResult }),
282
+ );
283
+ expect(result.content[0].text).toContain("Postgres");
284
+ expect(result.details.cancelled).toBe(false);
285
+ });
286
+
287
+ it("I-13: details passes through questions + answers", async () => {
288
+ const tool = getTool();
289
+ const fakeResult = {
290
+ questions: [{ question: "Which DB?", options: [{ label: "Postgres" }] }],
291
+ answers: { "Which DB?": "Postgres" },
292
+ cancelled: false,
293
+ };
294
+ const result = await tool.execute(
295
+ "id",
296
+ validSingle,
297
+ undefined,
298
+ undefined,
299
+ makeCtx({ customResult: fakeResult }),
300
+ );
301
+ expect(result.details.questions).toEqual(fakeResult.questions);
302
+ expect(result.details.answers["Which DB?"]).toBe("Postgres");
303
+ });
304
+
305
+ it("I-14: cancelled (null) → 'User cancelled'", async () => {
306
+ const tool = getTool();
307
+ const result = await tool.execute(
308
+ "id",
309
+ validSingle,
310
+ undefined,
311
+ undefined,
312
+ makeCtx({ customResult: null }),
313
+ );
314
+ expect(result.content[0].text).toContain("User cancelled");
315
+ // P1-2: cancel message must guide the LLM not to assume an answer
316
+ expect(result.content[0].text).toContain("Do not assume");
317
+ expect(result.details.cancelled).toBe(true);
318
+ });
319
+
320
+ it("I-15: result.cancelled=true treated as cancel", async () => {
321
+ const tool = getTool();
322
+ const fakeResult = {
323
+ questions: [],
324
+ answers: {},
325
+ cancelled: true,
326
+ };
327
+ const result = await tool.execute(
328
+ "id",
329
+ validSingle,
330
+ undefined,
331
+ undefined,
332
+ makeCtx({ customResult: fakeResult }),
333
+ );
334
+ expect(result.content[0].text).toContain("User cancelled");
335
+ expect(result.content[0].text).toContain("Do not assume");
336
+ expect(result.details.cancelled).toBe(true);
337
+ });
338
+ });
339
+
340
+ // ── I-16 ~ I-19: renderCall / renderResult(FR-9)──────
341
+
342
+ /** 渲染一个 Component 节点到连接后的纯文本(剥除 stubTheme passthrough 后即原始字符串)。 */
343
+ const renderText = (node: { render(width: number): string[] }, width = 80): string =>
344
+ node.render(width).join("\n");
345
+
346
+ describe("renderCall / renderResult (FR-9)", () => {
347
+ it("I-16: renderCall shows tool name + header topics", () => {
348
+ const tool = getTool();
349
+ const node = tool.renderCall(
350
+ { questions: [{ question: "Q", header: "MyHeader", options: [] }] },
351
+ stubTheme,
352
+ ) as unknown as { render(width: number): string[] };
353
+ const text = renderText(node);
354
+ expect(text).toContain("ask_user");
355
+ expect(text).toContain("MyHeader");
356
+ });
357
+
358
+ it("I-16b: renderCall falls back to truncated question when no header", () => {
359
+ const tool = getTool();
360
+ const node = tool.renderCall(
361
+ { questions: [{ question: "This is a very long question text", options: [] }] },
362
+ stubTheme,
363
+ ) as unknown as { render(width: number): string[] };
364
+ // 无 header → 用 truncateToWidth(question, 12):按显示宽度截断(带省略号),
365
+ // 不再按 UTF-16 slice,emoji 代理对安全。
366
+ const text = renderText(node);
367
+ expect(text).toContain("ask_user");
368
+ expect(text).not.toContain("This is a very long question text");
369
+ });
370
+
371
+ it("I-16c: renderCall tolerates missing questions (?? [] defensive branch)", () => {
372
+ // S-10: args.questions 缺失时 ?? [] 兜底,不崩溃、渲染工具名(topics 为空)
373
+ const tool = getTool();
374
+ const node = tool.renderCall({} as never, stubTheme) as unknown as {
375
+ render(width: number): string[];
376
+ };
377
+ expect(renderText(node)).toContain("ask_user");
378
+ });
379
+
380
+ it("I-17: renderResult with answers lists ✓ <header>: <answer>", () => {
381
+ const tool = getTool();
382
+ const node = tool.renderResult(
383
+ {
384
+ details: {
385
+ questions: [{ question: "Q", header: "H", options: [{ label: "A" }] }],
386
+ answers: { Q: "A" },
387
+ cancelled: false,
388
+ },
389
+ },
390
+ { expanded: false },
391
+ stubTheme,
392
+ ) as unknown as { render(width: number): string[] };
393
+ const text = renderText(node);
394
+ expect(text).toContain("✓");
395
+ expect(text).toContain("H:");
396
+ expect(text).toContain("A");
397
+ });
398
+
399
+ it("I-17b: renderResult shows (no answer) when question unanswered in details", () => {
400
+ const tool = getTool();
401
+ const node = tool.renderResult(
402
+ {
403
+ details: {
404
+ questions: [{ question: "Q", header: "H", options: [{ label: "A" }] }],
405
+ answers: {},
406
+ cancelled: false,
407
+ },
408
+ },
409
+ { expanded: false },
410
+ stubTheme,
411
+ ) as unknown as { render(width: number): string[] };
412
+ expect(renderText(node)).toContain("(no answer)");
413
+ });
414
+
415
+ it("I-18: renderResult cancelled shows Cancelled", () => {
416
+ const tool = getTool();
417
+ const node = tool.renderResult(
418
+ { details: { questions: [], answers: {}, cancelled: true } },
419
+ { expanded: false },
420
+ stubTheme,
421
+ ) as unknown as { render(width: number): string[] };
422
+ expect(renderText(node)).toContain("Cancelled");
423
+ });
424
+
425
+ it("I-19: renderResult error shows ✗ <error>", () => {
426
+ const tool = getTool();
427
+ const node = tool.renderResult(
428
+ { details: { error: "something broke" } },
429
+ { expanded: false },
430
+ stubTheme,
431
+ ) as unknown as { render(width: number): string[] };
432
+ const text = renderText(node);
433
+ expect(text).toContain("✗");
434
+ expect(text).toContain("something broke");
435
+ });
436
+
437
+ // S-3: options.expanded 展开 —— 显示全部选项 + ●/○ 选中标记(spec FR-9)
438
+ it("I-20: renderResult expanded shows all options with ●/○ marks", () => {
439
+ const tool = getTool();
440
+ const node = tool.renderResult(
441
+ {
442
+ details: {
443
+ questions: [
444
+ {
445
+ question: "Which DB?",
446
+ header: "DB",
447
+ options: [{ label: "Postgres" }, { label: "SQLite" }],
448
+ },
449
+ ],
450
+ answers: { "Which DB?": "Postgres" },
451
+ cancelled: false,
452
+ },
453
+ },
454
+ { expanded: true },
455
+ stubTheme,
456
+ ) as unknown as { render(width: number): string[] };
457
+ const text = renderText(node);
458
+ // 两个选项都展开显示
459
+ expect(text).toContain("Postgres");
460
+ expect(text).toContain("SQLite");
461
+ // 选中的 Postgres 用 ●,未选的 SQLite 用 ○
462
+ expect(text).toContain("●");
463
+ expect(text).toContain("○");
464
+ });
465
+ });
466
+
467
+ // ── FR-1: tool registration shape ───────────────────────
468
+ describe("factory registration (FR-1)", () => {
469
+ it("registers tool named 'ask_user' with full metadata", () => {
470
+ const tool = getTool();
471
+ expect(tool.name).toBe("ask_user");
472
+ expect(tool.label).toBe("Ask User");
473
+ expect(tool.description).toBeTruthy();
474
+ expect(tool.parameters).toBeTruthy();
475
+ expect(typeof tool.execute).toBe("function");
476
+ expect(typeof tool.renderCall).toBe("function");
477
+ expect(typeof tool.renderResult).toBe("function");
478
+ });
479
+ });