gentle-pi 3.3.0 → 3.4.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 +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- 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-shell.md +26 -2
- package/docs/readme-reference.md +32 -6
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +8 -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/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -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/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 +1 -1
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -3
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -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 +71 -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.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- 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/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,435 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import askUserQuestion 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 option = (label: string, description = `${label} description`, preview?: string) =>
|
|
111
|
+
preview === undefined ? { label, description } : { label, description, preview };
|
|
112
|
+
|
|
113
|
+
const single = () => [
|
|
114
|
+
{ question: "Proceed?", header: "Proceed", options: [option("Alpha"), option("Beta")] },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
test("ask_user_question opts out of the painted shell and pins the schema limits", () => {
|
|
118
|
+
const { tool } = registerQuestionTool();
|
|
119
|
+
const questions = tool.parameters.properties?.questions;
|
|
120
|
+
|
|
121
|
+
assert.equal(tool.renderShell, "self");
|
|
122
|
+
assert.equal(tool.name, "ask_user_question");
|
|
123
|
+
assert.equal(tool.label, "Ask User Question");
|
|
124
|
+
assert.equal(tool.executionMode, "sequential");
|
|
125
|
+
assert.equal(tool.parameters.additionalProperties, false);
|
|
126
|
+
assert.equal(questions?.minItems, 1);
|
|
127
|
+
assert.equal(questions?.maxItems, 4);
|
|
128
|
+
assert.equal(questions?.items?.additionalProperties, false);
|
|
129
|
+
assert.deepEqual(Object.keys(questions?.items?.properties ?? {}).sort(), ["header", "multiSelect", "options", "question"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("ask_user_question guides the model toward the supported contract", () => {
|
|
133
|
+
const { tool } = registerQuestionTool();
|
|
134
|
+
const guidelines = (tool.promptGuidelines ?? []).join(" ");
|
|
135
|
+
|
|
136
|
+
assert.match(tool.description ?? "", /one to four structured questions/i);
|
|
137
|
+
assert.match(guidelines, /at most 16 characters/);
|
|
138
|
+
assert.match(guidelines, /at most 60 characters/);
|
|
139
|
+
assert.match(guidelines, /preview/);
|
|
140
|
+
assert.match(guidelines, /multiSelect/);
|
|
141
|
+
assert.match(guidelines, /Type something\./);
|
|
142
|
+
assert.match(guidelines, /Never use this tool for decisions that must not be delegated to the user\./);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("ask_user_question rejects invalid parameters before mounting any UI", async () => {
|
|
146
|
+
const { tool, emitted } = registerQuestionTool();
|
|
147
|
+
let customCalls = 0;
|
|
148
|
+
const ctx = {
|
|
149
|
+
mode: "tui",
|
|
150
|
+
ui: {
|
|
151
|
+
custom: async () => {
|
|
152
|
+
customCalls++;
|
|
153
|
+
return undefined;
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const result = await run(tool, { questions: [] }, ctx);
|
|
159
|
+
|
|
160
|
+
assert.equal(result.content[0]?.text, "Invalid questionnaire: At least one question is required.");
|
|
161
|
+
assert.equal(result.details.errorKind, "no_questions");
|
|
162
|
+
assert.deepEqual(result.details.error, { code: "no_questions", message: "At least one question is required." });
|
|
163
|
+
assert.equal(customCalls, 0, "invalid input never reaches ctx.ui.custom");
|
|
164
|
+
assert.deepEqual(emitted, [], "invalid input emits no lifecycle event");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("ask_user_question stays unavailable outside the interactive TUI", async () => {
|
|
168
|
+
const { tool, emitted } = registerQuestionTool();
|
|
169
|
+
let customCalls = 0;
|
|
170
|
+
const ctx = {
|
|
171
|
+
mode: "print",
|
|
172
|
+
ui: {
|
|
173
|
+
custom: async () => {
|
|
174
|
+
customCalls++;
|
|
175
|
+
return undefined;
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const result = await run(tool, { questions: single() }, ctx);
|
|
181
|
+
|
|
182
|
+
assert.match(result.content[0]?.text ?? "", /unavailable outside the interactive TUI/);
|
|
183
|
+
assert.equal(result.details.errorKind, "unavailable_outside_tui");
|
|
184
|
+
assert.equal(customCalls, 0);
|
|
185
|
+
assert.deepEqual(emitted, []);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("ask_user_question commits a single-select answer end-to-end", async () => {
|
|
189
|
+
const { tool, emitted } = registerQuestionTool();
|
|
190
|
+
const rendered = { value: "" };
|
|
191
|
+
|
|
192
|
+
const result = await run(tool, { questions: single() }, tuiContext(["\r"], rendered));
|
|
193
|
+
|
|
194
|
+
assert.match(rendered.value, /\[1\/1\]/);
|
|
195
|
+
assert.match(rendered.value, /▸ Proceed/);
|
|
196
|
+
assert.match(rendered.value, /Alpha/);
|
|
197
|
+
assert.equal(result.content[0]?.text, "1. Proceed? — Alpha");
|
|
198
|
+
assert.deepEqual(result.details.answers, [
|
|
199
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha" },
|
|
200
|
+
]);
|
|
201
|
+
assert.deepEqual(emitted, [
|
|
202
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
|
|
203
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
|
|
204
|
+
]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("ask_user_question mounts through ctx.ui.custom without an overlay option", async () => {
|
|
208
|
+
const { tool } = registerQuestionTool();
|
|
209
|
+
let customArgCount = -1;
|
|
210
|
+
const ctx = {
|
|
211
|
+
mode: "tui",
|
|
212
|
+
ui: {
|
|
213
|
+
custom: async (...args: unknown[]) => {
|
|
214
|
+
customArgCount = args.length;
|
|
215
|
+
const factory = args[0] as CustomFactory;
|
|
216
|
+
let result: unknown;
|
|
217
|
+
const component = factory({ requestRender() {} }, theme, {}, (value) => {
|
|
218
|
+
result = value;
|
|
219
|
+
});
|
|
220
|
+
component.handleInput?.("\r");
|
|
221
|
+
return result;
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
await run(tool, { questions: single() }, ctx);
|
|
227
|
+
|
|
228
|
+
assert.equal(customArgCount, 1, "a dock swap passes the factory only; no overlay options");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("ask_user_question mounts exactly one active question and switches with Tab", async () => {
|
|
232
|
+
const { tool } = registerQuestionTool();
|
|
233
|
+
const questions = [
|
|
234
|
+
{ question: "First?", header: "First", options: [option("Alpha"), option("Beta")] },
|
|
235
|
+
{ question: "Second?", header: "Second", options: [option("Gamma"), option("Delta")] },
|
|
236
|
+
];
|
|
237
|
+
let component: Renderable | undefined;
|
|
238
|
+
const pending = run(tool, { questions }, {
|
|
239
|
+
mode: "tui",
|
|
240
|
+
ui: {
|
|
241
|
+
custom: (factory: CustomFactory) => new Promise((resolve) => {
|
|
242
|
+
component = factory({ requestRender() {} }, theme, {}, resolve);
|
|
243
|
+
}),
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
assert.ok(component, "the tool mounts the questionnaire component");
|
|
248
|
+
const first = component!.render(100).join("\n");
|
|
249
|
+
assert.match(first, /\[1\/2\]/);
|
|
250
|
+
assert.match(first, /▸ First/);
|
|
251
|
+
assert.match(first, /❯ Alpha/);
|
|
252
|
+
assert.doesNotMatch(first, /Gamma/);
|
|
253
|
+
assert.doesNotMatch(first, /Second\?/);
|
|
254
|
+
|
|
255
|
+
component!.handleInput?.("\t");
|
|
256
|
+
const second = component!.render(100).join("\n");
|
|
257
|
+
assert.match(second, /\[2\/2\]/);
|
|
258
|
+
assert.match(second, /▸ Second/);
|
|
259
|
+
assert.match(second, /❯ Gamma/);
|
|
260
|
+
assert.doesNotMatch(second, /First\?/);
|
|
261
|
+
|
|
262
|
+
component!.handleInput?.("\x1b"); // cancel to settle the tool
|
|
263
|
+
await pending;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("ask_user_question echoes an option preview beside the answer", async () => {
|
|
267
|
+
const { tool } = registerQuestionTool();
|
|
268
|
+
const questions = [
|
|
269
|
+
{ question: "Proceed?", header: "Proceed", options: [option("Alpha", "First choice", "Preview A"), option("Beta")] },
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
const result = await run(tool, { questions }, tuiContext(["\r"]));
|
|
273
|
+
|
|
274
|
+
assert.equal(result.content[0]?.text, "1. Proceed? — Alpha\n selected preview: Preview A");
|
|
275
|
+
assert.deepEqual(result.details.answers, [
|
|
276
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Alpha", preview: "Preview A" },
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("ask_user_question commits a multiSelect answer with every toggled option", async () => {
|
|
281
|
+
const { tool } = registerQuestionTool();
|
|
282
|
+
const questions = [
|
|
283
|
+
{ question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
const result = await run(tool, { questions }, tuiContext([" ", "\r"]));
|
|
287
|
+
|
|
288
|
+
assert.equal(result.content[0]?.text, "1. Pick? — selected: One");
|
|
289
|
+
assert.deepEqual(result.details.answers, [
|
|
290
|
+
{ questionIndex: 0, question: "Pick?", kind: "multi", answer: null, selected: ["One"] },
|
|
291
|
+
]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("ask_user_question commits a free-text custom answer", async () => {
|
|
295
|
+
const { tool } = registerQuestionTool();
|
|
296
|
+
|
|
297
|
+
const result = await run(
|
|
298
|
+
tool,
|
|
299
|
+
{ questions: single() },
|
|
300
|
+
tuiContext(["\x1b[B", "\x1b[B", "\r", "custom text", "\r"]),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
assert.equal(result.content[0]?.text, "1. Proceed? — (custom) custom text");
|
|
304
|
+
assert.deepEqual(result.details.answers, [
|
|
305
|
+
{ questionIndex: 0, question: "Proceed?", kind: "custom", answer: "custom text" },
|
|
306
|
+
]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("ask_user_question keeps toggled options in a multiSelect custom answer", async () => {
|
|
310
|
+
const { tool } = registerQuestionTool();
|
|
311
|
+
const questions = [
|
|
312
|
+
{ question: "Pick?", header: "Pick", options: [option("One"), option("Two")], multiSelect: true },
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
// Toggle One, move to the custom row, open the editor, type, and submit.
|
|
316
|
+
const result = await run(
|
|
317
|
+
tool,
|
|
318
|
+
{ questions },
|
|
319
|
+
tuiContext([" ", "\x1b[B", "\x1b[B", "\r", "free note", "\r"]),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
assert.equal(result.content[0]?.text, "1. Pick? — (custom) free note — selected: One");
|
|
323
|
+
assert.deepEqual(result.details.answers, [
|
|
324
|
+
{ questionIndex: 0, question: "Pick?", kind: "custom", answer: "free note", selected: ["One"] },
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
const rendered = tool.renderResult(result, { expanded: false }, theme).render(200).join("\n");
|
|
328
|
+
assert.equal(rendered.trimEnd(), "✓ Pick? — (custom) free note — selected: One");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("ask_user_question reports cancellation without answers", async () => {
|
|
332
|
+
const { tool, emitted } = registerQuestionTool();
|
|
333
|
+
|
|
334
|
+
const result = await run(tool, { questions: single() }, tuiContext(["\x1b"]));
|
|
335
|
+
|
|
336
|
+
assert.equal(result.content[0]?.text, "User cancelled the questionnaire");
|
|
337
|
+
assert.deepEqual(result.details, { cancelled: true });
|
|
338
|
+
assert.deepEqual(emitted, [
|
|
339
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
|
|
340
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
|
|
341
|
+
]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("ask_user_question settles its lifecycle after a custom UI error", async () => {
|
|
345
|
+
const { tool, emitted } = registerQuestionTool();
|
|
346
|
+
const failure = new Error("custom UI failed");
|
|
347
|
+
|
|
348
|
+
await assert.rejects(
|
|
349
|
+
() => run(tool, { questions: single() }, { mode: "tui", ui: { custom: async () => { throw failure; } } }),
|
|
350
|
+
(error: unknown) => error === failure,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
assert.deepEqual(emitted, [
|
|
354
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: true } },
|
|
355
|
+
{ channel: "gentle-pi:ask-user-question:blocked", data: { active: false } },
|
|
356
|
+
]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("ask_user_question owns an exclusive tool name across extensions", () => {
|
|
360
|
+
// Live-verified against the installed Pi runtime: tool names are exclusive
|
|
361
|
+
// across extensions. Loading two extensions that register
|
|
362
|
+
// `ask_user_question` aborts the whole load with a hard error
|
|
363
|
+
// (`Tool "ask_user_question" conflicts with <other extension>`; the runtime
|
|
364
|
+
// exits non-zero) -- there is no precedence, override, or silent shadowing.
|
|
365
|
+
// This fake registry is a per-extension Map and cannot reproduce Pi's
|
|
366
|
+
// cross-extension load error, so it pins the part it can: our single
|
|
367
|
+
// registration owns the name within its own extension, and the runtime, not
|
|
368
|
+
// resource order, enforces exclusivity outside it. The competing
|
|
369
|
+
// `@juicesharp/rpiv-ask-user-question` package must be removed from the
|
|
370
|
+
// user's settings before this extension can load.
|
|
371
|
+
const ours: ExtensionSlot = { path: OURS_PATH, tools: new Map() };
|
|
372
|
+
const registration = registerQuestionTool(ours);
|
|
373
|
+
|
|
374
|
+
assert.equal(ours.tools.get("ask_user_question"), registration.tool, "the first-party extension owns its name");
|
|
375
|
+
assert.equal(registration.tool.name, "ask_user_question");
|
|
376
|
+
assert.equal(registration.tool.label, "Ask User Question");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("re-registering inside one extension overwrites its own tool entry", () => {
|
|
380
|
+
const slot: ExtensionSlot = { path: OURS_PATH, tools: new Map() };
|
|
381
|
+
registerQuestionTool(slot);
|
|
382
|
+
const first = slot.tools.get("ask_user_question");
|
|
383
|
+
registerQuestionTool(slot);
|
|
384
|
+
const second = slot.tools.get("ask_user_question");
|
|
385
|
+
|
|
386
|
+
assert.equal(slot.tools.size, 1, "the extension map is keyed by tool name");
|
|
387
|
+
assert.notEqual(first, second, "a later registration replaces the same-name entry");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("ask_user_question renderCall summarizes the questions and option labels", () => {
|
|
391
|
+
const { tool } = registerQuestionTool();
|
|
392
|
+
const rendered = tool.renderCall({ questions: single() }, theme).render(100).join("\n");
|
|
393
|
+
|
|
394
|
+
assert.match(rendered, /ask_user_question/);
|
|
395
|
+
assert.match(rendered, /1\. Proceed \(Alpha, Beta\)/);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("ask_user_question renderCall truncates an oversized summary", () => {
|
|
399
|
+
const { tool } = registerQuestionTool();
|
|
400
|
+
const labels = [option("a".repeat(60)), option("b".repeat(60)), option("c".repeat(60))];
|
|
401
|
+
const rendered = tool.renderCall(
|
|
402
|
+
{ questions: [{ question: "Long?", header: "Long", options: labels }] },
|
|
403
|
+
theme,
|
|
404
|
+
).render(200).join("\n");
|
|
405
|
+
|
|
406
|
+
assert.match(rendered, /…\s*$/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("ask_user_question renderResult renders answered and cancelled rows", () => {
|
|
410
|
+
const { tool } = registerQuestionTool();
|
|
411
|
+
const answered = tool.renderResult(
|
|
412
|
+
{
|
|
413
|
+
content: [],
|
|
414
|
+
details: {
|
|
415
|
+
answers: [
|
|
416
|
+
{ questionIndex: 0, question: "Proceed?", kind: "option", answer: "Beta" },
|
|
417
|
+
{ questionIndex: 1, question: "Pick?", kind: "multi", answer: null, selected: ["One", "Two"] },
|
|
418
|
+
{ questionIndex: 2, question: "Explain?", kind: "custom", answer: "because" },
|
|
419
|
+
],
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{ expanded: false },
|
|
423
|
+
theme,
|
|
424
|
+
).render(200).join("\n");
|
|
425
|
+
assert.match(answered, /✓ Proceed\? — Beta/);
|
|
426
|
+
assert.match(answered, /✓ Pick\? — One, Two/);
|
|
427
|
+
assert.match(answered, /✓ Explain\? — \(custom\) because/);
|
|
428
|
+
|
|
429
|
+
const cancelled = tool.renderResult(
|
|
430
|
+
{ content: [], details: { cancelled: true } },
|
|
431
|
+
{ expanded: false },
|
|
432
|
+
theme,
|
|
433
|
+
).render(200).join("\n");
|
|
434
|
+
assert.match(cancelled, /Cancelled/);
|
|
435
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { installBackgroundCacheWarming, type WarmingState } from "../lib/background-cache-warming.ts";
|
|
5
|
+
|
|
6
|
+
test("native warming decisions use only current owned live background work", () => {
|
|
7
|
+
let handler: Parameters<Parameters<typeof installBackgroundCacheWarming>[0]["on"]>[1];
|
|
8
|
+
let state: WarmingState = { sessionId: "parent", ownedTaskIds: new Set(["task"]), tasks: [
|
|
9
|
+
{ id: "task", parentSessionId: "parent", mode: "background", status: "queued" },
|
|
10
|
+
] };
|
|
11
|
+
installBackgroundCacheWarming({ on(event, callback) {
|
|
12
|
+
assert.equal(event, "cache_warming_decision");
|
|
13
|
+
handler = callback;
|
|
14
|
+
} }, () => state);
|
|
15
|
+
const candidate = { warmCost: 0.01, missCost: 0.1, continuationProbability: 0.15, action: "stop" as const };
|
|
16
|
+
assert.deepEqual(handler!(candidate), { action: "warm" });
|
|
17
|
+
state.tasks[0].status = "running";
|
|
18
|
+
assert.deepEqual(handler!(candidate), { action: "warm" });
|
|
19
|
+
for (const status of ["waiting", "completed", "failed", "cancelled", "timed_out"] as const) {
|
|
20
|
+
state.tasks[0].status = status;
|
|
21
|
+
assert.equal(handler!(candidate), undefined, status);
|
|
22
|
+
}
|
|
23
|
+
state.tasks[0].status = "running";
|
|
24
|
+
for (const patch of [
|
|
25
|
+
{ sessionId: undefined }, { sessionId: "other" }, { ownedTaskIds: new Set<string>() },
|
|
26
|
+
{ tasks: [] }, { tasks: [{ ...state.tasks[0], mode: "task" }] },
|
|
27
|
+
]) {
|
|
28
|
+
const original = state;
|
|
29
|
+
state = { ...state, ...patch };
|
|
30
|
+
assert.equal(handler!(candidate), undefined);
|
|
31
|
+
state = original;
|
|
32
|
+
}
|
|
33
|
+
for (const warmCost of [0.06, 1, NaN, Infinity, -1]) {
|
|
34
|
+
assert.deepEqual(handler!({ ...candidate, warmCost }), { action: "stop" });
|
|
35
|
+
}
|
|
36
|
+
for (const missCost of [0, -1, NaN, Infinity]) {
|
|
37
|
+
assert.deepEqual(handler!({ ...candidate, missCost }), { action: "stop" });
|
|
38
|
+
}
|
|
39
|
+
assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.05 }), { action: "warm" });
|
|
40
|
+
assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.049 }), { action: "stop" });
|
|
41
|
+
state.tasks = [];
|
|
42
|
+
assert.equal(handler!({ ...candidate, action: "warm" }), undefined, "ordinary idle keeps native action");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Supplemental architecture guard; the extension lifecycle is exercised in gentle-agents.test.ts.
|
|
46
|
+
test("warming helper has no maintenance capabilities", () => {
|
|
47
|
+
const helper = readFileSync(new URL("../lib/background-cache-warming.ts", import.meta.url), "utf8");
|
|
48
|
+
assert.doesNotMatch(helper, /setTimeout|setInterval|sendMessage|sendUserMessage|subagent_|runner\./);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("background prompt prohibits cache polling and documents explicit native idle opt-in", () => {
|
|
52
|
+
const prompt = readFileSync(new URL("../assets/orchestrator-delegation.md", import.meta.url), "utf8");
|
|
53
|
+
assert.match(prompt, /completion or cache maintenance/);
|
|
54
|
+
assert.match(prompt, /user-requested inspection, relevant scope change, input request, or suspected abnormal behavior/);
|
|
55
|
+
assert.match(prompt, /Never relaunch equivalent work merely because it is queued or running/);
|
|
56
|
+
const docs = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8");
|
|
57
|
+
assert.match(docs, /"cacheWarming": "idle"/);
|
|
58
|
+
assert.match(docs, /\$0\.05/);
|
|
59
|
+
assert.match(docs, /"streaming".*no idle decision/s);
|
|
60
|
+
});
|
|
@@ -530,6 +530,28 @@ function notifyContext(
|
|
|
530
530
|
} as unknown as ExtensionContext;
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
+
/**
|
|
534
|
+
* The same minimal context as `notifyContext`, but interactive: `hasUI` is true
|
|
535
|
+
* and `ui.select` answers the command's no-argument menu. `notifyContext` stays
|
|
536
|
+
* `hasUI: false` so every existing sub-action test keeps the non-menu path.
|
|
537
|
+
*/
|
|
538
|
+
function selectingContext(
|
|
539
|
+
cwd: string,
|
|
540
|
+
notices: Array<{ message: string; type?: string }>,
|
|
541
|
+
select: (title: string, options: string[]) => Promise<string | undefined>,
|
|
542
|
+
): ExtensionContext {
|
|
543
|
+
return {
|
|
544
|
+
cwd,
|
|
545
|
+
hasUI: true,
|
|
546
|
+
ui: {
|
|
547
|
+
notify: (message: string, type?: string) => {
|
|
548
|
+
notices.push({ message, type });
|
|
549
|
+
},
|
|
550
|
+
select,
|
|
551
|
+
},
|
|
552
|
+
} as unknown as ExtensionContext;
|
|
553
|
+
}
|
|
554
|
+
|
|
533
555
|
/** Point the command's global config home at a scratch dir, never at ~/.pi. */
|
|
534
556
|
function scopedEnv(t: TestContext, values: Record<string, string | undefined>): void {
|
|
535
557
|
const previous = new Map<string, string | undefined>();
|
|
@@ -590,6 +612,52 @@ test("no argument reports the effective policy, the deciding default, and the ca
|
|
|
590
612
|
);
|
|
591
613
|
});
|
|
592
614
|
|
|
615
|
+
test("no argument opens a selectable menu and applies the chosen sub-action", async (t) => {
|
|
616
|
+
const cwd = makeScratch("gp-bg-cmd-select-");
|
|
617
|
+
const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
|
|
618
|
+
const globalFile = join(configHome, "background-subagents.json");
|
|
619
|
+
scopedEnv(t, {
|
|
620
|
+
GENTLE_PI_CONFIG_HOME: configHome,
|
|
621
|
+
GENTLE_PI_BACKGROUND_SUBAGENTS: undefined,
|
|
622
|
+
});
|
|
623
|
+
const command = registeredCommands().get("gentle:background-subagents");
|
|
624
|
+
assert.ok(command, "gentle:background-subagents must be registered");
|
|
625
|
+
|
|
626
|
+
// A dismissed menu (undefined selection) reports nothing and writes nothing.
|
|
627
|
+
// It runs first so the untouched config home can prove the no-write: a second
|
|
628
|
+
// scopedEnv save point in one test would restore out of order at teardown.
|
|
629
|
+
const dismissed: Array<{ message: string; type?: string }> = [];
|
|
630
|
+
await command!.handler("", selectingContext(cwd, dismissed, async () => undefined));
|
|
631
|
+
assert.equal(dismissed.length, 0, "a dismissed menu reports nothing");
|
|
632
|
+
assert.equal(existsSync(globalFile), false, "a dismissed menu writes no file");
|
|
633
|
+
|
|
634
|
+
// Choosing "enable" writes the global file and reports exactly once, in the
|
|
635
|
+
// same shape the direct `enable` sub-action already reports.
|
|
636
|
+
const notices: Array<{ message: string; type?: string }> = [];
|
|
637
|
+
await command!.handler(
|
|
638
|
+
"",
|
|
639
|
+
selectingContext(cwd, notices, async (title, options) => {
|
|
640
|
+
assert.equal(title, "Background subagents policy");
|
|
641
|
+
assert.deepEqual(options, ["status", "enable", "disable"]);
|
|
642
|
+
return "enable";
|
|
643
|
+
}),
|
|
644
|
+
);
|
|
645
|
+
assert.equal(notices.length, 1, "one invocation reports exactly once");
|
|
646
|
+
assert.equal(notices[0]!.type, "info");
|
|
647
|
+
assert.equal(
|
|
648
|
+
notices[0]!.message,
|
|
649
|
+
[
|
|
650
|
+
`background subagents: on (decided by global file ${globalFile}; capability: absent)`,
|
|
651
|
+
`Wrote on to the global file ${globalFile}.`,
|
|
652
|
+
"Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
|
|
653
|
+
].join("\n"),
|
|
654
|
+
);
|
|
655
|
+
assert.deepEqual(JSON.parse(readFileSync(globalFile, "utf8")), {
|
|
656
|
+
schema: "gentle-pi.background-subagents/v1",
|
|
657
|
+
policy: "on",
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
593
661
|
test("status names the project file that decided and the global file it shadows", async (t) => {
|
|
594
662
|
const cwd = makeScratch("gp-bg-cmd-project-");
|
|
595
663
|
const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
|
|
@@ -32,6 +32,14 @@ function createPalette(groups: readonly CommandPaletteGroup[], theme?: CommandPa
|
|
|
32
32
|
return { palette, results };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
test("animations is discoverable under Configuration with its live description", () => {
|
|
36
|
+
const groups = buildCommandPaletteGroups([{ name: "gentle:animations", description: "status|quality|performance|potato" }], {});
|
|
37
|
+
assert.equal(groups[0]?.title, "Configuration");
|
|
38
|
+
assert.equal(groups[0]?.items[0]?.command, "gentle:animations");
|
|
39
|
+
assert.match(groups[0]?.items[0]?.label ?? "", /[Aa]nimation/);
|
|
40
|
+
assert.equal(groups[0]?.items[0]?.description, "status|quality|performance|potato");
|
|
41
|
+
});
|
|
42
|
+
|
|
35
43
|
// --- rankPaletteGroups -------------------------------------------------------
|
|
36
44
|
|
|
37
45
|
test("rankPaletteGroups ranks label-prefix, label-contains, label-subsequence, command-contains, then description-contains", () => {
|
|
@@ -337,6 +345,7 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
|
|
|
337
345
|
"gentle:review-mode",
|
|
338
346
|
"gentle:background-subagents",
|
|
339
347
|
"gentle:double-esc-cancel",
|
|
348
|
+
"gentle:animations",
|
|
340
349
|
"gentle:telemetry",
|
|
341
350
|
"gentle:banner",
|
|
342
351
|
"gentle:banner-color",
|