gentle-pi 3.4.0 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -18
- package/bin/gentle-shell.mjs +373 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/readme-reference.md +129 -11
- 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 +867 -0
- package/lib/rpc-host.ts +36 -0
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +868 -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 +473 -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 +136 -0
- package/tests/gentle-shell-bin.test.ts +836 -0
- package/tests/gentle-shell-launcher.test.ts +1367 -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
|
@@ -133,6 +133,47 @@ const options = [
|
|
|
133
133
|
{ label: "Preserve requested hash", description: "Keep the hash from the original request.", value: "preserve_requested_hash" },
|
|
134
134
|
];
|
|
135
135
|
|
|
136
|
+
const INTERACTIVE_HOST_ENV = "GENTLE_SHELL_INTERACTIVE_HOST";
|
|
137
|
+
|
|
138
|
+
function withInteractiveHostEnv(t: { after(fn: () => void): void }): void {
|
|
139
|
+
const previous = process.env[INTERACTIVE_HOST_ENV];
|
|
140
|
+
process.env[INTERACTIVE_HOST_ENV] = "1";
|
|
141
|
+
t.after(() => {
|
|
142
|
+
if (previous === undefined) delete process.env[INTERACTIVE_HOST_ENV];
|
|
143
|
+
else process.env[INTERACTIVE_HOST_ENV] = previous;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Fake interactive-RPC-host ctx: scripted `select`/`input` answers, one per call. */
|
|
148
|
+
function rpcHostContext(selectAnswers: readonly (string | undefined)[], inputAnswers: readonly (string | undefined)[] = []) {
|
|
149
|
+
const selectCalls: Array<{ title: string; options: string[] }> = [];
|
|
150
|
+
const inputCalls: Array<{ title: string; placeholder?: string }> = [];
|
|
151
|
+
let selectIndex = 0;
|
|
152
|
+
let inputIndex = 0;
|
|
153
|
+
return {
|
|
154
|
+
ctx: {
|
|
155
|
+
mode: "rpc",
|
|
156
|
+
hasUI: true,
|
|
157
|
+
ui: {
|
|
158
|
+
select: async (title: string, opts: string[]) => {
|
|
159
|
+
selectCalls.push({ title, options: opts });
|
|
160
|
+
const answer = selectAnswers[selectIndex];
|
|
161
|
+
selectIndex += 1;
|
|
162
|
+
return answer;
|
|
163
|
+
},
|
|
164
|
+
input: async (title: string, placeholder?: string) => {
|
|
165
|
+
inputCalls.push({ title, placeholder });
|
|
166
|
+
const answer = inputAnswers[inputIndex];
|
|
167
|
+
inputIndex += 1;
|
|
168
|
+
return answer;
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
selectCalls,
|
|
173
|
+
inputCalls,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
136
177
|
function tuiContext(inputs: readonly string[], rendered: { value: string }) {
|
|
137
178
|
return {
|
|
138
179
|
mode: "tui",
|
|
@@ -483,6 +524,80 @@ test("ask_user_choice cancels without a value and remains unavailable outside th
|
|
|
483
524
|
);
|
|
484
525
|
});
|
|
485
526
|
|
|
527
|
+
test("ask_user_choice stays unavailable on a plain rpc host without the interactive-host variable", async (t) => {
|
|
528
|
+
const { tool } = registerChoiceTool();
|
|
529
|
+
t.after(() => { delete process.env[INTERACTIVE_HOST_ENV]; });
|
|
530
|
+
delete process.env[INTERACTIVE_HOST_ENV];
|
|
531
|
+
|
|
532
|
+
await assert.rejects(
|
|
533
|
+
() => tool.execute("call", { question: "Proceed?", options }, new AbortController().signal, undefined, { mode: "rpc", hasUI: true, ui: { select: async () => undefined } }),
|
|
534
|
+
/unavailable outside the interactive TUI/,
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
/** Expected `gentle-pi:ask-user-choice:blocked` sequence around one RPC-dialog turn. */
|
|
539
|
+
const RPC_BLOCKED_SEQUENCE = [
|
|
540
|
+
{ channel: "gentle-pi:ask-user-choice:blocked", data: { active: true } },
|
|
541
|
+
{ channel: "gentle-pi:ask-user-choice:blocked", data: { active: false } },
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
test("ask_user_choice resolves a selection through RPC dialogs on an interactive host", async (t) => {
|
|
545
|
+
withInteractiveHostEnv(t);
|
|
546
|
+
const registration = registerChoiceTool();
|
|
547
|
+
const { tool } = registration;
|
|
548
|
+
const { ctx, selectCalls } = rpcHostContext([options[1]!.label]);
|
|
549
|
+
|
|
550
|
+
const result = await tool.execute("call", { question: "Proceed?", options }, new AbortController().signal, undefined, ctx);
|
|
551
|
+
|
|
552
|
+
assert.equal(selectCalls.length, 1);
|
|
553
|
+
assert.equal(selectCalls[0]?.title, "Proceed?");
|
|
554
|
+
assert.deepEqual(selectCalls[0]?.options, [options[0]!.label, options[1]!.label]);
|
|
555
|
+
assert.deepEqual(result.details.selection, { value: options[1]!.value, label: options[1]!.label, index: 2 });
|
|
556
|
+
assert.equal(result.content[0]?.text, `User selected: 2. ${options[1]!.label} (value: ${options[1]!.value})`);
|
|
557
|
+
assert.deepEqual(registration.emittedEvents(), RPC_BLOCKED_SEQUENCE);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("ask_user_choice resolves an Other… custom response through ctx.ui.input on an interactive host", async (t) => {
|
|
561
|
+
withInteractiveHostEnv(t);
|
|
562
|
+
const registration = registerChoiceTool();
|
|
563
|
+
const { tool } = registration;
|
|
564
|
+
const { ctx, selectCalls, inputCalls } = rpcHostContext(["Other…"], ["a free-form reply"]);
|
|
565
|
+
|
|
566
|
+
const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, ctx);
|
|
567
|
+
|
|
568
|
+
assert.deepEqual(selectCalls[0]?.options, [options[0]!.label, options[1]!.label, "Other…"]);
|
|
569
|
+
assert.equal(inputCalls.length, 1);
|
|
570
|
+
assert.equal(inputCalls[0]?.title, "Explain?");
|
|
571
|
+
assert.deepEqual(result.details, { question: "Explain?", options, customResponse: "a free-form reply" });
|
|
572
|
+
assert.equal(result.content[0]?.text, "User responded: a free-form reply");
|
|
573
|
+
assert.deepEqual(registration.emittedEvents(), RPC_BLOCKED_SEQUENCE);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("ask_user_choice cancels through RPC dialogs like the TUI path when select returns undefined", async (t) => {
|
|
577
|
+
withInteractiveHostEnv(t);
|
|
578
|
+
const registration = registerChoiceTool();
|
|
579
|
+
const { tool } = registration;
|
|
580
|
+
const { ctx } = rpcHostContext([undefined]);
|
|
581
|
+
|
|
582
|
+
const result = await tool.execute("call", { question: "Proceed?", options }, new AbortController().signal, undefined, ctx);
|
|
583
|
+
|
|
584
|
+
assert.deepEqual(result.details, { question: "Proceed?", options, cancelled: true });
|
|
585
|
+
assert.equal(result.content[0]?.text, "User cancelled the choice");
|
|
586
|
+
assert.deepEqual(registration.emittedEvents(), RPC_BLOCKED_SEQUENCE, "the blocked lifecycle must still balance on a cancel");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("ask_user_choice cancels through RPC dialogs when the Other… input is cancelled", async (t) => {
|
|
590
|
+
withInteractiveHostEnv(t);
|
|
591
|
+
const registration = registerChoiceTool();
|
|
592
|
+
const { tool } = registration;
|
|
593
|
+
const { ctx } = rpcHostContext(["Other…"], [undefined]);
|
|
594
|
+
|
|
595
|
+
const result = await tool.execute("call", { question: "Explain?", options, allowCustomResponse: true }, new AbortController().signal, undefined, ctx);
|
|
596
|
+
|
|
597
|
+
assert.deepEqual(result.details, { question: "Explain?", options, cancelled: true });
|
|
598
|
+
assert.deepEqual(registration.emittedEvents(), RPC_BLOCKED_SEQUENCE, "the blocked lifecycle must still balance on a cancel");
|
|
599
|
+
});
|
|
600
|
+
|
|
486
601
|
test("ask_user_choice emits a private balanced lifecycle around selection and cancellation", async () => {
|
|
487
602
|
const sequence: string[] = [];
|
|
488
603
|
const selectedRegistration = registerChoiceTool([], ({ data }) => {
|
|
@@ -582,3 +697,17 @@ test("ask_user_choice is offered only for interactive TUI turns and preserves th
|
|
|
582
697
|
for (const hook of registration.hooks) await hook({}, { mode: "print" });
|
|
583
698
|
assert.deepEqual(registration.activeTools(), ["read", "ask_user_question"]);
|
|
584
699
|
});
|
|
700
|
+
|
|
701
|
+
test("ask_user_choice is offered on an interactive rpc host and withdrawn once the variable is gone", async (t) => {
|
|
702
|
+
const registration = registerChoiceTool(["read"]);
|
|
703
|
+
registration.allowRuntimeActions();
|
|
704
|
+
t.after(() => { delete process.env[INTERACTIVE_HOST_ENV]; });
|
|
705
|
+
|
|
706
|
+
process.env[INTERACTIVE_HOST_ENV] = "1";
|
|
707
|
+
for (const hook of registration.hooks) await hook({}, { mode: "rpc" });
|
|
708
|
+
assert.deepEqual(registration.activeTools(), ["read", "ask_user_choice"]);
|
|
709
|
+
|
|
710
|
+
delete process.env[INTERACTIVE_HOST_ENV];
|
|
711
|
+
for (const hook of registration.hooks) await hook({}, { mode: "rpc" });
|
|
712
|
+
assert.deepEqual(registration.activeTools(), ["read"]);
|
|
713
|
+
});
|
|
@@ -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,142 @@ 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
|
+
|
|
472
|
+
// Regression for the desktop app's Helpers tab showing helpers from every
|
|
473
|
+
// session: a resumed session's own finished tasks restore from disk into
|
|
474
|
+
// the shared `TaskStore` (see "resuming a session restores its own
|
|
475
|
+
// finished tasks as history, never another session's" above for the
|
|
476
|
+
// overlay-render side of this), and the RPC activity publisher created on
|
|
477
|
+
// `session_start` must scope its `setWidget` payload to the same session,
|
|
478
|
+
// never surfacing another session's restored task.
|
|
479
|
+
test("gentle-agents' RPC activity payload excludes a restored task from another session", async (t) => {
|
|
480
|
+
const h = fakePi();
|
|
481
|
+
const runtime = deps();
|
|
482
|
+
const scheduler = fakeScheduler();
|
|
483
|
+
runtime.deps.schedule = scheduler.schedule;
|
|
484
|
+
runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" };
|
|
485
|
+
const historyHome = join(root, "rpc-restore-history-home");
|
|
486
|
+
const own: TaskRecord = { id: "own-1", agent: "explore-a", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "resumed-session", status: TASK_STATUS.COMPLETED, createdAt: 1, startedAt: 1, endedAt: 100, model: "m", thinking: undefined, sessionPath: null, error: null, result: "done", lastStep: "responded", lastActivityAt: 100, turns: 1, toolCalls: 0, tokens: 0, cost: 0 };
|
|
487
|
+
const other: TaskRecord = { ...own, id: "not-mine", agent: "explore-other", parentSessionId: "other-session" };
|
|
488
|
+
await saveTask(historyDir(historyHome), own, emptyThread());
|
|
489
|
+
await saveTask(historyDir(historyHome), other, emptyThread());
|
|
490
|
+
runtime.deps.home = historyHome;
|
|
491
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
492
|
+
const { ctx } = fakeContext();
|
|
493
|
+
Object.assign(ctx, { mode: "rpc", hasUI: true });
|
|
494
|
+
ctx.sessionManager.getSessionId = () => "resumed-session";
|
|
495
|
+
const setWidget = t.mock.method(ctx.ui, "setWidget");
|
|
496
|
+
|
|
497
|
+
await h.fire("session_start", ctx, { reason: "resume" });
|
|
498
|
+
|
|
499
|
+
// The disk history read behind restoreSessionHistory is fire-and-forget
|
|
500
|
+
// real async I/O, unrelated to the fake coalescing scheduler; poll both
|
|
501
|
+
// until the resumed session's own restored task reaches a flushed frame.
|
|
502
|
+
let activity: { tasks: Array<{ summary: { id: string } }> } | undefined;
|
|
503
|
+
for (let attempt = 0; attempt < 40 && !activity; attempt += 1) {
|
|
504
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
505
|
+
scheduler.flushAll();
|
|
506
|
+
const activityCalls = setWidget.mock.calls.filter((call) => call.arguments[0] === "gentle-agents" && Array.isArray(call.arguments[1]));
|
|
507
|
+
if (activityCalls.length === 0) continue;
|
|
508
|
+
const [, lines] = activityCalls.at(-1)!.arguments as unknown as [string, string[]];
|
|
509
|
+
const parsed = JSON.parse(lines[0]!) as { tasks: Array<{ summary: { id: string } }> };
|
|
510
|
+
if (parsed.tasks.some((entry) => entry.summary.id === "own-1")) activity = parsed;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
assert.ok(activity, "the RPC activity payload must eventually include the resumed session's own restored task");
|
|
514
|
+
assert.ok(!activity!.tasks.some((entry) => entry.summary.id === "not-mine"), "another session's restored task must never appear in the RPC activity payload");
|
|
515
|
+
await h.fire("session_shutdown", ctx);
|
|
516
|
+
});
|
|
517
|
+
|
|
382
518
|
test("all nine subagent registrations own their transcript shell", () => {
|
|
383
519
|
const { pi, tools } = fakePi();
|
|
384
520
|
gentleAgents(pi, {}, deps().deps);
|