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.
@@ -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);