gentle-pi 3.3.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 +88 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/bin/gentle-shell.mjs +198 -0
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +99 -6
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +338 -0
- package/extensions/gentle-agents.ts +41 -1
- package/extensions/gentle-ai.ts +59 -18
- package/extensions/gentle-shell.ts +99 -10
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/inprocess-reviewer.ts +38 -1
- package/lib/native-review-cli.ts +36 -10
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +10 -0
- package/lib/review-integration-v2.ts +4 -1
- package/lib/rpc-host.ts +36 -0
- package/lib/shell-bar.ts +13 -0
- package/lib/shell-sidebar-layout.ts +10 -4
- package/lib/shell-usage-view.ts +5 -2
- package/lib/shell-usage.ts +120 -6
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +6 -3
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +661 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +9 -0
- package/tests/gentle-agents.test.ts +161 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +56 -4
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay.test.ts +21 -0
- package/tests/review-integration-v2.test.ts +30 -0
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +107 -2
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/rpc-host.test.ts +77 -0
- package/tests/shell-bar.test.ts +8 -0
- package/tests/shell-sidebar-layout.test.ts +60 -5
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/skills/release/SKILL.md +0 -137
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { installBackgroundCacheWarming, type WarmingState } from "../lib/background-cache-warming.ts";
|
|
5
|
+
|
|
6
|
+
test("native warming decisions use only current owned live background work", () => {
|
|
7
|
+
let handler: Parameters<Parameters<typeof installBackgroundCacheWarming>[0]["on"]>[1];
|
|
8
|
+
let state: WarmingState = { sessionId: "parent", ownedTaskIds: new Set(["task"]), tasks: [
|
|
9
|
+
{ id: "task", parentSessionId: "parent", mode: "background", status: "queued" },
|
|
10
|
+
] };
|
|
11
|
+
installBackgroundCacheWarming({ on(event, callback) {
|
|
12
|
+
assert.equal(event, "cache_warming_decision");
|
|
13
|
+
handler = callback;
|
|
14
|
+
} }, () => state);
|
|
15
|
+
const candidate = { warmCost: 0.01, missCost: 0.1, continuationProbability: 0.15, action: "stop" as const };
|
|
16
|
+
assert.deepEqual(handler!(candidate), { action: "warm" });
|
|
17
|
+
state.tasks[0].status = "running";
|
|
18
|
+
assert.deepEqual(handler!(candidate), { action: "warm" });
|
|
19
|
+
for (const status of ["waiting", "completed", "failed", "cancelled", "timed_out"] as const) {
|
|
20
|
+
state.tasks[0].status = status;
|
|
21
|
+
assert.equal(handler!(candidate), undefined, status);
|
|
22
|
+
}
|
|
23
|
+
state.tasks[0].status = "running";
|
|
24
|
+
for (const patch of [
|
|
25
|
+
{ sessionId: undefined }, { sessionId: "other" }, { ownedTaskIds: new Set<string>() },
|
|
26
|
+
{ tasks: [] }, { tasks: [{ ...state.tasks[0], mode: "task" }] },
|
|
27
|
+
]) {
|
|
28
|
+
const original = state;
|
|
29
|
+
state = { ...state, ...patch };
|
|
30
|
+
assert.equal(handler!(candidate), undefined);
|
|
31
|
+
state = original;
|
|
32
|
+
}
|
|
33
|
+
for (const warmCost of [0.06, 1, NaN, Infinity, -1]) {
|
|
34
|
+
assert.deepEqual(handler!({ ...candidate, warmCost }), { action: "stop" });
|
|
35
|
+
}
|
|
36
|
+
for (const missCost of [0, -1, NaN, Infinity]) {
|
|
37
|
+
assert.deepEqual(handler!({ ...candidate, missCost }), { action: "stop" });
|
|
38
|
+
}
|
|
39
|
+
assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.05 }), { action: "warm" });
|
|
40
|
+
assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.049 }), { action: "stop" });
|
|
41
|
+
state.tasks = [];
|
|
42
|
+
assert.equal(handler!({ ...candidate, action: "warm" }), undefined, "ordinary idle keeps native action");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Supplemental architecture guard; the extension lifecycle is exercised in gentle-agents.test.ts.
|
|
46
|
+
test("warming helper has no maintenance capabilities", () => {
|
|
47
|
+
const helper = readFileSync(new URL("../lib/background-cache-warming.ts", import.meta.url), "utf8");
|
|
48
|
+
assert.doesNotMatch(helper, /setTimeout|setInterval|sendMessage|sendUserMessage|subagent_|runner\./);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("background prompt prohibits cache polling and documents explicit native idle opt-in", () => {
|
|
52
|
+
const prompt = readFileSync(new URL("../assets/orchestrator-delegation.md", import.meta.url), "utf8");
|
|
53
|
+
assert.match(prompt, /completion or cache maintenance/);
|
|
54
|
+
assert.match(prompt, /user-requested inspection, relevant scope change, input request, or suspected abnormal behavior/);
|
|
55
|
+
assert.match(prompt, /Never relaunch equivalent work merely because it is queued or running/);
|
|
56
|
+
const docs = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8");
|
|
57
|
+
assert.match(docs, /"cacheWarming": "idle"/);
|
|
58
|
+
assert.match(docs, /\$0\.05/);
|
|
59
|
+
assert.match(docs, /"streaming".*no idle decision/s);
|
|
60
|
+
});
|
|
@@ -530,6 +530,28 @@ function notifyContext(
|
|
|
530
530
|
} as unknown as ExtensionContext;
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
+
/**
|
|
534
|
+
* The same minimal context as `notifyContext`, but interactive: `hasUI` is true
|
|
535
|
+
* and `ui.select` answers the command's no-argument menu. `notifyContext` stays
|
|
536
|
+
* `hasUI: false` so every existing sub-action test keeps the non-menu path.
|
|
537
|
+
*/
|
|
538
|
+
function selectingContext(
|
|
539
|
+
cwd: string,
|
|
540
|
+
notices: Array<{ message: string; type?: string }>,
|
|
541
|
+
select: (title: string, options: string[]) => Promise<string | undefined>,
|
|
542
|
+
): ExtensionContext {
|
|
543
|
+
return {
|
|
544
|
+
cwd,
|
|
545
|
+
hasUI: true,
|
|
546
|
+
ui: {
|
|
547
|
+
notify: (message: string, type?: string) => {
|
|
548
|
+
notices.push({ message, type });
|
|
549
|
+
},
|
|
550
|
+
select,
|
|
551
|
+
},
|
|
552
|
+
} as unknown as ExtensionContext;
|
|
553
|
+
}
|
|
554
|
+
|
|
533
555
|
/** Point the command's global config home at a scratch dir, never at ~/.pi. */
|
|
534
556
|
function scopedEnv(t: TestContext, values: Record<string, string | undefined>): void {
|
|
535
557
|
const previous = new Map<string, string | undefined>();
|
|
@@ -590,6 +612,52 @@ test("no argument reports the effective policy, the deciding default, and the ca
|
|
|
590
612
|
);
|
|
591
613
|
});
|
|
592
614
|
|
|
615
|
+
test("no argument opens a selectable menu and applies the chosen sub-action", async (t) => {
|
|
616
|
+
const cwd = makeScratch("gp-bg-cmd-select-");
|
|
617
|
+
const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
|
|
618
|
+
const globalFile = join(configHome, "background-subagents.json");
|
|
619
|
+
scopedEnv(t, {
|
|
620
|
+
GENTLE_PI_CONFIG_HOME: configHome,
|
|
621
|
+
GENTLE_PI_BACKGROUND_SUBAGENTS: undefined,
|
|
622
|
+
});
|
|
623
|
+
const command = registeredCommands().get("gentle:background-subagents");
|
|
624
|
+
assert.ok(command, "gentle:background-subagents must be registered");
|
|
625
|
+
|
|
626
|
+
// A dismissed menu (undefined selection) reports nothing and writes nothing.
|
|
627
|
+
// It runs first so the untouched config home can prove the no-write: a second
|
|
628
|
+
// scopedEnv save point in one test would restore out of order at teardown.
|
|
629
|
+
const dismissed: Array<{ message: string; type?: string }> = [];
|
|
630
|
+
await command!.handler("", selectingContext(cwd, dismissed, async () => undefined));
|
|
631
|
+
assert.equal(dismissed.length, 0, "a dismissed menu reports nothing");
|
|
632
|
+
assert.equal(existsSync(globalFile), false, "a dismissed menu writes no file");
|
|
633
|
+
|
|
634
|
+
// Choosing "enable" writes the global file and reports exactly once, in the
|
|
635
|
+
// same shape the direct `enable` sub-action already reports.
|
|
636
|
+
const notices: Array<{ message: string; type?: string }> = [];
|
|
637
|
+
await command!.handler(
|
|
638
|
+
"",
|
|
639
|
+
selectingContext(cwd, notices, async (title, options) => {
|
|
640
|
+
assert.equal(title, "Background subagents policy");
|
|
641
|
+
assert.deepEqual(options, ["status", "enable", "disable"]);
|
|
642
|
+
return "enable";
|
|
643
|
+
}),
|
|
644
|
+
);
|
|
645
|
+
assert.equal(notices.length, 1, "one invocation reports exactly once");
|
|
646
|
+
assert.equal(notices[0]!.type, "info");
|
|
647
|
+
assert.equal(
|
|
648
|
+
notices[0]!.message,
|
|
649
|
+
[
|
|
650
|
+
`background subagents: on (decided by global file ${globalFile}; capability: absent)`,
|
|
651
|
+
`Wrote on to the global file ${globalFile}.`,
|
|
652
|
+
"Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
|
|
653
|
+
].join("\n"),
|
|
654
|
+
);
|
|
655
|
+
assert.deepEqual(JSON.parse(readFileSync(globalFile, "utf8")), {
|
|
656
|
+
schema: "gentle-pi.background-subagents/v1",
|
|
657
|
+
policy: "on",
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
593
661
|
test("status names the project file that decided and the global file it shadows", async (t) => {
|
|
594
662
|
const cwd = makeScratch("gp-bg-cmd-project-");
|
|
595
663
|
const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
|
|
@@ -32,6 +32,14 @@ function createPalette(groups: readonly CommandPaletteGroup[], theme?: CommandPa
|
|
|
32
32
|
return { palette, results };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
test("animations is discoverable under Configuration with its live description", () => {
|
|
36
|
+
const groups = buildCommandPaletteGroups([{ name: "gentle:animations", description: "status|quality|performance|potato" }], {});
|
|
37
|
+
assert.equal(groups[0]?.title, "Configuration");
|
|
38
|
+
assert.equal(groups[0]?.items[0]?.command, "gentle:animations");
|
|
39
|
+
assert.match(groups[0]?.items[0]?.label ?? "", /[Aa]nimation/);
|
|
40
|
+
assert.equal(groups[0]?.items[0]?.description, "status|quality|performance|potato");
|
|
41
|
+
});
|
|
42
|
+
|
|
35
43
|
// --- rankPaletteGroups -------------------------------------------------------
|
|
36
44
|
|
|
37
45
|
test("rankPaletteGroups ranks label-prefix, label-contains, label-subsequence, command-contains, then description-contains", () => {
|
|
@@ -337,6 +345,7 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
|
|
|
337
345
|
"gentle:review-mode",
|
|
338
346
|
"gentle:background-subagents",
|
|
339
347
|
"gentle:double-esc-cancel",
|
|
348
|
+
"gentle:animations",
|
|
340
349
|
"gentle:telemetry",
|
|
341
350
|
"gentle:banner",
|
|
342
351
|
"gentle:banner-color",
|
|
@@ -147,8 +147,10 @@ function fakePi() {
|
|
|
147
147
|
await fire("session_shutdown", ctx, { reason: "quit" });
|
|
148
148
|
};
|
|
149
149
|
const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
|
|
150
|
+
const results: unknown[] = [];
|
|
150
151
|
try {
|
|
151
|
-
for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
|
|
152
|
+
for (const handler of handlers.get(event) ?? []) results.push(await handler(payload, ctx));
|
|
153
|
+
return results;
|
|
152
154
|
} finally {
|
|
153
155
|
if (event === "session_start") {
|
|
154
156
|
activeSession = ctx;
|
|
@@ -237,6 +239,73 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
|
|
|
237
239
|
};
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
test("cache warming follows actual Gentle Agents ownership and completion lifecycle", async (t) => {
|
|
243
|
+
const h = fakePi();
|
|
244
|
+
const runtime = deps();
|
|
245
|
+
let store: TaskStore | undefined;
|
|
246
|
+
const list = TaskStore.prototype.list;
|
|
247
|
+
t.mock.method(TaskStore.prototype, "list", function (this: TaskStore, ...args: Parameters<TaskStore["list"]>) {
|
|
248
|
+
store = this;
|
|
249
|
+
return list.apply(this, args);
|
|
250
|
+
});
|
|
251
|
+
const scheduled = t.mock.fn(() => () => {});
|
|
252
|
+
runtime.deps.schedule = scheduled;
|
|
253
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
254
|
+
const { ctx } = fakeContext();
|
|
255
|
+
let sessionId = "warming-parent";
|
|
256
|
+
ctx.sessionManager.getSessionId = () => sessionId;
|
|
257
|
+
await h.fire("session_start", ctx);
|
|
258
|
+
const candidate = { type: "cache_warming_decision", warmCost: 0.01, missCost: 0.1, continuationProbability: 0.15, action: "stop" };
|
|
259
|
+
const run = t.mock.method(AgentRunner.prototype, "run");
|
|
260
|
+
const status = t.mock.method(h.tools.get("subagent_status")!, "execute");
|
|
261
|
+
const result = t.mock.method(h.tools.get("subagent_result")!, "execute");
|
|
262
|
+
const decide = async (expected: unknown) => {
|
|
263
|
+
const before = [runtime.spawned.length, run.mock.callCount(), scheduled.mock.callCount(), status.mock.callCount(), result.mock.callCount()];
|
|
264
|
+
const sent = [...h.sent], entries = [...h.entries];
|
|
265
|
+
const childTraffic = runtime.children.map(child => JSON.stringify([child.written, child.sent, child.killed]));
|
|
266
|
+
const timeout = t.mock.method(globalThis, "setTimeout");
|
|
267
|
+
const interval = t.mock.method(globalThis, "setInterval");
|
|
268
|
+
try {
|
|
269
|
+
assert.deepEqual(await h.fire("cache_warming_decision", ctx, candidate), [expected]);
|
|
270
|
+
assert.equal(timeout.mock.callCount(), 0);
|
|
271
|
+
assert.equal(interval.mock.callCount(), 0);
|
|
272
|
+
} finally { timeout.mock.restore(); interval.mock.restore(); }
|
|
273
|
+
assert.deepEqual([runtime.spawned.length, run.mock.callCount(), scheduled.mock.callCount(), status.mock.callCount(), result.mock.callCount()], before);
|
|
274
|
+
assert.deepEqual(h.sent, sent, "no maintenance or duplicate completion message");
|
|
275
|
+
assert.deepEqual(h.entries, entries, "no maintenance context entry");
|
|
276
|
+
assert.deepEqual(runtime.children.map(child => JSON.stringify([child.written, child.sent, child.killed])), childTraffic, "no child inspection, steering, or cancellation");
|
|
277
|
+
};
|
|
278
|
+
await decide(undefined);
|
|
279
|
+
const launch = await h.tools.get("subagent_run")!.execute("warming-launch", { agent: "explore", task: "Map warming", mode: "background" }, undefined, undefined, ctx);
|
|
280
|
+
await tick();
|
|
281
|
+
const taskId = (launch.details.gentleAgents as { taskId: string }).taskId;
|
|
282
|
+
assert.equal(runtime.children.length, 1);
|
|
283
|
+
await decide({ action: "warm" });
|
|
284
|
+
// The same live owned child is foreign when the active session changes.
|
|
285
|
+
sessionId = "other-parent";
|
|
286
|
+
await decide(undefined);
|
|
287
|
+
sessionId = "warming-parent";
|
|
288
|
+
await decide({ action: "warm" });
|
|
289
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
|
|
290
|
+
await tick();
|
|
291
|
+
await decide({ action: "warm" });
|
|
292
|
+
assert.equal(h.sent.length, 0, "agent_end is not settlement");
|
|
293
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
294
|
+
await tick();
|
|
295
|
+
await decide(undefined);
|
|
296
|
+
assert.equal(h.sent.length, 1);
|
|
297
|
+
assert.equal(h.sent[0].message.customType, "gentle-agents.result");
|
|
298
|
+
assert.match(JSON.stringify(h.sent[0].message), new RegExp(taskId));
|
|
299
|
+
assert.equal(h.sent[0].options.triggerTurn, true);
|
|
300
|
+
assert.equal(run.mock.callCount(), 1, "warming never launches equivalent work");
|
|
301
|
+
// Seed the actual history-restore path with a stale live-looking record:
|
|
302
|
+
// sharing the parent ID and running status must not confer ownership.
|
|
303
|
+
assert.equal(store!.restore({ ...store!.get(taskId)!, id: "warming-restored", status: TASK_STATUS.RUNNING }, emptyThread()), true);
|
|
304
|
+
await decide(undefined);
|
|
305
|
+
await h.fire("session_shutdown", ctx);
|
|
306
|
+
await decide(undefined);
|
|
307
|
+
});
|
|
308
|
+
|
|
240
309
|
const PRINT_BACKGROUND_ERROR = "Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
|
|
241
310
|
|
|
242
311
|
for (const continuation of [false, true]) {
|
|
@@ -310,6 +379,96 @@ for (const mode of ["print", "tui", "rpc"] as const) {
|
|
|
310
379
|
});
|
|
311
380
|
}
|
|
312
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
|
+
|
|
313
472
|
test("all nine subagent registrations own their transcript shell", () => {
|
|
314
473
|
const { pi, tools } = fakePi();
|
|
315
474
|
gentleAgents(pi, {}, deps().deps);
|
|
@@ -1838,7 +1997,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
1838
1997
|
const { ctx } = fakeContext();
|
|
1839
1998
|
(ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
|
|
1840
1999
|
await h.fire("session_start", ctx);
|
|
1841
|
-
shutdown.push(() => h.fire("session_shutdown", ctx));
|
|
2000
|
+
shutdown.push(async () => { await h.fire("session_shutdown", ctx); });
|
|
1842
2001
|
return { h, ctx, result: h.tools.get("subagent_run")!.execute(`spawn-${mode}`, { agent: "explore", task: `Capture ${mode}`, mode }, undefined, undefined, ctx) };
|
|
1843
2002
|
};
|
|
1844
2003
|
const task = await launch("task", { PATH: "/bin", FIXTURE: "task" });
|
|
@@ -97,7 +97,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
|
|
|
97
97
|
method: "go-sumdb-source-build",
|
|
98
98
|
package: "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
|
|
99
99
|
module: "github.com/gentleman-programming/gentle-ai/v3",
|
|
100
|
-
tag: "v3.
|
|
100
|
+
tag: "v3.5.0",
|
|
101
101
|
architecture: process.arch === "x64" ? "x64" : "arm64",
|
|
102
102
|
binarySha256: createHash("sha256").update(binary).digest("hex"),
|
|
103
103
|
moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,
|