gentle-pi 2.6.3 → 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 +2 -2
- package/docs/gentle-shell.md +30 -18
- package/docs/readme-reference.md +8 -8
- package/extensions/gentle-agents.ts +8 -0
- package/extensions/gentle-ai.ts +174 -8
- 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-ai.test.ts +97 -1
- 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
package/tests/gentle-ai.test.ts
CHANGED
|
@@ -1875,6 +1875,102 @@ test("a profile store entry with only the orchestrator key counts zero roles", a
|
|
|
1875
1875
|
assert.match(applied, /Orchestrator set to nan\/glm5\.3 · high/);
|
|
1876
1876
|
});
|
|
1877
1877
|
|
|
1878
|
+
test("applying a profile replaces materialized routing for agents the profile omits", async (t) => {
|
|
1879
|
+
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
1880
|
+
writeSettings();
|
|
1881
|
+
// Routing materialized earlier (a previous profile, /gentle:models, or a
|
|
1882
|
+
// migration) for an agent the new profile does not mention.
|
|
1883
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
1884
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\nthinking: high\n---\nbody\n");
|
|
1885
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
1886
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
1887
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } });
|
|
1888
|
+
applyOnce(fixture);
|
|
1889
|
+
await fixture.run("gentle:profiles");
|
|
1890
|
+
|
|
1891
|
+
const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
1892
|
+
assert.deepEqual(profiles.model_profiles, { worker: { model: "openai/alpha" } }, "omitted agents lose their materialized route");
|
|
1893
|
+
const helper = readFileSync(helperPath, "utf8");
|
|
1894
|
+
assert.doesNotMatch(helper, /^model:/m);
|
|
1895
|
+
assert.doesNotMatch(helper, /^thinking:/m);
|
|
1896
|
+
assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/alpha/);
|
|
1897
|
+
// models.json stays the profile itself, not a padded copy.
|
|
1898
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
1899
|
+
const applied = fixture.notifications.at(-1)?.message ?? "";
|
|
1900
|
+
assert.match(applied, /applied profile "team"/);
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
test("a failed apply restores the previous profile's routing with the same replacement semantics", async (t) => {
|
|
1904
|
+
const { fixture, settingsPath, writeStore } = profilesStoreFixture(t);
|
|
1905
|
+
// An unreadable settings.json makes the orchestrator write fail after the
|
|
1906
|
+
// routing was already materialized, which triggers the rollback path.
|
|
1907
|
+
writeFileSync(settingsPath, "{ not json\n");
|
|
1908
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
1909
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
1910
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
1911
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta" } } }, null, 2)}\n`);
|
|
1912
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
1913
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ helper: { model: "openai/beta" } }, null, 2)}\n`);
|
|
1914
|
+
// "team" is listed first, so Enter applies it while "old" stays the active one.
|
|
1915
|
+
writeStore({
|
|
1916
|
+
team: { orchestrator: { model: "nan/glm5.3" }, worker: { model: "openai/alpha" } },
|
|
1917
|
+
old: { helper: { model: "openai/beta" } },
|
|
1918
|
+
}, "old");
|
|
1919
|
+
applyOnce(fixture);
|
|
1920
|
+
await fixture.run("gentle:profiles");
|
|
1921
|
+
|
|
1922
|
+
const warning = fixture.notifications.find((entry) => /could not apply profile "team"/.test(entry.message));
|
|
1923
|
+
assert.ok(warning, "the failed apply is reported");
|
|
1924
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { helper: { model: "openai/beta" } });
|
|
1925
|
+
const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
1926
|
+
assert.deepEqual(profiles.model_profiles, { helper: { model: "openai/beta" } }, "the previous routing is materialized again and the failed profile's routes are cleared");
|
|
1927
|
+
assert.match(readFileSync(helperPath, "utf8"), /model: openai\/beta/);
|
|
1928
|
+
assert.doesNotMatch(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /^model:/m);
|
|
1929
|
+
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
1930
|
+
assert.equal(store.active, "old");
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
test("the profiles command seeds and shows the routing the runtime uses when models.json is sparse", async (t) => {
|
|
1934
|
+
const { fixture } = profilesStoreFixture(t);
|
|
1935
|
+
// No models.json at all, but routing is materialized where the runtime
|
|
1936
|
+
// reads it: subagents.json for worker, frontmatter only for helper.
|
|
1937
|
+
writeMarkdown(join(fixture.root, ".pi", "agents", "helper.md"), "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
1938
|
+
writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { worker: { model: "openai/alpha", effort: "high" } } }, null, 2)}\n`);
|
|
1939
|
+
let rendered: string | undefined;
|
|
1940
|
+
fixture.onInput((panel) => {
|
|
1941
|
+
rendered = stripAnsi(renderComponent(panel));
|
|
1942
|
+
panel.handleInput("\x1b");
|
|
1943
|
+
});
|
|
1944
|
+
await fixture.run("gentle:profiles");
|
|
1945
|
+
|
|
1946
|
+
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
1947
|
+
assert.equal(store.active, "current", "materialized routing counts as existing routing");
|
|
1948
|
+
assert.deepEqual(store.profiles.current, {
|
|
1949
|
+
worker: { model: "openai/alpha", thinking: "high" },
|
|
1950
|
+
helper: { model: "openai/beta" },
|
|
1951
|
+
});
|
|
1952
|
+
assert.ok(rendered);
|
|
1953
|
+
assert.match(rendered, /Current routing \(effective\)/);
|
|
1954
|
+
assert.match(rendered, /worker\s+openai\/alpha\s+high/);
|
|
1955
|
+
assert.match(rendered, /helper\s+openai\/beta/);
|
|
1956
|
+
assert.doesNotMatch(rendered, /No routing entries/);
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
test("effective routing prefers models.json over the materialized stores", (t) => {
|
|
1960
|
+
const fixture = routingConsumerFixture(t, ["worker", "helper"]);
|
|
1961
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
1962
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
1963
|
+
writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({
|
|
1964
|
+
model_profiles: { worker: { model: "openai/beta", effort: "high" }, helper: { model: "openai/beta" } },
|
|
1965
|
+
}, null, 2)}\n`);
|
|
1966
|
+
const effective = JSON.parse(JSON.stringify(__testing.readEffectiveModelConfig(fixture.root)));
|
|
1967
|
+
assert.deepEqual(effective, {
|
|
1968
|
+
worker: { model: "openai/alpha" },
|
|
1969
|
+
helper: { model: "openai/beta" },
|
|
1970
|
+
});
|
|
1971
|
+
assert.equal(existsSync(join(fixture.root, ".pi", "gentle-ai", "models.json")), false, "reading never writes");
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1878
1974
|
test("the profiles panel fills the terminal, lists routing per agent, and scrolls", async (t) => {
|
|
1879
1975
|
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
1880
1976
|
writeStore({
|
|
@@ -1903,7 +1999,7 @@ test("the profiles panel fills the terminal, lists routing per agent, and scroll
|
|
|
1903
1999
|
// Routing is listed one agent per line, aligned in columns, never collapsed
|
|
1904
2000
|
// into "N agents → model: a, b, …" summaries.
|
|
1905
2001
|
assert.match(text, /Profile routing/);
|
|
1906
|
-
assert.match(text, /Current routing \(
|
|
2002
|
+
assert.match(text, /Current routing \(effective\)/);
|
|
1907
2003
|
assert.match(text, /orchestrator\s+nan\/glm5\.3 · high/);
|
|
1908
2004
|
assert.match(text, /worker\s+openai\/alpha\s+high/);
|
|
1909
2005
|
assert.match(text, /sdd-design\s+nan\/glm5\.3\s+high/);
|
|
@@ -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",
|