gentle-pi 3.3.0 → 3.5.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/README.md +88 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/bin/gentle-shell.mjs +198 -0
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +99 -6
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +338 -0
- package/extensions/gentle-agents.ts +41 -1
- package/extensions/gentle-ai.ts +59 -18
- package/extensions/gentle-shell.ts +99 -10
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/inprocess-reviewer.ts +38 -1
- package/lib/native-review-cli.ts +36 -10
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +10 -0
- package/lib/review-integration-v2.ts +4 -1
- package/lib/rpc-host.ts +36 -0
- package/lib/shell-bar.ts +13 -0
- package/lib/shell-sidebar-layout.ts +10 -4
- package/lib/shell-usage-view.ts +5 -2
- package/lib/shell-usage.ts +120 -6
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +6 -3
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +661 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +9 -0
- package/tests/gentle-agents.test.ts +161 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +56 -4
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay.test.ts +21 -0
- package/tests/review-integration-v2.test.ts +30 -0
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +107 -2
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/rpc-host.test.ts +77 -0
- package/tests/shell-bar.test.ts +8 -0
- package/tests/shell-sidebar-layout.test.ts +60 -5
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/skills/release/SKILL.md +0 -137
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import askUserQuestion, { askMultiSelect } from "../extensions/ask-user-question.ts";
|
|
4
|
+
|
|
5
|
+
/** Plain theme fake: identity styling keeps rendered assertions readable. */
|
|
6
|
+
interface Theme {
|
|
7
|
+
fg(color: string, text: string): string;
|
|
8
|
+
bg?(color: string, text: string): string;
|
|
9
|
+
bold?(text: string): string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const theme: Theme = {
|
|
13
|
+
fg: (_color: string, text: string) => text,
|
|
14
|
+
bg: (_color: string, text: string) => text,
|
|
15
|
+
bold: (text: string) => text,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
interface Renderable {
|
|
19
|
+
render(width: number): string[];
|
|
20
|
+
handleInput?(data: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type CustomFactory = (
|
|
24
|
+
tui: { requestRender(): void },
|
|
25
|
+
theme: Theme,
|
|
26
|
+
keybindings: unknown,
|
|
27
|
+
done: (value: unknown) => void,
|
|
28
|
+
) => Renderable;
|
|
29
|
+
|
|
30
|
+
interface ToolResult {
|
|
31
|
+
content: Array<{ type: string; text: string }>;
|
|
32
|
+
details: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RegisteredTool {
|
|
36
|
+
name: string;
|
|
37
|
+
label: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
renderShell?: string;
|
|
40
|
+
promptGuidelines?: string[];
|
|
41
|
+
parameters: {
|
|
42
|
+
additionalProperties?: boolean;
|
|
43
|
+
properties?: {
|
|
44
|
+
questions?: {
|
|
45
|
+
minItems?: number;
|
|
46
|
+
maxItems?: number;
|
|
47
|
+
items?: { additionalProperties?: boolean; properties?: Record<string, unknown> };
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
executionMode?: string;
|
|
52
|
+
execute(...args: unknown[]): Promise<ToolResult>;
|
|
53
|
+
renderCall(args: unknown, theme: Theme): Renderable;
|
|
54
|
+
renderResult(result: unknown, options: unknown, theme: Theme): Renderable;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface LifecycleEvent {
|
|
58
|
+
channel: string;
|
|
59
|
+
data: { active: boolean };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One fake extension slot: Pi keys tools per extension by name (`loader.js:240`). */
|
|
63
|
+
interface ExtensionSlot {
|
|
64
|
+
path: string;
|
|
65
|
+
tools: Map<string, RegisteredTool>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const OURS_PATH = "gentle-pi/extensions/ask-user-question.ts";
|
|
69
|
+
|
|
70
|
+
function registerQuestionTool(slot?: ExtensionSlot): { tool: RegisteredTool; slot: ExtensionSlot; emitted: LifecycleEvent[] } {
|
|
71
|
+
const target: ExtensionSlot = slot ?? { path: OURS_PATH, tools: new Map() };
|
|
72
|
+
const emitted: LifecycleEvent[] = [];
|
|
73
|
+
const pi = {
|
|
74
|
+
registerTool(tool: RegisteredTool) {
|
|
75
|
+
target.tools.set(tool.name, tool);
|
|
76
|
+
},
|
|
77
|
+
events: {
|
|
78
|
+
emit(channel: string, data: { active: boolean }) {
|
|
79
|
+
emitted.push({ channel, data });
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
askUserQuestion(pi as never);
|
|
84
|
+
const tool = target.tools.get("ask_user_question");
|
|
85
|
+
if (!tool) throw new Error("ask_user_question must register");
|
|
86
|
+
return { tool, slot: target, emitted };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tuiContext(inputs: readonly string[], rendered?: { value: string }) {
|
|
90
|
+
return {
|
|
91
|
+
mode: "tui",
|
|
92
|
+
ui: {
|
|
93
|
+
custom: async (factory: CustomFactory) => {
|
|
94
|
+
let result: unknown;
|
|
95
|
+
const component = factory({ requestRender() {} }, theme, {}, (value) => {
|
|
96
|
+
result = value;
|
|
97
|
+
});
|
|
98
|
+
if (rendered) rendered.value = component.render(100).join("\n");
|
|
99
|
+
for (const input of inputs) component.handleInput?.(input);
|
|
100
|
+
return result;
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function run(tool: RegisteredTool, params: unknown, ctx: unknown): Promise<ToolResult> {
|
|
107
|
+
return tool.execute("call", params, new AbortController().signal, undefined, ctx);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const INTERACTIVE_HOST_ENV = "GENTLE_SHELL_INTERACTIVE_HOST";
|
|
111
|
+
|
|
112
|
+
/** Fake interactive-RPC-host ctx: scripted `select` answers, one per call. */
|
|
113
|
+
function rpcHostContext(selectAnswers: readonly (string | undefined)[]) {
|
|
114
|
+
const selectCalls: Array<{ title: string; options: string[] }> = [];
|
|
115
|
+
let index = 0;
|
|
116
|
+
return {
|
|
117
|
+
ctx: {
|
|
118
|
+
mode: "rpc",
|
|
119
|
+
hasUI: true,
|
|
120
|
+
ui: {
|
|
121
|
+
select: async (title: string, options: string[]) => {
|
|
122
|
+
selectCalls.push({ title, options });
|
|
123
|
+
const answer = selectAnswers[index];
|
|
124
|
+
index += 1;
|
|
125
|
+
return answer;
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
selectCalls,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function withInteractiveHostEnv(t: { after(fn: () => void): void }): void {
|
|
134
|
+
const previous = process.env[INTERACTIVE_HOST_ENV];
|
|
135
|
+
process.env[INTERACTIVE_HOST_ENV] = "1";
|
|
136
|
+
t.after(() => {
|
|
137
|
+
if (previous === undefined) delete process.env[INTERACTIVE_HOST_ENV];
|
|
138
|
+
else process.env[INTERACTIVE_HOST_ENV] = previous;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const option = (label: string, description = `${label} description`, preview?: string) =>
|
|
143
|
+
preview === undefined ? { label, description } : { label, description, preview };
|
|
144
|
+
|
|
145
|
+
const single = () => [
|
|
146
|
+
{ question: "Proceed?", header: "Proceed", options: [option("Alpha"), option("Beta")] },
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
test("ask_user_question opts out of the painted shell and pins the schema limits", () => {
|
|
150
|
+
const { tool } = registerQuestionTool();
|
|
151
|
+
const questions = tool.parameters.properties?.questions;
|
|
152
|
+
|
|
153
|
+
assert.equal(tool.renderShell, "self");
|
|
154
|
+
assert.equal(tool.name, "ask_user_question");
|
|
155
|
+
assert.equal(tool.label, "Ask User Question");
|
|
156
|
+
assert.equal(tool.executionMode, "sequential");
|
|
157
|
+
assert.equal(tool.parameters.additionalProperties, false);
|
|
158
|
+
assert.equal(questions?.minItems, 1);
|
|
159
|
+
assert.equal(questions?.maxItems, 4);
|
|
160
|
+
assert.equal(questions?.items?.additionalProperties, false);
|
|
161
|
+
assert.deepEqual(Object.keys(questions?.items?.properties ?? {}).sort(), ["header", "multiSelect", "options", "question"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("ask_user_question guides the model toward the supported contract", () => {
|
|
165
|
+
const { tool } = registerQuestionTool();
|
|
166
|
+
const guidelines = (tool.promptGuidelines ?? []).join(" ");
|
|
167
|
+
|
|
168
|
+
assert.match(tool.description ?? "", /one to four structured questions/i);
|
|
169
|
+
assert.match(guidelines, /at most 16 characters/);
|
|
170
|
+
assert.match(guidelines, /at most 60 characters/);
|
|
171
|
+
assert.match(guidelines, /preview/);
|
|
172
|
+
assert.match(guidelines, /multiSelect/);
|
|
173
|
+
assert.match(guidelines, /Type something\./);
|
|
174
|
+
assert.match(guidelines, /Never use this tool for decisions that must not be delegated to the user\./);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("ask_user_question rejects invalid parameters before mounting any UI", async () => {
|
|
178
|
+
const { tool, emitted } = registerQuestionTool();
|
|
179
|
+
let customCalls = 0;
|
|
180
|
+
const ctx = {
|
|
181
|
+
mode: "tui",
|
|
182
|
+
ui: {
|
|
183
|
+
custom: async () => {
|
|
184
|
+
customCalls++;
|
|
185
|
+
return undefined;
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = await run(tool, { questions: [] }, ctx);
|
|
191
|
+
|
|
192
|
+
assert.equal(result.content[0]?.text, "Invalid questionnaire: At least one question is required.");
|
|
193
|
+
assert.equal(result.details.errorKind, "no_questions");
|
|
194
|
+
assert.deepEqual(result.details.error, { code: "no_questions", message: "At least one question is required." });
|
|
195
|
+
assert.equal(customCalls, 0, "invalid input never reaches ctx.ui.custom");
|
|
196
|
+
assert.deepEqual(emitted, [], "invalid input emits no lifecycle event");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("ask_user_question stays unavailable outside the interactive TUI", async () => {
|
|
200
|
+
const { tool, emitted } = registerQuestionTool();
|
|
201
|
+
let customCalls = 0;
|
|
202
|
+
const ctx = {
|
|
203
|
+
mode: "print",
|
|
204
|
+
ui: {
|
|
205
|
+
custom: async () => {
|
|
206
|
+
customCalls++;
|
|
207
|
+
return undefined;
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const result = await run(tool, { questions: single() }, ctx);
|
|
213
|
+
|
|
214
|
+
assert.match(result.content[0]?.text ?? "", /unavailable outside the interactive TUI/);
|
|
215
|
+
assert.equal(result.details.errorKind, "unavailable_outside_tui");
|
|
216
|
+
assert.equal(customCalls, 0);
|
|
217
|
+
assert.deepEqual(emitted, []);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("ask_user_question stays unavailable on a plain rpc host without the interactive-host variable", async (t) => {
|
|
221
|
+
const { tool, emitted } = registerQuestionTool();
|
|
222
|
+
let selectCalls = 0;
|
|
223
|
+
const ctx = {
|
|
224
|
+
mode: "rpc",
|
|
225
|
+
hasUI: true,
|
|
226
|
+
ui: { select: async () => { selectCalls++; return undefined; } },
|
|
227
|
+
};
|
|
228
|
+
t.after(() => { delete process.env[INTERACTIVE_HOST_ENV]; });
|
|
229
|
+
delete process.env[INTERACTIVE_HOST_ENV];
|
|
230
|
+
|
|
231
|
+
const result = await run(tool, { questions: single() }, ctx);
|
|
232
|
+
|
|
233
|
+
assert.match(result.content[0]?.text ?? "", /unavailable outside the interactive TUI/);
|
|
234
|
+
assert.equal(result.details.errorKind, "unavailable_outside_tui");
|
|
235
|
+
assert.equal(selectCalls, 0);
|
|
236
|
+
assert.deepEqual(emitted, []);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("ask_user_question resolves a single-select answer through RPC dialogs on an interactive host", async (t) => {
|
|
240
|
+
withInteractiveHostEnv(t);
|
|
241
|
+
const { tool, emitted } = registerQuestionTool();
|
|
242
|
+
const { ctx, selectCalls } = rpcHostContext(["Alpha"]);
|
|
243
|
+
|
|
244
|
+
const result = await run(tool, { questions: single() }, ctx);
|
|
245
|
+
|
|
246
|
+
assert.equal(selectCalls.length, 1);
|
|
247
|
+
assert.equal(selectCalls[0]?.title, "Proceed: Proceed?");
|
|
248
|
+
assert.deepEqual(selectCalls[0]?.options, ["Alpha", "Beta"]);
|
|
249
|
+
assert.equal(result.content[0]?.text, "1. Proceed? — Alpha");
|
|
250
|
+
assert.deepEqual(result.details.answers, [
|
|
251
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha" },
|
|
252
|
+
]);
|
|
253
|
+
assert.deepEqual(emitted, [
|
|
254
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
|
|
255
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("ask_user_question echoes an option preview through RPC dialogs", async (t) => {
|
|
260
|
+
withInteractiveHostEnv(t);
|
|
261
|
+
const { tool } = registerQuestionTool();
|
|
262
|
+
const questions = [
|
|
263
|
+
{ question: "Proceed?", header: "Proceed", options: [option("Alpha", "First choice", "Preview A"), option("Beta")] },
|
|
264
|
+
];
|
|
265
|
+
const { ctx } = rpcHostContext(["Alpha"]);
|
|
266
|
+
|
|
267
|
+
const result = await run(tool, { questions }, ctx);
|
|
268
|
+
|
|
269
|
+
assert.equal(result.content[0]?.text, "1. Proceed? — Alpha\n selected preview: Preview A");
|
|
270
|
+
assert.deepEqual(result.details.answers, [
|
|
271
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha", preview: "Preview A" },
|
|
272
|
+
]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("ask_user_question loops select with a trailing Done entry for a multiSelect question on an interactive host", async (t) => {
|
|
276
|
+
withInteractiveHostEnv(t);
|
|
277
|
+
const { tool } = registerQuestionTool();
|
|
278
|
+
const questions = [
|
|
279
|
+
{ question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
|
|
280
|
+
];
|
|
281
|
+
const { ctx, selectCalls } = rpcHostContext(["[ ] One", "Done"]);
|
|
282
|
+
|
|
283
|
+
const result = await run(tool, { questions }, ctx);
|
|
284
|
+
|
|
285
|
+
assert.deepEqual(selectCalls[0]?.options, ["[ ] One", "[ ] Two", "Done"]);
|
|
286
|
+
assert.deepEqual(selectCalls[1]?.options, ["[x] One", "[ ] Two", "Done"]);
|
|
287
|
+
assert.equal(result.content[0]?.text, "1. Pick? — selected: One");
|
|
288
|
+
assert.deepEqual(result.details.answers, [
|
|
289
|
+
{ questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] },
|
|
290
|
+
]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("ask_user_question multiSelect finishes once every option is toggled without needing Done", async (t) => {
|
|
294
|
+
withInteractiveHostEnv(t);
|
|
295
|
+
const { tool } = registerQuestionTool();
|
|
296
|
+
const questions = [
|
|
297
|
+
{ question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
|
|
298
|
+
];
|
|
299
|
+
const { ctx, selectCalls } = rpcHostContext(["[ ] One", "[ ] Two"]);
|
|
300
|
+
|
|
301
|
+
const result = await run(tool, { questions }, ctx);
|
|
302
|
+
|
|
303
|
+
assert.equal(selectCalls.length, 2, "bounded by the safety cap, but finished early once fully toggled");
|
|
304
|
+
assert.deepEqual(result.details.answers, [
|
|
305
|
+
{ questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One", "Two"] },
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("ask_user_question multiSelect toggle/untoggle/toggle sequence still selects correctly after Done", async (t) => {
|
|
310
|
+
withInteractiveHostEnv(t);
|
|
311
|
+
const { tool } = registerQuestionTool();
|
|
312
|
+
const questions = [
|
|
313
|
+
{ question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
|
|
314
|
+
];
|
|
315
|
+
// Toggle One on, then off, then on again, then explicit Done: four rounds,
|
|
316
|
+
// one more than the old options.length + 1 = 3 round bound, so an
|
|
317
|
+
// un-toggle must never count against the loop's budget.
|
|
318
|
+
const { ctx, selectCalls } = rpcHostContext(["[ ] One", "[x] One", "[ ] One", "Done"]);
|
|
319
|
+
|
|
320
|
+
const result = await run(tool, { questions }, ctx);
|
|
321
|
+
|
|
322
|
+
assert.equal(selectCalls.length, 4);
|
|
323
|
+
assert.deepEqual(result.details.answers, [
|
|
324
|
+
{ questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] },
|
|
325
|
+
]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("ask_user_question multiSelect cancels once the safety cap is hit without Done", async (t) => {
|
|
329
|
+
withInteractiveHostEnv(t);
|
|
330
|
+
const { tool } = registerQuestionTool();
|
|
331
|
+
const questions = [
|
|
332
|
+
{ question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
|
|
333
|
+
];
|
|
334
|
+
// Toggle only "One" back and forth forever: "Two" never toggles, so the
|
|
335
|
+
// loop never auto-finishes, and Done is never picked. It must hit the
|
|
336
|
+
// hard safety cap and refuse to commit whatever was toggled at that point.
|
|
337
|
+
const selectAnswers = Array.from({ length: 32 }, (_, round) => (round % 2 === 0 ? "[ ] One" : "[x] One"));
|
|
338
|
+
const { ctx, selectCalls } = rpcHostContext(selectAnswers);
|
|
339
|
+
|
|
340
|
+
const result = await run(tool, { questions }, ctx);
|
|
341
|
+
|
|
342
|
+
assert.equal(selectCalls.length, 32, "every round of the safety cap must be spent before giving up");
|
|
343
|
+
assert.equal(result.content[0]?.text, "User cancelled the questionnaire");
|
|
344
|
+
assert.deepEqual(result.details, { cancelled: true });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("askMultiSelect scales its round cap so a 40-option question can toggle every option and still reach Done", async () => {
|
|
348
|
+
// The shipped `ask_user_question` tool schema caps authored options at 4
|
|
349
|
+
// (`lib/questionnaire/schema.ts`'s `MAX_OPTIONS`), so this exercises
|
|
350
|
+
// `askMultiSelect` directly rather than through the schema-validated tool,
|
|
351
|
+
// the same way a future caller with more options would.
|
|
352
|
+
const labels = Array.from({ length: 40 }, (_, index) => `Option ${index + 1}`);
|
|
353
|
+
const question = { question: "Pick?", header: "Pick", options: labels.map((label) => option(label)), multiSelect: true };
|
|
354
|
+
// Toggle the first 39 options on, one per round, then an explicit Done:
|
|
355
|
+
// 40 rounds total, past the old flat 32-round cap but inside the new
|
|
356
|
+
// `Math.max(32, options.length + 2)` = 42 bound.
|
|
357
|
+
const toggleAnswers = labels.slice(0, 39).map((label) => `[ ] ${label}`);
|
|
358
|
+
const { ctx, selectCalls } = rpcHostContext([...toggleAnswers, "Done"]);
|
|
359
|
+
|
|
360
|
+
const answer = await askMultiSelect(ctx as never, question as never);
|
|
361
|
+
|
|
362
|
+
assert.equal(selectCalls.length, 40, "every toggle plus the explicit Done fits inside the scaled cap");
|
|
363
|
+
assert.deepEqual(answer, { questionIndex: -1, question: "Pick?", kind: "multi", answer: null, selected: labels.slice(0, 39) });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("ask_user_question multiSelect cancels on an unrecognised host answer instead of committing a partial state", async (t) => {
|
|
367
|
+
withInteractiveHostEnv(t);
|
|
368
|
+
const { tool } = registerQuestionTool();
|
|
369
|
+
const questions = [
|
|
370
|
+
{ question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
|
|
371
|
+
];
|
|
372
|
+
// "One" gets toggled on first, then the host answers with something that
|
|
373
|
+
// matches none of the current round's options: the loop must cancel the
|
|
374
|
+
// whole questionnaire, never commit the partial "One" toggle.
|
|
375
|
+
const { ctx, selectCalls } = rpcHostContext(["[ ] One", "not a real option"]);
|
|
376
|
+
|
|
377
|
+
const result = await run(tool, { questions }, ctx);
|
|
378
|
+
|
|
379
|
+
assert.equal(selectCalls.length, 2);
|
|
380
|
+
assert.equal(result.content[0]?.text, "User cancelled the questionnaire");
|
|
381
|
+
assert.deepEqual(result.details, { cancelled: true });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("ask_user_question cancels through RPC dialogs like the TUI path when select returns undefined", async (t) => {
|
|
385
|
+
withInteractiveHostEnv(t);
|
|
386
|
+
const { tool, emitted } = registerQuestionTool();
|
|
387
|
+
const { ctx } = rpcHostContext([undefined]);
|
|
388
|
+
|
|
389
|
+
const result = await run(tool, { questions: single() }, ctx);
|
|
390
|
+
|
|
391
|
+
assert.equal(result.content[0]?.text, "User cancelled the questionnaire");
|
|
392
|
+
assert.deepEqual(result.details, { cancelled: true });
|
|
393
|
+
assert.deepEqual(emitted, [
|
|
394
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
|
|
395
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
|
|
396
|
+
]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("ask_user_question cancels a questionnaire through RPC dialogs on a later question", async (t) => {
|
|
400
|
+
withInteractiveHostEnv(t);
|
|
401
|
+
const { tool } = registerQuestionTool();
|
|
402
|
+
const questions = [
|
|
403
|
+
{ question: "First?", header: "First", options: [option("Alpha"), option("Beta")] },
|
|
404
|
+
{ question: "Second?", header: "Second", options: [option("Gamma"), option("Delta")] },
|
|
405
|
+
];
|
|
406
|
+
const { ctx, selectCalls } = rpcHostContext(["Alpha", undefined]);
|
|
407
|
+
|
|
408
|
+
const result = await run(tool, { questions }, ctx);
|
|
409
|
+
|
|
410
|
+
assert.equal(selectCalls.length, 2, "the first question is answered before the cancel is observed");
|
|
411
|
+
assert.deepEqual(result.details, { cancelled: true });
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("ask_user_question commits a single-select answer end-to-end", async () => {
|
|
415
|
+
const { tool, emitted } = registerQuestionTool();
|
|
416
|
+
const rendered = { value: "" };
|
|
417
|
+
|
|
418
|
+
const result = await run(tool, { questions: single() }, tuiContext(["\r"], rendered));
|
|
419
|
+
|
|
420
|
+
assert.match(rendered.value, /\[1\/1\]/);
|
|
421
|
+
assert.match(rendered.value, /▸ Proceed/);
|
|
422
|
+
assert.match(rendered.value, /Alpha/);
|
|
423
|
+
assert.equal(result.content[0]?.text, "1. Proceed? — Alpha");
|
|
424
|
+
assert.deepEqual(result.details.answers, [
|
|
425
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha" },
|
|
426
|
+
]);
|
|
427
|
+
assert.deepEqual(emitted, [
|
|
428
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
|
|
429
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
|
|
430
|
+
]);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("ask_user_question mounts through ctx.ui.custom without an overlay option", async () => {
|
|
434
|
+
const { tool } = registerQuestionTool();
|
|
435
|
+
let customArgCount = -1;
|
|
436
|
+
const ctx = {
|
|
437
|
+
mode: "tui",
|
|
438
|
+
ui: {
|
|
439
|
+
custom: async (...args: unknown[]) => {
|
|
440
|
+
customArgCount = args.length;
|
|
441
|
+
const factory = args[0] as CustomFactory;
|
|
442
|
+
let result: unknown;
|
|
443
|
+
const component = factory({ requestRender() {} }, theme, {}, (value) => {
|
|
444
|
+
result = value;
|
|
445
|
+
});
|
|
446
|
+
component.handleInput?.("\r");
|
|
447
|
+
return result;
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
await run(tool, { questions: single() }, ctx);
|
|
453
|
+
|
|
454
|
+
assert.equal(customArgCount, 1, "a dock swap passes the factory only; no overlay options");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("ask_user_question mounts exactly one active question and switches with Tab", async () => {
|
|
458
|
+
const { tool } = registerQuestionTool();
|
|
459
|
+
const questions = [
|
|
460
|
+
{ question: "First?", header: "First", options: [option("Alpha"), option("Beta")] },
|
|
461
|
+
{ question: "Second?", header: "Second", options: [option("Gamma"), option("Delta")] },
|
|
462
|
+
];
|
|
463
|
+
let component: Renderable | undefined;
|
|
464
|
+
const pending = run(tool, { questions }, {
|
|
465
|
+
mode: "tui",
|
|
466
|
+
ui: {
|
|
467
|
+
custom: (factory: CustomFactory) => new Promise((resolve) => {
|
|
468
|
+
component = factory({ requestRender() {} }, theme, {}, resolve);
|
|
469
|
+
}),
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
assert.ok(component, "the tool mounts the questionnaire component");
|
|
474
|
+
const first = component!.render(100).join("\n");
|
|
475
|
+
assert.match(first, /\[1\/2\]/);
|
|
476
|
+
assert.match(first, /▸ First/);
|
|
477
|
+
assert.match(first, /❯ Alpha/);
|
|
478
|
+
assert.doesNotMatch(first, /Gamma/);
|
|
479
|
+
assert.doesNotMatch(first, /Second\?/);
|
|
480
|
+
|
|
481
|
+
component!.handleInput?.("\t");
|
|
482
|
+
const second = component!.render(100).join("\n");
|
|
483
|
+
assert.match(second, /\[2\/2\]/);
|
|
484
|
+
assert.match(second, /▸ Second/);
|
|
485
|
+
assert.match(second, /❯ Gamma/);
|
|
486
|
+
assert.doesNotMatch(second, /First\?/);
|
|
487
|
+
|
|
488
|
+
component!.handleInput?.("\x1b"); // cancel to settle the tool
|
|
489
|
+
await pending;
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("ask_user_question echoes an option preview beside the answer", async () => {
|
|
493
|
+
const { tool } = registerQuestionTool();
|
|
494
|
+
const questions = [
|
|
495
|
+
{ question: "Proceed?", header: "Proceed", options: [option("Alpha", "First choice", "Preview A"), option("Beta")] },
|
|
496
|
+
];
|
|
497
|
+
|
|
498
|
+
const result = await run(tool, { questions }, tuiContext(["\r"]));
|
|
499
|
+
|
|
500
|
+
assert.equal(result.content[0]?.text, "1. Proceed? — Alpha\n selected preview: Preview A");
|
|
501
|
+
assert.deepEqual(result.details.answers, [
|
|
502
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha", preview: "Preview A" },
|
|
503
|
+
]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("ask_user_question commits a multiSelect answer with every toggled option", async () => {
|
|
507
|
+
const { tool } = registerQuestionTool();
|
|
508
|
+
const questions = [
|
|
509
|
+
{ question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
|
|
510
|
+
];
|
|
511
|
+
|
|
512
|
+
const result = await run(tool, { questions }, tuiContext([" ", "\r"]));
|
|
513
|
+
|
|
514
|
+
assert.equal(result.content[0]?.text, "1. Pick? — selected: One");
|
|
515
|
+
assert.deepEqual(result.details.answers, [
|
|
516
|
+
{ questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] },
|
|
517
|
+
]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("ask_user_question commits a free-text custom answer", async () => {
|
|
521
|
+
const { tool } = registerQuestionTool();
|
|
522
|
+
|
|
523
|
+
const result = await run(
|
|
524
|
+
tool,
|
|
525
|
+
{ questions: single() },
|
|
526
|
+
tuiContext(["\x1b[B", "\x1b[B", "\r", "custom text", "\r"]),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
assert.equal(result.content[0]?.text, "1. Proceed? — (custom) custom text");
|
|
530
|
+
assert.deepEqual(result.details.answers, [
|
|
531
|
+
{ questionIndex: 0, question: "Proceed?", kind: "custom", answer: "custom text" },
|
|
532
|
+
]);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("ask_user_question keeps toggled options in a multiSelect custom answer", async () => {
|
|
536
|
+
const { tool } = registerQuestionTool();
|
|
537
|
+
const questions = [
|
|
538
|
+
{ question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
|
|
539
|
+
];
|
|
540
|
+
|
|
541
|
+
// Toggle One, move to the custom row, open the editor, type, and submit.
|
|
542
|
+
const result = await run(
|
|
543
|
+
tool,
|
|
544
|
+
{ questions },
|
|
545
|
+
tuiContext([" ", "\x1b[B", "\x1b[B", "\r", "free note", "\r"]),
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
assert.equal(result.content[0]?.text, "1. Pick? — (custom) free note — selected: One");
|
|
549
|
+
assert.deepEqual(result.details.answers, [
|
|
550
|
+
{ questionIndex: 0, question: "Pick?", kind: "custom", answer: "free note", selected: ["One"] },
|
|
551
|
+
]);
|
|
552
|
+
|
|
553
|
+
const rendered = tool.renderResult(result, { expanded: false }, theme).render(200).join("\n");
|
|
554
|
+
assert.equal(rendered.trimEnd(), "✓ Pick? — (custom) free note — selected: One");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("ask_user_question reports cancellation without answers", async () => {
|
|
558
|
+
const { tool, emitted } = registerQuestionTool();
|
|
559
|
+
|
|
560
|
+
const result = await run(tool, { questions: single() }, tuiContext(["\x1b"]));
|
|
561
|
+
|
|
562
|
+
assert.equal(result.content[0]?.text, "User cancelled the questionnaire");
|
|
563
|
+
assert.deepEqual(result.details, { cancelled: true });
|
|
564
|
+
assert.deepEqual(emitted, [
|
|
565
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
|
|
566
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
|
|
567
|
+
]);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("ask_user_question settles its lifecycle after a custom UI error", async () => {
|
|
571
|
+
const { tool, emitted } = registerQuestionTool();
|
|
572
|
+
const failure = new Error("custom UI failed");
|
|
573
|
+
|
|
574
|
+
await assert.rejects(
|
|
575
|
+
() => run(tool, { questions: single() }, { mode: "tui", ui: { custom: async () => { throw failure; } } }),
|
|
576
|
+
(error: unknown) => error === failure,
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
assert.deepEqual(emitted, [
|
|
580
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
|
|
581
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
|
|
582
|
+
]);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("ask_user_question owns an exclusive tool name across extensions", () => {
|
|
586
|
+
// Live-verified against the installed Pi runtime: tool names are exclusive
|
|
587
|
+
// across extensions. Loading two extensions that register
|
|
588
|
+
// `ask_user_question` aborts the whole load with a hard error
|
|
589
|
+
// (`Tool "ask_user_question" conflicts with <other extension>`; the runtime
|
|
590
|
+
// exits non-zero) -- there is no precedence, override, or silent shadowing.
|
|
591
|
+
// This fake registry is a per-extension Map and cannot reproduce Pi's
|
|
592
|
+
// cross-extension load error, so it pins the part it can: our single
|
|
593
|
+
// registration owns the name within its own extension, and the runtime, not
|
|
594
|
+
// resource order, enforces exclusivity outside it. The competing
|
|
595
|
+
// `@juicesharp/rpiv-ask-user-question` package must be removed from the
|
|
596
|
+
// user's settings before this extension can load.
|
|
597
|
+
const ours: ExtensionSlot = { path: OURS_PATH, tools: new Map() };
|
|
598
|
+
const registration = registerQuestionTool(ours);
|
|
599
|
+
|
|
600
|
+
assert.equal(ours.tools.get("ask_user_question"), registration.tool, "the first-party extension owns its name");
|
|
601
|
+
assert.equal(registration.tool.name, "ask_user_question");
|
|
602
|
+
assert.equal(registration.tool.label, "Ask User Question");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("re-registering inside one extension overwrites its own tool entry", () => {
|
|
606
|
+
const slot: ExtensionSlot = { path: OURS_PATH, tools: new Map() };
|
|
607
|
+
registerQuestionTool(slot);
|
|
608
|
+
const first = slot.tools.get("ask_user_question");
|
|
609
|
+
registerQuestionTool(slot);
|
|
610
|
+
const second = slot.tools.get("ask_user_question");
|
|
611
|
+
|
|
612
|
+
assert.equal(slot.tools.size, 1, "the extension map is keyed by tool name");
|
|
613
|
+
assert.notEqual(first, second, "a later registration replaces the same-name entry");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("ask_user_question renderCall summarizes the questions and option labels", () => {
|
|
617
|
+
const { tool } = registerQuestionTool();
|
|
618
|
+
const rendered = tool.renderCall({ questions: single() }, theme).render(100).join("\n");
|
|
619
|
+
|
|
620
|
+
assert.match(rendered, /ask_user_question/);
|
|
621
|
+
assert.match(rendered, /1\. Proceed \(Alpha, Beta\)/);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("ask_user_question renderCall truncates an oversized summary", () => {
|
|
625
|
+
const { tool } = registerQuestionTool();
|
|
626
|
+
const labels = [option("a".repeat(60)), option("b".repeat(60)), option("c".repeat(60))];
|
|
627
|
+
const rendered = tool.renderCall(
|
|
628
|
+
{ questions: [{ question: "Long?", header: "Long", options: labels }] },
|
|
629
|
+
theme,
|
|
630
|
+
).render(200).join("\n");
|
|
631
|
+
|
|
632
|
+
assert.match(rendered, /…\s*$/);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("ask_user_question renderResult renders answered and cancelled rows", () => {
|
|
636
|
+
const { tool } = registerQuestionTool();
|
|
637
|
+
const answered = tool.renderResult(
|
|
638
|
+
{
|
|
639
|
+
content: [],
|
|
640
|
+
details: {
|
|
641
|
+
answers: [
|
|
642
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Beta" },
|
|
643
|
+
{ questionIndex: 1, question: "Pick?", kind: "multi", answer: null, selected: ["One", "Two"] },
|
|
644
|
+
{ questionIndex: 2, question: "Explain?", kind: "custom", answer: "because" },
|
|
645
|
+
],
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
{ expanded: false },
|
|
649
|
+
theme,
|
|
650
|
+
).render(200).join("\n");
|
|
651
|
+
assert.match(answered, /✓ Proceed\? — Beta/);
|
|
652
|
+
assert.match(answered, /✓ Pick\? — One, Two/);
|
|
653
|
+
assert.match(answered, /✓ Explain\? — \(custom\) because/);
|
|
654
|
+
|
|
655
|
+
const cancelled = tool.renderResult(
|
|
656
|
+
{ content: [], details: { cancelled: true } },
|
|
657
|
+
{ expanded: false },
|
|
658
|
+
theme,
|
|
659
|
+
).render(200).join("\n");
|
|
660
|
+
assert.match(cancelled, /Cancelled/);
|
|
661
|
+
});
|