gentle-pi 3.4.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 +25 -0
- package/bin/gentle-shell.mjs +198 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/readme-reference.md +67 -0
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +131 -3
- package/extensions/gentle-agents.ts +33 -0
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/rpc-host.ts +36 -0
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +4 -0
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +227 -1
- package/tests/gentle-agents.test.ts +90 -0
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/package-manifest.test.ts +2 -2
- package/tests/rpc-host.test.ts +77 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import askUserQuestion from "../extensions/ask-user-question.ts";
|
|
3
|
+
import askUserQuestion, { askMultiSelect } from "../extensions/ask-user-question.ts";
|
|
4
4
|
|
|
5
5
|
/** Plain theme fake: identity styling keeps rendered assertions readable. */
|
|
6
6
|
interface Theme {
|
|
@@ -107,6 +107,38 @@ function run(tool: RegisteredTool, params: unknown, ctx: unknown): Promise<ToolR
|
|
|
107
107
|
return tool.execute("call", params, new AbortController().signal, undefined, ctx);
|
|
108
108
|
}
|
|
109
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
|
+
|
|
110
142
|
const option = (label: string, description = `${label} description`, preview?: string) =>
|
|
111
143
|
preview === undefined ? { label, description } : { label, description, preview };
|
|
112
144
|
|
|
@@ -185,6 +217,200 @@ test("ask_user_question stays unavailable outside the interactive TUI", async ()
|
|
|
185
217
|
assert.deepEqual(emitted, []);
|
|
186
218
|
});
|
|
187
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
|
+
|
|
188
414
|
test("ask_user_question commits a single-select answer end-to-end", async () => {
|
|
189
415
|
const { tool, emitted } = registerQuestionTool();
|
|
190
416
|
const rendered = { value: "" };
|
|
@@ -379,6 +379,96 @@ for (const mode of ["print", "tui", "rpc"] as const) {
|
|
|
379
379
|
});
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
/** Controllable fake for `AgentsDeps.schedule`: records every scheduled callback instead of running it, so a test can fire the RPC publisher's coalescing window deterministically. */
|
|
383
|
+
function fakeScheduler() {
|
|
384
|
+
const pending: Array<{ id: number; fn: () => void }> = [];
|
|
385
|
+
let nextId = 0;
|
|
386
|
+
return {
|
|
387
|
+
schedule: (fn: () => void, _ms: number) => {
|
|
388
|
+
const id = nextId++;
|
|
389
|
+
pending.push({ id, fn });
|
|
390
|
+
return () => {
|
|
391
|
+
const index = pending.findIndex((entry) => entry.id === id);
|
|
392
|
+
if (index !== -1) pending.splice(index, 1);
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
flushAll: () => {
|
|
396
|
+
const due = pending.splice(0, pending.length);
|
|
397
|
+
for (const entry of due) entry.fn();
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
for (const scenario of [
|
|
403
|
+
{ label: "an interactive RPC host", mode: "rpc", env: { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" }, expectPublish: true },
|
|
404
|
+
{ label: "plain RPC without the interactive-host variable", mode: "rpc", env: { PATH: "/bin" }, expectPublish: false },
|
|
405
|
+
{ label: "TUI", mode: "tui", env: { PATH: "/bin" }, expectPublish: false },
|
|
406
|
+
] as const) {
|
|
407
|
+
test(`gentle-agents publishes the live activity payload through setWidget only on ${scenario.label}`, async (t) => {
|
|
408
|
+
const h = fakePi();
|
|
409
|
+
const runtime = deps();
|
|
410
|
+
const scheduler = fakeScheduler();
|
|
411
|
+
runtime.deps.schedule = scheduler.schedule;
|
|
412
|
+
runtime.deps.env = scenario.env;
|
|
413
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
414
|
+
const { ctx } = fakeContext();
|
|
415
|
+
Object.assign(ctx, { mode: scenario.mode, hasUI: scenario.mode === "tui" || scenario.mode === "rpc" });
|
|
416
|
+
const setWidget = t.mock.method(ctx.ui, "setWidget");
|
|
417
|
+
|
|
418
|
+
await h.fire("session_start", ctx);
|
|
419
|
+
scheduler.flushAll(); // consume the publisher's own start-time frame, if any
|
|
420
|
+
setWidget.mock.resetCalls();
|
|
421
|
+
|
|
422
|
+
await h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
423
|
+
scheduler.flushAll();
|
|
424
|
+
|
|
425
|
+
// The TUI card's own setWidget call always carries a component-factory
|
|
426
|
+
// function, never an array; only the RPC publisher pushes an array.
|
|
427
|
+
const activityCalls = setWidget.mock.calls.filter((call) => call.arguments[0] === "gentle-agents" && Array.isArray(call.arguments[1]));
|
|
428
|
+
if (!scenario.expectPublish) {
|
|
429
|
+
assert.deepEqual(activityCalls, [], "no array-shaped setWidget push outside an interactive RPC host");
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
assert.equal(activityCalls.length, 1, "one push per coalescing window");
|
|
433
|
+
// The fake `ui.setWidget` types `content` as the TUI-only component factory;
|
|
434
|
+
// the RPC publisher instead calls it with a plain `string[]` (real pi's
|
|
435
|
+
// RPC-mode contract), which needs an unknown-mediated cast here.
|
|
436
|
+
const [key, lines] = activityCalls[0]!.arguments as unknown as [string, string[]];
|
|
437
|
+
assert.equal(key, "gentle-agents");
|
|
438
|
+
assert.equal(lines.length, 1);
|
|
439
|
+
const activity = JSON.parse(lines[0]!) as { schema: string; tasks: Array<{ summary: { id: string } }> };
|
|
440
|
+
assert.equal(activity.schema, "gentle-agents.activity/v1");
|
|
441
|
+
assert.ok(activity.tasks.some((task) => typeof task.summary.id === "string" && task.summary.id.length > 0), "the newly launched task must be in the payload");
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
test("gentle-agents notifies once, deduplicated, when the RPC activity publisher's setWidget throws", async (t) => {
|
|
446
|
+
const h = fakePi();
|
|
447
|
+
const runtime = deps();
|
|
448
|
+
const scheduler = fakeScheduler();
|
|
449
|
+
runtime.deps.schedule = scheduler.schedule;
|
|
450
|
+
runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" };
|
|
451
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
452
|
+
const { ctx } = fakeContext();
|
|
453
|
+
Object.assign(ctx, { mode: "rpc", hasUI: true });
|
|
454
|
+
const notify = t.mock.method(ctx.ui, "notify");
|
|
455
|
+
// Only the publisher's array-shaped push fails; the TUI card's own
|
|
456
|
+
// component-factory push (`showWidget`) must stay untouched.
|
|
457
|
+
ctx.ui.setWidget = ((_key: string, content: unknown) => {
|
|
458
|
+
if (Array.isArray(content)) throw new Error("boom");
|
|
459
|
+
}) as typeof ctx.ui.setWidget;
|
|
460
|
+
|
|
461
|
+
await h.fire("session_start", ctx);
|
|
462
|
+
scheduler.flushAll(); // the publisher's own start-time frame fails: one notify
|
|
463
|
+
|
|
464
|
+
await h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
465
|
+
scheduler.flushAll(); // a second flush with the same recurring failure must not notify again
|
|
466
|
+
|
|
467
|
+
assert.equal(notify.mock.callCount(), 1, "the same recurring setWidget failure is deduplicated to one notify per session");
|
|
468
|
+
assert.match(String(notify.mock.calls[0]?.arguments[0]), /boom/);
|
|
469
|
+
assert.equal(notify.mock.calls[0]?.arguments[1], "warning");
|
|
470
|
+
});
|
|
471
|
+
|
|
382
472
|
test("all nine subagent registrations own their transcript shell", () => {
|
|
383
473
|
const { pi, tools } = fakePi();
|
|
384
474
|
gentleAgents(pi, {}, deps().deps);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
// Integration tests for the thin bin/gentle-shell.mjs entry: they run the real
|
|
10
|
+
// file via spawnSync with an isolated HOME and a fake pi script standing in for
|
|
11
|
+
// the real @earendil-works/pi-coding-agent runtime, so the launcher's own logic
|
|
12
|
+
// (already covered at the unit level in tests/gentle-shell-launcher.test.ts)
|
|
13
|
+
// gets exercised end-to-end through real argv, env, and child-process wiring.
|
|
14
|
+
|
|
15
|
+
const binUrl = new URL("../bin/gentle-shell.mjs", import.meta.url);
|
|
16
|
+
const binPath = fileURLToPath(binUrl);
|
|
17
|
+
const packageRoot = dirname(dirname(binPath));
|
|
18
|
+
|
|
19
|
+
function fixture(t: test.TestContext) {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-bin-"));
|
|
21
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
22
|
+
const home = join(root, "home");
|
|
23
|
+
mkdirSync(home, { recursive: true });
|
|
24
|
+
const piScript = join(root, "fake-pi.mjs");
|
|
25
|
+
writePiScript(piScript, "0.85.1");
|
|
26
|
+
const gentleShellHome = join(root, "gentle-shell-home");
|
|
27
|
+
const env: NodeJS.ProcessEnv = {
|
|
28
|
+
...process.env,
|
|
29
|
+
HOME: home,
|
|
30
|
+
USERPROFILE: home,
|
|
31
|
+
GENTLE_SHELL_HOME: gentleShellHome,
|
|
32
|
+
GENTLE_SHELL_PI: piScript,
|
|
33
|
+
};
|
|
34
|
+
return { root, home, gentleShellHome, piScript, env };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writePiScript(path: string, version: string) {
|
|
38
|
+
writeFileSync(
|
|
39
|
+
path,
|
|
40
|
+
[
|
|
41
|
+
"#!/usr/bin/env node",
|
|
42
|
+
"const args = process.argv.slice(2);",
|
|
43
|
+
`if (args.includes("--version")) { console.log(${JSON.stringify(version)}); process.exit(0); }`,
|
|
44
|
+
"console.log(JSON.stringify({",
|
|
45
|
+
" args,",
|
|
46
|
+
" PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
|
|
47
|
+
" GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
|
|
48
|
+
"}));",
|
|
49
|
+
"process.exit(0);",
|
|
50
|
+
"",
|
|
51
|
+
].join("\n"),
|
|
52
|
+
);
|
|
53
|
+
chmodSync(path, 0o755);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function run(env: NodeJS.ProcessEnv, args: string[]) {
|
|
57
|
+
return spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("--help exits 0 and prints usage", (t) => {
|
|
61
|
+
const f = fixture(t);
|
|
62
|
+
const result = run(f.env, ["--help"]);
|
|
63
|
+
assert.equal(result.status, 0, result.stderr);
|
|
64
|
+
assert.match(result.stdout, /Usage: gentle-shell/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("home with no args prints the default isolated home", (t) => {
|
|
68
|
+
const f = fixture(t);
|
|
69
|
+
const result = run(f.env, ["home"]);
|
|
70
|
+
assert.equal(result.status, 0, result.stderr);
|
|
71
|
+
assert.equal(result.stdout.trim(), `isolated ${f.gentleShellHome}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("home link persists and a later home reflects it", (t) => {
|
|
75
|
+
const f = fixture(t);
|
|
76
|
+
const save = run(f.env, ["home", "link"]);
|
|
77
|
+
assert.equal(save.status, 0, save.stderr);
|
|
78
|
+
const configPath = join(f.home, ".gentle-shell", "config.json");
|
|
79
|
+
assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { home: "link" });
|
|
80
|
+
const check = run(f.env, ["home"]);
|
|
81
|
+
assert.equal(check.status, 0, check.stderr);
|
|
82
|
+
assert.match(check.stdout, /^link /);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("home <path> persists a custom directory", (t) => {
|
|
86
|
+
const f = fixture(t);
|
|
87
|
+
const target = join(f.root, "custom-home");
|
|
88
|
+
const save = run(f.env, ["home", target]);
|
|
89
|
+
assert.equal(save.status, 0, save.stderr);
|
|
90
|
+
const configPath = join(f.home, ".gentle-shell", "config.json");
|
|
91
|
+
assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { home: target });
|
|
92
|
+
const check = run(f.env, ["home"]);
|
|
93
|
+
assert.equal(check.stdout.trim(), `path ${target}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("first isolated run bootstraps the home, writes fullscreen, and prints the hint once", (t) => {
|
|
97
|
+
const f = fixture(t);
|
|
98
|
+
assert.equal(existsSync(f.gentleShellHome), false);
|
|
99
|
+
|
|
100
|
+
const first = run(f.env, []);
|
|
101
|
+
assert.equal(first.status, 0, first.stderr);
|
|
102
|
+
assert.match(first.stderr, /using a separate home at/);
|
|
103
|
+
assert.match(first.stderr, /gentle-shell --link/);
|
|
104
|
+
const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
|
|
105
|
+
assert.equal(settings.tuiMode, "fullscreen");
|
|
106
|
+
|
|
107
|
+
const second = run(f.env, []);
|
|
108
|
+
assert.equal(second.status, 0, second.stderr);
|
|
109
|
+
assert.doesNotMatch(second.stderr, /using a separate home at/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("forwarded args reach pi after the injected extension flags, in order", (t) => {
|
|
113
|
+
const f = fixture(t);
|
|
114
|
+
const result = run(f.env, ["--mode", "rpc", "-p", "hi"]);
|
|
115
|
+
assert.equal(result.status, 0, result.stderr);
|
|
116
|
+
const payload = JSON.parse(result.stdout);
|
|
117
|
+
assert.deepEqual(payload.args, [
|
|
118
|
+
"-e",
|
|
119
|
+
packageRoot,
|
|
120
|
+
"--theme",
|
|
121
|
+
join(packageRoot, "themes"),
|
|
122
|
+
"--skill",
|
|
123
|
+
join(packageRoot, "skills"),
|
|
124
|
+
"--prompt-template",
|
|
125
|
+
join(packageRoot, "prompts"),
|
|
126
|
+
"--mode",
|
|
127
|
+
"rpc",
|
|
128
|
+
"-p",
|
|
129
|
+
"hi",
|
|
130
|
+
]);
|
|
131
|
+
assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("--link skips injection and leaves settings.json byte-identical when it already declares gentle-pi", (t) => {
|
|
135
|
+
const f = fixture(t);
|
|
136
|
+
const piAgentDir = join(f.root, "pi-agent");
|
|
137
|
+
mkdirSync(piAgentDir, { recursive: true });
|
|
138
|
+
const settingsPath = join(piAgentDir, "settings.json");
|
|
139
|
+
const settingsText = '{"packages":["npm:gentle-pi"],"theme":"kept"}';
|
|
140
|
+
writeFileSync(settingsPath, settingsText);
|
|
141
|
+
const env = { ...f.env, PI_CODING_AGENT_DIR: piAgentDir };
|
|
142
|
+
|
|
143
|
+
const result = run(env, ["--link", "--mode", "rpc"]);
|
|
144
|
+
assert.equal(result.status, 0, result.stderr);
|
|
145
|
+
const payload = JSON.parse(result.stdout);
|
|
146
|
+
assert.deepEqual(payload.args, ["--mode", "rpc"]);
|
|
147
|
+
assert.equal(readFileSync(settingsPath, "utf8"), settingsText);
|
|
148
|
+
assert.equal(payload.PI_CODING_AGENT_DIR, piAgentDir);
|
|
149
|
+
assert.equal(existsSync(join(piAgentDir, ".gentle-shell")), false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("gentle-shell list forwards to pi as a bare subcommand, with no injected extension flags", (t) => {
|
|
153
|
+
const f = fixture(t);
|
|
154
|
+
const result = run(f.env, ["list"]);
|
|
155
|
+
assert.equal(result.status, 0, result.stderr);
|
|
156
|
+
const payload = JSON.parse(result.stdout);
|
|
157
|
+
assert.deepEqual(payload.args, ["list"]);
|
|
158
|
+
assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("gentle-shell install npm:<pkg> forwards the subcommand and its argument verbatim", (t) => {
|
|
162
|
+
const f = fixture(t);
|
|
163
|
+
const result = run(f.env, ["install", "npm:pi-btw"]);
|
|
164
|
+
assert.equal(result.status, 0, result.stderr);
|
|
165
|
+
const payload = JSON.parse(result.stdout);
|
|
166
|
+
assert.deepEqual(payload.args, ["install", "npm:pi-btw"]);
|
|
167
|
+
assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a too-old pi exits 1 naming both versions", (t) => {
|
|
171
|
+
const f = fixture(t);
|
|
172
|
+
writePiScript(f.piScript, "0.80.0");
|
|
173
|
+
const result = run(f.env, []);
|
|
174
|
+
assert.equal(result.status, 1);
|
|
175
|
+
assert.match(result.stderr, /0\.80\.0/);
|
|
176
|
+
assert.match(result.stderr, /0\.85\.1/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("--version prints three lines", (t) => {
|
|
180
|
+
const f = fixture(t);
|
|
181
|
+
const result = run(f.env, ["--version"]);
|
|
182
|
+
assert.equal(result.status, 0, result.stderr);
|
|
183
|
+
const lines = result.stdout.trim().split("\n");
|
|
184
|
+
assert.equal(lines.length, 3);
|
|
185
|
+
assert.match(lines[0], /^gentle-shell /);
|
|
186
|
+
assert.match(lines[1], /^pi 0\.85\.1$/);
|
|
187
|
+
assert.match(lines[2], /^home isolated /);
|
|
188
|
+
});
|