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
|
@@ -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
|
+
});
|