gentle-pi 4.4.0 → 4.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/gentle-shell.mjs +37 -4
- package/docs/gentle-music.md +28 -4
- package/docs/gentle-shell.md +5 -5
- package/docs/readme-reference.md +61 -46
- package/extensions/gentle-agents.ts +14 -2
- package/extensions/gentle-ai.ts +1677 -1240
- package/extensions/gentle-music.ts +83 -8
- package/extensions/gentle-shell.ts +88 -8
- package/lib/command-palette-catalog.ts +1 -2
- package/lib/gentle-shell-bundled.ts +18 -8
- package/lib/gentle-shell-launcher.ts +2 -0
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/shell-bar.ts +2 -4
- package/lib/shell-sidebar-layout.ts +1 -1
- package/lib/vim-editor-adapter.ts +3 -2
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +18 -8
- package/runtime/gentle-shell-launcher.mjs +2 -0
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/installer-downloads.mjs +24 -0
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +3 -1
- package/tests/bundled-install.test.ts +273 -2
- package/tests/command-palette.test.ts +3 -3
- package/tests/design-canvas-server.test.ts +6 -2
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +1 -1
- package/tests/gentle-agents.test.ts +4 -4
- package/tests/gentle-ai.test.ts +810 -609
- package/tests/gentle-music.test.ts +153 -1
- package/tests/gentle-shell-bundled.test.ts +20 -0
- package/tests/gentle-shell-launcher.test.ts +1 -0
- package/tests/gentle-shell.test.ts +44 -19
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/package-manifest.test.ts +2 -2
- package/tests/persona-reply-language.test.ts +42 -0
- package/tests/pi-update.test.ts +5 -0
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/shell-bar.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +17 -0
- package/tests/vim-editor-adapter.test.ts +2 -2
|
@@ -5,17 +5,27 @@ import { tmpdir } from "node:os";
|
|
|
5
5
|
import { delimiter, join } from "node:path";
|
|
6
6
|
import test, { type TestContext } from "node:test";
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import
|
|
8
|
+
import gentleMusicExtension, {
|
|
9
9
|
findOnPath,
|
|
10
10
|
formatMusicStatus,
|
|
11
11
|
GENTLE_STREAM_SPEC,
|
|
12
|
+
installHint,
|
|
12
13
|
MUSIC_COMMAND_NAME,
|
|
14
|
+
MUSIC_INSTALL_OFFER,
|
|
15
|
+
MUSIC_WINDOWS_UNSUPPORTED,
|
|
16
|
+
MUSIC_INSTALL_TITLE,
|
|
13
17
|
musicTuiArgs,
|
|
14
18
|
parseMusicArgs,
|
|
15
19
|
resolveGentleStream,
|
|
20
|
+
type BundledMusicPlayer,
|
|
16
21
|
type MusicSpawn,
|
|
17
22
|
} from "../extensions/gentle-music.ts";
|
|
18
23
|
|
|
24
|
+
// The music tests describe a supported (POSIX) host unless they pass `platform`,
|
|
25
|
+
// so they hold on every OS the suite runs on, Windows included.
|
|
26
|
+
const gentleMusic = (pi: Parameters<typeof gentleMusicExtension>[0], deps: Parameters<typeof gentleMusicExtension>[1] = {}) =>
|
|
27
|
+
gentleMusicExtension(pi, { platform: "linux", ...deps });
|
|
28
|
+
|
|
19
29
|
// /gentle:music hands the terminal to gentle-stream's own TUI, the way Pi's
|
|
20
30
|
// external editor does, and controls the background player with
|
|
21
31
|
// `gentle-stream ctl …` without taking the terminal.
|
|
@@ -333,3 +343,145 @@ test("Windows .cmd and .bat shims are not picked, so resolution falls back to uv
|
|
|
333
343
|
const resolved = resolveGentleStream((name) => findOnPath(name, env, "win32"));
|
|
334
344
|
assert.equal(resolved?.command, join(dir, "uv.EXE"));
|
|
335
345
|
});
|
|
346
|
+
|
|
347
|
+
// S5: a bundled Gentle Shell offers to install the player inside its own
|
|
348
|
+
// folder; any other install keeps the manual hint.
|
|
349
|
+
const PREVIOUS_HINT = "/gentle:music needs gentle-stream, and neither gentle-stream nor uv is on PATH. Install uv (https://docs.astral.sh/uv/getting-started/installation/) and run /gentle:music again, or install gentle-stream with: uv tool install 'gentle-stream[samples] @ git+https://github.com/Gentleman-Programming/gentle-stream'";
|
|
350
|
+
|
|
351
|
+
function withConfirm(context: ReturnType<typeof fakeContext>, answer: boolean) {
|
|
352
|
+
const asked: Array<[string, string]> = [];
|
|
353
|
+
(context.ctx.ui as unknown as { confirm: (title: string, message: string) => Promise<boolean> }).confirm = async (title, message) => {
|
|
354
|
+
asked.push([title, message]);
|
|
355
|
+
return answer;
|
|
356
|
+
};
|
|
357
|
+
return asked;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function fakeBundled({ installed = false, fail }: { installed?: boolean; fail?: Error } = {}) {
|
|
361
|
+
const state = { installed, installs: 0 };
|
|
362
|
+
const player: BundledMusicPlayer = {
|
|
363
|
+
gentleStream: "/prefix/music/bin/gentle-stream",
|
|
364
|
+
installed: () => state.installed,
|
|
365
|
+
install: async (progress) => {
|
|
366
|
+
state.installs += 1;
|
|
367
|
+
progress("Downloading uv 0.13.6…");
|
|
368
|
+
if (fail) throw fail;
|
|
369
|
+
state.installed = true;
|
|
370
|
+
return { gentleStream: "/prefix/music/bin/gentle-stream", samples: true };
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
return { state, bundled: async () => player };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
test("the manual hint outside a bundled install is unchanged", async () => {
|
|
377
|
+
assert.equal(installHint(), PREVIOUS_HINT);
|
|
378
|
+
const { pi, commands } = fakePi();
|
|
379
|
+
const { spawn, spawned } = fakeSpawn([]);
|
|
380
|
+
gentleMusic(pi, { which: onPath({}), spawn, bundled: async () => undefined });
|
|
381
|
+
const context = fakeContext();
|
|
382
|
+
const asked = withConfirm(context, true);
|
|
383
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
384
|
+
assert.deepEqual(asked, [], "no offer outside a bundled install");
|
|
385
|
+
assert.deepEqual(context.notices, [[PREVIOUS_HINT, "warning"]]);
|
|
386
|
+
assert.equal(spawned.length, 0);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("a bundled install without the player offers it; declining shows the hint and installs nothing", async () => {
|
|
390
|
+
const { pi, commands } = fakePi();
|
|
391
|
+
const { spawn, spawned } = fakeSpawn([]);
|
|
392
|
+
const { state, bundled } = fakeBundled();
|
|
393
|
+
gentleMusic(pi, { which: onPath({}), spawn, bundled });
|
|
394
|
+
const context = fakeContext();
|
|
395
|
+
const asked = withConfirm(context, false);
|
|
396
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
397
|
+
assert.deepEqual(asked, [[MUSIC_INSTALL_TITLE, MUSIC_INSTALL_OFFER]]);
|
|
398
|
+
assert.equal(MUSIC_INSTALL_OFFER, "Install the gentle-stream music player inside Gentle Shell? It downloads uv, Python 3.13 and the player (about 150 MB) into the Gentle Shell folder.");
|
|
399
|
+
assert.equal(state.installs, 0);
|
|
400
|
+
assert.deepEqual(context.notices, [[PREVIOUS_HINT, "warning"]]);
|
|
401
|
+
assert.equal(spawned.length, 0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("accepting the offer installs the player with progress, then starts the music from the prefix", async () => {
|
|
405
|
+
const { pi, commands } = fakePi();
|
|
406
|
+
const context = fakeContext();
|
|
407
|
+
const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
|
|
408
|
+
const { state, bundled } = fakeBundled();
|
|
409
|
+
gentleMusic(pi, { which: onPath({}), spawn, bundled, signals: fakeSignals().signals });
|
|
410
|
+
withConfirm(context, true);
|
|
411
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("calm", context.ctx);
|
|
412
|
+
assert.equal(state.installs, 1);
|
|
413
|
+
assert.deepEqual(context.notices.map(([, type]) => type), ["info", "info"]);
|
|
414
|
+
assert.match(context.notices[0]![0], /Downloading uv/);
|
|
415
|
+
assert.match(context.notices[1]![0], /installed/i);
|
|
416
|
+
assert.equal(spawned[0]!.command, "/prefix/music/bin/gentle-stream");
|
|
417
|
+
assert.deepEqual(spawned[0]!.args, ["--model", "anthropic/claude-sonnet-5-5", "--mood", "calm"]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("on Windows /gentle:music explains it is not available yet and never offers, installs or runs anything", async () => {
|
|
421
|
+
assert.equal(MUSIC_WINDOWS_UNSUPPORTED, "/gentle:music is not available on Windows yet: the gentle-stream player runs on macOS and Linux. It works when Gentle Shell runs inside WSL.");
|
|
422
|
+
for (const args of ["", "calm", "status", "stop"]) {
|
|
423
|
+
const { pi, commands } = fakePi();
|
|
424
|
+
const context = fakeContext();
|
|
425
|
+
const asked = withConfirm(context, true);
|
|
426
|
+
const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
|
|
427
|
+
const { state, bundled } = fakeBundled({ installed: true });
|
|
428
|
+
gentleMusic(pi, { which: onPath({ "gentle-stream": "C:\\bin\\gentle-stream.exe", uvx: "C:\\bin\\uvx.exe" }), spawn, bundled, signals: fakeSignals().signals, platform: "win32" });
|
|
429
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler(args, context.ctx);
|
|
430
|
+
assert.deepEqual(context.notices, [[MUSIC_WINDOWS_UNSUPPORTED, "warning"]], args);
|
|
431
|
+
assert.deepEqual(asked, [], args);
|
|
432
|
+
assert.equal(state.installs, 0, args);
|
|
433
|
+
assert.equal(spawned.length, 0, args);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("on Windows the not-available notice wins over the interactive-terminal notice", async () => {
|
|
438
|
+
for (const mode of ["rpc", "json", "print"]) {
|
|
439
|
+
const { pi, commands } = fakePi();
|
|
440
|
+
const context = fakeContext(mode);
|
|
441
|
+
const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
|
|
442
|
+
gentleMusic(pi, { which: onPath({ uvx: "C:\\bin\\uvx.exe" }), spawn, platform: "win32" });
|
|
443
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
444
|
+
assert.deepEqual(context.notices, [[MUSIC_WINDOWS_UNSUPPORTED, "warning"]], mode);
|
|
445
|
+
assert.equal(spawned.length, 0, mode);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("an installed prefix player is used without asking; PATH gentle-stream still comes first", async () => {
|
|
450
|
+
const { bundled } = fakeBundled({ installed: true });
|
|
451
|
+
for (const [names, expected] of [[{}, "/prefix/music/bin/gentle-stream"], [{ uvx: "/bin/uvx" }, "/prefix/music/bin/gentle-stream"], [{ "gentle-stream": "/bin/gentle-stream" }, "/bin/gentle-stream"]] as const) {
|
|
452
|
+
const { pi, commands } = fakePi();
|
|
453
|
+
const context = fakeContext();
|
|
454
|
+
const asked = withConfirm(context, false);
|
|
455
|
+
const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
|
|
456
|
+
gentleMusic(pi, { which: onPath(names), spawn, bundled, signals: fakeSignals().signals });
|
|
457
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
458
|
+
assert.deepEqual(asked, []);
|
|
459
|
+
assert.equal(spawned[0]!.command, expected);
|
|
460
|
+
assert.deepEqual(spawned[0]!.args, ["--model", "anthropic/claude-sonnet-5-5"]);
|
|
461
|
+
}
|
|
462
|
+
assert.deepEqual(resolveGentleStream(onPath({ uv: "/bin/uv" }), "/prefix/music/bin/gentle-stream"), { command: "/prefix/music/bin/gentle-stream", prefix: [] });
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("a bundled install with uv on PATH keeps the uvx route and does not offer", async () => {
|
|
466
|
+
const { pi, commands } = fakePi();
|
|
467
|
+
const context = fakeContext();
|
|
468
|
+
const asked = withConfirm(context, true);
|
|
469
|
+
const { spawn, spawned } = fakeSpawn([{ code: 0 }], context.calls);
|
|
470
|
+
gentleMusic(pi, { which: onPath({ uvx: "/bin/uvx" }), spawn, bundled: fakeBundled().bundled, signals: fakeSignals().signals });
|
|
471
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
472
|
+
assert.deepEqual(asked, []);
|
|
473
|
+
assert.equal(spawned[0]!.command, "/bin/uvx");
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("a failed install is reported and nothing starts", async () => {
|
|
477
|
+
const { pi, commands } = fakePi();
|
|
478
|
+
const { spawn, spawned } = fakeSpawn([]);
|
|
479
|
+
gentleMusic(pi, { which: onPath({}), spawn, bundled: fakeBundled({ fail: new Error("Download integrity mismatch") }).bundled });
|
|
480
|
+
const context = fakeContext();
|
|
481
|
+
withConfirm(context, true);
|
|
482
|
+
await commands.get(MUSIC_COMMAND_NAME)!.handler("", context.ctx);
|
|
483
|
+
assert.equal(spawned.length, 0);
|
|
484
|
+
const [message, type] = context.notices.at(-1)!;
|
|
485
|
+
assert.equal(type, "error");
|
|
486
|
+
assert.match(message, /could not install gentle-stream.*Download integrity mismatch/);
|
|
487
|
+
});
|
|
@@ -274,6 +274,18 @@ test("bundledUpdatePlan redirects Pi's own update and keeps the extension update
|
|
|
274
274
|
}
|
|
275
275
|
});
|
|
276
276
|
|
|
277
|
+
test("bundledUpdatePlan marks Pi's own update with --latest and keeps every other --latest form passing through", () => {
|
|
278
|
+
for (const args of [["update", "--latest"], ["update", "pi", "--latest"], ["update", "--latest", "self"], ["update", "--self", "--latest"]]) {
|
|
279
|
+
assert.deepEqual(bundledUpdatePlan(args), { self: true, latest: true }, args.join(" "));
|
|
280
|
+
}
|
|
281
|
+
assert.deepEqual(bundledUpdatePlan(["update", "--all", "--latest"]), { self: true, latest: true, piArgs: ["update", "--extensions"] });
|
|
282
|
+
assert.deepEqual(bundledUpdatePlan(["update", "--latest", "--self", "--extensions", "-a"]), { self: true, latest: true, piArgs: ["update", "--extensions", "-a"] });
|
|
283
|
+
for (const passthrough of [["update", "--extensions", "--latest"], ["update", "npm:x", "--latest"], ["update", "--models", "--latest"], ["update", "--all", "--self", "--latest"],
|
|
284
|
+
["update", "pi", "npm:x", "--latest"], ["update", "--latest", "--bogus"], ["update", "--help", "--latest"]]) {
|
|
285
|
+
assert.deepEqual(bundledUpdatePlan(passthrough), { self: false, piArgs: passthrough }, passthrough.join(" "));
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
277
289
|
test("bundledUpdateNext still updates extensions when the upgrade did not complete", () => {
|
|
278
290
|
assert.deepEqual(bundledUpdateNext({ self: false, piArgs: ["update", "npm:x"] }, undefined), { piArgs: ["update", "npm:x"] });
|
|
279
291
|
assert.deepEqual(bundledUpdateNext({ self: true }, 0), { exitCode: 0 });
|
|
@@ -283,6 +295,9 @@ test("bundledUpdateNext still updates extensions when the upgrade did not comple
|
|
|
283
295
|
const failed = bundledUpdateNext({ self: true, piArgs: ["update", "--extensions"] }, 1);
|
|
284
296
|
assert.deepEqual("piArgs" in failed ? failed.piArgs : [], ["update", "--extensions"]);
|
|
285
297
|
assert.match("note" in failed ? String(failed.note) : "", /upgrade did not complete.*extensions/);
|
|
298
|
+
const latest = bundledUpdateNext({ self: true, latest: true, piArgs: ["update", "--extensions"] }, 1);
|
|
299
|
+
assert.match("note" in latest ? String(latest.note) : "", /latest Pi update did not complete.*extensions/);
|
|
300
|
+
assert.deepEqual(bundledUpdateNext({ self: true, latest: true }, 1), { exitCode: 1 });
|
|
286
301
|
});
|
|
287
302
|
|
|
288
303
|
test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => {
|
|
@@ -290,6 +305,11 @@ test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell
|
|
|
290
305
|
assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
|
|
291
306
|
assert.match(interactive, /Running 'gentle-shell upgrade'/);
|
|
292
307
|
assert.match(bundledUpdateNotice({ piVersion: "1.2.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/);
|
|
308
|
+
for (const interactive of [true, false]) {
|
|
309
|
+
const lines = bundledUpdateNotice({ piVersion: "1.2.0", interactive });
|
|
310
|
+
assert.equal(lines[0], "Pi ships pinned with Gentle Shell (Pi 1.2.0): a newer Pi arrives with a Gentle Shell release.");
|
|
311
|
+
assert.equal(lines.at(-1), "Run 'gentle-shell update --latest' to install the newest published Pi now, without the release's verified lockfile.");
|
|
312
|
+
}
|
|
293
313
|
});
|
|
294
314
|
|
|
295
315
|
test("parseBundledUpgradeArgs accepts the release channel and --rollback and refuses the main channel", () => {
|
|
@@ -214,6 +214,7 @@ test("help text documents the bundled upgrade, its rollback and the pinned Pi up
|
|
|
214
214
|
assert.ok(text.includes(" gentle-shell upgrade --rollback"));
|
|
215
215
|
assert.match(text, /bundled install.*release channel only.*--rollback switches back to the previous kept version/s);
|
|
216
216
|
assert.match(text, /In a bundled install Pi ships pinned: 'update' without a package\n\s+runs 'gentle-shell upgrade'/);
|
|
217
|
+
assert.match(text, /'update --latest' installs the newest published Pi instead,\n\s+without the release's verified lockfile\./);
|
|
217
218
|
});
|
|
218
219
|
|
|
219
220
|
test("parseLauncherArgs treats home as a plain passthrough token when it is not argv[0]", () => {
|
|
@@ -24,6 +24,7 @@ import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
|
24
24
|
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
25
25
|
import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
26
26
|
import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
|
|
27
|
+
import { isJobsSidebarSnapshot } from "../lib/jobs-sidebar-state.ts";
|
|
27
28
|
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
28
29
|
import { resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
|
|
29
30
|
import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
|
|
@@ -111,7 +112,7 @@ const renderers = new Map<string, MessageRenderer>();
|
|
|
111
112
|
const FAKE_SOURCE_INFO: SourceInfo = { path: "extensions/gentle-shell.ts", source: "gentle-shell", scope: "project", origin: "top-level" };
|
|
112
113
|
|
|
113
114
|
const DEFAULT_COMMANDS: SlashCommandInfo[] = [
|
|
114
|
-
{ name: "gentle:
|
|
115
|
+
{ name: "gentle:profiles", description: "Configure models", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
115
116
|
{ name: "gentle:changes", description: "Browse changes", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
116
117
|
{ name: "gentle:status", description: "Show Gentle AI status", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
117
118
|
{ name: "skill-registry:refresh", description: "Regenerate the skill registry", source: "extension", sourceInfo: FAKE_SOURCE_INFO },
|
|
@@ -503,7 +504,7 @@ test("review sidebar rejects foreign-session events and unsubscribes on shutdown
|
|
|
503
504
|
}
|
|
504
505
|
});
|
|
505
506
|
|
|
506
|
-
test("jobs snapshots
|
|
507
|
+
test("jobs snapshots feed their own rail card, keep one compact fallback, and reject stale sessions", async () => {
|
|
507
508
|
const { pi, handlers } = fakePi();
|
|
508
509
|
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
|
|
509
510
|
const { ctx, ui } = fakeContext();
|
|
@@ -515,35 +516,56 @@ test("jobs snapshots populate Status, use one compact fallback, and reject stale
|
|
|
515
516
|
const component = factory(tui, plainTheme, data);
|
|
516
517
|
const state = sidebarState(tui as unknown as TUI);
|
|
517
518
|
const rail = state.parts.get("footer")!;
|
|
519
|
+
const jobs = state.parts.get("jobs")!;
|
|
518
520
|
const header = state.parts.get("header")!;
|
|
519
521
|
const publish = (sessionId: string, jobs: unknown) => pi.events.emit("gentle-ai:jobs-sidebar", { sessionId, snapshot: { jobs } });
|
|
520
522
|
try {
|
|
521
523
|
publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
|
|
522
|
-
assert.
|
|
524
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR 1860/, "the Status card never prints jobs; the jobs card owns them");
|
|
523
525
|
assert.match(rail.render(46).join("\n"), /MCP: 3 servers/);
|
|
524
|
-
assert.
|
|
526
|
+
assert.match(jobs.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/);
|
|
527
|
+
publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }, { id: "job-0", label: "Build runtime", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]);
|
|
528
|
+
assert.match(jobs.render(60).join("\n"), /1 running · 1 finished/, "finished tails ride the same snapshot");
|
|
529
|
+
assert.match(jobs.render(60).join("\n"), /✓\s+Build runtime/);
|
|
530
|
+
// The compact count is running-only: a finished tail still paints the
|
|
531
|
+
// card, but the header's ⧗ N jobs derives from running entries alone.
|
|
532
|
+
publish("shell-session", [{ id: "job-2", label: "Fresh tail", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]);
|
|
533
|
+
tui.terminal.columns = 80;
|
|
534
|
+
assert.match(jobs.render(46).join("\n"), /Fresh tail/, "the finished tail still paints the card");
|
|
535
|
+
assert.doesNotMatch(header.render(80).join("\n"), /⧗ 1 job/, "the compact count is running-only");
|
|
536
|
+
tui.terminal.columns = 160;
|
|
537
|
+
publish("shell-session", [{ id: "job-0", label: "Stale tail", startedAt: 1, status: "exited", endedAt: 5, exitCode: 0 }]);
|
|
538
|
+
assert.doesNotMatch(jobs.render(46).join("\n"), /Stale tail/, "a finished job past the TTL paints nothing");
|
|
539
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "the jobs counter never joins the integration statuses while display data exists");
|
|
525
540
|
state.active = true;
|
|
526
541
|
state.ownsHost = () => true;
|
|
527
|
-
assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible
|
|
542
|
+
assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible rail owns job details");
|
|
528
543
|
state.active = false;
|
|
529
544
|
tui.terminal.columns = 80;
|
|
530
|
-
|
|
531
|
-
|
|
545
|
+
publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]);
|
|
546
|
+
assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count of a running job");
|
|
547
|
+
const before = jobs.render(46).join("\n");
|
|
532
548
|
publish("foreign", []);
|
|
533
549
|
publish("shell-session", [{ label: "invalid" }]);
|
|
534
|
-
assert.equal(
|
|
550
|
+
assert.equal(jobs.render(46).join("\n"), before);
|
|
535
551
|
publish("shell-session", []);
|
|
536
|
-
assert.doesNotMatch(
|
|
552
|
+
assert.doesNotMatch(jobs.render(46).join("\n"), /Jobs|CI for PR/);
|
|
537
553
|
assert.doesNotMatch(header.render(80).join("\n"), /1 job/);
|
|
538
554
|
publish("shell-session", [{ id: "job-2", label: "Build runtime", startedAt: 2 }]);
|
|
539
555
|
await fire(handlers, "session_start", ctx);
|
|
540
|
-
assert.doesNotMatch(
|
|
556
|
+
assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /Build runtime/);
|
|
541
557
|
await fire(handlers, "session_shutdown", ctx);
|
|
542
558
|
publish("shell-session", [{ id: "job-3", label: "late", startedAt: 3 }]);
|
|
543
|
-
assert.doesNotMatch(
|
|
559
|
+
assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /late/);
|
|
544
560
|
} finally { component.dispose(); }
|
|
545
561
|
});
|
|
546
562
|
|
|
563
|
+
test("the jobs snapshot validator admits the finished-tail statuses", () => {
|
|
564
|
+
assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-1", label: "Spawn failed", startedAt: 1, status: "failed", endedAt: 60_000, exitCode: null }] }));
|
|
565
|
+
assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-2", label: "Stopped", startedAt: 1, status: "stopped", endedAt: 2 }] }));
|
|
566
|
+
assert.equal(isJobsSidebarSnapshot({ jobs: [{ id: "job-3", label: "Bad", startedAt: 1, status: "exploded" }] }), false);
|
|
567
|
+
});
|
|
568
|
+
|
|
547
569
|
test("hidden Status leaves only a jobs count in regular bottom and fullscreen header chrome", async (t) => {
|
|
548
570
|
const home = scopedDoubleEscCancelConfigHome(t);
|
|
549
571
|
writeVisualSettings({ ...resolveVisualSettings({ gentlePiConfigHome: home }).settings, statusPlacement: "hidden" }, { gentlePiConfigHome: home });
|
|
@@ -559,6 +581,9 @@ test("hidden Status leaves only a jobs count in regular bottom and fullscreen he
|
|
|
559
581
|
pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
|
|
560
582
|
assert.equal(component.render(80).join("\n"), "⧗ 1 job");
|
|
561
583
|
assert.doesNotMatch(component.render(80).join("\n"), /CI for PR|gpt-5/);
|
|
584
|
+
pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }] } });
|
|
585
|
+
assert.doesNotMatch(component.render(80).join("\n"), /⧗/, "a finished-only tail never paints a ⧗ 0 jobs row");
|
|
586
|
+
pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } });
|
|
562
587
|
tui.mode = "fullscreen";
|
|
563
588
|
const header = sidebarState(tui as unknown as TUI).parts.get("header")!;
|
|
564
589
|
assert.match(header.render(160).join("\n"), /⧗ 1 job/);
|
|
@@ -2168,7 +2193,7 @@ test("vim NORMAL slash uses Pi's command and skill completion without displacing
|
|
|
2168
2193
|
fg: (_color, text) => text, bold: (text) => text, requestRender() {}, pending: () => false,
|
|
2169
2194
|
now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {}, tuiVersion: () => INSTALLED_PI,
|
|
2170
2195
|
});
|
|
2171
|
-
const entries = ["gentle:vim", "gentle:
|
|
2196
|
+
const entries = ["gentle:vim", "gentle:profiles", "skill:example"];
|
|
2172
2197
|
const requests: string[] = [];
|
|
2173
2198
|
editor.setAutocompleteProvider({
|
|
2174
2199
|
async getSuggestions(lines: string[], line: number, col: number) {
|
|
@@ -5668,11 +5693,11 @@ test("/gentle:commands shows only curated, registered commands, grouped, by thei
|
|
|
5668
5693
|
assert.match(rendered, /Diagnostics/);
|
|
5669
5694
|
assert.match(rendered, /Skills/);
|
|
5670
5695
|
assert.doesNotMatch(rendered, /\bSDD\b/, "the SDD group has no registered commands and must not appear");
|
|
5671
|
-
assert.match(rendered, /
|
|
5696
|
+
assert.match(rendered, /Models and profiles/);
|
|
5672
5697
|
assert.match(rendered, /Browse changes/);
|
|
5673
5698
|
assert.match(rendered, /Gentle AI status/);
|
|
5674
5699
|
assert.match(rendered, /Refresh skill registry/);
|
|
5675
|
-
assert.doesNotMatch(rendered, /gentle:
|
|
5700
|
+
assert.doesNotMatch(rendered, /gentle:profiles|gentle:changes|gentle:status|skill-registry:refresh/, "raw command names must not leak; only labels are shown");
|
|
5676
5701
|
assert.doesNotMatch(rendered, /gentle:not-in-catalog/);
|
|
5677
5702
|
assert.doesNotMatch(rendered, /skill:foo/);
|
|
5678
5703
|
const changesLine = lines.find((line) => line.includes("Browse changes"));
|
|
@@ -5687,13 +5712,13 @@ test("selecting a command from the palette by its label sends the underlying com
|
|
|
5687
5712
|
const { ctx, ui, overlayReady } = fakeContext();
|
|
5688
5713
|
const opened = commands.get("gentle:commands")!.handler("", ctx);
|
|
5689
5714
|
await overlayReady;
|
|
5690
|
-
// "mod" only matches the "
|
|
5691
|
-
//
|
|
5715
|
+
// "mod" only matches the "Models and profiles" label (it contains "mod"
|
|
5716
|
+
// via "Models"); nothing else in the fixture does.
|
|
5692
5717
|
for (const ch of "mod") ui.overlayView!.handleInput(ch);
|
|
5693
|
-
assert.match(ui.overlayView!.render(100).join("\n"), /
|
|
5718
|
+
assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/);
|
|
5694
5719
|
ui.overlayView!.handleInput("\r");
|
|
5695
5720
|
await opened;
|
|
5696
|
-
assert.deepEqual(sentMessages, [{ content: "/gentle:
|
|
5721
|
+
assert.deepEqual(sentMessages, [{ content: "/gentle:profiles", options: { expandPromptTemplates: true } }]);
|
|
5697
5722
|
});
|
|
5698
5723
|
|
|
5699
5724
|
test("escaping the palette sends no message", async () => {
|
|
@@ -5736,7 +5761,7 @@ test("the alt+k shortcut opens the same command palette as the command", async (
|
|
|
5736
5761
|
assert.ok(shortcut, "alt+k not registered");
|
|
5737
5762
|
const opened = shortcut.handler(ctx);
|
|
5738
5763
|
await overlayReady;
|
|
5739
|
-
assert.match(ui.overlayView!.render(100).join("\n"), /
|
|
5764
|
+
assert.match(ui.overlayView!.render(100).join("\n"), /Models and profiles/);
|
|
5740
5765
|
ui.overlayView!.handleInput("\x1b");
|
|
5741
5766
|
await opened;
|
|
5742
5767
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { artifactFor, uvPinVersion } from "../scripts/installer-downloads.mjs";
|
|
4
|
+
|
|
5
|
+
// The uv the /gentle:music offer installs inside a bundled prefix (S5): the
|
|
6
|
+
// astral-sh/uv release archives, each re-hashed after download before pinning.
|
|
7
|
+
const uvPins = {
|
|
8
|
+
"darwin-arm64": ["uv-aarch64-apple-darwin.tar.gz", "60fbd5c8a1ed681845eea28d9d622fcde03c51e91a43465b64a1e17db1ed3472", 14235670],
|
|
9
|
+
"darwin-x64": ["uv-x86_64-apple-darwin.tar.gz", "63ba17fdda55ad1a230d4b37fc6017ffa10c23688c83818bd3bfdf914c652a02", 17004934],
|
|
10
|
+
"linux-x64": ["uv-x86_64-unknown-linux-gnu.tar.gz", "f29c3e94ffabd7e6f3ba521dba169a9b7ec12100bf3257087f0f24f6123f3645", 16799024],
|
|
11
|
+
"linux-arm64": ["uv-aarch64-unknown-linux-gnu.tar.gz", "229e194255c7958d86d56e8790d969a6f74736592133f0dfd0076f754c642c1d", 15794886],
|
|
12
|
+
"win32-x64": ["uv-x86_64-pc-windows-msvc.zip", "a2a8820d5eea32f08ef467ef3b7f66883a9b076d5737a2fc2fbb5f458d1bb50f", 15239500],
|
|
13
|
+
"win32-arm64": ["uv-aarch64-pc-windows-msvc.zip", "c2426c0c8e28b53d8c440e47a18d96ad4db51e159866c0e81435b196d7f0081c", 15439263],
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
test("fixed uv descriptors are the astral-sh/uv release archives and checksums, and nothing else", () => {
|
|
17
|
+
assert.equal(uvPinVersion, "0.13.6");
|
|
18
|
+
for (const [target, [asset, sha256, size]] of Object.entries(uvPins)) {
|
|
19
|
+
const [platform, arch] = target.split("-");
|
|
20
|
+
const artifact = artifactFor("uv", platform, arch);
|
|
21
|
+
assert.equal(artifact.name, "uv");
|
|
22
|
+
assert.equal(artifact.version, "0.13.6");
|
|
23
|
+
assert.equal(artifact.url, `https://github.com/astral-sh/uv/releases/download/0.13.6/${asset}`);
|
|
24
|
+
assert.equal(artifact.integrity, `sha256-${sha256}`);
|
|
25
|
+
assert.deepEqual([artifact.size, artifact.maxBytes], [size, size]);
|
|
26
|
+
assert.deepEqual(artifact.redirectHosts, ["objects.githubusercontent.com", "release-assets.githubusercontent.com"]);
|
|
27
|
+
assert.ok(Object.isFrozen(artifact));
|
|
28
|
+
}
|
|
29
|
+
for (const [platform, arch] of [["linux", "ia32"], ["freebsd", "x64"], ["win32", "ia32"], ["darwin", "ppc64"]]) {
|
|
30
|
+
assert.throws(() => artifactFor("uv", platform, arch), /Unsupported/);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
@@ -306,8 +306,8 @@ test("/api/plan names the found and required version of an older Gentle Shell an
|
|
|
306
306
|
const view = await plan(port, await login());
|
|
307
307
|
assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["incompatible-tool", "shell"]]);
|
|
308
308
|
assert.equal(view.blockers[0].guidance,
|
|
309
|
-
"Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.
|
|
310
|
-
"Update it with `pnpm add -g gentle-pi@4.
|
|
309
|
+
"Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.5.1 or newer. Nothing was replaced. " +
|
|
310
|
+
"Update it with `pnpm add -g gentle-pi@4.5.1`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
|
|
311
311
|
assert.deepEqual(view.actions, []);
|
|
312
312
|
} finally {
|
|
313
313
|
await host.close("test");
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test, { after, before } from "node:test";
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { JOBS_GLYPH, renderJobsCard, widgetJobs } from "../lib/jobs-widget.ts";
|
|
5
|
+
import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
|
|
6
|
+
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
7
|
+
|
|
8
|
+
// The Jobs sidebar card replaces the Jobs group the Status card used to print.
|
|
9
|
+
// Rendering is pure, so it verifies without a TUI: newest job first, one row
|
|
10
|
+
// per running job with its elapsed time, collapsible like Agents and Todos.
|
|
11
|
+
|
|
12
|
+
// Width math counts every painted character, so these assertions use a plain
|
|
13
|
+
// theme; colored roles are covered by the widget's paint calls, not here.
|
|
14
|
+
const plainTheme = {
|
|
15
|
+
fg(_color: string, text: string) {
|
|
16
|
+
return text;
|
|
17
|
+
},
|
|
18
|
+
bold(text: string) {
|
|
19
|
+
return text;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const jobs = [
|
|
24
|
+
{ id: "job-2", label: "Watcher CI ronda 2", startedAt: 40_000 },
|
|
25
|
+
{ id: "job-1", label: "Build runtime", startedAt: 10_000 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// The card style defaults to float; these assertions pin the outlined (neon)
|
|
29
|
+
// panels unless a test switches the style itself.
|
|
30
|
+
const initialCardStyle = cardStyle();
|
|
31
|
+
before(() => setCardStyle(CARD_STYLE.NEON));
|
|
32
|
+
after(() => setCardStyle(initialCardStyle));
|
|
33
|
+
|
|
34
|
+
test("the jobs card lists running jobs newest first with elapsed time", () => {
|
|
35
|
+
const lines = renderJobsCard(jobs, plainTheme, 50, 100_000, { collapsed: false, collapseControl: true });
|
|
36
|
+
const text = lines.join("\n");
|
|
37
|
+
assert.match(text, new RegExp(`${JOBS_GLYPH} Jobs`));
|
|
38
|
+
assert.match(text, /2 running/);
|
|
39
|
+
const watcher = text.indexOf("Watcher CI ronda 2");
|
|
40
|
+
const build = text.indexOf("Build runtime");
|
|
41
|
+
assert.ok(watcher >= 0, "the newest job paints");
|
|
42
|
+
assert.ok(build > watcher, "newest first, matching the /gentle:jobs running group");
|
|
43
|
+
assert.match(text, /1m 0s/, "the 60s job shows its elapsed time");
|
|
44
|
+
assert.match(text, /1m 30s/, "the 90s job shows its elapsed time");
|
|
45
|
+
assert.match(text, /▾ Collapse/);
|
|
46
|
+
for (const line of lines) assert.ok(visibleWidth(stripAnsi(line)) <= 50, `row fits: ${stripAnsi(line)}`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("an empty job list paints nothing", () => {
|
|
50
|
+
assert.deepEqual(renderJobsCard([], plainTheme, 50, 100_000, { collapsed: false }), []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a collapsed jobs card keeps the newest job and folds the rest", () => {
|
|
54
|
+
// Wide enough for the header to also fit the "ctrl+shift+j expand" hint.
|
|
55
|
+
const lines = renderJobsCard(jobs, plainTheme, 64, 100_000, { collapsed: true, collapseKey: "ctrl+shift+j", collapseControl: true });
|
|
56
|
+
const text = lines.join("\n");
|
|
57
|
+
assert.match(text, /Watcher CI ronda 2/);
|
|
58
|
+
assert.doesNotMatch(text, /Build runtime/);
|
|
59
|
+
assert.match(text, /… 1 more/);
|
|
60
|
+
assert.match(text, /ctrl\+shift\+j expand/);
|
|
61
|
+
assert.match(text, /▸ Expand/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("the collapse control is opt-in and the sort never mutates the snapshot", () => {
|
|
65
|
+
const snapshot = [{ id: "job-1", label: "Build runtime", startedAt: 10_000 }];
|
|
66
|
+
const lines = renderJobsCard(snapshot, plainTheme, 50, 100_000, { collapsed: false });
|
|
67
|
+
assert.doesNotMatch(lines.join("\n"), /Collapse|Expand/);
|
|
68
|
+
const sorted = widgetJobs(snapshot, 100_000);
|
|
69
|
+
assert.deepEqual(snapshot, [{ id: "job-1", label: "Build runtime", startedAt: 10_000 }]);
|
|
70
|
+
assert.equal(sorted[0]?.id, "job-1");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Finished tails mirror the Agents card's results: an exit lingers for a
|
|
74
|
+
// minute with the /gentle:jobs outcome glyph, its elapsed frozen at the end.
|
|
75
|
+
|
|
76
|
+
const exitedJob = { id: "job-1", label: "Build runtime", startedAt: 10_000, status: "exited" as const, endedAt: 70_000, exitCode: 0 as number | null };
|
|
77
|
+
|
|
78
|
+
function finishedJobs(): Array<typeof exitedJob> {
|
|
79
|
+
return [1, 2, 3, 4].map((n) => ({ ...exitedJob, id: `job-${n}`, label: `Job ${n}`, startedAt: n * 1_000, endedAt: 100_000 + n * 1_000 }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test("a finished job lingers with a ✓ row and its elapsed frozen at the end", () => {
|
|
83
|
+
const lines = renderJobsCard([exitedJob], plainTheme, 50, 100_000, { collapsed: false });
|
|
84
|
+
const text = lines.join("\n");
|
|
85
|
+
assert.match(text, /✓\s+Build runtime/);
|
|
86
|
+
assert.match(text, /1m 0s/, "elapsed freezes at endedAt instead of ticking to 1m 30s");
|
|
87
|
+
assert.match(text, /1 finished/);
|
|
88
|
+
for (const line of lines) assert.ok(visibleWidth(stripAnsi(line)) <= 50, `row fits: ${stripAnsi(line)}`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a nonzero exit paints ✗ and a stopped job paints –", () => {
|
|
92
|
+
const lines = renderJobsCard([
|
|
93
|
+
{ ...exitedJob, exitCode: 1 },
|
|
94
|
+
{ id: "job-2", label: "Watcher CI", startedAt: 5_000, status: "stopped", endedAt: 60_000 },
|
|
95
|
+
], plainTheme, 50, 100_000, { collapsed: false });
|
|
96
|
+
const text = lines.join("\n");
|
|
97
|
+
assert.match(text, /✗\s+Build runtime/);
|
|
98
|
+
assert.match(text, /–\s+Watcher CI/);
|
|
99
|
+
assert.match(text, /2 finished/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a failed job rides the tail with the ✗ glyph", () => {
|
|
103
|
+
const lines = renderJobsCard([{ ...exitedJob, status: "failed" as const }], plainTheme, 50, 100_000, { collapsed: false });
|
|
104
|
+
const text = lines.join("\n");
|
|
105
|
+
assert.match(text, /✗\s+Build runtime/);
|
|
106
|
+
assert.match(text, /1m 0s/, "elapsed freezes at endedAt like the other outcomes");
|
|
107
|
+
assert.match(text, /1 finished/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("finished jobs past the TTL paint nothing", () => {
|
|
111
|
+
// now - endedAt lands exactly on the TTL: not fresh anymore.
|
|
112
|
+
assert.deepEqual(renderJobsCard([{ ...exitedJob, endedAt: 40_000 }], plainTheme, 50, 100_000, { collapsed: false }), []);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("at most the three newest finished jobs survive", () => {
|
|
116
|
+
const jobs = finishedJobs();
|
|
117
|
+
const shown = widgetJobs(jobs, 105_000);
|
|
118
|
+
assert.deepEqual(shown.map((job) => job.id), ["job-4", "job-3", "job-2"]);
|
|
119
|
+
assert.deepEqual(jobs, finishedJobs(), "the TTL filter never mutates the snapshot");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("running entries sort before the finished tail and never expire", () => {
|
|
123
|
+
const snapshot = [
|
|
124
|
+
{ ...exitedJob, id: "done", label: "Old build", startedAt: 1_000 },
|
|
125
|
+
{ id: "old", label: "Old run", startedAt: 1_000 },
|
|
126
|
+
{ id: "new", label: "New run", startedAt: 50_000 },
|
|
127
|
+
];
|
|
128
|
+
const shown = widgetJobs(snapshot, 100_000);
|
|
129
|
+
assert.deepEqual(shown.map((job) => job.id), ["new", "old", "done"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the subtitle mixes running and finished counts", () => {
|
|
133
|
+
const lines = renderJobsCard([
|
|
134
|
+
{ id: "job-1", label: "New run", startedAt: 50_000 },
|
|
135
|
+
{ id: "job-2", label: "Watcher CI", startedAt: 5_000, status: "stopped", endedAt: 60_000 },
|
|
136
|
+
], plainTheme, 50, 100_000, { collapsed: false });
|
|
137
|
+
assert.match(lines.join("\n"), /1 running · 1 finished/);
|
|
138
|
+
});
|
|
@@ -7,7 +7,7 @@ import test from "node:test";
|
|
|
7
7
|
import { applyModelConfig, applyModelConfigAsync, applySavedModelConfig } from "../extensions/gentle-ai.ts";
|
|
8
8
|
|
|
9
9
|
// gentle-ai#4946: the activation sweep re-applies saved routing without user
|
|
10
|
-
// consent. Persisted clear entries (an "inherit" saved from /gentle:
|
|
10
|
+
// consent. Persisted clear entries (an "inherit" saved from /gentle:profiles is
|
|
11
11
|
// `{}`) must not delete `model_profiles` entries the user authored in the
|
|
12
12
|
// agent-home `subagents.json`. Clearing stays owned by the consented flows
|
|
13
13
|
// that issue it: the panel save and a confirmed profile apply.
|
|
@@ -66,7 +66,7 @@ function preservedProfilesFixture(t: test.TestContext) {
|
|
|
66
66
|
|
|
67
67
|
test("startup sweep keeps user model_profiles entries for persisted clears", async (t) => {
|
|
68
68
|
const fixture = preservedProfilesFixture(t);
|
|
69
|
-
// Saved routing as /gentle:
|
|
69
|
+
// Saved routing as /gentle:profiles leaves it after pressing "inherit" on
|
|
70
70
|
// worker: a persisted clear entry. The sweep must not delete the entries.
|
|
71
71
|
writeFileSync(fixture.globalModelsPath, JSON.stringify({ worker: {} }));
|
|
72
72
|
const result = await applySavedModelConfig(fixture.context);
|
|
@@ -1793,9 +1793,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
|
|
|
1793
1793
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1794
1794
|
});
|
|
1795
1795
|
|
|
1796
|
-
test("Gentle Shell v4.
|
|
1796
|
+
test("Gentle Shell v4.5.1 package manifest declares the release version", () => {
|
|
1797
1797
|
const packageJson = readPackageJson();
|
|
1798
|
-
assert.equal(packageJson.version, "4.
|
|
1798
|
+
assert.equal(packageJson.version, "4.5.1", "the release manifest must be explicitly pinned to v4.5.1");
|
|
1799
1799
|
assert.equal(packageJson.scripts?.test, "node scripts/run-test-suite.mjs");
|
|
1800
1800
|
assert.ok(packageJson.files?.includes("assets/"));
|
|
1801
1801
|
assert.ok(packageJson.files?.includes("contracts/"));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { __testing } from "../extensions/gentle-ai.ts";
|
|
4
|
+
|
|
5
|
+
// gentle-shell #55 / #57 / #125: the reply language is inferred every turn,
|
|
6
|
+
// never stored, so the persona prompt itself must say how to infer it. The
|
|
7
|
+
// rule lives on the single existing language-match bullet (the concept-level
|
|
8
|
+
// duplication guard allows exactly one), so these tests read the rendered
|
|
9
|
+
// prompt rather than a second rule.
|
|
10
|
+
|
|
11
|
+
for (const persona of ["gentleman", "neutral"] as const) {
|
|
12
|
+
test(`[${persona}] the reply language follows the user's latest message, never a Spanish default (#125, #57)`, () => {
|
|
13
|
+
const prompt = __testing.buildGentlePrompt(persona);
|
|
14
|
+
assert.match(prompt, /Always respond in the same language the user writes in\./);
|
|
15
|
+
assert.match(
|
|
16
|
+
prompt,
|
|
17
|
+
/latest message, never by earlier context or a compaction summary/,
|
|
18
|
+
`${persona} prompt must judge the reply language by the user's latest message, so a compaction summary cannot flip it (#57)`,
|
|
19
|
+
);
|
|
20
|
+
assert.match(
|
|
21
|
+
prompt,
|
|
22
|
+
/never default to Spanish: a message in English gets a reply in English/,
|
|
23
|
+
`${persona} prompt must forbid a Spanish default, so an English message never gets a Spanish reply (#125)`,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("[neutral] the voseo prohibition covers regular and irregular forms, not only four irregular verbs (#55)", () => {
|
|
29
|
+
const prompt = __testing.buildGentlePrompt("neutral");
|
|
30
|
+
assert.match(prompt, /Do NOT use voseo/);
|
|
31
|
+
assert.match(prompt, /regular or irregular: use tú or usted forms, never vos forms/);
|
|
32
|
+
for (const pair of ["ajustas, not ajustás", "vives, not vivís", "mira, not mirá", "haz, not hacé"]) {
|
|
33
|
+
assert.ok(prompt.includes(pair), `neutral prompt must name the voseo replacement "${pair}"`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("[gentleman] Spanish replies keep Rioplatense voseo and get no neutral-only rule", () => {
|
|
38
|
+
const prompt = __testing.buildGentlePrompt("gentleman");
|
|
39
|
+
assert.match(prompt, /When the user writes Spanish, answer in natural Rioplatense Spanish with voseo\./);
|
|
40
|
+
assert.match(prompt, /Language: natural Rioplatense Spanish with voseo when the user writes Spanish\./);
|
|
41
|
+
assert.doesNotMatch(prompt, /never vos forms/, "gentleman prompt must not inherit the neutral voseo prohibition");
|
|
42
|
+
});
|