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.
@@ -0,0 +1,407 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord, type TaskStatus } from "../lib/agents-protocol.ts";
4
+ import {
5
+ ACTIVITY_SCHEMA,
6
+ ACTIVITY_WIDGET_KEY,
7
+ createRpcActivityPublisher,
8
+ encodeActivityLines,
9
+ projectRpcActivity,
10
+ type RpcActivity,
11
+ type RpcTask,
12
+ } from "../lib/agents-rpc-publisher.ts";
13
+
14
+ // gentle-agents RPC publisher: a pure projection of TaskStore state into the
15
+ // bounded JSON payload an interactive RPC host receives through setWidget,
16
+ // plus the store-driven, coalesced publisher that pushes it.
17
+
18
+ function task(id: string, parentSessionId: string, overrides: Partial<TaskRecord> = {}): TaskRecord {
19
+ return {
20
+ id,
21
+ agent: "worker",
22
+ mode: "task",
23
+ prompt: "p",
24
+ label: "p",
25
+ cwd: "/r",
26
+ parentSessionId,
27
+ status: TASK_STATUS.RUNNING,
28
+ createdAt: 1000,
29
+ startedAt: 1000,
30
+ endedAt: null,
31
+ model: "gpt",
32
+ thinking: undefined,
33
+ sessionPath: "/sessions/child.jsonl",
34
+ error: null,
35
+ result: null,
36
+ lastStep: "working",
37
+ lastActivityAt: 1000,
38
+ turns: 0,
39
+ toolCalls: 0,
40
+ tokens: 0,
41
+ cost: 0,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ /** Build one already-projected `RpcTask` fixture directly, bypassing `projectRpcActivity`, so `encodeActivityLines` shrink-stage tests control exact sizes. */
47
+ function rpcTask(id: string, status: TaskStatus, endedAt: number | null, opts: { items?: number; itemTextLen?: number; labelLen?: number } = {}): RpcTask {
48
+ const { items = 1, itemTextLen = 20, labelLen = 4 } = opts;
49
+ return {
50
+ summary: {
51
+ id,
52
+ agent: "a",
53
+ label: "l".repeat(labelLen),
54
+ prompt: "p",
55
+ status,
56
+ createdAt: 1,
57
+ startedAt: 1,
58
+ endedAt,
59
+ lastStep: "s",
60
+ lastActivityAt: 1,
61
+ turns: 0,
62
+ toolCalls: 0,
63
+ error: null,
64
+ },
65
+ thread: {
66
+ version: 1,
67
+ dropped: 0,
68
+ items: Array.from({ length: items }, (_v, index) => ({ kind: "note" as const, text: `${"n".repeat(itemTextLen)}${index}` })),
69
+ },
70
+ };
71
+ }
72
+
73
+ function byteLength(value: string): number {
74
+ return Buffer.byteLength(value, "utf8");
75
+ }
76
+
77
+ test("projectRpcActivity whitelists task fields and orders running, waiting, queued, then finished by endedAt desc", () => {
78
+ const store = new TaskStore();
79
+ store.add(task("running-1", "s1", { status: TASK_STATUS.RUNNING, agent: "explore", label: "Explore X", prompt: "short prompt", turns: 2, toolCalls: 1, createdAt: 1, lastActivityAt: 5 }));
80
+ store.add(task("waiting-1", "s1", { status: TASK_STATUS.WAITING, createdAt: 2, lastActivityAt: 6 }));
81
+ store.add(task("queued-1", "s1", { status: TASK_STATUS.QUEUED, createdAt: 3, startedAt: null, lastActivityAt: 3 }));
82
+ store.add(task("finished-old", "s1", { status: TASK_STATUS.COMPLETED, createdAt: 4, endedAt: 100, lastActivityAt: 100 }));
83
+ store.add(task("finished-new", "s1", { status: TASK_STATUS.FAILED, createdAt: 5, endedAt: 200, lastActivityAt: 200, error: "boom" }));
84
+
85
+ const activity = projectRpcActivity(store);
86
+
87
+ assert.equal(activity.schema, ACTIVITY_SCHEMA);
88
+ assert.deepEqual(activity.summary, store.summary());
89
+ assert.deepEqual(activity.tasks.map((entry) => entry.summary.id), ["running-1", "waiting-1", "queued-1", "finished-new", "finished-old"]);
90
+
91
+ const runningSummary = activity.tasks[0]!.summary;
92
+ assert.deepEqual(
93
+ Object.keys(runningSummary).sort(),
94
+ ["id", "agent", "label", "prompt", "status", "createdAt", "startedAt", "endedAt", "lastStep", "lastActivityAt", "turns", "toolCalls", "error"].sort(),
95
+ "only the whitelisted fields are projected: cwd, parentSessionId, mode, model, thinking, sessionPath, result, tokens, and cost never leak",
96
+ );
97
+ assert.equal(runningSummary.agent, "explore");
98
+ assert.equal(runningSummary.label, "Explore X");
99
+ assert.equal(runningSummary.prompt, "short prompt");
100
+ assert.equal(activity.tasks[4]!.summary.error, null);
101
+ assert.equal(activity.tasks[3]!.summary.error, "boom");
102
+ });
103
+
104
+ test("projectRpcActivity truncates the prompt, tool args, and tool output to their bounds", () => {
105
+ const store = new TaskStore();
106
+ store.add(task("t1", "s1", { prompt: "x".repeat(250) }));
107
+ store.apply("t1", { type: TASK_EVENT.TOOL_START, callId: "c1", name: "bash", args: { note: "y".repeat(600) } }, 1);
108
+ store.apply("t1", { type: TASK_EVENT.TOOL_END, callId: "c1", output: "z".repeat(600), isError: false }, 2);
109
+
110
+ const activity = projectRpcActivity(store);
111
+ const summary = activity.tasks[0]!.summary;
112
+ assert.equal(summary.prompt.length, 200);
113
+ assert.ok(summary.prompt.endsWith("…"));
114
+
115
+ const toolItem = activity.tasks[0]!.thread.items.at(-1) as { kind: string; name: string; args: string; running: boolean; isError: boolean; output: string };
116
+ assert.equal(toolItem.kind, "tool");
117
+ assert.equal(toolItem.name, "bash");
118
+ assert.equal(toolItem.running, false);
119
+ assert.equal(toolItem.isError, false);
120
+ assert.ok(toolItem.args.length <= 500 && toolItem.args.endsWith("…"));
121
+ assert.ok(toolItem.output.length <= 500 && toolItem.output.endsWith("…"));
122
+ assert.match(toolItem.args, /^\{"note":"y+…$/);
123
+ });
124
+
125
+ test("projectRpcActivity keeps only the last N thread items per task (default 40)", () => {
126
+ const store = new TaskStore();
127
+ store.add(task("t1", "s1"));
128
+ for (let index = 0; index < 50; index += 1) store.apply("t1", { type: TASK_EVENT.NOTE, text: `note-${index}` }, index);
129
+
130
+ const activity = projectRpcActivity(store);
131
+ const items = activity.tasks[0]!.thread.items as { text: string }[];
132
+ assert.equal(items.length, 40);
133
+ assert.equal(items[0]!.text, "note-10");
134
+ assert.equal(items.at(-1)!.text, "note-49");
135
+ });
136
+
137
+ test("projectRpcActivity truncates text, thinking, and note item text to 2000 characters", () => {
138
+ const store = new TaskStore();
139
+ store.add(task("t1", "s1"));
140
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "a".repeat(3000) }, 1);
141
+ store.apply("t1", { type: TASK_EVENT.THINKING, text: "b".repeat(3000) }, 2);
142
+ store.apply("t1", { type: TASK_EVENT.NOTE, text: "c".repeat(3000) }, 3);
143
+
144
+ const activity = projectRpcActivity(store);
145
+ const items = activity.tasks[0]!.thread.items as { kind: string; text: string }[];
146
+
147
+ assert.equal(items.length, 3);
148
+ for (const item of items) {
149
+ assert.ok(item.text.length <= 2000, `${item.kind} item must be bounded to 2000 characters`);
150
+ assert.ok(item.text.endsWith("…"), `${item.kind} item must carry the truncation marker`);
151
+ }
152
+ });
153
+
154
+ test("projectRpcActivity truncates a synthetic 1 MiB text item to 2000 characters", () => {
155
+ const store = new TaskStore();
156
+ store.add(task("t1", "s1"));
157
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "x".repeat(1024 * 1024) }, 1);
158
+
159
+ const activity = projectRpcActivity(store);
160
+ const item = activity.tasks[0]!.thread.items[0] as { text: string };
161
+
162
+ assert.equal(item.text.length, 2000);
163
+ assert.ok(item.text.endsWith("…"));
164
+ });
165
+
166
+ test("projectRpcActivity truncates the summary error, label, and lastStep fields to 500 characters", () => {
167
+ const store = new TaskStore();
168
+ store.add(task("t1", "s1", { label: "l".repeat(600) }));
169
+ store.apply("t1", { type: TASK_EVENT.AGENT_END, text: "", outcome: "error", diagnostic: "e".repeat(600) }, 1);
170
+
171
+ const activity = projectRpcActivity(store);
172
+ const summary = activity.tasks[0]!.summary;
173
+
174
+ assert.ok(summary.label.length <= 500 && summary.label.endsWith("…"));
175
+ assert.ok(summary.lastStep.length <= 500 && summary.lastStep.endsWith("…"));
176
+ assert.ok(summary.error !== null && summary.error.length <= 500 && summary.error.endsWith("…"));
177
+ });
178
+
179
+ test("projectRpcActivity honors a custom maxThreadItems override", () => {
180
+ const store = new TaskStore();
181
+ store.add(task("t1", "s1"));
182
+ for (let index = 0; index < 5; index += 1) store.apply("t1", { type: TASK_EVENT.NOTE, text: `n${index}` }, index);
183
+
184
+ const activity = projectRpcActivity(store, { maxThreadItems: 2 });
185
+ assert.deepEqual((activity.tasks[0]!.thread.items as { text: string }[]).map((item) => item.text), ["n3", "n4"]);
186
+ });
187
+
188
+ test("encodeActivityLines returns the activity untouched as a single line when it already fits", () => {
189
+ const activity: RpcActivity = { schema: ACTIVITY_SCHEMA, summary: { running: 0, queued: 0, waiting: 0, finished: 0 }, tasks: [] };
190
+
191
+ const lines = encodeActivityLines(activity);
192
+
193
+ assert.equal(lines.length, 1);
194
+ assert.deepEqual(JSON.parse(lines[0]!), activity);
195
+ });
196
+
197
+ test("encodeActivityLines halves thread items per task when only halving can help", () => {
198
+ const activity: RpcActivity = {
199
+ schema: ACTIVITY_SCHEMA,
200
+ summary: { running: 1, queued: 0, waiting: 0, finished: 0 },
201
+ tasks: [rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 16, itemTextLen: 50 })],
202
+ };
203
+ const maxBytes = Math.floor(byteLength(JSON.stringify(activity)) / 2);
204
+
205
+ const [line] = encodeActivityLines(activity, maxBytes);
206
+ const shrunk = JSON.parse(line!) as RpcActivity;
207
+
208
+ assert.equal(shrunk.tasks.length, 1);
209
+ assert.ok(shrunk.tasks[0]!.thread.items.length < 16, "items must have been halved down");
210
+ assert.ok(shrunk.tasks[0]!.thread.items.length >= 1, "halving never removes the last item on a running task");
211
+ });
212
+
213
+ test("encodeActivityLines drops a finished task's thread items before dropping the task itself", () => {
214
+ const activity: RpcActivity = {
215
+ schema: ACTIVITY_SCHEMA,
216
+ summary: { running: 1, queued: 0, waiting: 0, finished: 1 },
217
+ tasks: [rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 1, itemTextLen: 10 }), rpcTask("f1", TASK_STATUS.COMPLETED, 100, { items: 1, itemTextLen: 5000 })],
218
+ };
219
+ const full = byteLength(JSON.stringify(activity));
220
+ const maxBytes = full - 4000; // enough slack that only emptying f1's thread is needed
221
+
222
+ const [line] = encodeActivityLines(activity, maxBytes);
223
+ const shrunk = JSON.parse(line!) as RpcActivity;
224
+
225
+ const finishedTask = shrunk.tasks.find((entry) => entry.summary.id === "f1")!;
226
+ assert.deepEqual(finishedTask.thread.items, [], "the finished task's thread is emptied, not the task itself");
227
+ assert.ok(shrunk.tasks.some((entry) => entry.summary.id === "r1"), "the running task is untouched");
228
+ });
229
+
230
+ test("encodeActivityLines drops whole finished tasks, oldest first, once emptying threads is not enough", () => {
231
+ const activity: RpcActivity = {
232
+ schema: ACTIVITY_SCHEMA,
233
+ summary: { running: 1, queued: 0, waiting: 0, finished: 2 },
234
+ tasks: [
235
+ rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 1, itemTextLen: 10 }),
236
+ rpcTask("f-new", TASK_STATUS.COMPLETED, 200, { items: 1, itemTextLen: 10, labelLen: 3000 }),
237
+ rpcTask("f-old", TASK_STATUS.COMPLETED, 100, { items: 1, itemTextLen: 10, labelLen: 3000 }),
238
+ ],
239
+ };
240
+ const emptiedThreads: RpcActivity = { ...activity, tasks: activity.tasks.map((entry) => (entry.summary.status === TASK_STATUS.COMPLETED ? { ...entry, thread: { ...entry.thread, items: [] } } : entry)) };
241
+ const maxBytes = byteLength(JSON.stringify(emptiedThreads)) - 1000; // still too big with both finished threads emptied
242
+
243
+ const [line] = encodeActivityLines(activity, maxBytes);
244
+ const shrunk = JSON.parse(line!) as RpcActivity;
245
+
246
+ assert.ok(!shrunk.tasks.some((entry) => entry.summary.id === "f-old"), "the oldest finished task must be dropped first");
247
+ assert.ok(shrunk.tasks.some((entry) => entry.summary.id === "f-new"), "the more recently finished task survives longer");
248
+ assert.ok(shrunk.tasks.some((entry) => entry.summary.id === "r1"), "the running task must never be dropped");
249
+ });
250
+
251
+ test("encodeActivityLines empties every remaining active task's thread as a last resort, emitting a summary-only payload", () => {
252
+ const activity: RpcActivity = {
253
+ schema: ACTIVITY_SCHEMA,
254
+ summary: { running: 2, queued: 0, waiting: 0, finished: 0 },
255
+ tasks: [
256
+ rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 1, itemTextLen: 2000 }),
257
+ rpcTask("r2", TASK_STATUS.RUNNING, null, { items: 1, itemTextLen: 2000 }),
258
+ ],
259
+ };
260
+ // No finished tasks to drop, and both running tasks are already down to
261
+ // one item each; only emptying every thread can still shrink this.
262
+ const maxBytes = byteLength(JSON.stringify(activity)) - 1000;
263
+
264
+ const [line] = encodeActivityLines(activity, maxBytes);
265
+ const shrunk = JSON.parse(line!) as RpcActivity;
266
+
267
+ assert.equal(shrunk.tasks.length, 2, "no active task's summary is ever dropped");
268
+ for (const entry of shrunk.tasks) assert.deepEqual(entry.thread.items, [], "every task's thread is emptied, summary-only");
269
+ });
270
+
271
+ test("encodeActivityLines never throws even when nothing left can be dropped to fit", () => {
272
+ const activity: RpcActivity = {
273
+ schema: ACTIVITY_SCHEMA,
274
+ summary: { running: 1, queued: 0, waiting: 0, finished: 0 },
275
+ tasks: [rpcTask("r1", TASK_STATUS.RUNNING, null, { items: 3, itemTextLen: 50 })],
276
+ };
277
+
278
+ const lines = encodeActivityLines(activity, 1);
279
+
280
+ assert.equal(lines.length, 1);
281
+ assert.doesNotThrow(() => JSON.parse(lines[0]!));
282
+ });
283
+
284
+ function fakeScheduler() {
285
+ const pending: Array<{ id: number; fn: () => void }> = [];
286
+ let nextId = 0;
287
+ return {
288
+ schedule: (fn: () => void, _ms: number) => {
289
+ const id = nextId++;
290
+ pending.push({ id, fn });
291
+ return () => {
292
+ const index = pending.findIndex((entry) => entry.id === id);
293
+ if (index !== -1) pending.splice(index, 1);
294
+ };
295
+ },
296
+ pendingCount: () => pending.length,
297
+ flushAll: () => {
298
+ const due = pending.splice(0, pending.length);
299
+ for (const entry of due) entry.fn();
300
+ },
301
+ };
302
+ }
303
+
304
+ test("createRpcActivityPublisher coalesces multiple task changes into one flush per window", () => {
305
+ const store = new TaskStore();
306
+ store.add(task("t1", "s1"));
307
+ const scheduler = fakeScheduler();
308
+ const calls: string[][] = [];
309
+ const publisher = createRpcActivityPublisher({ store, ui: { setWidget: (key, lines) => { assert.equal(key, ACTIVITY_WIDGET_KEY); calls.push(lines); } }, schedule: scheduler.schedule });
310
+
311
+ publisher.start();
312
+ assert.equal(scheduler.pendingCount(), 1, "start schedules exactly one coalescing flush");
313
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "hello" }, 1);
314
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: " world" }, 2);
315
+ assert.equal(scheduler.pendingCount(), 1, "further changes inside the coalescing window do not add timers");
316
+ assert.equal(calls.length, 0, "nothing is published before the coalescing timer fires");
317
+
318
+ scheduler.flushAll();
319
+
320
+ assert.equal(calls.length, 1);
321
+ const activity = JSON.parse(calls[0]![0]!) as RpcActivity;
322
+ assert.equal(activity.schema, ACTIVITY_SCHEMA);
323
+ assert.equal(activity.tasks[0]!.summary.id, "t1");
324
+ });
325
+
326
+ test("createRpcActivityPublisher tracks a task created after start through the summary subscription", () => {
327
+ const store = new TaskStore();
328
+ const scheduler = fakeScheduler();
329
+ const calls: string[][] = [];
330
+ const publisher = createRpcActivityPublisher({ store, ui: { setWidget: (_key, lines) => calls.push(lines) }, schedule: scheduler.schedule });
331
+
332
+ publisher.start();
333
+ scheduler.flushAll();
334
+ calls.length = 0;
335
+
336
+ store.add(task("late-task", "s1"));
337
+ assert.equal(scheduler.pendingCount(), 1, "adding a task schedules a coalesced flush");
338
+ scheduler.flushAll();
339
+ calls.length = 0;
340
+
341
+ store.apply("late-task", { type: TASK_EVENT.TEXT, text: "streamed" }, 1);
342
+ assert.equal(scheduler.pendingCount(), 1, "the new task's own thread changes must also be tracked");
343
+ scheduler.flushAll();
344
+
345
+ const activity = JSON.parse(calls[0]![0]!) as RpcActivity;
346
+ assert.deepEqual((activity.tasks[0]!.thread.items as { text: string }[]).map((item) => item.text), ["streamed"]);
347
+ });
348
+
349
+ test("createRpcActivityPublisher stop unsubscribes everything and publishes exactly one final frame", () => {
350
+ const store = new TaskStore();
351
+ store.add(task("t1", "s1"));
352
+ const scheduler = fakeScheduler();
353
+ const calls: string[][] = [];
354
+ const publisher = createRpcActivityPublisher({ store, ui: { setWidget: (_key, lines) => calls.push(lines) }, schedule: scheduler.schedule });
355
+ publisher.start();
356
+ scheduler.flushAll();
357
+ calls.length = 0;
358
+
359
+ publisher.stop();
360
+
361
+ assert.equal(calls.length, 1, "stop publishes exactly one final frame");
362
+ assert.equal(scheduler.pendingCount(), 0, "stop cancels any pending coalescing timer");
363
+
364
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "after stop" }, 10);
365
+ assert.equal(scheduler.pendingCount(), 0, "task subscriptions are torn down by stop");
366
+ assert.equal(calls.length, 1, "no further frame is published after stop");
367
+ });
368
+
369
+ test("createRpcActivityPublisher flush publishes immediately and cancels a pending coalescing timer", () => {
370
+ const store = new TaskStore();
371
+ store.add(task("t1", "s1"));
372
+ const scheduler = fakeScheduler();
373
+ const calls: string[][] = [];
374
+ const publisher = createRpcActivityPublisher({ store, ui: { setWidget: (_key, lines) => calls.push(lines) }, schedule: scheduler.schedule });
375
+ publisher.start();
376
+ scheduler.flushAll();
377
+ calls.length = 0;
378
+
379
+ store.apply("t1", { type: TASK_EVENT.TEXT, text: "pending" }, 1);
380
+ assert.equal(scheduler.pendingCount(), 1);
381
+
382
+ publisher.flush();
383
+
384
+ assert.equal(calls.length, 1);
385
+ assert.equal(scheduler.pendingCount(), 0, "flush cancels the timer it preempted");
386
+ });
387
+
388
+ test("createRpcActivityPublisher swallows setWidget errors through an injectable onError", () => {
389
+ const store = new TaskStore();
390
+ store.add(task("t1", "s1"));
391
+ const scheduler = fakeScheduler();
392
+ const errors: unknown[] = [];
393
+ const publisher = createRpcActivityPublisher({
394
+ store,
395
+ ui: { setWidget: () => { throw new Error("boom"); } },
396
+ schedule: scheduler.schedule,
397
+ onError: (error) => errors.push(error),
398
+ });
399
+
400
+ assert.doesNotThrow(() => {
401
+ publisher.start();
402
+ scheduler.flushAll();
403
+ });
404
+ assert.equal(errors.length, 1);
405
+ assert.match(String((errors[0] as Error).message), /boom/);
406
+ assert.doesNotThrow(() => publisher.stop());
407
+ });
@@ -5,6 +5,7 @@ import { AGENT_MODE, parseAgentsConfig, resolveAgentProfile, type AgentDefinitio
5
5
  import { TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
6
6
  import { AgentRunner, childArguments, JsonLines, piCommand, abortReasonText, type ChildLike, type RunnerDeps, type RunnerHooks, type TaskRequest } from "../lib/agents-runner.ts";
7
7
  import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
8
+ import { INTERACTIVE_HOST_ENV } from "../lib/rpc-host.ts";
8
9
 
9
10
  // Gentle Agents runner: every subagent is a child `pi --mode rpc` process.
10
11
  // The host only parses JSON lines, applies deltas to the store, answers
@@ -806,6 +807,15 @@ for (const [platform, detached] of [["win32", false], ["linux", true]] as const)
806
807
  assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
807
808
  });
808
809
 
810
+ test("AgentRunner strips the interactive-host signal from every spawned child env", async () => {
811
+ const { runner, spawnOptions } = harness();
812
+ runner.run(request({ env: { PATH: "/fixture", [INTERACTIVE_HOST_ENV]: "1" } }));
813
+ await tick();
814
+
815
+ assert.equal(spawnOptions[0]?.env[INTERACTIVE_HOST_ENV], undefined, "subagent children never see the interactive-host signal");
816
+ assert.equal(spawnOptions[0]?.env.PATH, "/fixture", "unrelated inherited env is preserved");
817
+ });
818
+
809
819
  function ipcCleanupHarness(connected: boolean | undefined) {
810
820
  const child = fakeChild({ exitOnKill: false });
811
821
  const disconnectListeners: Array<(...args: unknown[]) => void> = [];
@@ -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
+ });