gentle-pi 4.3.0 → 4.4.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 +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -4,7 +4,7 @@ import * as fsPromises from "node:fs/promises";
|
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
|
-
import { CHANNEL_SCHEMA, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
|
|
7
|
+
import { CHANNEL_SCHEMA, GENTLE_AI_MODULE, MainChannelError, ownerManager, readChannel, removeGlobalShell, runUpgrade, writeChannel } from "../scripts/main-channel.mjs";
|
|
8
8
|
import { requirements } from "../scripts/installer-preflight.mjs";
|
|
9
9
|
|
|
10
10
|
const AI_SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
|
|
@@ -19,7 +19,8 @@ type Invocation = { command: string; prefix: string[] } | null;
|
|
|
19
19
|
* `which` finds `<bin>/<name><suffix>`; `invocation`, when given, is the host's adapter. */
|
|
20
20
|
function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell: SHELL_SHA }, tools = ["go", "pnpm", "npm"], platform = "darwin",
|
|
21
21
|
bin = "/usr/bin", suffix = "", invocation = undefined as ((name: string) => Promise<Invocation>) | undefined, fs = fsPromises as typeof fsPromises,
|
|
22
|
-
migrate = undefined as ((input: { latest: string; owner: () => Promise<unknown> }) => Promise<unknown>) | undefined
|
|
22
|
+
migrate = undefined as ((input: { latest: string; owner: () => Promise<unknown> }) => Promise<unknown>) | undefined,
|
|
23
|
+
failGoInstall = false, failShellInstall = false } = {}) {
|
|
23
24
|
// owner "linked": `npm link` of a source checkout, so npm's global entry points outside npm's root.
|
|
24
25
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "upgrade-")));
|
|
25
26
|
const home = join(root, "home");
|
|
@@ -50,13 +51,22 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
|
|
|
50
51
|
}
|
|
51
52
|
calls.push({ command, argv });
|
|
52
53
|
if (argv[0] === "install" && argv[1]?.includes("gentle-ai/v4/cmd/gentle-ai@")) {
|
|
54
|
+
if (failGoInstall) return { code: 1, stdout: "" };
|
|
53
55
|
mkdirSync(options.env!.GOBIN, { recursive: true });
|
|
54
56
|
for (const name of ["gentle-ai", "gentle-ai.exe"]) writeFileSync(join(options.env!.GOBIN, name), "#!/bin/sh\n", { mode: 0o755 });
|
|
55
57
|
return { code: 0, stdout: "" };
|
|
56
58
|
}
|
|
57
59
|
if (argv[0] === "version" && command === `${bin}/go`) {
|
|
60
|
+
if (argv[1] === "-m") {
|
|
61
|
+
const commit = String(argv[2] ?? "").split("/").at(-2) ?? "";
|
|
62
|
+
return { code: 0, stdout: `${command} (binary)\n\tmod\t${GENTLE_AI_MODULE}\tv4.0.1-0.20261008202137-${commit.slice(0, 12)}\th1:J8EluS7QrfM+m3vmQlF26B/shZRjD6rBfsKtrABL5mc=\n` };
|
|
63
|
+
}
|
|
58
64
|
return { code: 0, stdout: `go version go${requirements.go} ${platform}/amd64\n` };
|
|
59
65
|
}
|
|
66
|
+
if (argv[0] === "list" && command === `${bin}/go`) {
|
|
67
|
+
const commit = String(argv[3] ?? "").slice(`${GENTLE_AI_MODULE}@`.length);
|
|
68
|
+
return { code: 0, stdout: JSON.stringify({ Path: GENTLE_AI_MODULE, Version: `v4.0.1-0.20261008202137-${commit.slice(0, 12)}`, Origin: { VCS: "git", URL: "https://github.com/gentleman-programming/gentle-ai", Hash: commit } }) };
|
|
69
|
+
}
|
|
60
70
|
if (argv[0] === "version") {
|
|
61
71
|
const sha = command.split("/").at(-2) ?? "";
|
|
62
72
|
return { code: 0, stdout: `gentle-ai 4.0.1-0.20261008202137-${sha.slice(0, 12)}\n` };
|
|
@@ -65,6 +75,7 @@ function world({ owner = "pnpm", latest = "4.1.0", commits = { ai: AI_SHA, shell
|
|
|
65
75
|
writeFileSync(join(argv[argv.indexOf("-C") + 1], "package.json"), JSON.stringify({ name: "gentle-pi", version: "4.1.0", scripts: { prepack: "x" } }));
|
|
66
76
|
return { code: 0, stdout: "" };
|
|
67
77
|
}
|
|
78
|
+
if (failShellInstall && argv.includes("-g") && argv.some((arg) => arg.endsWith(".tgz"))) return { code: 1, stdout: "" };
|
|
68
79
|
if (argv.includes("pack")) {
|
|
69
80
|
const version = JSON.parse(readFileSync(join(options.cwd!, "package.json"), "utf8")).version;
|
|
70
81
|
writeFileSync(join(argv[argv.indexOf("--pack-destination") + 1], `gentle-pi-${version}.tgz`), "tgz");
|
|
@@ -186,6 +197,225 @@ test("a main install rebuilds only what moved on main and records the new commit
|
|
|
186
197
|
} finally { w.cleanup(); }
|
|
187
198
|
});
|
|
188
199
|
|
|
200
|
+
// #2356: a Gentle AI step that fails still installs a main Shell that moved, so the
|
|
201
|
+
// retry runs the new Shell's code; the channel is recorded only when both succeed.
|
|
202
|
+
const GO_INSTALL_FAILED = `main-gentle-ai-build-failed: go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@${NEW_AI} failed`;
|
|
203
|
+
const PRIOR_REGISTRATION = JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: "/prior/main/gentle-ai" });
|
|
204
|
+
const NEW_SHELL_TGZ = (home: string) => join(home, ".pi", "gentle-ai", "main", "packages", "gentle-pi-4.1.0-main.333333333333.tgz");
|
|
205
|
+
async function onPriorMain(w: ReturnType<typeof world>) {
|
|
206
|
+
await writeChannel(w.ctx, { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA }, fsPromises);
|
|
207
|
+
writeFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), PRIOR_REGISTRATION);
|
|
208
|
+
return () => ({ channel: readFileSync(join(w.home, ".pi", "gentle-ai", "channel.json"), "utf8"), registration: readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8") });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
test("a failed Gentle AI build still installs the main Shell that moved, records no channel and names the retry", posixOnly, async () => {
|
|
212
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true });
|
|
213
|
+
try {
|
|
214
|
+
const files = await onPriorMain(w);
|
|
215
|
+
const before = files();
|
|
216
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
217
|
+
assert.equal(error.code, "main-gentle-ai-build-failed");
|
|
218
|
+
assert.equal(error.message, `${GO_INSTALL_FAILED}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`);
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
222
|
+
assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
|
|
223
|
+
assert.deepEqual(files(), before);
|
|
224
|
+
assert.deepEqual(JSON.parse(before.registration), JSON.parse(PRIOR_REGISTRATION));
|
|
225
|
+
} finally { w.cleanup(); }
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("a Go that cannot be obtained for the Gentle AI build still installs the main Shell that moved", posixOnly, async () => {
|
|
229
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, tools: ["pnpm", "npm"] });
|
|
230
|
+
try {
|
|
231
|
+
const files = await onPriorMain(w);
|
|
232
|
+
const before = files();
|
|
233
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
234
|
+
assert.equal(error.code, "main-requires-tools");
|
|
235
|
+
assert.match(error.message, /^main-requires-tools: the main channel builds Gentle AI with Go .*Unexpected Go acquisition in sandboxed upgrade fixture.*; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish$/);
|
|
236
|
+
return true;
|
|
237
|
+
});
|
|
238
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
239
|
+
assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
|
|
240
|
+
assert.deepEqual(files(), before);
|
|
241
|
+
} finally { w.cleanup(); }
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("after a fail-forward, the retry from the new Shell builds Gentle AI and records both commits", posixOnly, async () => {
|
|
245
|
+
// The first copy of the built binary fails (as a rejected build would); the retry's succeeds.
|
|
246
|
+
let copies = 0;
|
|
247
|
+
const fs = { ...fsPromises, copyFile: async (...args: Parameters<typeof fsPromises.copyFile>) => {
|
|
248
|
+
if (copies++ === 0) throw new MainChannelError("main-gentle-ai-unverified", "first attempt rejected");
|
|
249
|
+
return fsPromises.copyFile(...args);
|
|
250
|
+
} } as typeof fsPromises;
|
|
251
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs });
|
|
252
|
+
try {
|
|
253
|
+
const files = await onPriorMain(w);
|
|
254
|
+
const before = files();
|
|
255
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => error.code === "main-gentle-ai-unverified");
|
|
256
|
+
assert.deepEqual(files(), before);
|
|
257
|
+
// The retry is simulated as the new Shell's run: the same runUpgrade with the new
|
|
258
|
+
// version (the installed package itself is not executed). No channel was recorded,
|
|
259
|
+
// so it rebuilds Gentle AI and repacks the Shell before recording both commits.
|
|
260
|
+
w.lines.length = 0;
|
|
261
|
+
assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0);
|
|
262
|
+
assert.deepEqual(w.lines, ["Updated to the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]);
|
|
263
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`, `/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
264
|
+
assert.deepEqual(await readChannel(w.ctx, fsPromises), { channel: "main", shellCommit: NEW_SHELL, gentleAiCommit: NEW_AI });
|
|
265
|
+
assert.equal(JSON.parse(readFileSync(join(w.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8")).path,
|
|
266
|
+
join(w.home, ".pi", "gentle-ai", "main", "gentle-ai", NEW_AI, "gentle-ai"));
|
|
267
|
+
// A third run is a no-op: both commits are current.
|
|
268
|
+
w.lines.length = 0;
|
|
269
|
+
assert.equal(await w.upgrade([], "4.1.0-main.333333333333"), 0);
|
|
270
|
+
assert.deepEqual(w.lines, ["gentle-shell is already at the latest main: Gentle Shell 333333333333, Gentle AI 222222222222."]);
|
|
271
|
+
} finally { w.cleanup(); }
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("a failed Gentle AI build with the main Shell already current fails as before: nothing installed or recorded", posixOnly, async () => {
|
|
275
|
+
const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, failGoInstall: true });
|
|
276
|
+
try {
|
|
277
|
+
const files = await onPriorMain(w);
|
|
278
|
+
const before = files();
|
|
279
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
280
|
+
assert.equal(error.code, "main-gentle-ai-build-failed");
|
|
281
|
+
assert.equal(error.message, GO_INSTALL_FAILED);
|
|
282
|
+
return true;
|
|
283
|
+
});
|
|
284
|
+
assert.deepEqual(w.installs(), []);
|
|
285
|
+
assert.deepEqual(w.lines, []);
|
|
286
|
+
assert.deepEqual(files(), before);
|
|
287
|
+
} finally { w.cleanup(); }
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("when the Gentle AI build and the Shell install both fail, the Shell failure is reported with the Gentle AI one", posixOnly, async () => {
|
|
291
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, failGoInstall: true, failShellInstall: true });
|
|
292
|
+
try {
|
|
293
|
+
const files = await onPriorMain(w);
|
|
294
|
+
const before = files();
|
|
295
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
296
|
+
assert.equal(error.code, "upgrade-install-failed");
|
|
297
|
+
assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (${GO_INSTALL_FAILED})`);
|
|
298
|
+
return true;
|
|
299
|
+
});
|
|
300
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
301
|
+
assert.deepEqual(w.lines, []);
|
|
302
|
+
assert.deepEqual(files(), before);
|
|
303
|
+
} finally { w.cleanup(); }
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// An ordinary failure that is not a MainChannelError (EACCES) keeps the retry advice and both errors.
|
|
307
|
+
const eacces = (what: string) => Object.assign(new Error(`EACCES: permission denied, ${what}`), { code: "EACCES" });
|
|
308
|
+
const COPY_DENIED = eacces("copyfile");
|
|
309
|
+
const MANIFEST_DENIED = eacces("open package.json");
|
|
310
|
+
const deniedFs = ({ copy = false, manifest = false }) => ({ ...fsPromises,
|
|
311
|
+
copyFile: async (...args: Parameters<typeof fsPromises.copyFile>) => { if (copy) throw COPY_DENIED; return fsPromises.copyFile(...args); },
|
|
312
|
+
readFile: (async (path: string, options?: unknown) => {
|
|
313
|
+
if (manifest && String(path).endsWith(join(".source", "src", "package.json"))) throw MANIFEST_DENIED;
|
|
314
|
+
return fsPromises.readFile(path, options as never);
|
|
315
|
+
}) as typeof fsPromises.readFile }) as typeof fsPromises;
|
|
316
|
+
|
|
317
|
+
test("an ordinary error in the Gentle AI step still installs the main Shell that moved and names the retry", posixOnly, async () => {
|
|
318
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true }) });
|
|
319
|
+
try {
|
|
320
|
+
const files = await onPriorMain(w);
|
|
321
|
+
const before = files();
|
|
322
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
323
|
+
assert.ok(error instanceof MainChannelError);
|
|
324
|
+
assert.equal(error.code, "main-upgrade-incomplete");
|
|
325
|
+
assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, copyfile; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish");
|
|
326
|
+
assert.equal(error.cause, COPY_DENIED);
|
|
327
|
+
return true;
|
|
328
|
+
});
|
|
329
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
330
|
+
assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
|
|
331
|
+
assert.deepEqual(files(), before);
|
|
332
|
+
} finally { w.cleanup(); }
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("ordinary errors in both steps report the Shell error with the Gentle AI one", posixOnly, async () => {
|
|
336
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: deniedFs({ copy: true, manifest: true }) });
|
|
337
|
+
try {
|
|
338
|
+
const files = await onPriorMain(w);
|
|
339
|
+
const before = files();
|
|
340
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
341
|
+
assert.ok(error instanceof MainChannelError);
|
|
342
|
+
assert.equal(error.code, "main-upgrade-incomplete");
|
|
343
|
+
assert.equal(error.message, "main-upgrade-incomplete: EACCES: permission denied, open package.json; Gentle AI was not updated either (EACCES: permission denied, copyfile)");
|
|
344
|
+
assert.equal(error.cause, MANIFEST_DENIED);
|
|
345
|
+
return true;
|
|
346
|
+
});
|
|
347
|
+
assert.deepEqual(w.installs(), []);
|
|
348
|
+
assert.deepEqual(w.lines, []);
|
|
349
|
+
assert.deepEqual(files(), before);
|
|
350
|
+
} finally { w.cleanup(); }
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("an ordinary error in the Gentle AI step with the main Shell already current is rethrown unchanged", posixOnly, async () => {
|
|
354
|
+
const w = world({ commits: { ai: NEW_AI, shell: SHELL_SHA }, fs: deniedFs({ copy: true }) });
|
|
355
|
+
try {
|
|
356
|
+
const files = await onPriorMain(w);
|
|
357
|
+
const before = files();
|
|
358
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: Error) => error === COPY_DENIED);
|
|
359
|
+
assert.deepEqual(w.installs(), []);
|
|
360
|
+
assert.deepEqual(w.lines, []);
|
|
361
|
+
assert.deepEqual(files(), before);
|
|
362
|
+
} finally { w.cleanup(); }
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// A rejection that is not an Error, even a falsy one, is still a failed Gentle AI step.
|
|
366
|
+
const rejectingCopy = (value: unknown) => ({ ...fsPromises, copyFile: async () => { throw value; } }) as typeof fsPromises;
|
|
367
|
+
|
|
368
|
+
test("a falsy rejection in the Gentle AI step still records no channel and names the retry", posixOnly, async () => {
|
|
369
|
+
for (const value of [undefined, null, 0, false]) {
|
|
370
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(value) });
|
|
371
|
+
try {
|
|
372
|
+
const files = await onPriorMain(w);
|
|
373
|
+
const before = files();
|
|
374
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
375
|
+
assert.ok(error instanceof MainChannelError);
|
|
376
|
+
assert.equal(error.code, "main-upgrade-incomplete");
|
|
377
|
+
assert.equal(error.message, `main-upgrade-incomplete: ${String(value)}; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish`);
|
|
378
|
+
return true;
|
|
379
|
+
});
|
|
380
|
+
assert.deepEqual(w.installs(), [`/usr/bin/pnpm add -g ${NEW_SHELL_TGZ(w.home)} --allow-build=gentle-pi`]);
|
|
381
|
+
assert.deepEqual(w.lines, ["Updated Gentle Shell to the latest main (333333333333); Gentle AI was not updated."]);
|
|
382
|
+
assert.deepEqual(files(), before);
|
|
383
|
+
} finally { w.cleanup(); }
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("a rejection that is not an Error but has a message keeps that message", posixOnly, async () => {
|
|
388
|
+
for (const failShellInstall of [false, true]) {
|
|
389
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy({ message: "EACCES: copy denied", code: "EACCES" }), failShellInstall });
|
|
390
|
+
try {
|
|
391
|
+
const files = await onPriorMain(w);
|
|
392
|
+
const before = files();
|
|
393
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
394
|
+
assert.equal(error.message, failShellInstall
|
|
395
|
+
? `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (EACCES: copy denied)`
|
|
396
|
+
: "main-upgrade-incomplete: EACCES: copy denied; Gentle Shell was updated to main 333333333333 but Gentle AI was not: run gentle-shell upgrade --channel main to finish");
|
|
397
|
+
return true;
|
|
398
|
+
});
|
|
399
|
+
assert.deepEqual(files(), before);
|
|
400
|
+
} finally { w.cleanup(); }
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("a null rejection in the Gentle AI step is kept when the Shell install fails too", posixOnly, async () => {
|
|
405
|
+
const w = world({ commits: { ai: NEW_AI, shell: NEW_SHELL }, fs: rejectingCopy(null), failShellInstall: true });
|
|
406
|
+
try {
|
|
407
|
+
const files = await onPriorMain(w);
|
|
408
|
+
const before = files();
|
|
409
|
+
await assert.rejects(w.upgrade([], "4.0.0-main.6e7e3a18f794"), (error: MainChannelError) => {
|
|
410
|
+
assert.equal(error.code, "upgrade-install-failed");
|
|
411
|
+
assert.equal(error.message, `upgrade-install-failed: pnpm could not install ${NEW_SHELL_TGZ(w.home)}; Gentle AI was not updated either (null)`);
|
|
412
|
+
return true;
|
|
413
|
+
});
|
|
414
|
+
assert.deepEqual(w.lines, []);
|
|
415
|
+
assert.deepEqual(files(), before);
|
|
416
|
+
} finally { w.cleanup(); }
|
|
417
|
+
});
|
|
418
|
+
|
|
189
419
|
test("a main install whose Gentle AI did not move rebuilds only Gentle Shell", posixOnly, async () => {
|
|
190
420
|
const w = world({ commits: { ai: AI_SHA, shell: NEW_SHELL } });
|
|
191
421
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import * as fsPromises from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join, win32 } from "node:path";
|
|
@@ -7,6 +7,7 @@ import test from "node:test";
|
|
|
7
7
|
import {
|
|
8
8
|
CHANNEL_SCHEMA,
|
|
9
9
|
GENTLE_AI_MAIN_PACKAGE,
|
|
10
|
+
GENTLE_AI_MODULE,
|
|
10
11
|
MainChannelError,
|
|
11
12
|
buildMainGentleAi,
|
|
12
13
|
channelStatePath,
|
|
@@ -23,6 +24,8 @@ import { artifactFor } from "../scripts/installer-downloads.mjs";
|
|
|
23
24
|
|
|
24
25
|
const SHELL_SHA = "6e7e3a18f794223396527a54c7c36d19c7d236c6";
|
|
25
26
|
const AI_SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
|
|
27
|
+
const PSEUDO = "v4.0.1-0.20261008202137-1f9d5e6423e3";
|
|
28
|
+
const LIST_FAILURE = "go: github.com/gentleman-programming/gentle-ai/v4@v4.0.1-0.20261008202137-1f9d5e6423e3: module lookup failed";
|
|
26
29
|
const posixOnly = { skip: process.platform === "win32" };
|
|
27
30
|
|
|
28
31
|
function sandbox() {
|
|
@@ -33,6 +36,38 @@ function sandbox() {
|
|
|
33
36
|
}
|
|
34
37
|
type Call = { command: string; argv: string[]; options: { env?: Record<string, string>; cwd?: string } };
|
|
35
38
|
|
|
39
|
+
// Emulates the Go and binary invocations buildMainGentleAi makes, each driven by
|
|
40
|
+
// what `go list -m -json` resolves, what the binary embeds, and what it prints.
|
|
41
|
+
function gentleAiBuildRun(world: {
|
|
42
|
+
commit: string;
|
|
43
|
+
listVersion?: string;
|
|
44
|
+
originHash?: string;
|
|
45
|
+
embeddedVersion?: string;
|
|
46
|
+
probeVersion?: string;
|
|
47
|
+
listFails?: boolean;
|
|
48
|
+
}) {
|
|
49
|
+
const { commit, listVersion = "v4.2.0", originHash = commit, embeddedVersion = listVersion, probeVersion = listVersion.replace(/^v/, ""), listFails = false } = world;
|
|
50
|
+
const calls: Call[] = [];
|
|
51
|
+
const run = async (command: string, argv: string[], options: Call["options"]) => {
|
|
52
|
+
calls.push({ command, argv, options });
|
|
53
|
+
if (argv[0] === "install") {
|
|
54
|
+
mkdirSync(options.env!.GOBIN, { recursive: true });
|
|
55
|
+
writeFileSync(join(options.env!.GOBIN, "gentle-ai"), "#!/bin/sh\n", { mode: 0o755 });
|
|
56
|
+
return { code: 0, stdout: "" };
|
|
57
|
+
}
|
|
58
|
+
if (argv[0] === "list") {
|
|
59
|
+
if (listFails) return { code: 1, stdout: "", stderrTail: `${LIST_FAILURE}\n` };
|
|
60
|
+
return { code: 0, stdout: JSON.stringify({ Path: GENTLE_AI_MODULE, Version: listVersion,
|
|
61
|
+
Origin: { VCS: "git", URL: "https://github.com/gentleman-programming/gentle-ai", Hash: originHash, Ref: "refs/tags/v4.2.0" } }) };
|
|
62
|
+
}
|
|
63
|
+
if (argv[0] === "version" && argv[1] === "-m") {
|
|
64
|
+
return { code: 0, stdout: `${command} (binary)\n\tmod\t${GENTLE_AI_MODULE}\t${embeddedVersion}\th1:J8EluS7QrfM+m3vmQlF26B/shZRjD6rBfsKtrABL5mc=\n` };
|
|
65
|
+
}
|
|
66
|
+
return { code: 0, stdout: `gentle-ai ${probeVersion}\n` };
|
|
67
|
+
};
|
|
68
|
+
return { calls, run };
|
|
69
|
+
}
|
|
70
|
+
|
|
36
71
|
test("channel state defaults to release and round-trips a main record", async () => {
|
|
37
72
|
const s = sandbox();
|
|
38
73
|
try {
|
|
@@ -169,18 +204,7 @@ test("Gentle AI main is built from the exact commit through Go's checksum databa
|
|
|
169
204
|
const s = sandbox();
|
|
170
205
|
try {
|
|
171
206
|
const goPath = join(s.root, "go", "bin", "go");
|
|
172
|
-
const calls
|
|
173
|
-
const run = async (command: string, argv: string[], options: Call["options"]) => {
|
|
174
|
-
calls.push({ command, argv, options });
|
|
175
|
-
if (argv[0] === "install") {
|
|
176
|
-
mkdirSync(options.env!.GOBIN, { recursive: true });
|
|
177
|
-
const binary = join(options.env!.GOBIN, "gentle-ai");
|
|
178
|
-
writeFileSync(binary, "#!/bin/sh\n");
|
|
179
|
-
chmodSync(binary, 0o755);
|
|
180
|
-
return { code: 0, stdout: "" };
|
|
181
|
-
}
|
|
182
|
-
return { code: 0, stdout: "gentle-ai 4.0.1-0.20261008202137-1f9d5e6423e3\n" };
|
|
183
|
-
};
|
|
207
|
+
const { calls, run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO });
|
|
184
208
|
const built = await buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath, run, fs: fsPromises });
|
|
185
209
|
const install = calls[0];
|
|
186
210
|
assert.equal(install.command, goPath);
|
|
@@ -194,19 +218,107 @@ test("Gentle AI main is built from the exact commit through Go's checksum databa
|
|
|
194
218
|
} finally { s.cleanup(); }
|
|
195
219
|
});
|
|
196
220
|
|
|
197
|
-
|
|
221
|
+
// #2301: when main's HEAD is exactly a tag commit, Go stamps the tag (v4.2.0), not a
|
|
222
|
+
// pseudo-version, so the suffix check alone rejects every tagged release.
|
|
223
|
+
test("a main commit that is exactly a release tag is accepted when Go resolves that tag to the commit", posixOnly, async () => {
|
|
198
224
|
const s = sandbox();
|
|
199
225
|
try {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
226
|
+
const TAGGED = "4acaf4241beae1e0c977c92dfe406a270f8d40de";
|
|
227
|
+
const { run } = gentleAiBuildRun({ commit: TAGGED, listVersion: "v4.2.0", originHash: TAGGED });
|
|
228
|
+
const built = await buildMainGentleAi({ commit: TAGGED, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises });
|
|
229
|
+
assert.equal(built.binaryPath, join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", TAGGED, "gentle-ai"));
|
|
230
|
+
assert.equal(built.version, "4.2.0");
|
|
231
|
+
assert.deepEqual(JSON.parse(readFileSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json"), "utf8")),
|
|
232
|
+
{ schema: "gentle-pi.dev-binary/v1", path: built.binaryPath });
|
|
233
|
+
} finally { s.cleanup(); }
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("a go list whose Origin.Hash names another commit is rejected, and the build is removed", posixOnly, async () => {
|
|
237
|
+
const s = sandbox();
|
|
238
|
+
try {
|
|
239
|
+
const OTHER = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
240
|
+
const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, originHash: OTHER });
|
|
241
|
+
await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
|
|
242
|
+
(error: MainChannelError) => error.code === "main-gentle-ai-unverified" && error.message.includes(OTHER) && error.message.includes(AI_SHA.slice(0, 12)));
|
|
243
|
+
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), false);
|
|
244
|
+
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA)), false);
|
|
245
|
+
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", ".build")), false);
|
|
246
|
+
} finally { s.cleanup(); }
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("a binary whose embedded module version differs from what Go resolved is rejected and never registered", posixOnly, async () => {
|
|
250
|
+
const s = sandbox();
|
|
251
|
+
try {
|
|
252
|
+
const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, embeddedVersion: "v4.1.2" });
|
|
253
|
+
await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
|
|
254
|
+
(error: MainChannelError) => error.code === "main-gentle-ai-unverified");
|
|
255
|
+
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), false);
|
|
256
|
+
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA)), false);
|
|
257
|
+
} finally { s.cleanup(); }
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a failed go list is rejected as unverified, with its stderr tail as the cause, and nothing is registered", posixOnly, async () => {
|
|
261
|
+
const s = sandbox();
|
|
262
|
+
try {
|
|
263
|
+
const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, listFails: true });
|
|
264
|
+
await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
|
|
265
|
+
(error: MainChannelError) => error.code === "main-gentle-ai-unverified" && error.cause instanceof Error && error.cause.message === LIST_FAILURE);
|
|
266
|
+
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), false);
|
|
267
|
+
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA)), false);
|
|
268
|
+
} finally { s.cleanup(); }
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Rebuilding a commit that is already registered (a rerun wizard step, a lost
|
|
272
|
+
// channel record) must not delete the working override when verification fails.
|
|
273
|
+
for (const [name, failure] of Object.entries({
|
|
274
|
+
"go list fails": { listFails: true },
|
|
275
|
+
"the embedded version differs": { embeddedVersion: "v4.1.2" },
|
|
276
|
+
"the reported version differs": { probeVersion: "4.0.1-0.20261008202137-aaaaaaaaaaaa" },
|
|
277
|
+
})) test(`a registered build of the same commit survives a rebuild whose verification fails because ${name}`, posixOnly, async () => {
|
|
278
|
+
const s = sandbox();
|
|
279
|
+
try {
|
|
280
|
+
const directory = join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA);
|
|
281
|
+
const binary = join(directory, "gentle-ai");
|
|
282
|
+
const registration = join(s.home, ".pi", "gentle-ai", "dev-binary.json");
|
|
283
|
+
mkdirSync(directory, { recursive: true });
|
|
284
|
+
writeFileSync(binary, "#!/bin/sh\n# registered\n", { mode: 0o755 });
|
|
285
|
+
const record = JSON.stringify({ schema: "gentle-pi.dev-binary/v1", path: binary });
|
|
286
|
+
writeFileSync(registration, record);
|
|
287
|
+
const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, ...failure });
|
|
288
|
+
await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
|
|
289
|
+
(error: MainChannelError) => error.code === "main-gentle-ai-unverified");
|
|
290
|
+
assert.equal(readFileSync(binary, "utf8"), "#!/bin/sh\n# registered\n");
|
|
291
|
+
assert.equal(readFileSync(registration, "utf8"), record);
|
|
292
|
+
assert.deepEqual(readdirSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai")), [AI_SHA]);
|
|
293
|
+
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", ".build")), false);
|
|
294
|
+
} finally { s.cleanup(); }
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("go list and go version -m run with the same sealed environment and cwd as go install", posixOnly, async () => {
|
|
298
|
+
const s = sandbox();
|
|
299
|
+
try {
|
|
300
|
+
const goPath = join(s.root, "go", "bin", "go");
|
|
301
|
+
const { calls, run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO });
|
|
302
|
+
await buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath, run, fs: fsPromises });
|
|
303
|
+
const sealed = { GOPROXY: "https://proxy.golang.org", GOSUMDB: "sum.golang.org", GOTOOLCHAIN: "local",
|
|
304
|
+
GOFLAGS: "-modcacherw", GONOSUMDB: "", GOPRIVATE: "", GOINSECURE: "", GOENV: "off", GOWORK: "off" };
|
|
305
|
+
const goCalls = calls.filter((call) => call.command === goPath);
|
|
306
|
+
assert.deepEqual(goCalls.map((call) => call.argv[0]), ["install", "list", "version"]);
|
|
307
|
+
for (const call of goCalls) {
|
|
308
|
+
assert.equal(call.options.cwd, join(s.home, ".pi", "gentle-ai", "main", ".build"), call.argv.join(" "));
|
|
309
|
+
for (const [key, value] of Object.entries(sealed)) assert.equal(call.options.env![key], value, `${key} for ${call.argv.join(" ")}`);
|
|
310
|
+
}
|
|
311
|
+
} finally { s.cleanup(); }
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a Gentle AI build whose reported version differs from the resolved module version is rejected and never registered", posixOnly, async () => {
|
|
315
|
+
const s = sandbox();
|
|
316
|
+
try {
|
|
317
|
+
const { run } = gentleAiBuildRun({ commit: AI_SHA, listVersion: PSEUDO, probeVersion: "4.0.1-0.20261008202137-aaaaaaaaaaaa" });
|
|
207
318
|
await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "darwin", goPath: "/go/bin/go", run, fs: fsPromises }),
|
|
208
319
|
(error: MainChannelError) => error.code === "main-gentle-ai-unverified");
|
|
209
320
|
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), false);
|
|
321
|
+
assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "main", "gentle-ai", AI_SHA)), false);
|
|
210
322
|
} finally { s.cleanup(); }
|
|
211
323
|
});
|
|
212
324
|
|
|
@@ -278,6 +390,13 @@ async function upgradeWorld({ userGo = null as string | null, pinned = false, ac
|
|
|
278
390
|
const fetch = async (url: string) => ({ ok: true, status: 200, text: async () => (url.endsWith("/gentle-ai/commits/main") ? NEW_AI : SHELL_SHA) });
|
|
279
391
|
const run = async (command: string, argv: string[], options: Call["options"]) => {
|
|
280
392
|
calls.push({ command, argv, options });
|
|
393
|
+
const isGo = command === "/usr/bin/go" || command === pinnedGo;
|
|
394
|
+
const pseudo = (commit: string) => `v4.0.1-0.20261008202137-${commit.slice(0, 12)}`;
|
|
395
|
+
if (isGo && argv[0] === "version" && argv[1] === "-m") return { code: 0, stdout: `${command} (binary)\n\tmod\t${GENTLE_AI_MODULE}\t${pseudo(NEW_AI)}\th1:J8EluS7QrfM+m3vmQlF26B/shZRjD6rBfsKtrABL5mc=\n` };
|
|
396
|
+
if (isGo && argv[0] === "list") {
|
|
397
|
+
const requested = argv[3].slice(`${GENTLE_AI_MODULE}@`.length);
|
|
398
|
+
return { code: 0, stdout: JSON.stringify({ Path: GENTLE_AI_MODULE, Version: pseudo(requested), Origin: { VCS: "git", URL: "https://github.com/gentleman-programming/gentle-ai", Hash: requested } }) };
|
|
399
|
+
}
|
|
281
400
|
if (command === "/usr/bin/go" && argv[0] === "version") return { code: 0, stdout: `go version ${userGo === "devel" ? "devel go1.26-abc" : `go${userGo}`} darwin/arm64\n` };
|
|
282
401
|
if (argv[0] === "install") {
|
|
283
402
|
mkdirSync(options.env!.GOBIN, { recursive: true });
|
|
@@ -3,6 +3,7 @@ import test from "node:test";
|
|
|
3
3
|
import type { RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
4
4
|
import { clampThinkingLevel, createModels, getSupportedThinkingLevels, InMemoryCredentialStore } from "@earendil-works/pi-ai";
|
|
5
5
|
import type { Provider } from "@earendil-works/pi-ai";
|
|
6
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
6
7
|
import nanProviderExtension from "../extensions/nan-provider.ts";
|
|
7
8
|
import { createNanProviderConfig as createNativeProvider, isNanAdaptiveReasoningModel, NAN_PROVIDER_BASE_URL, NAN_PROVIDER_ID } from "../lib/nan-provider.ts";
|
|
8
9
|
|
|
@@ -195,6 +196,41 @@ test("initial catalog contains all seven documented chat models with configured
|
|
|
195
196
|
assert.deepEqual(model?.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
196
197
|
});
|
|
197
198
|
|
|
199
|
+
test("only NaN DeepSeek serializes its unchanged output cap as max_tokens", async () => {
|
|
200
|
+
const models = createNativeProvider().getModels();
|
|
201
|
+
const model = models.find((model) => model.id === "deepseek-v4-flash")!;
|
|
202
|
+
let body: Record<string, unknown> | undefined;
|
|
203
|
+
const result = await completeSimple(model, { messages: [{ role: "user", content: "offline probe", timestamp: 0 }] }, {
|
|
204
|
+
apiKey: "synthetic-key", reasoning: "minimal",
|
|
205
|
+
fetch: async (input, init) => {
|
|
206
|
+
assert.equal(String(input), `${NAN_PROVIDER_BASE_URL}/chat/completions`);
|
|
207
|
+
body = JSON.parse(String(init?.body));
|
|
208
|
+
return new Response('data: {"id":"offline","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', {
|
|
209
|
+
headers: { "content-type": "text/event-stream" },
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
assert.equal(result.stopReason, "stop");
|
|
214
|
+
assert.equal(body?.max_tokens, 16_384);
|
|
215
|
+
assert.ok(!Object.hasOwn(body!, "max_completion_tokens"));
|
|
216
|
+
assert.equal(body?.reasoning_effort, "minimal");
|
|
217
|
+
assert.equal(model.maxTokens, 16_384);
|
|
218
|
+
assert.equal(model.compat?.maxTokensField, "max_tokens");
|
|
219
|
+
assert.ok(models.filter((candidate) => candidate.id !== model.id).every((candidate) => candidate.compat === undefined));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("DeepSeek compat is isolated across snapshots, providers, and live refreshes", async () => {
|
|
223
|
+
const provider = createNativeProvider({ fetchImpl: async () => jsonResponse({ data: [{ id: "deepseek-v4-flash" }] }) });
|
|
224
|
+
for (const live of [false, true]) {
|
|
225
|
+
if (live) await provider.refreshModels!(refreshContext({ type: "api_key", key: "synthetic-key" }));
|
|
226
|
+
const model = provider.getModels().find((model) => model.id === "deepseek-v4-flash")!;
|
|
227
|
+
assert.equal(model.compat?.maxTokensField, "max_tokens");
|
|
228
|
+
model.compat!.maxTokensField = "max_completion_tokens";
|
|
229
|
+
assert.equal(provider.getModels().find((candidate) => candidate.id === model.id)!.compat?.maxTokensField, "max_tokens");
|
|
230
|
+
assert.equal(createNativeProvider().getModels().find((model) => model.id === "deepseek-v4-flash")!.compat?.maxTokensField, "max_tokens");
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
198
234
|
test("GLM models expose exactly NaN's four efforts and no off", () => {
|
|
199
235
|
const models = createNativeProvider().getModels();
|
|
200
236
|
for (const id of ["glm5.3", "glm5.3-flash"]) {
|
|
@@ -103,9 +103,10 @@ test("2.4.0 repeats 2.2.3 because the START envelope Pi consumes still omits ris
|
|
|
103
103
|
// the envelope carries, not a capability it lost.
|
|
104
104
|
// delivery `review validate --gate pre-commit` still answers
|
|
105
105
|
// `gentle-ai.review-gate-result/v1` with
|
|
106
|
-
// `delivery: "disabled/unmanaged"` at exit 0.
|
|
107
|
-
//
|
|
108
|
-
//
|
|
106
|
+
// `delivery: "disabled/unmanaged"` at exit 0. RDD is on by
|
|
107
|
+
// default again since gentle-ai v4.0.0, so this is the answer
|
|
108
|
+
// for an install that turned review off, and the column still
|
|
109
|
+
// matters.
|
|
109
110
|
//
|
|
110
111
|
// riskEvidence and hint stay dark for exactly the reason the 2.2.0 comment
|
|
111
112
|
// gives, re-proven rather than inherited: the negotiated
|
|
@@ -385,11 +386,17 @@ test("4.2.0 explicitly repeats 4.1.1 because capabilities/v2.6 is unchanged desp
|
|
|
385
386
|
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["4.1.1"] as Record<string, boolean>);
|
|
386
387
|
});
|
|
387
388
|
|
|
389
|
+
test("4.3.1 explicitly repeats 4.2.0 because capabilities/v2.6 and provider contract 1.4.0 are unchanged", () => {
|
|
390
|
+
const contract = NATIVE_CLI_CONTRACTS["4.3.1"] as Record<string, boolean>;
|
|
391
|
+
assert.ok(contract);
|
|
392
|
+
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["4.2.0"] as Record<string, boolean>);
|
|
393
|
+
});
|
|
394
|
+
|
|
388
395
|
test("no shipped version key was added beyond the pin bump", () => {
|
|
389
396
|
// Rows are promises to consumers, so a new key only ever appears in a
|
|
390
397
|
// dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
|
|
391
398
|
// v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
|
|
392
399
|
// so they get no row: a row asserts ground truth measured against a binary
|
|
393
400
|
// Pi actually ran, and the table only has to be ascending, not gapless.
|
|
394
|
-
assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0", "3.6.0", "3.6.1", "3.7.0", "4.0.0", "4.1.1", "4.2.0"]);
|
|
401
|
+
assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0", "3.6.0", "3.6.1", "3.7.0", "4.0.0", "4.1.1", "4.2.0", "4.3.1"]);
|
|
395
402
|
});
|
|
@@ -384,7 +384,7 @@ test("review-mode gate retains every off-source continuation and fails closed on
|
|
|
384
384
|
for (const [source, expected] of [
|
|
385
385
|
["clone_local", /clear this clone-local override/],
|
|
386
386
|
["global", /cannot override a global off/],
|
|
387
|
-
["default", /
|
|
387
|
+
["default", /RDD is on by default, but this install could not resolve the review mode and failed closed to off/],
|
|
388
388
|
] as const) {
|
|
389
389
|
const native = {
|
|
390
390
|
reviewMode: async () => ({ operation: "status", scope: "clone", status: { global: source === "global" ? "off" : "", cloneLocal: source === "clone_local" ? "off" : "", effective: "off" as const, source } }),
|