gentle-pi 4.1.0 → 4.2.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 +39 -14
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +16 -11
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +8 -6
- package/docs/gentle-shell.md +17 -10
- package/docs/install-wizard.md +9 -9
- package/docs/permission-modes.md +50 -5
- package/docs/readme-reference.md +48 -12
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +2 -1
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/gentle-agents.ts +54 -23
- package/extensions/gentle-ai.ts +105 -25
- package/extensions/gentle-shell.ts +26 -31
- package/extensions/gentle-todo.ts +26 -16
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-shell-bundled.ts +23 -5
- package/lib/gentle-shell-launcher.ts +1 -1
- package/lib/model-routing-authority.ts +6 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +10 -15
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +20 -8
- package/lib/vim-editor-adapter.ts +22 -4
- package/package.json +5 -5
- package/runtime/gentle-shell-bundled.mjs +23 -5
- package/runtime/gentle-shell-launcher.mjs +1 -1
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +223 -0
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +86 -16
- package/tests/bundled-install.test.ts +3 -3
- package/tests/engram-safety.test.ts +34 -6
- package/tests/gentle-agents.test.ts +162 -6
- package/tests/gentle-shell-bin.test.ts +17 -17
- package/tests/gentle-shell-bundled.test.ts +67 -33
- package/tests/gentle-shell-launcher.test.ts +18 -13
- package/tests/gentle-shell.test.ts +71 -5
- package/tests/gentle-todo.test.ts +79 -27
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +5 -5
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-profile-freeze.test.ts +14 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/yolo-mode.test.ts +6 -1
|
@@ -39,7 +39,7 @@ function fakeFs({ real = {}, files = {}, links = {} }: { real?: Record<string, s
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const ID = "4.1.0-1.
|
|
42
|
+
const ID = "4.1.0-1.2.0";
|
|
43
43
|
const POSIX_ROOT = "/home/ana/.gentle-shell";
|
|
44
44
|
const POSIX_VERSION = posix.join(POSIX_ROOT, "versions", ID);
|
|
45
45
|
const POSIX_PACKAGE = posix.join(POSIX_VERSION, "node_modules", ".pnpm", "gentle-pi@4.1.0", "node_modules", "gentle-pi");
|
|
@@ -106,29 +106,29 @@ const install = { root: POSIX_ROOT, id: ID, versionDir: POSIX_VERSION };
|
|
|
106
106
|
const messageOf = (pi: BundledPi) => ("message" in pi ? pi.message : "");
|
|
107
107
|
|
|
108
108
|
test("bundledPi returns only the Pi the version pins, inside the version folder", () => {
|
|
109
|
-
assert.deepEqual(bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.
|
|
110
|
-
{ ok: true, version: "1.
|
|
109
|
+
assert.deepEqual(bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.2.0", "1.2.0") }) }),
|
|
110
|
+
{ ok: true, version: "1.2.0", cli: posix.join(PI_PACKAGE(POSIX_VERSION), "dist", "bundle", "cli.js") });
|
|
111
111
|
const windows = { root: "C:\\G", id: ID, versionDir: `C:\\G\\versions\\${ID}` };
|
|
112
|
-
assert.deepEqual(bundledPi(windows, { platform: "win32", fs: fakeFs({ files: piFiles("1.
|
|
113
|
-
{ ok: true, version: "1.
|
|
112
|
+
assert.deepEqual(bundledPi(windows, { platform: "win32", fs: fakeFs({ files: piFiles("1.2.0", "1.2.0", win32, windows.versionDir) }) }),
|
|
113
|
+
{ ok: true, version: "1.2.0", cli: win32.join(PI_PACKAGE(windows.versionDir, win32), "dist", "bundle", "cli.js") });
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
test("bundledPi fails with the version, the pinned Pi and the way to repair it", () => {
|
|
117
|
-
const repair = /Gentle Shell 4\.1\.0-1\.
|
|
118
|
-
const mismatch = bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.
|
|
117
|
+
const repair = /Gentle Shell 4\.1\.0-1\.2\.0 .*Pi 1\.2\.0.*gentle-shell upgrade.*installer/s;
|
|
118
|
+
const mismatch = bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.2.0", "1.0.0") }) });
|
|
119
119
|
assert.equal(mismatch.ok, false);
|
|
120
120
|
assert.match(messageOf(mismatch), repair);
|
|
121
121
|
assert.match(messageOf(mismatch), /found Pi 1\.0\.0/);
|
|
122
|
-
const files = piFiles("1.
|
|
122
|
+
const files = piFiles("1.2.0", "1.2.0");
|
|
123
123
|
delete files[posix.join(PI_PACKAGE(POSIX_VERSION), "dist", "bundle", "cli.js")];
|
|
124
124
|
const missing = bundledPi(install, { platform: "linux", fs: fakeFs({ files }) });
|
|
125
125
|
assert.match(messageOf(missing), repair);
|
|
126
126
|
// A Pi linked from outside the version folder is not the bundled one.
|
|
127
|
-
const outside = fakeFs({ real: { [PI_PACKAGE(POSIX_VERSION)]: "/usr/lib/node_modules/@earendil-works/pi-coding-agent" }, files: piFiles("1.
|
|
127
|
+
const outside = fakeFs({ real: { [PI_PACKAGE(POSIX_VERSION)]: "/usr/lib/node_modules/@earendil-works/pi-coding-agent" }, files: piFiles("1.2.0", "1.2.0") });
|
|
128
128
|
assert.equal(bundledPi(install, { platform: "linux", fs: outside }).ok, false);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
const ISOLATION = { prefix: "/g", engram: "/g/runtime/engram-3.
|
|
131
|
+
const ISOLATION = { prefix: "/g", engram: "/g/runtime/engram-3.4.0/engram" };
|
|
132
132
|
|
|
133
133
|
test("bundledChildEnv keeps the user's environment and adds only the bundled Pi, the prefix and Engram, no Pi override and no Pi update check", () => {
|
|
134
134
|
const user = { PATH: "/home/ana/.nvm/bin:/usr/bin", NODE_OPTIONS: "--max-old-space-size=4096", npm_config_registry: "https://npm.example", npm_config_prefix: "/home/ana/.npm-global",
|
|
@@ -140,7 +140,7 @@ test("bundledChildEnv keeps the user's environment and adds only the bundled Pi,
|
|
|
140
140
|
// folder from the prefix, and Gentle AI 4.0.0 reads that variable as a parent.
|
|
141
141
|
assert.deepEqual(env, { PATH: user.PATH, NODE_OPTIONS: user.NODE_OPTIONS, npm_config_registry: user.npm_config_registry, npm_config_prefix: user.npm_config_prefix,
|
|
142
142
|
GENTLE_PI_CONFIG_HOME: user.GENTLE_PI_CONFIG_HOME, GENTLE_PI_AGENTS_PI: "/g/node /g/cli.js", PI_SKIP_VERSION_CHECK: "1", GENTLE_SHELL_PREFIX: "/g",
|
|
143
|
-
ENGRAM_BIN: "/g/runtime/engram-3.
|
|
143
|
+
ENGRAM_BIN: "/g/runtime/engram-3.4.0/engram" });
|
|
144
144
|
// The subagent runner splits that variable on whitespace: a spaced path falls
|
|
145
145
|
// back to its own default, the running node and cli.js.
|
|
146
146
|
const spaced = bundledChildEnv({ GENTLE_PI_AGENTS_PI: "pi" }, { node: "C:\\Users\\Ana Li\\node.exe", cli: "C:\\cli.js", ...ISOLATION });
|
|
@@ -205,10 +205,10 @@ test("bundledUpdateNext still updates extensions when the upgrade did not comple
|
|
|
205
205
|
});
|
|
206
206
|
|
|
207
207
|
test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => {
|
|
208
|
-
const interactive = bundledUpdateNotice({ piVersion: "1.
|
|
209
|
-
assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.
|
|
208
|
+
const interactive = bundledUpdateNotice({ piVersion: "1.2.0", interactive: true }).join("\n");
|
|
209
|
+
assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
|
|
210
210
|
assert.match(interactive, /Running 'gentle-shell upgrade'/);
|
|
211
|
-
assert.match(bundledUpdateNotice({ piVersion: "1.
|
|
211
|
+
assert.match(bundledUpdateNotice({ piVersion: "1.2.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/);
|
|
212
212
|
});
|
|
213
213
|
|
|
214
214
|
test("parseBundledUpgradeArgs accepts the release channel and --rollback and refuses the main channel", () => {
|
|
@@ -233,7 +233,7 @@ test("parseBundledUpgradeArgs accepts the release channel and --rollback and ref
|
|
|
233
233
|
const repository = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
234
234
|
const posixHost = process.platform === "win32" ? "the bundled prefix fixture uses a POSIX current symlink" : false;
|
|
235
235
|
const SHELL = JSON.parse(readFileSync(join(repository, "package.json"), "utf8")).version;
|
|
236
|
-
const FIXTURE_ID = `${SHELL}-1.
|
|
236
|
+
const FIXTURE_ID = `${SHELL}-1.2.0`;
|
|
237
237
|
const FAKE_PI = [
|
|
238
238
|
"import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import {join, dirname} from 'node:path';",
|
|
239
239
|
"const args = process.argv.slice(2);",
|
|
@@ -244,7 +244,7 @@ const FAKE_PI = [
|
|
|
244
244
|
" if (process.env.FIXTURE_INSTALL_CAPABILITY !== 'metadata-only') { const packages = settings.packages ?? []; const index = packages.findIndex(p => /^npm:gentle-engram(?:@|$)/.test(typeof p === 'string' ? p : p.source));",
|
|
245
245
|
" if (index < 0) packages.push(args[1]); else packages[index] = typeof packages[index] === 'string' ? args[1] : {...packages[index], source:args[1]}; settings.packages = packages; writeFileSync(path, JSON.stringify(settings)); }",
|
|
246
246
|
"}",
|
|
247
|
-
"if (args.includes('--version')) { console.log('1.
|
|
247
|
+
"if (args.includes('--version')) { console.log('1.2.0'); process.exit(0); }",
|
|
248
248
|
// Stands in for gentle-engram creating the store through the bundled core during the session.
|
|
249
249
|
"if (process.env.FIXTURE_CREATE_STORE && args[0] !== 'install' && args[0] !== 'list') {",
|
|
250
250
|
" const data = join(process.env.HOME, '.engram');",
|
|
@@ -273,7 +273,7 @@ const FIXTURE_CORE = [
|
|
|
273
273
|
"",
|
|
274
274
|
].join("\n");
|
|
275
275
|
|
|
276
|
-
function bundledPrefix(t: TestContext, { pi = "1.
|
|
276
|
+
function bundledPrefix(t: TestContext, { pi = "1.2.0", core = true } = {}) {
|
|
277
277
|
const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-shell-bundled-")));
|
|
278
278
|
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
279
279
|
const home = join(base, "home");
|
|
@@ -295,7 +295,7 @@ function bundledPrefix(t: TestContext, { pi = "1.1.0", core = true } = {}) {
|
|
|
295
295
|
mkdirSync(join(piDir, "dist", "bundle"), { recursive: true });
|
|
296
296
|
writeFileSync(join(piDir, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: pi, bin: { pi: "dist/bundle/cli.js" } }));
|
|
297
297
|
writeFileSync(join(piDir, "dist", "bundle", "cli.js"), FAKE_PI);
|
|
298
|
-
writeFileSync(join(version, "package.json"), JSON.stringify({ dependencies: { "gentle-pi": SHELL, "@earendil-works/pi-coding-agent": "1.
|
|
298
|
+
writeFileSync(join(version, "package.json"), JSON.stringify({ dependencies: { "gentle-pi": SHELL, "@earendil-works/pi-coding-agent": "1.2.0" } }));
|
|
299
299
|
writeFileSync(join(version, ".gentle-shell-version"), `${FIXTURE_ID}\n`);
|
|
300
300
|
symlinkSync(`versions/${FIXTURE_ID}`, join(root, "current"));
|
|
301
301
|
// The bundled npm the npmCommand entry loads.
|
|
@@ -457,10 +457,20 @@ test("provenance mismatches disable memory behind the guard: instance, core dige
|
|
|
457
457
|
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
|
458
458
|
const seen = lastLaunch(result);
|
|
459
459
|
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard], name);
|
|
460
|
-
assert.match(result.stderr, /memory disabled.*Back up
|
|
460
|
+
assert.match(result.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s, name);
|
|
461
461
|
}
|
|
462
462
|
});
|
|
463
463
|
|
|
464
|
+
test("public help explains compatibility evidence without promising update recovery", { skip: posixHost }, (t) => {
|
|
465
|
+
const prefix = bundledPrefix(t);
|
|
466
|
+
const result = prefix.run(["--help"]);
|
|
467
|
+
assert.equal(result.status, 0, result.stderr);
|
|
468
|
+
assert.equal(result.stderr, "");
|
|
469
|
+
assert.match(result.stdout, /Matching versions or updating alone do not prove compatibility/);
|
|
470
|
+
assert.match(result.stdout, /\/compatibility/);
|
|
471
|
+
assert.doesNotMatch(result.stdout, /update Engram, restart/);
|
|
472
|
+
});
|
|
473
|
+
|
|
464
474
|
test("a foreign pre-existing store stays disabled and never receives provenance", { skip: posixHost }, (t) => {
|
|
465
475
|
const prefix = bundledPrefix(t);
|
|
466
476
|
const data = join(prefix.home, ".engram");
|
|
@@ -510,6 +520,23 @@ test("a verified /compatibility server is reused through an explicit URL and the
|
|
|
510
520
|
assert.equal(lastLaunch(unready.run([], { FIXTURE_ENGRAM_PORT: notReady })).env.ENGRAM_STARTUP_POLICY, "disabled");
|
|
511
521
|
});
|
|
512
522
|
|
|
523
|
+
test("a foreign store behind a 3.3.2 server stays guarded and the launch names the server version to update", { skip: posixHost }, async (t) => {
|
|
524
|
+
const prefix = bundledPrefix(t);
|
|
525
|
+
const data = join(prefix.home, ".engram");
|
|
526
|
+
mkdirSync(data);
|
|
527
|
+
writeHeader(join(data, "engram.db"), 1);
|
|
528
|
+
const before = readFileSync(join(data, "engram.db"));
|
|
529
|
+
// Engram 3.3.2 has no /compatibility route (the fixture answers 404).
|
|
530
|
+
const port = await engramServer(t, { "/health": [200, { status: "ok", service: "engram", version: "3.3.2", instance_id: OTHER_STORE_ID }] });
|
|
531
|
+
const result = prefix.run([], { FIXTURE_ENGRAM_PORT: port });
|
|
532
|
+
assert.equal(result.status, 0, result.stderr);
|
|
533
|
+
const seen = lastLaunch(result);
|
|
534
|
+
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL], ["disabled", prefix.guard, undefined]);
|
|
535
|
+
assert.match(result.stderr, /memory disabled.*reports version 3\.3\.2.*update that server to 3\.4\.0 or later/s);
|
|
536
|
+
assert.deepEqual(readFileSync(join(data, "engram.db")), before);
|
|
537
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
538
|
+
});
|
|
539
|
+
|
|
513
540
|
test("a fresh launch whose launcher is killed during Pi already has finalized provenance, and the next launch allows memory", { skip: posixHost }, (t) => {
|
|
514
541
|
const prefix = bundledPrefix(t);
|
|
515
542
|
const first = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID, FIXTURE_KILL_LAUNCHER: "1" });
|
|
@@ -634,7 +661,8 @@ test("setup with a foreign existing store runs the installer like the baseline,
|
|
|
634
661
|
assert.equal(setup.status, 0, setup.stderr);
|
|
635
662
|
assert.equal(installer.runs(), 1);
|
|
636
663
|
assert.equal(existsSync(prefix.engram), true);
|
|
637
|
-
assert.match(setup.stderr, /memory disabled.*Back up
|
|
664
|
+
assert.match(setup.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s);
|
|
665
|
+
assert.doesNotMatch(setup.stderr, /update Engram and restart/);
|
|
638
666
|
assert.doesNotMatch(setup.stderr, /deferred/);
|
|
639
667
|
const guarded = prefix.run([]);
|
|
640
668
|
assert.equal(guarded.status, 0, guarded.stderr);
|
|
@@ -768,7 +796,7 @@ test("the bundled launcher runs only its version's Pi, with the user's environme
|
|
|
768
796
|
const prefix = bundledPrefix(t);
|
|
769
797
|
const version = prefix.run(["--version"]);
|
|
770
798
|
assert.equal(version.status, 0, version.stderr);
|
|
771
|
-
assert.match(version.stdout, new RegExp(`^gentle-shell ${SHELL.replaceAll(".", "\\.")}\npi 1\\.
|
|
799
|
+
assert.match(version.stdout, new RegExp(`^gentle-shell ${SHELL.replaceAll(".", "\\.")}\npi 1\\.2\\.0\n`));
|
|
772
800
|
const launched = prefix.run(["list"]);
|
|
773
801
|
assert.equal(launched.status, 0, launched.stderr);
|
|
774
802
|
const seen = JSON.parse(launched.stdout.trim().split("\n").at(-1) ?? "{}");
|
|
@@ -894,19 +922,19 @@ test("the version check before activation runs that version in bundled mode, nev
|
|
|
894
922
|
encoding: "utf8", input: "" });
|
|
895
923
|
const good = verify({ [BUNDLED_VERIFY_ENV]: FIXTURE_ID });
|
|
896
924
|
assert.equal(good.status, 0, good.stderr);
|
|
897
|
-
assert.match(good.stdout, /\npi 1\.
|
|
925
|
+
assert.match(good.stdout, /\npi 1\.2\.0\n/);
|
|
898
926
|
// The version's own Pi is broken, and the user's Pi prints the expected version.
|
|
899
|
-
writeFileSync(join(prefix.userBin, "pi"), "#!/bin/sh\necho 1.
|
|
927
|
+
writeFileSync(join(prefix.userBin, "pi"), "#!/bin/sh\necho 1.2.0\n", { mode: 0o755 });
|
|
900
928
|
rmSync(join(prefix.piDir, "dist", "bundle", "cli.js"));
|
|
901
929
|
const broken = verify({ [BUNDLED_VERIFY_ENV]: FIXTURE_ID });
|
|
902
930
|
assert.equal(broken.status, 1);
|
|
903
|
-
assert.match(broken.stderr, /bundled Pi 1\.
|
|
931
|
+
assert.match(broken.stderr, /bundled Pi 1\.2\.0/);
|
|
904
932
|
// Without the verification input, the same entry is an ordinary launcher that would pass.
|
|
905
|
-
assert.match(verify({}).stdout, /\npi 1\.
|
|
933
|
+
assert.match(verify({}).stdout, /\npi 1\.2\.0\n/);
|
|
906
934
|
// Only --version honors it.
|
|
907
935
|
const list = spawnSync(process.execPath, [prefix.entry, "list"], { env: { HOME: prefix.home, PATH: `${prefix.userBin}:/usr/bin:/bin`, [BUNDLED_VERIFY_ENV]: FIXTURE_ID,
|
|
908
936
|
GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8", input: "" });
|
|
909
|
-
assert.equal(list.stdout, "1.
|
|
937
|
+
assert.equal(list.stdout, "1.2.0\n");
|
|
910
938
|
});
|
|
911
939
|
|
|
912
940
|
test("the bundled launcher refuses a Pi that is not the pinned one", { skip: posixHost }, (t) => {
|
|
@@ -919,7 +947,7 @@ test("the bundled launcher refuses a Pi that is not the pinned one", { skip: pos
|
|
|
919
947
|
test("gentle-shell update in a bundled install explains the pinned Pi instead of updating it", { skip: posixHost }, (t) => {
|
|
920
948
|
const result = bundledPrefix(t).run(["update"]);
|
|
921
949
|
assert.equal(result.status, 0, result.stderr);
|
|
922
|
-
assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.
|
|
950
|
+
assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
|
|
923
951
|
assert.match(result.stdout, /Run 'gentle-shell upgrade'/);
|
|
924
952
|
assert.doesNotMatch(result.stdout, /"args"/);
|
|
925
953
|
});
|
|
@@ -928,23 +956,29 @@ test("gentle-shell update pi --extensions in a bundled install redirects Pi's pa
|
|
|
928
956
|
const prefix = bundledPrefix(t);
|
|
929
957
|
const result = prefix.run(["update", "pi", "--extensions"]);
|
|
930
958
|
assert.equal(result.status, 0, result.stderr);
|
|
931
|
-
assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.
|
|
959
|
+
assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.2\.0\)/);
|
|
932
960
|
const seen = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}");
|
|
933
961
|
assert.deepEqual(seen.args, ["update", "--extensions"]);
|
|
934
962
|
assert.ok(JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8")).npmCommand);
|
|
935
963
|
});
|
|
936
964
|
|
|
937
|
-
test("a kept version that current does not name keeps the ordinary
|
|
965
|
+
test("a kept version that current does not name keeps the ordinary Pi override and refuses an unowned update", { skip: posixHost }, (t) => {
|
|
938
966
|
const prefix = bundledPrefix(t);
|
|
939
967
|
const other = join(prefix.root, "versions", "0.0.1-1.0.0");
|
|
940
968
|
mkdirSync(other);
|
|
941
969
|
writeFileSync(join(other, ".gentle-shell-version"), "0.0.1-1.0.0\n");
|
|
942
970
|
rmSync(join(prefix.root, "current"));
|
|
943
971
|
symlinkSync("versions/0.0.1-1.0.0", join(prefix.root, "current"));
|
|
944
|
-
const
|
|
945
|
-
GENTLE_SHELL_PI: join(dirname(dirname(prefix.root)), "user-bin", "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8", input: "" }
|
|
946
|
-
|
|
947
|
-
assert.equal(
|
|
972
|
+
const options = { env: { HOME: dirname(prefix.root), PATH: "/usr/bin:/bin",
|
|
973
|
+
GENTLE_SHELL_PI: join(dirname(dirname(prefix.root)), "user-bin", "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8" as const, input: "" };
|
|
974
|
+
const version = spawnSync(process.execPath, [prefix.entry, "--version"], options);
|
|
975
|
+
assert.equal(version.status, 0, version.stderr);
|
|
976
|
+
assert.match(version.stdout, /pi 9\.9\.9/);
|
|
977
|
+
const update = spawnSync(process.execPath, [prefix.entry, "update"], options);
|
|
978
|
+
assert.equal(update.status, 1);
|
|
979
|
+
assert.equal(update.stdout, "");
|
|
980
|
+
// The fake override is a plain script: neither a Pi entry nor a package-manager shim.
|
|
981
|
+
assert.match(update.stderr, /ownership selected executable is not a Pi entry or shim.*No installation changed\./);
|
|
948
982
|
});
|
|
949
983
|
|
|
950
984
|
test("gentle-shell upgrade in a bundled install refuses the main channel and rolls back to the kept version", { skip: posixHost }, (t) => {
|
|
@@ -683,9 +683,10 @@ test("checkPeerVersionPin passes for a matching pin", () => {
|
|
|
683
683
|
assert.deepEqual(result, { ok: true, pinned: ">=0.85.1" });
|
|
684
684
|
});
|
|
685
685
|
|
|
686
|
-
test("Pi
|
|
686
|
+
test("Pi 1.2.0 baseline and host peers follow the open development range policy", () => {
|
|
687
687
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
688
|
-
assert.equal(MIN_PI_VERSION, "
|
|
688
|
+
assert.equal(MIN_PI_VERSION, "1.2.0");
|
|
689
|
+
assert.equal(pkg.peerDependencies["@earendil-works/pi-coding-agent"], ">=1.2.0");
|
|
689
690
|
assert.equal(pkg.engines.node, ">=22.19.0");
|
|
690
691
|
for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
|
|
691
692
|
assert.equal(pkg.peerDependencies[name], "*");
|
|
@@ -695,34 +696,38 @@ test("Pi 0.99.1 baseline and host peers follow the open development range policy
|
|
|
695
696
|
// Development ranges are policy specifiers; their floor never drops below
|
|
696
697
|
// the runtime minimum the launcher enforces.
|
|
697
698
|
for (const name of ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai", "@earendil-works/pi-tui"]) {
|
|
698
|
-
assert.equal(pkg.devDependencies[name], ">=1.
|
|
699
|
+
assert.equal(pkg.devDependencies[name], ">=1.2.0", name);
|
|
699
700
|
assert.equal(checkPiVersion(pkg.devDependencies[name].slice(2)).ok, true, name);
|
|
700
701
|
}
|
|
701
702
|
});
|
|
702
703
|
|
|
703
704
|
test("checkPiVersion accepts a version equal to the minimum", () => {
|
|
704
|
-
assert.deepEqual(checkPiVersion("
|
|
705
|
+
assert.deepEqual(checkPiVersion("1.2.0"), { ok: true, version: "1.2.0" });
|
|
705
706
|
});
|
|
706
707
|
|
|
707
708
|
test("checkPiVersion accepts a version above the minimum", () => {
|
|
708
|
-
|
|
709
|
+
for (const version of ["1.2.1", "1.3.0", "2.0.0"]) {
|
|
710
|
+
assert.deepEqual(checkPiVersion(version), { ok: true, version });
|
|
711
|
+
}
|
|
709
712
|
});
|
|
710
713
|
|
|
711
714
|
test("checkPiVersion accepts a v-prefixed version", () => {
|
|
712
|
-
assert.deepEqual(checkPiVersion("
|
|
715
|
+
assert.deepEqual(checkPiVersion("v1.2.0"), { ok: true, version: "1.2.0" });
|
|
713
716
|
});
|
|
714
717
|
|
|
715
718
|
test("checkPiVersion accepts a prerelease suffix at the minimum", () => {
|
|
716
|
-
assert.deepEqual(checkPiVersion("pi version
|
|
719
|
+
assert.deepEqual(checkPiVersion("pi version 1.2.0-rc.2"), { ok: true, version: "1.2.0" });
|
|
717
720
|
});
|
|
718
721
|
|
|
719
722
|
test("checkPiVersion rejects a version below the minimum and names both versions", () => {
|
|
720
|
-
const result = checkPiVersion("
|
|
723
|
+
const result = checkPiVersion("1.1.0");
|
|
721
724
|
assert.equal(result.ok, false);
|
|
722
725
|
if (result.ok) throw new Error("expected a failing result");
|
|
723
|
-
assert.equal(result.version, "
|
|
724
|
-
assert.match(result.message, /
|
|
725
|
-
assert.match(result.message, /
|
|
726
|
+
assert.equal(result.version, "1.1.0");
|
|
727
|
+
assert.match(result.message, /1\.1\.0/);
|
|
728
|
+
assert.match(result.message, /1\.2\.0/);
|
|
729
|
+
assert.equal(checkPiVersion("1.0.0").ok, false);
|
|
730
|
+
assert.equal(checkPiVersion("0.99.1").ok, false);
|
|
726
731
|
});
|
|
727
732
|
|
|
728
733
|
test("checkPiVersion rejects a prerelease below the minimum", () => {
|
|
@@ -736,7 +741,7 @@ test("checkPiVersion reports unparsable output with the raw text and the minimum
|
|
|
736
741
|
if (result.ok) throw new Error("expected a failing result");
|
|
737
742
|
assert.equal(result.version, undefined);
|
|
738
743
|
assert.match(result.message, /not a version/);
|
|
739
|
-
assert.match(result.message, /
|
|
744
|
+
assert.match(result.message, /1\.2\.0/);
|
|
740
745
|
});
|
|
741
746
|
|
|
742
747
|
test("checkPiVersion accepts a custom minimum", () => {
|
|
@@ -2015,7 +2020,7 @@ test("the isolated-home bootstrap creates ~/.gentle-shell as 0700 under umask 00
|
|
|
2015
2020
|
const root = mkdtempSync(join(tmpdir(), "gentle-shell-umask-"));
|
|
2016
2021
|
try {
|
|
2017
2022
|
const piScript = join(root, "fake-pi.mjs");
|
|
2018
|
-
writeFileSync(piScript, "#!/usr/bin/env node\nif (process.argv.includes('--version')) console.log('
|
|
2023
|
+
writeFileSync(piScript, "#!/usr/bin/env node\nif (process.argv.includes('--version')) console.log('1.2.0');\n");
|
|
2019
2024
|
chmodSync(piScript, 0o755);
|
|
2020
2025
|
const binPath = fileURLToPath(new URL("../bin/gentle-shell.mjs", import.meta.url));
|
|
2021
2026
|
const env = { HOME: root, USERPROFILE: root, PATH: process.env.PATH ?? "/usr/bin:/bin", GENTLE_SHELL_PI: piScript, GENTLE_SHELL_NO_AUTO_SETUP: "1" };
|
|
@@ -4,19 +4,23 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSy
|
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import test, { after } from "node:test";
|
|
7
|
+
import test, { after, afterEach } from "node:test";
|
|
8
8
|
import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { CURSOR_MARKER, matchesKey, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
9
|
+
import { CURSOR_MARKER, matchesKey, stripTerminalSequences as stripAnsi, TuiMainScreen, visibleWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
10
10
|
import installGentleShell, { backgroundChangeRefreshesSettled, buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts";
|
|
11
11
|
import { CODEX_USAGE_URL, NAN_QUOTA_URL, USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts";
|
|
12
12
|
import { bindSessionProfile, clearSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts";
|
|
13
13
|
import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
|
|
14
|
+
import { readFrozenInheritedProfile, resetFrozenInheritedProfilesForTesting } from "../lib/session-profile-freeze.ts";
|
|
15
|
+
|
|
16
|
+
// Fixture sessions share ids across tests; the inherited-profile freeze is
|
|
17
|
+
// process state keyed by session id (gentle-shell#1064 slice 3b-ii).
|
|
18
|
+
afterEach(() => resetFrozenInheritedProfilesForTesting());
|
|
14
19
|
import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
|
|
15
20
|
import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE } from "../lib/shell-changes.ts";
|
|
16
21
|
import { sidebarPart, sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
|
|
17
22
|
import { renderTodoCard } from "../lib/shell-todo.ts";
|
|
18
23
|
import type { ShellBarTheme } from "../lib/shell-bar.ts";
|
|
19
|
-
import { stripAnsi } from "../lib/terminal-theme.ts";
|
|
20
24
|
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
21
25
|
import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
22
26
|
import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts";
|
|
@@ -751,7 +755,9 @@ test("profile polling refreshes both fullscreen surfaces only on change and stop
|
|
|
751
755
|
t.mock.method(globalThis, "clearInterval", (handle: { timer: (typeof intervals)[number] }) => { handle.timer.stopped = true; });
|
|
752
756
|
const { pi, handlers } = fakePi();
|
|
753
757
|
let resolutions = 0;
|
|
754
|
-
|
|
758
|
+
// follow mode keeps the live p → P → G resolution, so a pin edit reaches the
|
|
759
|
+
// open session through the poll (gentle-shell#1064 slice 3b-ii).
|
|
760
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_PROFILE_FOLLOW: "1" }, {
|
|
755
761
|
resolveWorktree: () => { resolutions++; return { root: repo, commonDir }; },
|
|
756
762
|
});
|
|
757
763
|
const first = fakeContext();
|
|
@@ -1800,6 +1806,8 @@ function reverseColumns(row: string): number[] {
|
|
|
1800
1806
|
let inverse = false;
|
|
1801
1807
|
let column = 0;
|
|
1802
1808
|
const selected: number[] = [];
|
|
1809
|
+
const host = TuiMainScreen.prototype as unknown as { resolveFakeCursors(rows: string[]): string[] };
|
|
1810
|
+
row = host.resolveFakeCursors.call({ showHardwareCursor: false }, [row])[0]!;
|
|
1803
1811
|
const tokens = row.match(/\x1b\[[0-9;]*m|\x1b_pi:c\x07|[^\x1b]/g) ?? [];
|
|
1804
1812
|
assert.equal(tokens.join(""), row);
|
|
1805
1813
|
for (const token of tokens) {
|
|
@@ -5945,7 +5953,10 @@ test("after a profile switch the panel stops presenting the old profile's provid
|
|
|
5945
5953
|
ui.closeOverlay?.();
|
|
5946
5954
|
await opened;
|
|
5947
5955
|
|
|
5948
|
-
|
|
5956
|
+
// An open session changes profile only through its own Enter (gentle-shell#1064
|
|
5957
|
+
// slice 3b-ii): a new global default would leave its frozen routing in place.
|
|
5958
|
+
bindSessionProfile("shell-session", "solo", {});
|
|
5959
|
+
t.after(() => resetSessionProfileBindingsForTesting());
|
|
5949
5960
|
const reopened = commands.get("gentle:usage")!.handler("", ctx);
|
|
5950
5961
|
await settle();
|
|
5951
5962
|
assert.equal(ui.overlayView!.render(90).map(stripAnsi).some((line) => line.includes("nan")), false, "a provider recorded under the previous profile's routing is not current scope");
|
|
@@ -5953,6 +5964,61 @@ test("after a profile switch the panel stops presenting the old profile's provid
|
|
|
5953
5964
|
await reopened;
|
|
5954
5965
|
});
|
|
5955
5966
|
|
|
5967
|
+
test("an open session keeps its frozen profile in the status and Usage scope when the defaults change", async (t) => {
|
|
5968
|
+
const home = mkdtempSync(join(tmpdir(), "shell-frozen-"));
|
|
5969
|
+
const commonDir = mkdtempSync(join(tmpdir(), "shell-frozen-git-"));
|
|
5970
|
+
t.after(() => {
|
|
5971
|
+
rmSync(home, { recursive: true, force: true });
|
|
5972
|
+
rmSync(commonDir, { recursive: true, force: true });
|
|
5973
|
+
});
|
|
5974
|
+
mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
|
|
5975
|
+
writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "team");
|
|
5976
|
+
const { pi, handlers, commands } = fakePi();
|
|
5977
|
+
gentleShell(pi, { GENTLE_PI_CONFIG_HOME: home, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { fetch: fakeFetch(NAN_QUOTA_PAYLOAD).fetchFn, now: () => 1_788_600_000_000, resolveWorktree: () => ({ root: "/repo", commonDir }) });
|
|
5978
|
+
const { ctx, ui } = fakeContext({ token: JWT });
|
|
5979
|
+
await fire(handlers, "session_start", ctx);
|
|
5980
|
+
assert.equal(readFrozenInheritedProfile("shell-session")?.profile?.name, "team", "the session froze the global active profile at start");
|
|
5981
|
+
writeProfilesStore(home, { team: { reviewer: { model: "nan/glm5.3" } }, solo: { reviewer: { model: "openai-codex/gpt-5.5" } } }, "solo");
|
|
5982
|
+
writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile: "solo" }));
|
|
5983
|
+
const opened = commands.get("gentle:usage")!.handler("", ctx);
|
|
5984
|
+
await settle();
|
|
5985
|
+
assert.ok(openPanelLines(ui).some((line) => /^│ nan ·/.test(line)), "Usage keeps the frozen profile's nan route");
|
|
5986
|
+
ui.closeOverlay?.();
|
|
5987
|
+
await opened;
|
|
5988
|
+
const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, data: unknown) => { dispose(): void };
|
|
5989
|
+
const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} };
|
|
5990
|
+
const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} });
|
|
5991
|
+
try {
|
|
5992
|
+
const status = (sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail).render(60).join("\n");
|
|
5993
|
+
assert.match(status, /Profile.*team/, "the status keeps the bare global name of the frozen profile");
|
|
5994
|
+
assert.doesNotMatch(status, /solo/);
|
|
5995
|
+
} finally {
|
|
5996
|
+
component.dispose();
|
|
5997
|
+
}
|
|
5998
|
+
});
|
|
5999
|
+
|
|
6000
|
+
test("active profile reader: a bound session shows its frozen profile, and follow shows the live default", (t) => {
|
|
6001
|
+
const root = mkdtempSync(join(tmpdir(), "shell-profile-frozen-"));
|
|
6002
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
6003
|
+
const commonDir = join(root, "git");
|
|
6004
|
+
mkdirSync(join(commonDir, "gentle-ai"), { recursive: true });
|
|
6005
|
+
writeFileSync(join(root, "profiles.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} } }));
|
|
6006
|
+
const pin = (profile: string) => writeFileSync(join(commonDir, "gentle-ai", "profile-pin.json"), JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile }));
|
|
6007
|
+
pin("other");
|
|
6008
|
+
const frozen = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
|
|
6009
|
+
frozen.bind(root, () => ({ root, commonDir }), "session-frozen");
|
|
6010
|
+
assert.equal(frozen(), "other (local)");
|
|
6011
|
+
const follow = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_PROFILE_FOLLOW: "1" });
|
|
6012
|
+
follow.bind(root, () => ({ root, commonDir }), "session-follow");
|
|
6013
|
+
assert.equal(follow(), "other (local)");
|
|
6014
|
+
rmSync(join(commonDir, "gentle-ai", "profile-pin.json"));
|
|
6015
|
+
assert.equal(frozen.refresh(), false, "the frozen label does not move");
|
|
6016
|
+
assert.equal(frozen(), "other (local)");
|
|
6017
|
+
assert.equal(follow.refresh(), true);
|
|
6018
|
+
assert.equal(follow(), "team", "follow falls to the global active profile");
|
|
6019
|
+
assert.equal(readFrozenInheritedProfile("session-follow"), undefined);
|
|
6020
|
+
});
|
|
6021
|
+
|
|
5956
6022
|
function openPanelLines(ui: FakeUi): string[] {
|
|
5957
6023
|
return ui.overlayView!.render(90).map(stripAnsi);
|
|
5958
6024
|
}
|
|
@@ -114,12 +114,27 @@ test("long Todo widgets stay bounded, scroll, resize and keep their collapse con
|
|
|
114
114
|
assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].title, title.trim());
|
|
115
115
|
assert.equal((result.details.gentleTodo as { tasks: { title: string; note: string }[] }).tasks[0].note, note.trim());
|
|
116
116
|
await shortcuts.get("ctrl+shift+t")!.handler(ctx);
|
|
117
|
-
await shortcuts.get("
|
|
117
|
+
await shortcuts.get("alt+shift+down")!.handler(ctx);
|
|
118
118
|
assert.notDeepEqual(bottom.render(40), collapsed, "keyboard scrolling is available without mouse");
|
|
119
119
|
}
|
|
120
120
|
t.after(() => setCardStyle(CARD_STYLE.NEON));
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
test("Todo shortcuts never take a key Pi binds by default, so startup reports no shortcut conflict", async () => {
|
|
124
|
+
// Pi keeps its default table in dist/core/keybindings.js, next to the package entry.
|
|
125
|
+
const { KEYBINDINGS } = await import(new URL("core/keybindings.js", import.meta.resolve("@earendil-works/pi-coding-agent")).href) as
|
|
126
|
+
{ KEYBINDINGS: Record<string, { defaultKeys?: string | string[] }> };
|
|
127
|
+
assert.ok("tui.altScreen.previousPrompt" in KEYBINDINGS, "the table includes Pi's built-in prompt navigation");
|
|
128
|
+
const owners = new Map<string, string[]>();
|
|
129
|
+
for (const [id, binding] of Object.entries(KEYBINDINGS)) {
|
|
130
|
+
for (const key of ([] as string[]).concat(binding.defaultKeys ?? [])) owners.set(key, [...(owners.get(key) ?? []), id]);
|
|
131
|
+
}
|
|
132
|
+
const { pi, shortcuts } = fakePi();
|
|
133
|
+
gentleTodo(pi, {});
|
|
134
|
+
assert.deepEqual([...shortcuts.keys()].sort(), ["alt+shift+down", "alt+shift+up", "ctrl+shift+t"]);
|
|
135
|
+
for (const key of shortcuts.keys()) assert.equal(owners.get(key), undefined, `${key} is a Pi default for ${owners.get(key)?.join(", ")}`);
|
|
136
|
+
});
|
|
137
|
+
|
|
123
138
|
test("Todo keyboard scrolling targets the visible card after a cached sidebar breakpoint round-trip", async (t) => {
|
|
124
139
|
const node = Symbol.for("@earendil-works/pi-tui/layout-node");
|
|
125
140
|
for (const style of [CARD_STYLE.NEON, CARD_STYLE.FLOAT]) {
|
|
@@ -146,11 +161,11 @@ test("Todo keyboard scrolling targets the visible card after a cached sidebar br
|
|
|
146
161
|
bottom.render(40);
|
|
147
162
|
tui.terminal.columns = 160;
|
|
148
163
|
const before = railLines();
|
|
149
|
-
await shortcuts.get("
|
|
164
|
+
await shortcuts.get("alt+shift+down")!.handler(ctx);
|
|
150
165
|
const after = railLines();
|
|
151
166
|
assert.notDeepEqual(after, before, "DOWN changes the visible cached rail, not the hidden bottom widget");
|
|
152
167
|
assert.match(after.join("\n"), /↑↓ 4–/);
|
|
153
|
-
await shortcuts.get("
|
|
168
|
+
await shortcuts.get("alt+shift+up")!.handler(ctx);
|
|
154
169
|
assert.deepEqual(railLines(), before, "UP restores the expanded viewport");
|
|
155
170
|
} finally {
|
|
156
171
|
dispose();
|
|
@@ -288,11 +303,25 @@ function toolTurn(toolName = "read") {
|
|
|
288
303
|
return { message: { role: "assistant", content: [{ type: "toolCall", id: "call", name: toolName, arguments: {} }] }, toolResults: [] };
|
|
289
304
|
}
|
|
290
305
|
|
|
291
|
-
|
|
306
|
+
type ContextMessage = { role: string; content: unknown; customType?: string; display?: boolean; timestamp?: number };
|
|
307
|
+
|
|
308
|
+
async function contextMessages(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage[] | undefined> {
|
|
292
309
|
const messages = [{ role: "user", content: "Keep working", timestamp: 1 }];
|
|
293
|
-
const result = await fire("context", ctx, { messages }) as { messages:
|
|
310
|
+
const result = await fire("context", ctx, { messages }) as { messages: ContextMessage[] } | undefined;
|
|
294
311
|
assert.deepEqual(messages, [{ role: "user", content: "Keep working", timestamp: 1 }], "request-local injection must not mutate the input transcript");
|
|
295
|
-
return
|
|
312
|
+
if (result === undefined) return undefined;
|
|
313
|
+
assert.deepEqual(result.messages[0], { role: "user", content: "Keep working", timestamp: 1 }, "the transcript stays first; todo messages trail it");
|
|
314
|
+
return result.messages;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The request-local reminder, if the next model request carries one. */
|
|
318
|
+
async function contextReminder(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
|
|
319
|
+
return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-reminder");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The request-local current-list message, if the next model request carries one (#1662). */
|
|
323
|
+
async function contextState(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext): Promise<ContextMessage | undefined> {
|
|
324
|
+
return (await contextMessages(fire, ctx))?.find((message) => message.customType === "gentle-todo-state");
|
|
296
325
|
}
|
|
297
326
|
|
|
298
327
|
async function workTurns(fire: ReturnType<typeof fakePi>["fire"], ctx: ExtensionContext, count: number) {
|
|
@@ -312,13 +341,14 @@ test("runtime reminder arrives after four tool-use turns, only once, without cha
|
|
|
312
341
|
await workTurns(fire, ctx, 3);
|
|
313
342
|
assert.equal(await contextReminder(fire, ctx), undefined);
|
|
314
343
|
await workTurns(fire, ctx, 1);
|
|
315
|
-
const
|
|
316
|
-
assert.ok(
|
|
317
|
-
assert.equal(
|
|
318
|
-
assert.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
344
|
+
const messages = await contextMessages(fire, ctx);
|
|
345
|
+
assert.ok(messages, "the next model request must carry a reminder within the same run");
|
|
346
|
+
assert.equal(messages.length, 3, "transcript, current list, then the reminder");
|
|
347
|
+
assert.equal(messages[1]!.customType, "gentle-todo-state");
|
|
348
|
+
const reminder = messages[2]!;
|
|
349
|
+
assert.deepEqual(reminder, { role: "custom", customType: "gentle-todo-reminder", content: REMINDER, display: false, timestamp: reminder.timestamp });
|
|
350
|
+
const converted = convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
|
|
351
|
+
assert.deepEqual(converted[2], { role: "user", content: [{ type: "text", text: REMINDER }], timestamp: reminder.timestamp }, "Pi forwards the reminder as provider-compatible text");
|
|
322
352
|
await workTurns(fire, ctx, 4);
|
|
323
353
|
assert.equal(await contextReminder(fire, ctx), undefined, "no repeated nagging without a todo write");
|
|
324
354
|
const listed = await tool.execute("list", { action: "list" }, undefined, undefined, ctx);
|
|
@@ -418,31 +448,53 @@ function promptEvent(): { systemPrompt: string; systemPromptOptions: { appendSys
|
|
|
418
448
|
return { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
|
|
419
449
|
}
|
|
420
450
|
|
|
421
|
-
test("
|
|
451
|
+
test("the system prompt carries only a byte-stable todo block; the current list travels as a request-local message (#1662)", async () => {
|
|
422
452
|
const { pi, tools, fire } = fakePi();
|
|
423
453
|
gentleTodo(pi, {});
|
|
424
454
|
const { ctx, widget } = fakeContext();
|
|
425
455
|
await fire("session_start", ctx);
|
|
426
|
-
|
|
456
|
+
const emptyEvent = promptEvent();
|
|
457
|
+
assert.equal(await fire("before_agent_start", ctx, emptyEvent), undefined, "the handler must not return a replacement systemPrompt");
|
|
458
|
+
assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /^## Todo list/);
|
|
459
|
+
assert.match(emptyEvent.systemPromptOptions.appendSystemPrompt, /mark a task in_progress before starting/);
|
|
460
|
+
assert.equal(await contextState(fire, ctx), undefined, "an empty list sends no state message");
|
|
461
|
+
|
|
427
462
|
await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, ctx);
|
|
428
463
|
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
429
|
-
|
|
430
464
|
const withTasksEvent = promptEvent();
|
|
431
|
-
|
|
432
|
-
assert.equal(
|
|
433
|
-
assert.
|
|
434
|
-
|
|
465
|
+
await fire("before_agent_start", ctx, withTasksEvent);
|
|
466
|
+
assert.equal(withTasksEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "writing a list must not change the system prompt");
|
|
467
|
+
assert.doesNotMatch(withTasksEvent.systemPromptOptions.appendSystemPrompt, /Fix the bug/, "task content never enters the system prompt");
|
|
468
|
+
const state = await contextState(fire, ctx);
|
|
469
|
+
assert.ok(state, "open tasks arrive as a trailing request-local message");
|
|
470
|
+
assert.equal(state.role, "custom");
|
|
471
|
+
assert.equal(state.display, false);
|
|
472
|
+
assert.match(String(state.content), /1\. \[pending\] Fix the bug/);
|
|
435
473
|
assert.doesNotMatch(widget()![0], /stale/);
|
|
436
474
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
475
|
+
// Silent turns: the UI card goes stale, the system prompt stays frozen (#1367).
|
|
476
|
+
for (const expected of ["stale · 2 turns", "stale · 3 turns"]) {
|
|
477
|
+
const silent = promptEvent();
|
|
478
|
+
await fire("before_agent_start", ctx, silent);
|
|
479
|
+
assert.equal(silent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt);
|
|
480
|
+
assert.match(widget()![1], new RegExp(expected));
|
|
481
|
+
}
|
|
442
482
|
|
|
483
|
+
// Status, note, title and ordering changes reach the model through the message only.
|
|
443
484
|
await tools.get("todo")!.execute("c2", { action: "update", id: 1, status: "in_progress", note: "on it" }, undefined, undefined, ctx);
|
|
444
485
|
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
486
|
+
const updatedEvent = promptEvent();
|
|
487
|
+
await fire("before_agent_start", ctx, updatedEvent);
|
|
488
|
+
assert.equal(updatedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "a status change must not change the system prompt");
|
|
489
|
+
assert.match(String((await contextState(fire, ctx))!.content), /1\. \[in_progress\] Fix the bug — on it/);
|
|
445
490
|
assert.doesNotMatch(widget()![0], /stale/);
|
|
491
|
+
|
|
492
|
+
await tools.get("todo")!.execute("c3", { action: "update", id: 1, status: "done" }, undefined, undefined, ctx);
|
|
493
|
+
await fire("tool_execution_end", ctx, { toolName: "todo" });
|
|
494
|
+
assert.equal(await contextState(fire, ctx), undefined, "a finished list sends no state message");
|
|
495
|
+
const finishedEvent = promptEvent();
|
|
496
|
+
await fire("before_agent_start", ctx, finishedEvent);
|
|
497
|
+
assert.equal(finishedEvent.systemPromptOptions.appendSystemPrompt, emptyEvent.systemPromptOptions.appendSystemPrompt, "finishing the list must not change the system prompt");
|
|
446
498
|
});
|
|
447
499
|
|
|
448
500
|
test("before_agent_start is idempotent: a todo block already present in appendSystemPrompt is not duplicated", async () => {
|
|
@@ -555,8 +607,8 @@ test("a list left with only dropped tasks open finishes and clears; a blocked-on
|
|
|
555
607
|
await fire("agent_end", ctx);
|
|
556
608
|
const event = promptEvent();
|
|
557
609
|
await fire("before_agent_start", ctx, event);
|
|
558
|
-
assert.
|
|
559
|
-
assert.
|
|
610
|
+
assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /Deploy/);
|
|
611
|
+
assert.match(String((await contextState(fire, ctx))!.content), /2\. \[blocked\] Deploy — waiting for an admin/);
|
|
560
612
|
}
|
|
561
613
|
assert.match(widget()![2], /⊘ Deploy · waiting for an admin/);
|
|
562
614
|
});
|