gentle-pi 2.6.4 → 2.7.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 +1 -1
- package/docs/gentle-shell.md +30 -18
- package/docs/readme-reference.md +5 -5
- package/extensions/gentle-agents.ts +8 -0
- package/extensions/gentle-shell.ts +52 -70
- package/lib/agents-runner.ts +7 -2
- package/lib/native-review-cli.ts +9 -0
- package/lib/session-change-capture.ts +87 -0
- package/lib/session-changes.ts +140 -0
- package/lib/shell-bar.ts +6 -1
- package/lib/shell-changes-view.ts +2 -1
- package/lib/shell-changes.ts +4 -2
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +9 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-runner.test.ts +14 -0
- package/tests/gentle-agents.test.ts +34 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-shell.test.ts +136 -196
- package/tests/native-review-capability-contract.test.ts +15 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/session-change-capture.test.ts +68 -0
- package/tests/session-changes-shell.test.ts +38 -0
- package/tests/session-changes.test.ts +103 -0
- package/tests/shell-bar.test.ts +14 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync, execFile } from "node:child_process";
|
|
3
|
-
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import type { TUI } from "@earendil-works/pi-tui";
|
|
9
|
-
import installGentleShell, { buildShellBarModel, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
9
|
+
import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
10
10
|
import { CHANGE_STATUS } from "../lib/shell-changes.ts";
|
|
11
11
|
import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
|
|
12
12
|
import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
@@ -232,7 +232,8 @@ test("gentleShell installs the footer on session_start when a UI exists", () =>
|
|
|
232
232
|
|
|
233
233
|
test("the fullscreen Status rail carries a live digest so a model switch refreshes it", async () => {
|
|
234
234
|
const { pi, handlers } = fakePi();
|
|
235
|
-
|
|
235
|
+
let profile: string | undefined = "team";
|
|
236
|
+
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => profile });
|
|
236
237
|
const entries: unknown[] = [];
|
|
237
238
|
const { ctx, ui } = fakeContext({ entries });
|
|
238
239
|
await fire(handlers, "session_start", ctx);
|
|
@@ -248,6 +249,14 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
|
|
|
248
249
|
assert.equal(typeof rail.digest, "function", "the Status card paints live state and must declare a digest");
|
|
249
250
|
assert.match(rail.render(46).join("\n"), /gpt-5\.5/);
|
|
250
251
|
|
|
252
|
+
assert.match(rail.render(46).join("\n"), /Profile.*team/);
|
|
253
|
+
const beforeProfile = live();
|
|
254
|
+
profile = "other";
|
|
255
|
+
assert.notEqual(live(), beforeProfile);
|
|
256
|
+
assert.match(rail.render(46).join("\n"), /Profile.*other/);
|
|
257
|
+
profile = undefined;
|
|
258
|
+
assert.doesNotMatch(rail.render(46).join("\n"), /Profile/);
|
|
259
|
+
|
|
251
260
|
const beforeModel = live();
|
|
252
261
|
(ctx.model as { id: string }).id = "gpt-5.6";
|
|
253
262
|
assert.notEqual(live(), beforeModel, "/model must change the digest");
|
|
@@ -273,10 +282,44 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
|
|
|
273
282
|
}
|
|
274
283
|
});
|
|
275
284
|
|
|
285
|
+
test("profile reader follows store changes and rejects missing or invalid active markers", (t) => {
|
|
286
|
+
const root = mkdtempSync(join(tmpdir(), "shell-profile-"));
|
|
287
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
288
|
+
const path = join(root, "profiles.json");
|
|
289
|
+
const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
|
|
290
|
+
const save = (active: string | undefined) => writeFileSync(path, JSON.stringify({
|
|
291
|
+
kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} },
|
|
292
|
+
}));
|
|
293
|
+
assert.equal(read(), undefined);
|
|
294
|
+
save("team");
|
|
295
|
+
assert.equal(read(), "team");
|
|
296
|
+
assert.equal(read(), "team");
|
|
297
|
+
save("other");
|
|
298
|
+
assert.equal(read(), "other");
|
|
299
|
+
const replacement = join(root, "replacement.json");
|
|
300
|
+
writeFileSync(replacement, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {} } }));
|
|
301
|
+
renameSync(replacement, path);
|
|
302
|
+
assert.equal(read(), "team", "atomic replacement refreshes the cached profile");
|
|
303
|
+
const isolated = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: join(root, "other-home") });
|
|
304
|
+
assert.equal(isolated(), undefined);
|
|
305
|
+
assert.equal(read(), "team", "another shell's config home does not alter this cache");
|
|
306
|
+
save("missing");
|
|
307
|
+
assert.equal(read(), undefined);
|
|
308
|
+
save(undefined);
|
|
309
|
+
assert.equal(read(), undefined);
|
|
310
|
+
writeFileSync(path, "{broken");
|
|
311
|
+
assert.equal(read(), undefined);
|
|
312
|
+
save("team");
|
|
313
|
+
assert.equal(read(), "team");
|
|
314
|
+
rmSync(path);
|
|
315
|
+
assert.equal(read(), undefined);
|
|
316
|
+
});
|
|
317
|
+
|
|
276
318
|
test("gentleShell stays out of the way without a UI or when disabled", () => {
|
|
277
319
|
const disabled = fakePi();
|
|
278
320
|
gentleShell(disabled.pi, { GENTLE_PI_SHELL: "0" });
|
|
279
|
-
assert.equal(disabled.
|
|
321
|
+
assert.equal(disabled.commands.size, 0);
|
|
322
|
+
assert.ok(disabled.handlers.has("tool_call"), "capture remains available to headless children");
|
|
280
323
|
|
|
281
324
|
const headless = fakePi();
|
|
282
325
|
gentleShell(headless.pi, {});
|
|
@@ -364,152 +407,87 @@ function renderFooter(ui: FakeUi): string {
|
|
|
364
407
|
return factory(fakeTui, plainTheme, footerData).render(160)[0];
|
|
365
408
|
}
|
|
366
409
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
await fire(handlers, "session_start", ctx);
|
|
375
|
-
assert.deepEqual(git[0].slice(0, 2), ["-C", "/repo"]);
|
|
376
|
-
assert.equal(ui.widgets.has("gentle-shell-changes"), false);
|
|
377
|
-
assert.doesNotMatch(renderFooter(ui), /±/);
|
|
378
|
-
|
|
379
|
-
await fire(handlers, "tool_execution_end", ctx);
|
|
380
|
-
const factory = ui.widgets.get("gentle-shell-changes") as (tui: unknown, theme: ShellBarTheme) => { render(width: number): string[] };
|
|
381
|
-
const [line] = factory(fakeTui, plainTheme).render(120);
|
|
382
|
-
assert.match(line, /^✎ 1 file · \+10 −0 · lib\/b\.ts +\/gentle:changes$/);
|
|
383
|
-
assert.match(renderFooter(ui), /main ±1/);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
test("gentleShell registers /gentle:changes and opens the overlay only when there are changes", async () => {
|
|
387
|
-
const { pi, handlers, commands } = fakePi([
|
|
388
|
-
{ numstat: "", porcelain: "" },
|
|
389
|
-
{ numstat: "", porcelain: "" },
|
|
390
|
-
{ numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
|
|
391
|
-
]);
|
|
392
|
-
gentleShell(pi, {});
|
|
393
|
-
const { ctx, ui } = fakeContext();
|
|
394
|
-
await fire(handlers, "session_start", ctx);
|
|
395
|
-
const command = commands.get("gentle:changes");
|
|
396
|
-
assert.ok(command, "command not registered");
|
|
397
|
-
|
|
398
|
-
await command.handler("", ctx);
|
|
399
|
-
assert.deepEqual(ui.notices, ["No changes in the working tree."]);
|
|
400
|
-
assert.equal(ui.overlay, undefined);
|
|
410
|
+
function sessionChange(ctx: ExtensionContext, id: string, root: string, path: string, before = "", after = "agent\n"): void {
|
|
411
|
+
const entries = ctx.sessionManager.getEntries() as any[];
|
|
412
|
+
entries.push({ type: "custom", customType: "gentle-pi.session-change/v1", data: {
|
|
413
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
414
|
+
evidence: { id, root, path, before: before ? {kind:"text",text:before} : {kind:"absent"}, after:{kind:"text",text:after} },
|
|
415
|
+
} });
|
|
416
|
+
}
|
|
401
417
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
418
|
+
test("captured changes update the widget and bar without repository scans", async () => {
|
|
419
|
+
const { pi, handlers, git } = fakePi([{numstat:"999\t0\tforeign.ts\n",porcelain:"?? foreign.ts\0"}]);
|
|
420
|
+
gentleShell(pi,{});
|
|
421
|
+
const {ctx,ui}=fakeContext();
|
|
422
|
+
await fire(handlers,"session_start",ctx);
|
|
423
|
+
assert.equal(git.length,0);
|
|
424
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"),false);
|
|
425
|
+
sessionChange(ctx,"a","/repo","lib/b.ts","","one\ntwo\n");
|
|
426
|
+
pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
|
|
427
|
+
await new Promise(resolve=>setImmediate(resolve));
|
|
428
|
+
const factory=ui.widgets.get("gentle-shell-changes") as any;
|
|
429
|
+
assert.match(factory(fakeTui,plainTheme).render(140)[0],/1 file · \+2 −0/);
|
|
430
|
+
assert.match(renderFooter(ui),/main ±1/);
|
|
431
|
+
assert.equal(git.length,0);
|
|
432
|
+
await fire(handlers,"session_shutdown",ctx);
|
|
408
433
|
});
|
|
409
434
|
|
|
410
|
-
test("
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const open = commands.get("gentle:changes")!.handler("", ctx);
|
|
428
|
-
try {
|
|
429
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
430
|
-
assert.match(ui.overlayView!.render(120).join("\n"), /linked · linked/);
|
|
431
|
-
assert.equal(diffs.length, 0);
|
|
432
|
-
ui.overlayView!.handleInput("j");
|
|
433
|
-
ui.overlayView!.handleInput("\r");
|
|
434
|
-
ui.overlayView!.handleInput("j");
|
|
435
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
436
|
-
assert.deepEqual(diffs[0], ["-C", "/linked", "diff", "HEAD", "--", "same.ts"]);
|
|
437
|
-
ui.overlayView!.handleInput("\x1b[D");
|
|
438
|
-
roots.push("/new");
|
|
439
|
-
await tools.get("session_worktree_register")!.execute("register", { path: "/new" }, undefined, undefined, ctx);
|
|
440
|
-
ui.overlayView!.handleInput("r");
|
|
441
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
442
|
-
assert.match(ui.overlayView!.render(120).join("\n"), /new · new/);
|
|
443
|
-
roots.push("/polled");
|
|
444
|
-
await tools.get("session_worktree_register")!.execute("register", { path: "/polled" }, undefined, undefined, ctx);
|
|
445
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
446
|
-
assert.match(ui.overlayView!.render(120).join("\n"), /polled · polled/);
|
|
447
|
-
} finally {
|
|
448
|
-
ui.closeOverlay?.();
|
|
449
|
-
await open;
|
|
450
|
-
await fire(handlers, "session_shutdown", ctx);
|
|
451
|
-
}
|
|
435
|
+
test("Changes opens only for captured mutations, not registered dirty roots", async () => {
|
|
436
|
+
const {pi,handlers,commands,tools,git}=fakePi();
|
|
437
|
+
gentleShell(pi,{});
|
|
438
|
+
const {ctx,ui,overlayReady}=fakeContext();
|
|
439
|
+
await fire(handlers,"session_start",ctx);
|
|
440
|
+
await tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,ctx);
|
|
441
|
+
await commands.get("gentle:changes")!.handler("",ctx);
|
|
442
|
+
assert.match(ui.notices.join("\n"),/No captured agent changes/);
|
|
443
|
+
assert.equal(ui.overlay,undefined);
|
|
444
|
+
sessionChange(ctx,"a","/linked","file.ts");
|
|
445
|
+
await fire(handlers,"agent_end",ctx);
|
|
446
|
+
const opened=commands.get("gentle:changes")!.handler("",ctx);
|
|
447
|
+
await overlayReady;
|
|
448
|
+
assert.match(ui.overlayView!.render(140).join("\n"),/linked/);
|
|
449
|
+
assert.equal(git.length,0);
|
|
450
|
+
ui.closeOverlay?.(); await opened;
|
|
451
|
+
await fire(handlers,"session_shutdown",ctx);
|
|
452
452
|
});
|
|
453
453
|
|
|
454
|
-
test("
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
await emit("tool_execution_end", { toolCallId: "call", toolName, isError });
|
|
475
|
-
};
|
|
476
|
-
await complete("read", { path: "/failed" }, true);
|
|
477
|
-
await complete("bash", { command: "cd /opaque && edit stuff", path: "/opaque" });
|
|
478
|
-
assert.deepEqual(new Set(queried), new Set(["/repo"]));
|
|
479
|
-
await complete("read", { path: "/used" });
|
|
480
|
-
await complete("write", { path: "/used" });
|
|
481
|
-
assert.deepEqual(new Set(queried), new Set(["/repo", "/used"]));
|
|
482
|
-
assert.equal(ctx.sessionManager.getEntries().length, 2, "multiple completed tools dedupe");
|
|
483
|
-
assert.match(renderFooter(ui), /±4/);
|
|
484
|
-
const widget = ui.widgets.get("gentle-shell-changes") as (host: unknown, theme: unknown) => { render(width: number): string[] };
|
|
485
|
-
assert.match(widget(fakeTui, plainTheme).render(140)[0], /4 files/);
|
|
486
|
-
const open = commands.get("gentle:changes")!.handler("", ctx);
|
|
487
|
-
await overlayReady;
|
|
488
|
-
assert.doesNotMatch(ui.overlayView!.render(140).join("\n"), /hidden|opaque|failed/);
|
|
489
|
-
ui.closeOverlay?.();
|
|
490
|
-
await open;
|
|
491
|
-
await tools.get("session_worktree_register")!.execute("explicit", { path: "/opaque" }, undefined, undefined, ctx);
|
|
492
|
-
assert.match(renderFooter(ui), /±6/);
|
|
493
|
-
await fire(handlers, "session_shutdown", ctx);
|
|
454
|
+
test("overlay groups captured roots and refreshes same-count diffs without HEAD or external files", async () => {
|
|
455
|
+
const {pi,handlers,commands,git}=fakePi();
|
|
456
|
+
gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_POLL_MS:"5"});
|
|
457
|
+
const {ctx,ui,overlayReady}=fakeContext();
|
|
458
|
+
sessionChange(ctx,"a","/repo","same.ts","old\n","first\n");
|
|
459
|
+
sessionChange(ctx,"child:a","/linked","same.ts","old\n","child\n");
|
|
460
|
+
await fire(handlers,"session_start",ctx);
|
|
461
|
+
const opened=commands.get("gentle:changes")!.handler("",ctx);
|
|
462
|
+
await overlayReady;
|
|
463
|
+
try {
|
|
464
|
+
ui.overlayView!.handleInput("\r"); ui.overlayView!.handleInput("j");
|
|
465
|
+
await new Promise(resolve=>setTimeout(resolve,10));
|
|
466
|
+
assert.match(ui.overlayView!.render(140).join("\n"),/first/);
|
|
467
|
+
sessionChange(ctx,"b","/repo","same.ts","first\n","second\n");
|
|
468
|
+
pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
|
|
469
|
+
await new Promise(resolve=>setTimeout(resolve,20));
|
|
470
|
+
assert.match(ui.overlayView!.render(140).join("\n"),/second/);
|
|
471
|
+
assert.doesNotMatch(ui.overlayView!.render(140).join("\n"),/first/);
|
|
472
|
+
assert.equal(git.length,0);
|
|
473
|
+
} finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
|
|
494
474
|
});
|
|
495
475
|
|
|
496
|
-
test("
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
assert.ok(!h.git.some((args) => args[1] === "/foreign"));
|
|
512
|
-
await fire(h.handlers, "session_shutdown", next.ctx);
|
|
476
|
+
test("new sessions ignore inherited captures, while reload restores the same session", async () => {
|
|
477
|
+
const h=fakePi(); gentleShell(h.pi,{});
|
|
478
|
+
const first=fakeContext();
|
|
479
|
+
sessionChange(first.ctx,"a","/repo","own.ts");
|
|
480
|
+
await fire(h.handlers,"session_start",first.ctx);
|
|
481
|
+
assert.match(renderFooter(first.ui),/±1/);
|
|
482
|
+
await fire(h.handlers,"session_start",first.ctx);
|
|
483
|
+
assert.match(renderFooter(first.ui),/±1/);
|
|
484
|
+
const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
|
|
485
|
+
(next.ctx.sessionManager as any).getSessionId=()=>"new-session";
|
|
486
|
+
await fire(h.handlers,"session_start",next.ctx);
|
|
487
|
+
h.pi.events.emit("gentle-pi:session-change",{sessionId:"shell-session"});
|
|
488
|
+
assert.doesNotMatch(renderFooter(next.ui),/±1/);
|
|
489
|
+
assert.equal(h.git.length,0);
|
|
490
|
+
await fire(h.handlers,"session_shutdown",next.ctx);
|
|
513
491
|
});
|
|
514
492
|
|
|
515
493
|
test("registered canonical root governs real Git discovery, status and diff despite inherited routing", async (t) => {
|
|
@@ -550,10 +528,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
|
|
|
550
528
|
installGentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { devBinary: () => undefined, gitRunner: (cwd) => shellGitRunner(cwd, poisoned) });
|
|
551
529
|
await fire(h.handlers, "session_start", ctx);
|
|
552
530
|
t.after(() => fire(h.handlers, "session_shutdown", ctx));
|
|
553
|
-
|
|
554
|
-
const rendered = widget(fakeTui, plainTheme).render(300).join("\n");
|
|
555
|
-
assert.match(rendered, /selected-only\.txt/);
|
|
556
|
-
assert.doesNotMatch(rendered, /foreign-only\.txt/);
|
|
531
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"), false, "preexisting dirty files are not agent changes");
|
|
557
532
|
const diff = await loadFileDiff(run, { path: "tracked.txt", added: 1, deleted: 1, status: CHANGE_STATUS.MODIFIED });
|
|
558
533
|
assert.match(diff, /\+selected change/);
|
|
559
534
|
assert.doesNotMatch(diff, /foreign change/);
|
|
@@ -567,12 +542,8 @@ test("registered canonical root governs real Git discovery, status and diff desp
|
|
|
567
542
|
assert.equal(largeDiff.code, 0);
|
|
568
543
|
assert.ok(largeDiff.stdout.length > 1024 * 1024, "output must not inherit execFile's default one MiB cap");
|
|
569
544
|
assert.match(largeDiff.stdout, /\+selected final marker/);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
ui.overlayView!.handleInput("\r");
|
|
573
|
-
ui.overlayView!.handleInput("j");
|
|
574
|
-
ui.closeOverlay?.();
|
|
575
|
-
await open;
|
|
545
|
+
await h.commands.get("gentle:changes")!.handler("", ctx);
|
|
546
|
+
assert.equal(ui.overlay, undefined);
|
|
576
547
|
});
|
|
577
548
|
|
|
578
549
|
test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked files", async () => {
|
|
@@ -644,55 +615,24 @@ test("gentleShell binds the changes shortcut to the same handler as the command"
|
|
|
644
615
|
const shortcut = shortcuts.get("alt+g");
|
|
645
616
|
assert.ok(shortcut, "alt+g not registered");
|
|
646
617
|
await shortcut.handler(ctx);
|
|
647
|
-
assert.
|
|
618
|
+
assert.match(ui.notices.join("\n"), /No captured agent changes/);
|
|
648
619
|
|
|
649
620
|
const silent = fakePi();
|
|
650
621
|
gentleShell(silent.pi, { GENTLE_PI_SHELL_CHANGES_KEY: "off" });
|
|
651
622
|
assert.equal(silent.shortcuts.size, 0);
|
|
652
623
|
});
|
|
653
624
|
|
|
654
|
-
test("
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
ui.overlayView!.handleInput("\r");
|
|
666
|
-
const plain = ui.overlayView!.render(100).map(stripAnsi);
|
|
667
|
-
assert.match(plain[2], /2 files · \+13 −1/);
|
|
668
|
-
assert.match(plain[3], /lib\/c\.ts/);
|
|
669
|
-
assert.match(renderFooter(ui), /main ±2/);
|
|
670
|
-
ui.closeOverlay?.();
|
|
671
|
-
await open;
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
test("gentleShell watches git in the background so the widget and bar follow external edits", async () => {
|
|
675
|
-
const { pi, handlers, git } = fakePi([
|
|
676
|
-
{ numstat: "", porcelain: "" },
|
|
677
|
-
{ numstat: "", porcelain: "" },
|
|
678
|
-
{ numstat: "3\t1\tlib/c.ts\n", porcelain: " M lib/c.ts\0" },
|
|
679
|
-
]);
|
|
680
|
-
gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "5" });
|
|
681
|
-
const { ctx, ui } = fakeContext();
|
|
682
|
-
await fire(handlers, "session_start", ctx);
|
|
683
|
-
assert.equal(ui.widgets.has("gentle-shell-changes"), false);
|
|
684
|
-
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
685
|
-
assert.equal(ui.widgets.has("gentle-shell-changes"), true);
|
|
686
|
-
assert.match(renderFooter(ui), /main ±1/);
|
|
687
|
-
assert.ok(git.length >= 6, "background watch should keep polling git");
|
|
688
|
-
|
|
689
|
-
const widgetSetsBefore = ui.widgetSets;
|
|
690
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
691
|
-
assert.equal(ui.widgetSets, widgetSetsBefore, "unchanged tree must not rewrite the widget");
|
|
692
|
-
await fire(handlers, "session_shutdown", ctx);
|
|
693
|
-
const gitCallsAfterShutdown = git.length;
|
|
694
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
695
|
-
assert.equal(git.length, gitCallsAfterShutdown, "shutdown must stop the watch");
|
|
625
|
+
test("external edits do not pollute Changes or trigger background Git scans", async () => {
|
|
626
|
+
const {pi,handlers,git}=fakePi([{numstat:"4\t2\texternal.ts\n",porcelain:" M external.ts\0"}]);
|
|
627
|
+
gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_WATCH_MS:"5"});
|
|
628
|
+
const {ctx,ui}=fakeContext();
|
|
629
|
+
await fire(handlers,"session_start",ctx);
|
|
630
|
+
await new Promise(resolve=>setTimeout(resolve,30));
|
|
631
|
+
await fire(handlers,"agent_end",ctx);
|
|
632
|
+
assert.equal(git.length,0);
|
|
633
|
+
assert.equal(ui.widgets.has("gentle-shell-changes"),false);
|
|
634
|
+
assert.doesNotMatch(renderFooter(ui),/±/);
|
|
635
|
+
await fire(handlers,"session_shutdown",ctx);
|
|
696
636
|
});
|
|
697
637
|
|
|
698
638
|
const JWT = `h.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } })).toString("base64url")}.s`;
|
|
@@ -232,11 +232,25 @@ test("2.9.0 repeats 2.8.2 because the negotiated lane Pi consumes is unchanged",
|
|
|
232
232
|
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.8.2"] as Record<string, boolean>);
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
test("2.9.1 repeats 2.9.0 because the negotiated lane Pi consumes is unchanged", () => {
|
|
236
|
+
// v2.9.1 shipped restoring compatible OpenCode review consent (#4584) and
|
|
237
|
+
// deriving Claude Code SDD dispatch authority from the session transcript
|
|
238
|
+
// (#4575, #4551). Diffing contracts/review-integration/v2 and
|
|
239
|
+
// contracts/review-provider-contract between the v2.9.0 and v2.9.1 tags
|
|
240
|
+
// in the gentle-ai source tree showed zero byte changes, so this row
|
|
241
|
+
// repeats 2.9.0. riskEvidence and hint remain dark because neither is
|
|
242
|
+
// proven to reach Pi's negotiated START path.
|
|
243
|
+
const contract = NATIVE_CLI_CONTRACTS["2.9.1"] as Record<string, boolean>;
|
|
244
|
+
assert.equal(contract.riskEvidence, false);
|
|
245
|
+
assert.equal(contract.hint, false);
|
|
246
|
+
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.9.0"] as Record<string, boolean>);
|
|
247
|
+
});
|
|
248
|
+
|
|
235
249
|
test("no shipped version key was added beyond the pin bump", () => {
|
|
236
250
|
// Rows are promises to consumers, so a new key only ever appears in a
|
|
237
251
|
// dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
|
|
238
252
|
// v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
|
|
239
253
|
// so they get no row: a row asserts ground truth measured against a binary
|
|
240
254
|
// Pi actually ran, and the table only has to be ascending, not gapless.
|
|
241
|
-
assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0"]);
|
|
255
|
+
assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1"]);
|
|
242
256
|
});
|
|
@@ -278,20 +278,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
|
|
|
278
278
|
);
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
-
test("package verification binds the published Gentle AI v2.9.
|
|
281
|
+
test("package verification binds the published Gentle AI v2.9.1 runtime pin", () => {
|
|
282
282
|
const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
|
|
283
283
|
const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
|
|
284
284
|
const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
|
|
285
285
|
|
|
286
|
-
assert.match(installer, /INSTALLER_VERSION = "2\.9\.
|
|
286
|
+
assert.match(installer, /INSTALLER_VERSION = "2\.9\.1"/);
|
|
287
287
|
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
|
|
288
|
-
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:
|
|
288
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:roCuxlF\+L4YOb1X0los4RpjcLfxAmwmHxteSQMcfThY="/);
|
|
289
289
|
assert.match(installer, /GOTOOLCHAIN: "local"/);
|
|
290
290
|
assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
|
|
291
291
|
assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
|
|
292
292
|
assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
|
|
293
293
|
assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
|
|
294
|
-
assert.match(verifier, /v2\.9\.
|
|
294
|
+
assert.match(verifier, /v2\.9\.1/);
|
|
295
295
|
});
|
|
296
296
|
|
|
297
297
|
|
|
@@ -1509,9 +1509,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
|
|
|
1509
1509
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1510
1510
|
});
|
|
1511
1511
|
|
|
1512
|
-
test("v2.
|
|
1512
|
+
test("v2.7.0 release package and runtime stop before publication", () => {
|
|
1513
1513
|
const packageJson = readPackageJson();
|
|
1514
|
-
assert.equal(packageJson.version, "2.
|
|
1514
|
+
assert.equal(packageJson.version, "2.7.0", "the release manifest must remain explicitly pinned to v2.7.0");
|
|
1515
1515
|
assert.equal(
|
|
1516
1516
|
packageJson.scripts?.test,
|
|
1517
1517
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, writeFile, rm, realpath } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
|
|
7
|
+
import { SessionChanges, SESSION_CHANGE_ENTRY } from "../lib/session-changes.ts";
|
|
8
|
+
|
|
9
|
+
async function fixture(run: (f: any) => Promise<void>, child = false) {
|
|
10
|
+
const root = await realpath(await mkdtemp(join(tmpdir(), "change-capture-")));
|
|
11
|
+
const handlers = new Map<string, Function>();
|
|
12
|
+
const entries: any[] = [];
|
|
13
|
+
const listeners = new Map<string, Function>();
|
|
14
|
+
const pi = { on: (key, fn) => handlers.set(key, fn), appendEntry: (customType, data) => entries.push({type:"custom",customType,data}),
|
|
15
|
+
events: { on: (key, fn) => { listeners.set(key, fn); return () => listeners.delete(key); }, emit: (key, data) => listeners.get(key)?.(data) } };
|
|
16
|
+
let id = "session";
|
|
17
|
+
const ctx = { cwd:root, sessionManager: { getSessionId: () => id, getEntries: () => entries } };
|
|
18
|
+
installSessionChangeCapture(pi as never, child ? {GENTLE_PI_AGENTS_CHILD:"1"} : {}, () => ({root,commonDir:root}));
|
|
19
|
+
const fire = (key, event = {}) => handlers.get(key)?.(event, ctx);
|
|
20
|
+
try { await fire("session_start"); await run({root, entries, ctx, fire, switchSession: () => id = "other"}); }
|
|
21
|
+
finally { await rm(root, {recursive:true,force:true}); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("capture reads no inventory at startup and ignores read-only tools", async () => fixture(async ({fire, entries}) => {
|
|
25
|
+
await fire("tool_call", {toolCallId:"r",toolName:"read",input:{path:"missing"}});
|
|
26
|
+
await fire("tool_result", {toolCallId:"r",toolName:"read",input:{path:"missing"},isError:false});
|
|
27
|
+
await fire("tool_execution_end", {toolCallId:"r",toolName:"read",isError:false});
|
|
28
|
+
assert.deepEqual(entries, []);
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
test("successful writes capture only the exact tool target and persist for reload", async () => fixture(async ({root,fire,entries}) => {
|
|
32
|
+
await writeFile(join(root,"file"),"human baseline\n");
|
|
33
|
+
const event = {toolCallId:"w",toolName:"write",input:{path:"file",content:"agent output\n"}};
|
|
34
|
+
await fire("tool_call",event);
|
|
35
|
+
await writeFile(join(root,"file"),event.input.content);
|
|
36
|
+
await fire("tool_result",{...event,isError:false});
|
|
37
|
+
assert.equal(entries.length,0);
|
|
38
|
+
await fire("tool_execution_end",{toolCallId:"w",toolName:"write",isError:false});
|
|
39
|
+
assert.equal(entries[0].customType,SESSION_CHANGE_ENTRY);
|
|
40
|
+
await writeFile(join(root,"file"),"later human output\n");
|
|
41
|
+
const changes = new SessionChanges("session",entries);
|
|
42
|
+
assert.match(changes.loadDiff(root,changes.model.files[0]),/\+agent output/);
|
|
43
|
+
assert.doesNotMatch(changes.loadDiff(root,changes.model.files[0]),/later human/);
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
test("failed and stale-session tool outcomes never add session changes", async () => fixture(async ({root,fire,entries,switchSession}) => {
|
|
47
|
+
const event={toolCallId:"w",toolName:"write",input:{path:"new",content:"agent\n"}};
|
|
48
|
+
await fire("tool_call",event);
|
|
49
|
+
await writeFile(join(root,"new"),event.input.content);
|
|
50
|
+
await fire("tool_result",{...event,isError:false});
|
|
51
|
+
await fire("tool_execution_end",{toolCallId:"w",toolName:"write",isError:true});
|
|
52
|
+
assert.equal(entries.length,0);
|
|
53
|
+
await fire("tool_call",{...event,toolCallId:"x"});
|
|
54
|
+
switchSession();
|
|
55
|
+
await fire("tool_result",{...event,toolCallId:"x",isError:false});
|
|
56
|
+
await fire("tool_execution_end",{toolCallId:"x",toolName:"write",isError:false});
|
|
57
|
+
assert.equal(entries.length,0);
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
test("child carries bounded evidence in the existing tool-result details transport", async () => fixture(async ({root,fire,entries}) => {
|
|
61
|
+
const event={toolCallId:"w",toolName:"write",input:{path:"new",content:"agent\n"}};
|
|
62
|
+
await fire("tool_call",event); await writeFile(join(root,"new"),event.input.content);
|
|
63
|
+
const result=await fire("tool_result",{...event,isError:false,details:{original:"preserved"}});
|
|
64
|
+
assert.equal(result.details.original,"preserved");
|
|
65
|
+
assert.equal(result.details.gentleSessionChange.id,"w");
|
|
66
|
+
assert.equal(result.details.gentleSessionChange.path,"new");
|
|
67
|
+
assert.deepEqual(entries,[]);
|
|
68
|
+
},true));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import shell from "../extensions/gentle-shell.ts";
|
|
4
|
+
import { SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
|
|
5
|
+
|
|
6
|
+
function fixture(entries: any[] = []) {
|
|
7
|
+
const handlers = new Map<string, Function[]>(), listeners = new Map<string, Function[]>();
|
|
8
|
+
const commands = new Map<string, any>(), widgets = new Map<string, any>();
|
|
9
|
+
let gitCalls = 0;
|
|
10
|
+
const pi: any = { on: (key, fn) => handlers.set(key, [...(handlers.get(key) ?? []), fn]),
|
|
11
|
+
events: { on: (key, fn) => { listeners.set(key,[...(listeners.get(key) ?? []), fn]); return () => {}; }, emit: (key,data) => listeners.get(key)?.forEach(fn=>fn(data)) },
|
|
12
|
+
appendEntry: (customType,data) => entries.push({type:"custom",customType,data}), registerTool() {}, registerShortcut() {}, registerMessageRenderer() {},
|
|
13
|
+
registerCommand: (key, registration) => commands.set(key,registration) };
|
|
14
|
+
const notices: string[] = [];
|
|
15
|
+
const ctx: any = { hasUI:true, cwd:"/repo", sessionManager:{getSessionId:()=>"session",getEntries:()=>entries},
|
|
16
|
+
ui: { setFooter() {}, getEditorComponent:()=>({}), setWorkingVisible() {}, setWidget:(key,value)=>widgets.set(key,value), notify:(text)=>notices.push(text) } };
|
|
17
|
+
shell(pi,{}, {resolveWorktree:()=>({root:"/repo",commonDir:"/git"}),devBinary:()=>undefined,
|
|
18
|
+
gitRunner:()=>async()=>{gitCalls++; return await new Promise<any>(()=>{});} });
|
|
19
|
+
const fire=async(key,event={})=>{for(const fn of handlers.get(key)??[]) await fn(event,ctx);};
|
|
20
|
+
return {pi,ctx,entries,notices,commands,widgets,fire,gitCalls:()=>gitCalls};
|
|
21
|
+
}
|
|
22
|
+
test("Gentle Shell startup never waits for a repository scan",async()=>{
|
|
23
|
+
const f=fixture();
|
|
24
|
+
await Promise.race([f.fire("session_start"),new Promise((_,reject)=>setTimeout(()=>reject(new Error("startup blocked by Git inventory")),100))]);
|
|
25
|
+
assert.equal(f.gitCalls(),0);
|
|
26
|
+
await f.commands.get("gentle:changes").handler("",f.ctx);
|
|
27
|
+
assert.match(f.notices.join("\n"),/captured.*agent|agent.*changes/i);
|
|
28
|
+
await f.fire("session_shutdown");
|
|
29
|
+
});
|
|
30
|
+
test("reload and new evidence refresh Changes without Git or live file reads",async()=>{
|
|
31
|
+
const f=fixture([{type:"custom",customType:SESSION_CHANGE_ENTRY,data:{sessionId:"session",evidence:{id:"child:1",root:"/repo",path:"own.ts",before:{kind:"absent"},after:{kind:"text",text:"own\n"}}}}]);
|
|
32
|
+
await Promise.race([f.fire("session_start"),new Promise((_,reject)=>setTimeout(()=>reject(new Error("startup blocked")),100))]);
|
|
33
|
+
f.pi.events.emit(SESSION_CHANGE_EVENT,{sessionId:"session"});
|
|
34
|
+
await new Promise(resolve=>setImmediate(resolve));
|
|
35
|
+
assert.equal(f.gitCalls(),0);
|
|
36
|
+
assert.equal(typeof f.widgets.get("gentle-shell-changes"),"function");
|
|
37
|
+
await f.fire("session_shutdown");
|
|
38
|
+
});
|