gentle-pi 4.2.0 → 4.3.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 +336 -137
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/gentle-shell.mjs +170 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +33 -3
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +5 -4
- package/docs/install-wizard.md +8 -1
- package/docs/permission-modes.md +1 -1
- package/docs/readme-reference.md +107 -19
- package/docs/yolo-mode.md +21 -8
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +99 -12
- package/extensions/gentle-ai.ts +284 -52
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +27 -12
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +39 -3
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +145 -11
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-todo.ts +23 -4
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +38 -2
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +143 -9
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/autonomous-guard.test.ts +122 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +1 -1
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +282 -3
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +169 -6
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +79 -0
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +42 -9
- package/tests/gentle-todo.test.ts +179 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +24 -20
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +18 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import test from "node:test";
|
|
3
6
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
7
|
import { createChildSafetyExtension } from "../extensions/child-safety.ts";
|
|
@@ -59,6 +62,23 @@ test("headless children reject corrected literal forms without prompting", async
|
|
|
59
62
|
}
|
|
60
63
|
});
|
|
61
64
|
|
|
65
|
+
// gentle-pi#2285: removal behind reserved words, in backticks or in eval is still data loss.
|
|
66
|
+
test("children block destruction after reserved words, in backticks and in eval", async () => {
|
|
67
|
+
for (const primary of [false, true]) {
|
|
68
|
+
const h = harness(true, primary);
|
|
69
|
+
const ctx = context(async () => { throw new Error("child must never prompt"); }, false);
|
|
70
|
+
for (const command of [
|
|
71
|
+
`if true; then rm -rf data; fi`, `for d in a b; do rm -rf "$d"; done`, "echo `rm -rf data`", `eval 'rm -rf data'`,
|
|
72
|
+
`if true; then rm -rf ~; fi`, `{ rm -rf data; }`, `while true; do rm -rf data; done`,
|
|
73
|
+
]) {
|
|
74
|
+
assert.equal((await h.handlers[0](event(command), ctx) as { block?: boolean })?.block, true, command);
|
|
75
|
+
}
|
|
76
|
+
for (const command of [`if [ -f x ]; then echo ok; fi`, `for f in *; do cat "$f"; done`, "echo `date`", `grep "then rm -rf" x`]) {
|
|
77
|
+
assert.equal(await h.handlers[0](event(command), ctx), undefined, command);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
62
82
|
test("primary requires fresh confirmation, cancellation and missing UI block", async () => {
|
|
63
83
|
const h = harness(false, true);
|
|
64
84
|
const command = `psql -c 'DROP TABLE users'`;
|
|
@@ -81,6 +101,25 @@ test("primary requires fresh confirmation, cancellation and missing UI block", a
|
|
|
81
101
|
assert.deepEqual(blockers, [true, false, true, false, true, false, true, false]);
|
|
82
102
|
});
|
|
83
103
|
|
|
104
|
+
// The YOLO temp-scratch waiver is primary-only: children still block it and
|
|
105
|
+
// Normal mode still asks, whatever the targets look like on disk.
|
|
106
|
+
test("temp scratch removal stays blocked in children and confirmed in Normal mode", async () => {
|
|
107
|
+
const scratch = mkdtempSync(join(tmpdir(), "tmp."));
|
|
108
|
+
try {
|
|
109
|
+
const command = `rm -rf ${scratch} /tmp/tmp.8phya1BpFr /tmp/ctx-backstop-pnpm-test.log; cd /tmp && git log --oneline -1`;
|
|
110
|
+
for (const primary of [false, true]) {
|
|
111
|
+
const result = await harness(true, primary).handlers[0](event(command), context(async () => { throw new Error("child must never prompt"); }));
|
|
112
|
+
assert.equal((result as { block?: boolean })?.block, true, `child via ${primary ? "primary" : "safety"} entry`);
|
|
113
|
+
}
|
|
114
|
+
const titles: string[] = [];
|
|
115
|
+
const normal = harness(false, true);
|
|
116
|
+
assert.equal(await normal.handlers[0](event(command), context(async (title) => { titles.push(title); return true; })), undefined);
|
|
117
|
+
assert.deepEqual(titles, ["Allow recognized data-loss command?"]);
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
84
123
|
test("children block recognized destructive commands sent to bash_background too", async () => {
|
|
85
124
|
const h = harness(true);
|
|
86
125
|
const result = await h.handlers[0]({ toolName: "bash_background", input: { command: "echo ok && rm -rf ./data" } }, context(async () => { throw new Error("child must never prompt"); }));
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import codeGraphTools, {
|
|
@@ -356,3 +356,124 @@ test("Windows launcher passes command metacharacters as one literal argv value",
|
|
|
356
356
|
}]);
|
|
357
357
|
assert.equal(existsSync(join(cwd, "shell-injection.txt")), false);
|
|
358
358
|
});
|
|
359
|
+
|
|
360
|
+
// Real CodeGraph writes .codegraph/.gitignore with `*` and `!.gitignore`, so the
|
|
361
|
+
// .gitignore itself stays untracked and would otherwise enter every diff.
|
|
362
|
+
const writeIndex: CodeGraphRunner = async (_args, options) => {
|
|
363
|
+
mkdirSync(join(options.cwd, ".codegraph"), { recursive: true });
|
|
364
|
+
writeFileSync(join(options.cwd, ".codegraph", ".gitignore"), "*\n!.gitignore\n");
|
|
365
|
+
writeFileSync(join(options.cwd, ".codegraph", "codegraph.db"), "index");
|
|
366
|
+
return { stdout: "indexed", stderr: "" };
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
function gitOutput(cwd: string, ...args: string[]): string {
|
|
370
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function excludeFile(cwd: string): string {
|
|
374
|
+
return realpathSync(resolve(cwd, gitOutput(cwd, "rev-parse", "--git-path", "info/exclude")));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function excludeLines(cwd: string): string[] {
|
|
378
|
+
return readFileSync(excludeFile(cwd), "utf8").split("\n").filter((line) => line.trim() === ".codegraph/");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
test("CodeGraph init keeps the whole .codegraph index out of git through the local exclude file, idempotently", async (t) => {
|
|
382
|
+
const cwd = workspace(t);
|
|
383
|
+
writeFileSync(join(cwd, "tracked.txt"), "tracked\n");
|
|
384
|
+
execFileSync("git", ["add", "tracked.txt"], { cwd });
|
|
385
|
+
const tool = createCodeGraphTool(writeIndex);
|
|
386
|
+
const ctx = { cwd } as ExtensionContext;
|
|
387
|
+
|
|
388
|
+
const first = await tool.execute("test", { operation: "init" }, undefined, undefined, ctx);
|
|
389
|
+
const second = await tool.execute("test", { operation: "init" }, undefined, undefined, ctx);
|
|
390
|
+
|
|
391
|
+
assert.deepEqual(first.content, [{ type: "text", text: "indexed" }]);
|
|
392
|
+
assert.equal(gitOutput(cwd, "status", "--porcelain", "--untracked-files=all"), "A tracked.txt");
|
|
393
|
+
assert.deepEqual(excludeLines(cwd), [".codegraph/"], "exactly one exclude line after repeated init");
|
|
394
|
+
assert.equal(existsSync(join(cwd, ".gitignore")), false, "never creates or edits a tracked .gitignore");
|
|
395
|
+
assert.deepEqual((first.details as { localExclude?: unknown }).localExclude, { status: "added", path: excludeFile(cwd) });
|
|
396
|
+
assert.deepEqual((second.details as { localExclude?: unknown }).localExclude, { status: "present", path: excludeFile(cwd) });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("CodeGraph exclusion appends on a new line and recognizes an equivalent existing pattern", async (t) => {
|
|
400
|
+
const appended = workspace(t);
|
|
401
|
+
writeFileSync(excludeFile(appended), "# local\n*.log");
|
|
402
|
+
await createCodeGraphTool(writeIndex).execute("test", { operation: "init" }, undefined, undefined, { cwd: appended } as ExtensionContext);
|
|
403
|
+
assert.equal(readFileSync(excludeFile(appended), "utf8"), "# local\n*.log\n.codegraph/\n");
|
|
404
|
+
|
|
405
|
+
const equivalent = workspace(t);
|
|
406
|
+
writeFileSync(excludeFile(equivalent), "/.codegraph\n");
|
|
407
|
+
const result = await createCodeGraphTool(writeIndex).execute("test", { operation: "init" }, undefined, undefined, { cwd: equivalent } as ExtensionContext);
|
|
408
|
+
assert.equal(readFileSync(excludeFile(equivalent), "utf8"), "/.codegraph\n");
|
|
409
|
+
assert.equal((result.details as { localExclude?: { status: string } }).localExclude?.status, "present");
|
|
410
|
+
assert.equal(gitOutput(equivalent, "status", "--porcelain", "--untracked-files=all"), "");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("CodeGraph exclusion uses the exclude file git resolves for a linked worktree", async (t) => {
|
|
414
|
+
const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-codegraph-worktree-")));
|
|
415
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
416
|
+
const primary = join(base, "primary");
|
|
417
|
+
const linked = join(base, "linked");
|
|
418
|
+
mkdirSync(primary);
|
|
419
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: primary, stdio: "ignore" });
|
|
420
|
+
execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "fixture"], { cwd: primary, stdio: "ignore" });
|
|
421
|
+
execFileSync("git", ["worktree", "add", "-b", "feature", linked], { cwd: primary, stdio: "ignore" });
|
|
422
|
+
|
|
423
|
+
const result = await createCodeGraphTool(writeIndex).execute("test", { operation: "init" }, undefined, undefined, { cwd: linked } as ExtensionContext);
|
|
424
|
+
|
|
425
|
+
assert.deepEqual(result.content, [{ type: "text", text: "indexed" }]);
|
|
426
|
+
assert.equal(gitOutput(linked, "status", "--porcelain", "--untracked-files=all"), "");
|
|
427
|
+
assert.deepEqual(excludeLines(linked), [".codegraph/"]);
|
|
428
|
+
assert.equal((result.details as { localExclude?: { path: string } }).localExclude?.path, excludeFile(linked));
|
|
429
|
+
assert.equal(existsSync(join(linked, ".git", "info")), false, "the linked worktree's .git file is never treated as a directory");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("CodeGraph query excludes an index that already exists, and leaves the exclude file alone without one", async (t) => {
|
|
433
|
+
const indexed = workspace(t);
|
|
434
|
+
await writeIndex(["init", indexed], { cwd: indexed, maxBuffer: 1 });
|
|
435
|
+
const answer: CodeGraphRunner = async () => ({ stdout: "found", stderr: "" });
|
|
436
|
+
await createCodeGraphTool(answer).execute("test", { operation: "query", query: "symbol" }, undefined, undefined, { cwd: indexed } as ExtensionContext);
|
|
437
|
+
assert.deepEqual(excludeLines(indexed), [".codegraph/"]);
|
|
438
|
+
assert.equal(gitOutput(indexed, "status", "--porcelain", "--untracked-files=all"), "");
|
|
439
|
+
|
|
440
|
+
const bare = workspace(t);
|
|
441
|
+
const result = await createCodeGraphTool(answer).execute("test", { operation: "query", query: "symbol" }, undefined, undefined, { cwd: bare } as ExtensionContext);
|
|
442
|
+
assert.deepEqual(excludeLines(bare), []);
|
|
443
|
+
assert.equal((result.details as { localExclude?: unknown }).localExclude, undefined);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("CodeGraph init still succeeds and reports when git is unavailable for the local exclusion", async (t) => {
|
|
447
|
+
const cwd = workspace(t);
|
|
448
|
+
const empty = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-codegraph-nogit-")));
|
|
449
|
+
const originalPath = process.env.PATH;
|
|
450
|
+
t.after(() => {
|
|
451
|
+
process.env.PATH = originalPath;
|
|
452
|
+
rmSync(empty, { recursive: true, force: true });
|
|
453
|
+
});
|
|
454
|
+
const result = await createCodeGraphTool(async (args, options) => {
|
|
455
|
+
const output = await writeIndex(args, options);
|
|
456
|
+
process.env.PATH = empty; // git disappears after the workspace check
|
|
457
|
+
return output;
|
|
458
|
+
}).execute("test", { operation: "init" }, undefined, undefined, { cwd } as ExtensionContext);
|
|
459
|
+
process.env.PATH = originalPath;
|
|
460
|
+
|
|
461
|
+
assert.match(result.content[0].text, /^indexed\n\n/);
|
|
462
|
+
assert.match(result.content[0].text, /\.codegraph\/ is not excluded from git locally/);
|
|
463
|
+
assert.match(result.content[0].text, /keep \.codegraph\/ out of the change/i);
|
|
464
|
+
assert.equal((result.details as { localExclude?: { status: string } }).localExclude?.status, "failed");
|
|
465
|
+
assert.deepEqual(excludeLines(cwd), []);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("CodeGraph excludes a partial index left behind by a failed init without changing the fallback text", async (t) => {
|
|
469
|
+
const cwd = workspace(t);
|
|
470
|
+
const result = await createCodeGraphTool(async (args, options) => {
|
|
471
|
+
await writeIndex(args, options);
|
|
472
|
+
throw new Error("CodeGraph exited with status 1");
|
|
473
|
+
}).execute("test", { operation: "init" }, undefined, undefined, { cwd } as ExtensionContext);
|
|
474
|
+
|
|
475
|
+
assert.deepEqual(result.content, [{ type: "text", text: "CodeGraph failed to run. Use read, grep, and find for this exploration." }]);
|
|
476
|
+
assert.equal((result.details as { status: string }).status, "failed");
|
|
477
|
+
assert.equal((result.details as { localExclude?: { status: string } }).localExclude?.status, "added");
|
|
478
|
+
assert.equal(gitOutput(cwd, "status", "--porcelain", "--untracked-files=all"), "");
|
|
479
|
+
});
|
|
@@ -4,6 +4,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
5
5
|
import { CommandPalette, commandsKey, rankPaletteGroups, type CommandPaletteGroup, type CommandPaletteItem, type CommandPaletteResult, type CommandPaletteTheme } from "../lib/command-palette.ts";
|
|
6
6
|
import { buildCommandPaletteGroups, COMMAND_PALETTE_CATALOG } from "../lib/command-palette-catalog.ts";
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import startupBanner from "../extensions/startup-banner.ts";
|
|
7
9
|
|
|
8
10
|
// Command palette: a pure, curated, grouped overlay component for
|
|
9
11
|
// `/gentle:commands` and its `alt+k` shortcut. These tests drive ranking,
|
|
@@ -370,10 +372,6 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
|
|
|
370
372
|
"gentle:animations",
|
|
371
373
|
"gentle:vim",
|
|
372
374
|
"gentle:telemetry",
|
|
373
|
-
"gentle:banner",
|
|
374
|
-
"gentle:banner-color",
|
|
375
|
-
"gentle:toggle-rose",
|
|
376
|
-
"gentle:toggle-text-logo",
|
|
377
375
|
"gentle:dev-binary",
|
|
378
376
|
]);
|
|
379
377
|
assert.deepEqual(byTitle("Session"), ["gentle:yolo", "gentle:changes", "gentle:agents", "gentle:jobs", "gentle:usage", "gentle:review-session-permission"]);
|
|
@@ -382,6 +380,18 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
|
|
|
382
380
|
assert.deepEqual(byTitle("Skills"), ["skill-registry:refresh"]);
|
|
383
381
|
});
|
|
384
382
|
|
|
383
|
+
// #1664: banner settings live under Customization > Banner; the palette keeps
|
|
384
|
+
// one route while the slash commands stay registered for compatibility.
|
|
385
|
+
test("banner settings have one palette route and keep their slash commands", () => {
|
|
386
|
+
const banner = ["gentle:banner", "gentle:banner-color", "gentle:toggle-rose", "gentle:toggle-text-logo"];
|
|
387
|
+
const catalogCommands = COMMAND_PALETTE_CATALOG.flatMap((group) => group.items.map((item) => item.command));
|
|
388
|
+
for (const command of banner) assert.ok(!catalogCommands.includes(command), `${command} must not appear in the palette`);
|
|
389
|
+
assert.ok(catalogCommands.includes("gentle:customize"));
|
|
390
|
+
const registered: string[] = [];
|
|
391
|
+
startupBanner({ on() {}, registerCommand(name: string) { registered.push(name); }, getCommands: () => [], getAllTools: () => [] } as unknown as ExtensionAPI);
|
|
392
|
+
for (const command of banner) assert.ok(registered.includes(command), `${command} stays registered`);
|
|
393
|
+
});
|
|
394
|
+
|
|
385
395
|
test("retired SDD commands are absent from the palette and extension registrations", () => {
|
|
386
396
|
const retired = ["gentle:sdd-preflight", "gentle-sdd-status", "gentle-sdd-continue", "gentle-sdd-init", "gentle:install-sdd"];
|
|
387
397
|
const catalogCommands = COMMAND_PALETTE_CATALOG.flatMap((group) => group.items.map((item) => item.command));
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { appendFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test, { type TestContext } from "node:test";
|
|
7
|
+
import { commitSliceReport, commitSliceToolResult, commitTargetDirectory, isGitCommitCommand } from "../lib/commit-slice-signal.ts";
|
|
8
|
+
|
|
9
|
+
// Harness roadmap T4 (R1): the ~400-line slice rule lived in the prompt and the
|
|
10
|
+
// model had to estimate line counts. After each parent commit the runtime now
|
|
11
|
+
// reports the authored changed lines on the branch, so slicing reads a number.
|
|
12
|
+
|
|
13
|
+
function repo(t: TestContext): { dir: string; git: (...args: string[]) => string; commitFile: (path: string, lines: number) => void } {
|
|
14
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), "commit-slice-")));
|
|
15
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
16
|
+
const git = (...args: string[]) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: dir, encoding: "utf8" });
|
|
17
|
+
const commitFile = (path: string, lines: number) => {
|
|
18
|
+
mkdirSync(join(dir, path, ".."), { recursive: true });
|
|
19
|
+
writeFileSync(join(dir, path), Array.from({ length: lines }, (_, i) => `line ${i}`).join("\n") + "\n");
|
|
20
|
+
git("add", "--", path);
|
|
21
|
+
git("commit", "-q", "-m", `add ${path}`);
|
|
22
|
+
};
|
|
23
|
+
git("init", "-q", "-b", "main");
|
|
24
|
+
commitFile("README.md", 3);
|
|
25
|
+
return { dir, git, commitFile };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("isGitCommitCommand recognizes commits inside shell commands, nothing else", () => {
|
|
29
|
+
for (const command of ["git commit -m 'feat: x'", "git add -A && git commit -qm x", "cd pkg && git -C . commit --amend --no-edit", "git -c user.name=a commit -m x"]) {
|
|
30
|
+
assert.equal(isGitCommitCommand(command), true, command);
|
|
31
|
+
}
|
|
32
|
+
for (const command of ["git status", "git log --grep commit", "echo git commit", "npm test", "git commit-tree abc"]) {
|
|
33
|
+
assert.equal(isGitCommitCommand(command), false, command);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// R1 review advisory: any `echo ... git` segment hid a real commit elsewhere in
|
|
38
|
+
// the command, while text that only mentioned `git commit` counted as one.
|
|
39
|
+
test("isGitCommitCommand counts a git invocation that starts a shell segment, not text that mentions one", () => {
|
|
40
|
+
for (const command of ["echo 'staging git files' && git commit -m x", "echo git status; git commit -m x", "GIT_AUTHOR_DATE=now git commit -m x", "(cd pkg && git commit -m x)", "git add -A\ngit commit -m x", "git commit -m 'feat: git commit counting'"]) {
|
|
41
|
+
assert.equal(isGitCommitCommand(command), true, command);
|
|
42
|
+
}
|
|
43
|
+
for (const command of ["printf 'run git commit now'", "gh pr create --body \"then git commit -m x\"", "grep -rn 'git commit' docs", "echo \"git commit -m x\""]) {
|
|
44
|
+
assert.equal(isGitCommitCommand(command), false, command);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("commitTargetDirectory follows git -C and a leading cd", () => {
|
|
49
|
+
assert.equal(commitTargetDirectory("git commit -m x", "/work/repo"), "/work/repo");
|
|
50
|
+
assert.equal(commitTargetDirectory("git -C ../other commit -m x", "/work/repo"), "/work/other");
|
|
51
|
+
assert.equal(commitTargetDirectory("cd /tmp/x && git commit -m x", "/work/repo"), "/tmp/x");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// PR #2290 review: only a leading `cd <dir> &&` moved the count, so a commit after
|
|
55
|
+
// `cd <dir>;` or a later `cd` counted the session cwd instead.
|
|
56
|
+
test("commitTargetDirectory follows a cd that precedes the commit through ;, && or ||", () => {
|
|
57
|
+
assert.equal(commitTargetDirectory("cd /tmp/x; git commit -m x", "/work/repo"), "/tmp/x");
|
|
58
|
+
assert.equal(commitTargetDirectory("git add -A && cd pkg && git commit -m x", "/work/repo"), "/work/repo/pkg");
|
|
59
|
+
assert.equal(commitTargetDirectory("cd /tmp/x || exit 1; git commit -m x", "/work/repo"), "/tmp/x");
|
|
60
|
+
assert.equal(commitTargetDirectory("cd a && cd 'b c'\ngit -C d commit -m x", "/work/repo"), "/work/repo/a/b c/d");
|
|
61
|
+
assert.equal(commitTargetDirectory("git commit -m x && cd /tmp/x", "/work/repo"), "/work/repo", "a cd after the commit does not move it");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a feature branch reports authored changed lines since the default branch, lock files excluded", async (t) => {
|
|
65
|
+
const { dir, git, commitFile } = repo(t);
|
|
66
|
+
git("switch", "-q", "-c", "feat/slice");
|
|
67
|
+
commitFile("src/a.ts", 120);
|
|
68
|
+
commitFile("pnpm-lock.yaml", 900);
|
|
69
|
+
commitFile("tests/a.test.ts", 30);
|
|
70
|
+
const report = await commitSliceReport(dir);
|
|
71
|
+
assert.match(report ?? "", /^Commit slice: 150 authored changed lines in 3 commits on feat\/slice since main/);
|
|
72
|
+
assert.match(report ?? "", /250 lines left before the ~400-line slice/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// PR #2290 review: `--no-renames` counted a moved file as all of its lines deleted
|
|
76
|
+
// and added again; rename detection counts only what the move changed.
|
|
77
|
+
test("a move-only commit counts no authored lines", async (t) => {
|
|
78
|
+
const { dir, git, commitFile } = repo(t);
|
|
79
|
+
commitFile("src/old.ts", 120);
|
|
80
|
+
git("switch", "-q", "-c", "feat/move");
|
|
81
|
+
git("mv", "src/old.ts", "src/new.ts");
|
|
82
|
+
git("commit", "-q", "-m", "move src/old.ts");
|
|
83
|
+
assert.match(await commitSliceReport(dir) ?? "", /^Commit slice: 0 authored changed lines in 1 commit on feat\/move/);
|
|
84
|
+
git("mv", "src/new.ts", "src/newer.ts");
|
|
85
|
+
appendFileSync(join(dir, "src/newer.ts"), "line 120\n");
|
|
86
|
+
git("commit", "-qam", "move and extend src/new.ts");
|
|
87
|
+
assert.match(await commitSliceReport(dir) ?? "", /^Commit slice: 1 authored changed lines in 2 commits on feat\/move/, "a move that edits counts only the edit");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// PR #2290 review: Git quotes an unusual path in plain numstat output, so the
|
|
91
|
+
// trailing quote hid a lock file from the generated-file filter.
|
|
92
|
+
test("a lock file whose path Git quotes is still excluded", async (t) => {
|
|
93
|
+
const { dir, git, commitFile } = repo(t);
|
|
94
|
+
git("switch", "-q", "-c", "feat/quoted");
|
|
95
|
+
commitFile("packages/caf\u00e9/pnpm-lock.yaml", 900);
|
|
96
|
+
commitFile("src/a.ts", 10);
|
|
97
|
+
assert.match(git("-c", "core.quotePath=true", "diff", "--numstat", "main", "HEAD"), /"packages\/caf\\303\\251\/pnpm-lock\.yaml"/, "the fixture path needs quoting");
|
|
98
|
+
assert.match(await commitSliceReport(dir) ?? "", /^Commit slice: 10 authored changed lines in 2 commits on feat\/quoted/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("past ~400 lines the report says to apply the delivery strategy before the next commit", async (t) => {
|
|
102
|
+
const { dir, git, commitFile } = repo(t);
|
|
103
|
+
git("switch", "-q", "-c", "feat/big");
|
|
104
|
+
commitFile("src/big.ts", 450);
|
|
105
|
+
assert.match(await commitSliceReport(dir) ?? "", /450 authored changed lines in 1 commit on feat\/big since main\b.*Past the ~400-line slice: apply the feature's delivery strategy before the next commit/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// R1 review advisory: the report ran git synchronously in the parent's
|
|
109
|
+
// tool_result handler, so the interface froze for as long as git took.
|
|
110
|
+
test("the parent keeps running while git computes the report", async (t) => {
|
|
111
|
+
const { dir, git, commitFile } = repo(t);
|
|
112
|
+
git("switch", "-q", "-c", "feat/async");
|
|
113
|
+
commitFile("src/a.ts", 10);
|
|
114
|
+
let ran = false;
|
|
115
|
+
setImmediate(() => { ran = true; });
|
|
116
|
+
const report = await commitSliceReport(dir);
|
|
117
|
+
assert.equal(ran, true, "the event loop ran while git answered");
|
|
118
|
+
assert.match(report ?? "", /^Commit slice: 10 authored changed lines in 1 commit on feat\/async/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// R1 review advisory: a failed or timed-out `git diff` read as an empty diff
|
|
122
|
+
// and reported "0 authored changed lines"; no number beats a wrong one.
|
|
123
|
+
test("a failed diff reports nothing instead of zero lines", async () => {
|
|
124
|
+
const answers: Record<string, string | undefined> = {
|
|
125
|
+
"symbolic-ref -q --short HEAD": "feat/slice",
|
|
126
|
+
"symbolic-ref -q --short refs/remotes/origin/HEAD": "origin/main",
|
|
127
|
+
"merge-base HEAD origin/main": "base",
|
|
128
|
+
"rev-list --count base..HEAD": "2",
|
|
129
|
+
"diff --numstat -z -M base HEAD": "12\t3\tsrc/a.ts\0",
|
|
130
|
+
};
|
|
131
|
+
const git = async (args: string[]) => answers[args.join(" ")];
|
|
132
|
+
assert.match(await commitSliceReport("/repo", git) ?? "", /^Commit slice: 15 authored changed lines in 2 commits/);
|
|
133
|
+
delete answers["diff --numstat -z -M base HEAD"];
|
|
134
|
+
assert.equal(await commitSliceReport("/repo", git), undefined);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// R1 review advisory: a background job's result arrives when the job starts,
|
|
138
|
+
// before its commit exists, so its slice line would describe the old HEAD.
|
|
139
|
+
test("only a foreground bash commit carries the slice line", async () => {
|
|
140
|
+
const git = async (args: string[]) => ({
|
|
141
|
+
"symbolic-ref -q --short HEAD": "feat/slice",
|
|
142
|
+
"symbolic-ref -q --short refs/remotes/origin/HEAD": "origin/main",
|
|
143
|
+
"merge-base HEAD origin/main": "base",
|
|
144
|
+
"rev-list --count base..HEAD": "1",
|
|
145
|
+
"diff --numstat -z -M base HEAD": "5\t0\tsrc/a.ts\0",
|
|
146
|
+
} as Record<string, string>)[args.join(" ")];
|
|
147
|
+
const event = (toolName: string) => ({ toolName, input: { command: "git commit -m x" }, isError: false, content: [{ type: "text" as const, text: "ok" }], structuredContent: undefined });
|
|
148
|
+
assert.match(JSON.stringify((await commitSliceToolResult(event("bash"), "/repo", git))?.content), /Commit slice: 5 authored changed lines/);
|
|
149
|
+
assert.equal(await commitSliceToolResult(event("bash_background"), "/repo", git), undefined);
|
|
150
|
+
assert.equal(await commitSliceToolResult(event("monitor"), "/repo", git), undefined);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("commits on the default branch, outside Git, or with nothing ahead report nothing", async (t) => {
|
|
154
|
+
const { dir, git, commitFile } = repo(t);
|
|
155
|
+
commitFile("src/direct.ts", 10);
|
|
156
|
+
assert.equal(await commitSliceReport(dir), undefined, "the default branch has no slice");
|
|
157
|
+
git("switch", "-q", "-c", "feat/empty");
|
|
158
|
+
assert.equal(await commitSliceReport(dir), undefined, "nothing ahead of the default branch");
|
|
159
|
+
const plain = realpathSync(mkdtempSync(join(tmpdir(), "commit-slice-plain-")));
|
|
160
|
+
t.after(() => rmSync(plain, { recursive: true, force: true }));
|
|
161
|
+
assert.equal(await commitSliceReport(plain), undefined, "outside Git");
|
|
162
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test, { type TestContext } from "node:test";
|
|
7
|
+
import type { ContextUsage, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
|
|
9
|
+
import {
|
|
10
|
+
CONTEXT_BACKSTOP_FRACTION_ENV,
|
|
11
|
+
CONTEXT_BACKSTOP_TOKENS_ENV,
|
|
12
|
+
contextBackstopReached,
|
|
13
|
+
contextBackstopThreshold,
|
|
14
|
+
} from "../lib/context-backstop.ts";
|
|
15
|
+
|
|
16
|
+
// Harness roadmap R1: the parent-context backstop was a fixed "~150k tokens" in prompt text, so the model had to
|
|
17
|
+
// estimate its own context. The runtime now measures it against min(absolute cap, fraction x context window) and
|
|
18
|
+
// tells the primary session once per crossing.
|
|
19
|
+
|
|
20
|
+
test("the threshold is the smaller of the absolute cap and 15% of the context window", () => {
|
|
21
|
+
assert.equal(contextBackstopThreshold(1_000_000, {}), 150_000, "a large window keeps the absolute cap");
|
|
22
|
+
assert.equal(contextBackstopThreshold(400_000, {}), 60_000);
|
|
23
|
+
// Bench gate (T12): a 272k window reaches it near 41k, where delegating the wide task paid off.
|
|
24
|
+
assert.equal(contextBackstopThreshold(272_000, {}), 40_800);
|
|
25
|
+
assert.equal(contextBackstopThreshold(200_000, {}), 30_000, "a small window reaches it at 15% of its size");
|
|
26
|
+
assert.equal(contextBackstopThreshold(128_000, {}), 19_200);
|
|
27
|
+
for (const unknown of [undefined, null, 0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
28
|
+
assert.equal(contextBackstopThreshold(unknown, {}), 150_000, `window ${unknown} falls back to the cap`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("the cap and the fraction are overridable by environment; invalid values keep the defaults", () => {
|
|
33
|
+
assert.equal(contextBackstopThreshold(1_000_000, { [CONTEXT_BACKSTOP_TOKENS_ENV]: "100000" }), 100_000);
|
|
34
|
+
assert.equal(contextBackstopThreshold(4_000_000, { [CONTEXT_BACKSTOP_TOKENS_ENV]: "300000" }), 300_000, "a raised cap applies where the fraction allows it");
|
|
35
|
+
assert.equal(contextBackstopThreshold(1_000_000, { [CONTEXT_BACKSTOP_FRACTION_ENV]: "0.1" }), 100_000);
|
|
36
|
+
assert.equal(contextBackstopThreshold(200_000, { [CONTEXT_BACKSTOP_TOKENS_ENV]: "80000", [CONTEXT_BACKSTOP_FRACTION_ENV]: "1" }), 80_000);
|
|
37
|
+
for (const value of ["", "abc", "0", "-5", "1.5e999"]) {
|
|
38
|
+
assert.equal(contextBackstopThreshold(1_000_000, { [CONTEXT_BACKSTOP_TOKENS_ENV]: value }), 150_000, `tokens ${JSON.stringify(value)}`);
|
|
39
|
+
}
|
|
40
|
+
for (const value of ["", "abc", "0", "-0.5", "1.5"]) {
|
|
41
|
+
assert.equal(contextBackstopThreshold(200_000, { [CONTEXT_BACKSTOP_FRACTION_ENV]: value }), 30_000, `fraction ${JSON.stringify(value)}`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// R1 review advisory: a tiny window or fraction floored the threshold to 0, so an
|
|
46
|
+
// empty context counted as reaching the backstop (and admitted every writer).
|
|
47
|
+
test("the threshold is never below one token, so an empty context never reaches it", () => {
|
|
48
|
+
assert.equal(contextBackstopThreshold(200_000, { [CONTEXT_BACKSTOP_FRACTION_ENV]: "0.000001" }), 1);
|
|
49
|
+
assert.equal(contextBackstopThreshold(1, {}), 1);
|
|
50
|
+
assert.equal(contextBackstopReached({ tokens: 0, contextWindow: 1 }, {}), false);
|
|
51
|
+
assert.equal(contextBackstopReached({ tokens: 1, contextWindow: 1 }, {}), true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("unknown tokens never count as reaching the backstop", () => {
|
|
55
|
+
assert.equal(contextBackstopReached({ tokens: 150_000, contextWindow: 1_000_000 }, {}), true);
|
|
56
|
+
assert.equal(contextBackstopReached({ tokens: 149_999, contextWindow: 1_000_000 }, {}), false);
|
|
57
|
+
assert.equal(contextBackstopReached({ tokens: 19_200, contextWindow: 128_000 }, {}), true);
|
|
58
|
+
assert.equal(contextBackstopReached({ tokens: 19_199, contextWindow: 128_000 }, {}), false);
|
|
59
|
+
assert.equal(contextBackstopReached({ tokens: null, contextWindow: 128_000 }, {}), false, "null right after compaction");
|
|
60
|
+
assert.equal(contextBackstopReached(undefined, {}), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
64
|
+
type Patch = { content?: Array<{ type: string; text?: string }>; structuredContent?: unknown; isError?: boolean } | undefined;
|
|
65
|
+
|
|
66
|
+
function session(t: TestContext, child: boolean) {
|
|
67
|
+
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "context-backstop-")));
|
|
68
|
+
const configHome = mkdtempSync(join(tmpdir(), "context-backstop-config-"));
|
|
69
|
+
const previous = process.env.GENTLE_PI_CONFIG_HOME;
|
|
70
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
71
|
+
t.after(() => {
|
|
72
|
+
if (previous === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
73
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previous;
|
|
74
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
75
|
+
rmSync(configHome, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
const handlers = new Map<string, Handler>();
|
|
78
|
+
const pi = { on: (name: string, handler: Handler) => { handlers.set(name, handler); }, events: { on() {}, emit() {} }, registerCommand() {}, registerTool() {} } as unknown as ExtensionAPI;
|
|
79
|
+
createGentleAiExtension({ nativeReviewCli: null, processEnv: { GENTLE_PI_AGENTS_CHILD: child ? "1" : "0", GENTLE_AI_TELEMETRY: "0" } })(pi);
|
|
80
|
+
let usage: ContextUsage | undefined = { tokens: 10_000, contextWindow: 1_000_000, percent: 1 };
|
|
81
|
+
const sessionManager = { getSessionId: () => "session-backstop", getCwd: () => cwd, getEntries: () => [], getBranch: () => [] };
|
|
82
|
+
const ctx = { cwd, hasUI: false, ui: { notify() {} }, sessionManager, getContextUsage: () => usage } as unknown as ExtensionContext;
|
|
83
|
+
const setTokens = (tokens: number | null, contextWindow = 1_000_000) => {
|
|
84
|
+
usage = { tokens, contextWindow, percent: tokens === null ? null : (tokens / contextWindow) * 100 };
|
|
85
|
+
};
|
|
86
|
+
const read = (overrides: Record<string, unknown> = {}) => handlers.get("tool_result")!({
|
|
87
|
+
toolName: "read", toolCallId: "call-read", isError: false, input: { path: "a.ts" },
|
|
88
|
+
content: [{ type: "text", text: "file body" }], structuredContent: { lines: 1 }, ...overrides,
|
|
89
|
+
}, ctx) as Promise<Patch>;
|
|
90
|
+
return { cwd, ctx, handlers, setTokens, read };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test("the primary session gets one notice when its measured context first reaches the backstop", async (t) => {
|
|
94
|
+
const s = session(t, false);
|
|
95
|
+
assert.equal(await s.read(), undefined, "below the backstop nothing is appended");
|
|
96
|
+
s.setTokens(160_000);
|
|
97
|
+
const crossed = await s.read();
|
|
98
|
+
assert.equal(crossed?.content?.[0]?.text, "file body", "the tool output is kept first");
|
|
99
|
+
const notice = crossed?.content?.[1]?.text ?? "";
|
|
100
|
+
assert.match(notice, /Context backstop/);
|
|
101
|
+
assert.match(notice, /160,000 tokens/, "measured tokens");
|
|
102
|
+
assert.match(notice, /16% of the 1,000,000-token context window/, "percent of the window");
|
|
103
|
+
assert.match(notice, /150,000-token backstop/, "the threshold");
|
|
104
|
+
assert.match(notice, /delegate the next bounded unit that needs several steps \(its reads, edits, and checks\) with a short handoff/);
|
|
105
|
+
assert.match(notice, /single small edits and state checks stay inline/);
|
|
106
|
+
assert.deepEqual(crossed?.structuredContent, { lines: 1 }, "structured content is kept");
|
|
107
|
+
assert.equal(crossed && "isError" in crossed, false, "the error flag is never altered");
|
|
108
|
+
assert.equal(await s.read(), undefined, "once per crossing");
|
|
109
|
+
s.setTokens(null);
|
|
110
|
+
assert.equal(await s.read(), undefined, "unknown tokens neither notify nor re-arm");
|
|
111
|
+
s.setTokens(170_000);
|
|
112
|
+
assert.equal(await s.read(), undefined, "still past the same crossing");
|
|
113
|
+
s.setTokens(40_000);
|
|
114
|
+
assert.equal(await s.read(), undefined, "dropping below the backstop re-arms it");
|
|
115
|
+
s.setTokens(19_200, 128_000);
|
|
116
|
+
const again = await s.read({ isError: true, structuredContent: undefined });
|
|
117
|
+
assert.match(again?.content?.[1]?.text ?? "", /19,200 tokens \(15% of the 128,000-token context window\), at or past the 19,200-token backstop/, "a small window reaches it at 15% of its size");
|
|
118
|
+
assert.equal(again && "structuredContent" in again, false, "absent structured content stays absent");
|
|
119
|
+
assert.equal(again && "isError" in again, false, "an error result keeps its flag");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("child sessions never get the notice", async (t) => {
|
|
123
|
+
const s = session(t, true);
|
|
124
|
+
s.setTokens(390_000);
|
|
125
|
+
assert.equal(await s.read(), undefined);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a parent commit result carries both the slice line and the backstop notice", async (t) => {
|
|
129
|
+
const s = session(t, false);
|
|
130
|
+
const git = (...args: string[]) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: s.cwd, encoding: "utf8" });
|
|
131
|
+
git("init", "-q", "-b", "main");
|
|
132
|
+
writeFileSync(join(s.cwd, "README.md"), "base\n");
|
|
133
|
+
git("add", "README.md");
|
|
134
|
+
git("commit", "-q", "-m", "base");
|
|
135
|
+
git("switch", "-q", "-c", "feat/x");
|
|
136
|
+
writeFileSync(join(s.cwd, "a.ts"), "one\n");
|
|
137
|
+
git("add", "a.ts");
|
|
138
|
+
git("commit", "-q", "-m", "feat: a");
|
|
139
|
+
s.setTokens(200_000);
|
|
140
|
+
const result = await s.handlers.get("tool_result")!({
|
|
141
|
+
toolName: "bash", toolCallId: "call-1", isError: false, input: { command: "git commit -m 'feat: a'" },
|
|
142
|
+
content: [{ type: "text", text: "[feat/x 1234567] feat: a" }], structuredContent: { exitCode: 0 },
|
|
143
|
+
}, s.ctx) as Patch;
|
|
144
|
+
assert.equal(result?.content?.[0]?.text, "[feat/x 1234567] feat: a");
|
|
145
|
+
assert.match(result?.content?.[1]?.text ?? "", /Commit slice: 1 authored changed lines/);
|
|
146
|
+
assert.match(result?.content?.[2]?.text ?? "", /Context backstop/);
|
|
147
|
+
assert.deepEqual(result?.structuredContent, { exitCode: 0 });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a parent write result carries the notice after its own bookkeeping", async (t) => {
|
|
151
|
+
const s = session(t, false);
|
|
152
|
+
s.setTokens(150_000);
|
|
153
|
+
const result = await s.handlers.get("tool_result")!({
|
|
154
|
+
toolName: "write", toolCallId: "call-w", isError: false, input: { path: "src/a.ts" },
|
|
155
|
+
content: [{ type: "text", text: "Wrote src/a.ts" }],
|
|
156
|
+
}, s.ctx) as Patch;
|
|
157
|
+
assert.equal(result?.content?.[0]?.text, "Wrote src/a.ts");
|
|
158
|
+
assert.match(result?.content?.[1]?.text ?? "", /Context backstop/);
|
|
159
|
+
});
|