gentle-pi 4.2.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 +340 -138
- 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/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 +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 +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- 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 +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- 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/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -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/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- 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 +20 -5
- 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-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- 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-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- 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/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 +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- 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-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- 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 +485 -7
- 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 +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- 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/destructive-command-guard.test.ts +85 -1
- 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 +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 +645 -21
- 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-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-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- 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 +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- 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 +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- 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 +147 -3
- 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 +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- 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-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-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- 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/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 +70 -10
- 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 +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- 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 +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -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
|
@@ -4,7 +4,7 @@ import childProcess from "node:child_process";
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { syncBuiltinESMExports } from "node:module";
|
|
6
6
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
-
import { activitySummary, ActivityPublisher, createActivityColumns, metadataArgs, metadataTransport } from "../lib/herdr-activity.ts";
|
|
7
|
+
import { activitySummary, ActivityPublisher, createActivityColumns, herdrDetected, herdrSetupHint, metadataArgs, metadataTransport } from "../lib/herdr-activity.ts";
|
|
8
8
|
|
|
9
9
|
const tasks = [{ title: "Revisar integración Herdr", status: "in_progress" }];
|
|
10
10
|
const project = (title: string, columns = 30) => activitySummary([{ title, status: "in_progress" }], columns);
|
|
@@ -241,3 +241,36 @@ test("geometry is cached, local/named, bounded and fail-safe without reading use
|
|
|
241
241
|
assert.equal(paths.length, before, "relative paths must not read arbitrary snapshots");
|
|
242
242
|
} finally { for (const stub of stubs) stub.mock.restore(); syncBuiltinESMExports(); }
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
const herdrEnv = { HERDR_ENV: "1", HERDR_PANE_ID: "pane", HERDR_SOCKET_PATH: "/herdr.sock" };
|
|
246
|
+
|
|
247
|
+
test("Herdr detection uses the activity gate: HERDR_ENV=1 plus nonblank pane and socket", () => {
|
|
248
|
+
assert.equal(herdrDetected(herdrEnv), true);
|
|
249
|
+
for (const env of [{}, { ...herdrEnv, HERDR_ENV: "0" }, { ...herdrEnv, HERDR_PANE_ID: " " }, { ...herdrEnv, HERDR_SOCKET_PATH: "" }]) {
|
|
250
|
+
assert.equal(herdrDetected(env), false);
|
|
251
|
+
assert.deepEqual(herdrSetupHint(env, { interactive: true, isSocket: () => true }), []);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("setup hint states active publishing and gives read-only sidebar guidance", () => {
|
|
256
|
+
const sockets: string[] = [];
|
|
257
|
+
const lines = herdrSetupHint(herdrEnv, { interactive: true, isSocket: (path) => { sockets.push(path); return true; } });
|
|
258
|
+
assert.deepEqual(sockets, ["/herdr.sock"]);
|
|
259
|
+
assert.deepEqual(lines, [
|
|
260
|
+
"Herdr",
|
|
261
|
+
"pass: Herdr task-summary publishing active",
|
|
262
|
+
"info: Herdr's default sidebar does not show task titles; add these two rows to the Agents sidebar rows in your Herdr config: ['$summary'], ['$summary2']",
|
|
263
|
+
"info: Sidebar rows belong to the config of the Herdr client that displays the session, including when Pi runs over SSH",
|
|
264
|
+
"info: After editing, reload or restart Herdr; Gentle Shell never changes Herdr config",
|
|
265
|
+
"info: Details: https://github.com/Gentleman-Programming/gentle-shell/blob/main/docs/readme-reference.md#herdr-active-work-summary",
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("setup hint names why publishing is inactive and keeps the guidance", () => {
|
|
270
|
+
const reason = (env: NodeJS.ProcessEnv, interactive: boolean, socket: boolean) =>
|
|
271
|
+
herdrSetupHint(env, { interactive, isSocket: () => socket })[1];
|
|
272
|
+
assert.equal(reason({ ...herdrEnv, GENTLE_PI_AGENTS_CHILD: "1" }, true, true), "warn: Herdr task-summary publishing inactive: Gentle Agents child session");
|
|
273
|
+
assert.equal(reason(herdrEnv, false, true), "warn: Herdr task-summary publishing inactive: requires the interactive Pi TUI");
|
|
274
|
+
assert.equal(reason(herdrEnv, true, false), "warn: Herdr task-summary publishing inactive: HERDR_SOCKET_PATH is not a Unix socket");
|
|
275
|
+
assert.equal(herdrSetupHint(herdrEnv, { interactive: false, isSocket: () => true }).length, 6);
|
|
276
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { describeAttachment, formatBytes, ImageAttachments, imageToken } from "../lib/image-attachments.ts";
|
|
4
|
+
|
|
5
|
+
// A 2×1 PNG header: signature, IHDR length/type, width 2, height 1.
|
|
6
|
+
const PNG_2X1 = new Uint8Array([
|
|
7
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
8
|
+
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
9
|
+
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01,
|
|
10
|
+
0x08, 0x06, 0x00, 0x00, 0x00,
|
|
11
|
+
]);
|
|
12
|
+
const png = () => ({ bytes: PNG_2X1, mimeType: "image/png" });
|
|
13
|
+
const base64 = Buffer.from(PNG_2X1).toString("base64");
|
|
14
|
+
|
|
15
|
+
const graphemes = (text: string) => [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)];
|
|
16
|
+
|
|
17
|
+
test("pasted images get sequential whitespace-free chips and keep one in-memory copy", () => {
|
|
18
|
+
const attachments = new ImageAttachments();
|
|
19
|
+
const first = attachments.add(png());
|
|
20
|
+
const second = attachments.add(png());
|
|
21
|
+
assert.equal(first.token, "[Image#1]");
|
|
22
|
+
assert.equal(second.token, imageToken(2));
|
|
23
|
+
assert.doesNotMatch(first.token, /\s/);
|
|
24
|
+
assert.deepEqual(first.dimensions, { widthPx: 2, heightPx: 1 });
|
|
25
|
+
assert.equal(first.base64, base64);
|
|
26
|
+
assert.equal(first.byteLength, PNG_2X1.length);
|
|
27
|
+
assert.equal("bytes" in first, false);
|
|
28
|
+
assert.equal(attachments.size, 2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("referenced chips follow draft order, appear once, and ignore unregistered tokens", () => {
|
|
32
|
+
const attachments = new ImageAttachments();
|
|
33
|
+
attachments.add(png());
|
|
34
|
+
attachments.add(png());
|
|
35
|
+
const ids = attachments.referenced("see [Image#2] then [Image#9] and [Image#1] [Image#2]").map((attachment) => attachment.id);
|
|
36
|
+
assert.deepEqual(ids, [2, 1]);
|
|
37
|
+
assert.deepEqual(attachments.referenced("the chip was deleted"), []);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("submitting a draft attaches every chip still in the text as a real image", () => {
|
|
41
|
+
const attachments = new ImageAttachments();
|
|
42
|
+
attachments.add(png());
|
|
43
|
+
attachments.add(png());
|
|
44
|
+
const submitted = attachments.submit("what changed in [Image#2]?", true);
|
|
45
|
+
assert.deepEqual(submitted, {
|
|
46
|
+
text: "what changed in [Image#2]?",
|
|
47
|
+
images: [{ type: "image", data: base64, mimeType: "image/png" }],
|
|
48
|
+
omitted: 0,
|
|
49
|
+
});
|
|
50
|
+
// The deleted chip (#1) was never sent; both are released once the editor is empty.
|
|
51
|
+
assert.equal(attachments.size, 0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("numbers never repeat, so a chip recalled from history cannot bind to a newer image", () => {
|
|
55
|
+
const attachments = new ImageAttachments();
|
|
56
|
+
attachments.add(png());
|
|
57
|
+
attachments.submit("describe [Image#1]", true);
|
|
58
|
+
const next = attachments.add(png());
|
|
59
|
+
assert.equal(next.token, "[Image#2]");
|
|
60
|
+
// Up-arrow recall of the earlier prompt: its chip is no longer registered.
|
|
61
|
+
assert.deepEqual(attachments.referenced("describe [Image#1]"), []);
|
|
62
|
+
assert.deepEqual(attachments.spans("describe [Image#1]"), []);
|
|
63
|
+
attachments.clear();
|
|
64
|
+
assert.equal(attachments.add(png()).token, "[Image#3]");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a submit Pi runs late keeps the chips of the draft already in the editor", () => {
|
|
68
|
+
const attachments = new ImageAttachments();
|
|
69
|
+
attachments.add(png());
|
|
70
|
+
attachments.add(png());
|
|
71
|
+
attachments.add(png());
|
|
72
|
+
// Queued during compaction: `[Image#1]` is sent after `[Image#2]` was pasted
|
|
73
|
+
// into a new draft; `[Image#3]` was pasted and deleted again.
|
|
74
|
+
const submitted = attachments.submit("look [Image#1]", true, "and now [Image#2]");
|
|
75
|
+
assert.equal(submitted?.images.length, 1);
|
|
76
|
+
assert.deepEqual(attachments.referenced("and now [Image#2]").map((attachment) => attachment.id), [2]);
|
|
77
|
+
assert.equal(attachments.size, 1);
|
|
78
|
+
assert.equal(attachments.submit("and now [Image#2]", true)?.images.length, 1);
|
|
79
|
+
assert.equal(attachments.size, 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a model without image input gets a plain note instead of a silent drop", () => {
|
|
83
|
+
const attachments = new ImageAttachments();
|
|
84
|
+
attachments.add(png());
|
|
85
|
+
const submitted = attachments.submit("look at [Image#1] and [Image#4]", false);
|
|
86
|
+
assert.deepEqual(submitted, {
|
|
87
|
+
text: "look at [Image#1 omitted: the current model does not accept images] and [Image#4]",
|
|
88
|
+
images: [],
|
|
89
|
+
omitted: 1,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("drafts without registered chips pass through untouched and release stale images", () => {
|
|
94
|
+
const attachments = new ImageAttachments();
|
|
95
|
+
assert.equal(attachments.submit("plain [Image#1] text", true), undefined);
|
|
96
|
+
attachments.add(png());
|
|
97
|
+
assert.equal(attachments.submit("chip deleted before submit", true), undefined);
|
|
98
|
+
assert.equal(attachments.size, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a registered chip is one segment, so one Backspace removes it", () => {
|
|
102
|
+
const attachments = new ImageAttachments();
|
|
103
|
+
const text = "a [Image#1] b";
|
|
104
|
+
// No registered chip yet: the host segmentation is returned as-is.
|
|
105
|
+
const native = graphemes(text);
|
|
106
|
+
assert.equal(attachments.segment(text, native), native);
|
|
107
|
+
attachments.add(png());
|
|
108
|
+
const merged = [...attachments.segment(text, graphemes(text))].map((segment) => segment.segment);
|
|
109
|
+
assert.deepEqual(merged, ["a", " ", "[Image#1]", " ", "b"]);
|
|
110
|
+
assert.deepEqual(attachments.spans(text), [{ start: 2, end: 11 }]);
|
|
111
|
+
const unregistered = "x [Image#7]";
|
|
112
|
+
assert.equal([...attachments.segment(unregistered, graphemes(unregistered))].length, unregistered.length);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("chip descriptions name format, pixel size and weight", () => {
|
|
116
|
+
assert.equal(describeAttachment({ mimeType: "image/png", byteLength: 86_016, dimensions: { widthPx: 1280, heightPx: 720 } }), "png · 1280×720 · 84 KB");
|
|
117
|
+
assert.equal(describeAttachment({ mimeType: "image/jpeg", byteLength: 512 }), "jpg · 512 B");
|
|
118
|
+
assert.equal(formatBytes(3 * 1024 * 1024), "3.0 MB");
|
|
119
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test, { afterEach } from "node:test";
|
|
3
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { resetCapabilitiesCache, setCapabilities, stripTerminalSequences as stripAnsi } from "@earendil-works/pi-tui";
|
|
5
|
+
import { GentlePromptEditor, imageAttachmentsWidget, modelAcceptsImages } from "../extensions/gentle-shell.ts";
|
|
6
|
+
import type { ClipboardImage } from "../lib/clipboard-image.ts";
|
|
7
|
+
import { ImageAttachments } from "../lib/image-attachments.ts";
|
|
8
|
+
import { createVimEditorAdapter } from "../lib/vim-editor-adapter.ts";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
|
|
11
|
+
const INSTALLED_PI = (createRequire(import.meta.url)("@earendil-works/pi-tui/package.json") as { version: string }).version;
|
|
12
|
+
|
|
13
|
+
// Prompt chips for pasted images (gentle-shell#2464): the paste key inserts an
|
|
14
|
+
// atomic `[Image#N]`, one Backspace removes it, and Pi's own paste handler
|
|
15
|
+
// stays the fallback for files, text and unsupported formats.
|
|
16
|
+
|
|
17
|
+
type CtorParams = ConstructorParameters<typeof CustomEditor>;
|
|
18
|
+
|
|
19
|
+
const PNG_2X1 = new Uint8Array([
|
|
20
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
21
|
+
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
22
|
+
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01,
|
|
23
|
+
0x08, 0x06, 0x00, 0x00, 0x00,
|
|
24
|
+
]);
|
|
25
|
+
const PASTE_KEY = "\x1bv";
|
|
26
|
+
const BACKSPACE = "\x7f";
|
|
27
|
+
const png = (): ClipboardImage => ({ bytes: PNG_2X1, mimeType: "image/png" });
|
|
28
|
+
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
29
|
+
|
|
30
|
+
afterEach(() => resetCapabilitiesCache());
|
|
31
|
+
|
|
32
|
+
function chipEditor(read: (() => Promise<ClipboardImage | null>) | undefined, attachments: ImageAttachments | undefined = new ImageAttachments()) {
|
|
33
|
+
const tui = { terminal: { rows: 30, columns: 100 }, requestRender() {} } as unknown as CtorParams[0];
|
|
34
|
+
const identity = (text: string) => text;
|
|
35
|
+
const theme = { borderColor: identity, selectList: {
|
|
36
|
+
selectedPrefix: identity, selectedText: identity, description: identity, scrollInfo: identity, noMatch: identity,
|
|
37
|
+
} } as unknown as CtorParams[1];
|
|
38
|
+
const keybindings = { matches: (data: string, action: string) => action === "app.clipboard.pasteImage" && data === PASTE_KEY } as unknown as CtorParams[2];
|
|
39
|
+
const editor = new GentlePromptEditor(tui, theme, keybindings, {
|
|
40
|
+
fg: (_role, text) => text, bold: identity, requestRender() {}, pending: () => false,
|
|
41
|
+
now: () => 0, doubleEscCancelEnabled: () => false, dispatchQueuedText() {},
|
|
42
|
+
attachments: read ? attachments : undefined, readClipboardImage: read,
|
|
43
|
+
notifyCompatibility: (message) => notices.push(message),
|
|
44
|
+
});
|
|
45
|
+
editor.focused = true;
|
|
46
|
+
const piPastes: string[] = [];
|
|
47
|
+
const notices: string[] = [];
|
|
48
|
+
// Pi's interactive mode installs its handler only when none is set yet.
|
|
49
|
+
if (!editor.onPasteImage) editor.onPasteImage = () => piPastes.push(editor.getText());
|
|
50
|
+
return { editor, attachments, piPastes, notices };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function pastedChip(text: string) {
|
|
54
|
+
const chip = chipEditor(async () => png());
|
|
55
|
+
chip.editor.setText(text);
|
|
56
|
+
chip.editor.handleInput(PASTE_KEY);
|
|
57
|
+
await settle();
|
|
58
|
+
return chip;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CTRL_W = "\x17";
|
|
62
|
+
const DELETE = "\x1b[3~";
|
|
63
|
+
const CTRL_LEFT = "\x1b[1;5D";
|
|
64
|
+
const HOME = "\x1b[H";
|
|
65
|
+
|
|
66
|
+
test("the paste key inserts an image chip, spaced from adjacent text", async () => {
|
|
67
|
+
const { editor, attachments, piPastes } = chipEditor(async () => png());
|
|
68
|
+
editor.setText("look");
|
|
69
|
+
editor.handleInput(PASTE_KEY);
|
|
70
|
+
await settle();
|
|
71
|
+
assert.equal(editor.getText(), "look [Image#1]");
|
|
72
|
+
assert.deepEqual(piPastes, []);
|
|
73
|
+
assert.equal(attachments.size, 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("one Backspace removes the whole chip; typed look-alike text stays ordinary", async () => {
|
|
77
|
+
const { editor } = chipEditor(async () => png());
|
|
78
|
+
editor.setText("look");
|
|
79
|
+
editor.handleInput(PASTE_KEY);
|
|
80
|
+
await settle();
|
|
81
|
+
editor.handleInput(BACKSPACE);
|
|
82
|
+
assert.equal(editor.getText(), "look ");
|
|
83
|
+
editor.setText("[Image#7]");
|
|
84
|
+
editor.handleInput(BACKSPACE);
|
|
85
|
+
assert.equal(editor.getText(), "[Image#7");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("word deletion and word motion treat a chip as one word", async () => {
|
|
89
|
+
const { editor } = await pastedChip("see");
|
|
90
|
+
assert.equal(editor.getText(), "see [Image#1]");
|
|
91
|
+
editor.handleInput(CTRL_W);
|
|
92
|
+
assert.equal(editor.getText(), "see ", "Ctrl+W removes the chip, never the word before it");
|
|
93
|
+
const moved = await pastedChip("see");
|
|
94
|
+
moved.editor.handleInput(CTRL_LEFT);
|
|
95
|
+
assert.deepEqual(moved.editor.getCursor(), { line: 0, col: 4 }, "Ctrl+Left stops at the chip start");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("Delete in front of a chip removes the whole chip", async () => {
|
|
99
|
+
const { editor } = await pastedChip("");
|
|
100
|
+
editor.handleInput(HOME);
|
|
101
|
+
editor.handleInput(DELETE);
|
|
102
|
+
assert.equal(editor.getText(), "");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the Vim adapter keeps NORMAL boundaries out of a registered chip", async () => {
|
|
106
|
+
const { editor } = await pastedChip("a");
|
|
107
|
+
assert.deepEqual(editor.atomicSpans("a [Image#1]"), [{ start: 2, end: 11 }]);
|
|
108
|
+
assert.deepEqual(editor.atomicSpans("a [Image#9]"), [], "unregistered look-alikes stay ordinary text");
|
|
109
|
+
const adapter = createVimEditorAdapter(editor, INSTALLED_PI);
|
|
110
|
+
assert.deepEqual(adapter.motionBoundaries()[0], [0, 1, 2, 11]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a clipboard read that finishes after the prompt is disposed changes nothing", async () => {
|
|
114
|
+
let resolve!: (image: ClipboardImage) => void;
|
|
115
|
+
const { editor, attachments, piPastes } = chipEditor(() => new Promise((done) => { resolve = done; }));
|
|
116
|
+
editor.handleInput(PASTE_KEY);
|
|
117
|
+
editor.dispose();
|
|
118
|
+
resolve(png());
|
|
119
|
+
await settle();
|
|
120
|
+
assert.equal(editor.getText(), "");
|
|
121
|
+
assert.equal(attachments.size, 0);
|
|
122
|
+
assert.deepEqual(piPastes, []);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a clipboard read that finishes after the draft was sent is discarded with a notice", async () => {
|
|
126
|
+
let resolve!: (image: ClipboardImage) => void;
|
|
127
|
+
const { editor, attachments, piPastes, notices } = chipEditor(() => new Promise((done) => { resolve = done; }));
|
|
128
|
+
const sent: string[] = [];
|
|
129
|
+
editor.onSubmit = (text) => sent.push(text);
|
|
130
|
+
editor.setText("what is this?");
|
|
131
|
+
editor.handleInput(PASTE_KEY);
|
|
132
|
+
editor.handleInput("\r");
|
|
133
|
+
resolve(png());
|
|
134
|
+
await settle();
|
|
135
|
+
assert.deepEqual(sent, ["what is this?"]);
|
|
136
|
+
assert.equal(editor.getText(), "", "the next draft stays empty");
|
|
137
|
+
assert.equal(attachments.size, 0, "no image waits to ride along with the next message");
|
|
138
|
+
assert.deepEqual(piPastes, []);
|
|
139
|
+
assert.deepEqual(notices, ["Image paste discarded: the message was sent before the clipboard finished reading. Paste again to attach it."]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a clipboard read during an unsent draft still lands in that draft", async () => {
|
|
143
|
+
let resolve!: (image: ClipboardImage) => void;
|
|
144
|
+
const { editor, notices } = chipEditor(() => new Promise((done) => { resolve = done; }));
|
|
145
|
+
editor.setText("look");
|
|
146
|
+
editor.handleInput(PASTE_KEY);
|
|
147
|
+
editor.handleInput("!");
|
|
148
|
+
resolve(png());
|
|
149
|
+
await settle();
|
|
150
|
+
assert.equal(editor.getText(), "look! [Image#1]");
|
|
151
|
+
assert.deepEqual(notices, []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a failure after the clipboard read is reported, never an unhandled rejection", async () => {
|
|
155
|
+
const attachments = new ImageAttachments();
|
|
156
|
+
attachments.add = () => { throw new Error("out of memory"); };
|
|
157
|
+
const { editor, notices } = chipEditor(async () => png(), attachments);
|
|
158
|
+
const unhandled: unknown[] = [];
|
|
159
|
+
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
|
160
|
+
process.on("unhandledRejection", onUnhandled);
|
|
161
|
+
try {
|
|
162
|
+
editor.handleInput(PASTE_KEY);
|
|
163
|
+
await settle();
|
|
164
|
+
await settle();
|
|
165
|
+
} finally {
|
|
166
|
+
process.off("unhandledRejection", onUnhandled);
|
|
167
|
+
}
|
|
168
|
+
assert.deepEqual(unhandled, []);
|
|
169
|
+
assert.deepEqual(notices, ["Image paste failed: out of memory"]);
|
|
170
|
+
assert.equal(editor.getText(), "");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("without a clipboard image Pi's own paste handler runs unchanged", async () => {
|
|
174
|
+
const { editor, piPastes } = chipEditor(async () => null);
|
|
175
|
+
editor.setText("draft");
|
|
176
|
+
editor.handleInput(PASTE_KEY);
|
|
177
|
+
await settle();
|
|
178
|
+
assert.equal(editor.getText(), "draft");
|
|
179
|
+
assert.deepEqual(piPastes, ["draft"]);
|
|
180
|
+
const failing = chipEditor(async () => { throw new Error("clipboard busy"); });
|
|
181
|
+
failing.editor.handleInput(PASTE_KEY);
|
|
182
|
+
await settle();
|
|
183
|
+
assert.equal(failing.piPastes.length, 1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a repeated paste key during a slow clipboard read inserts one chip", async () => {
|
|
187
|
+
let resolve!: (image: ClipboardImage) => void;
|
|
188
|
+
const { editor, piPastes } = chipEditor(() => new Promise((done) => { resolve = done; }));
|
|
189
|
+
editor.handleInput(PASTE_KEY);
|
|
190
|
+
editor.handleInput(PASTE_KEY);
|
|
191
|
+
resolve(png());
|
|
192
|
+
await settle();
|
|
193
|
+
assert.equal(editor.getText(), "[Image#1]");
|
|
194
|
+
assert.deepEqual(piPastes, []);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("an editor without attachments keeps Pi's paste handler as a plain property", async () => {
|
|
198
|
+
const { editor, piPastes } = chipEditor(undefined);
|
|
199
|
+
editor.handleInput(PASTE_KEY);
|
|
200
|
+
await settle();
|
|
201
|
+
assert.equal(piPastes.length, 1);
|
|
202
|
+
assert.equal(editor.getText(), "");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("the attachment strip lists chips still in the draft and falls back to text", () => {
|
|
206
|
+
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
|
207
|
+
const attachments = new ImageAttachments();
|
|
208
|
+
attachments.add(png());
|
|
209
|
+
let draft = "see [Image#1]";
|
|
210
|
+
let vision = true;
|
|
211
|
+
const theme = { fg: (role: string, text: string) => `<${role}>${text}</${role}>` };
|
|
212
|
+
const strip = imageAttachmentsWidget(attachments, () => draft, theme, () => vision);
|
|
213
|
+
assert.deepEqual(strip.render(80), ["<accent>[Image#1]</accent> <dim>png · 2×1 · 29 B</dim>"]);
|
|
214
|
+
vision = false;
|
|
215
|
+
assert.match(strip.render(200)[0] ?? "", /<warning> · not sent: the current model does not accept images<\/warning>/);
|
|
216
|
+
draft = "chip deleted";
|
|
217
|
+
assert.deepEqual(strip.render(80), []);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("terminals with an image protocol get a thumbnail under the chip label", () => {
|
|
221
|
+
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
|
222
|
+
const attachments = new ImageAttachments();
|
|
223
|
+
attachments.add(png());
|
|
224
|
+
const strip = imageAttachmentsWidget(attachments, () => "[Image#1]", { fg: (_role, text) => text }, () => true, () => true);
|
|
225
|
+
const lines = strip.render(80);
|
|
226
|
+
assert.equal(stripAnsi(lines[0] ?? ""), "[Image#1] png · 2×1 · 29 B");
|
|
227
|
+
assert.ok(lines.length > 1, "a preview row follows the label");
|
|
228
|
+
assert.ok(lines.slice(1).some((line) => line.includes("\x1b_G")), "the preview uses the Kitty graphics protocol");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("models without declared input capabilities are treated as image-capable", () => {
|
|
232
|
+
assert.equal(modelAcceptsImages(undefined), true);
|
|
233
|
+
assert.equal(modelAcceptsImages({}), true);
|
|
234
|
+
assert.equal(modelAcceptsImages({ input: ["text", "image"] }), true);
|
|
235
|
+
assert.equal(modelAcceptsImages({ input: ["text"] }), false);
|
|
236
|
+
});
|
|
@@ -221,7 +221,7 @@ test("refuses when the reviewer attempts a tool call", async () => {
|
|
|
221
221
|
assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED);
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
-
test("refuses empty assistant text with
|
|
224
|
+
test("refuses empty assistant text with safe completion evidence", async () => {
|
|
225
225
|
const assistant = assistantText("", { content: [], stopReason: "stop" });
|
|
226
226
|
const outcome = await runInProcessReviewer(baseRequest(), {
|
|
227
227
|
registry: fakeRegistry([fakeModel()]),
|
|
@@ -229,7 +229,60 @@ test("refuses empty assistant text with stopReason evidence", async () => {
|
|
|
229
229
|
});
|
|
230
230
|
const refused = expectRefused(outcome);
|
|
231
231
|
assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
|
|
232
|
-
assert.deepEqual(refused.evidence, {
|
|
232
|
+
assert.deepEqual(refused.evidence, {
|
|
233
|
+
reviewerModel: "openai/gpt-5", stopReason: "stop",
|
|
234
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
|
|
235
|
+
content: { blocks: 0, textBlocks: 0, thinkingBlocks: 0, textChars: 0, thinkingChars: 0 },
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("empty-output diagnostics allowlist facts, not provider secrets, and complete once", async () => {
|
|
240
|
+
const model = fakeModel({ provider: "nan", id: "deepseek-v4-flash" });
|
|
241
|
+
const assistant = assistantText("", {
|
|
242
|
+
content: [{ type: "text", text: "" }, { type: "thinking", thinking: "PRIVATE THINKING", thinkingSignature: "PRIVATE SIGNATURE" }],
|
|
243
|
+
stopReason: "length", errorMessage: "PRIVATE ERROR", rawStopReason: "PRIVATE STOP",
|
|
244
|
+
usage: { input: 12, output: 64, reasoning: 64, cacheRead: 0, cacheWrite: 0, totalTokens: 76, unknown: "PRIVATE USAGE", cost: { secret: "PRIVATE COST" } } as never,
|
|
245
|
+
});
|
|
246
|
+
const { complete, calls } = capturingComplete(assistant);
|
|
247
|
+
const refused = expectRefused(await runInProcessReviewer(baseRequest(), {
|
|
248
|
+
registry: { ...fakeRegistry([model]), find: () => model }, complete,
|
|
249
|
+
}));
|
|
250
|
+
assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
|
|
251
|
+
assert.equal(calls.length, 1);
|
|
252
|
+
assert.deepEqual(refused.evidence, {
|
|
253
|
+
reviewerModel: "nan/deepseek-v4-flash", stopReason: "length",
|
|
254
|
+
usage: { input: 12, output: 64, reasoning: 64, cacheRead: 0, cacheWrite: 0, totalTokens: 76 },
|
|
255
|
+
content: { blocks: 2, textBlocks: 1, thinkingBlocks: 1, textChars: 0, thinkingChars: 16 },
|
|
256
|
+
});
|
|
257
|
+
assert.doesNotMatch(JSON.stringify(refused), /PRIVATE|errorMessage|rawStopReason|Signature|unknown|cost/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("empty-output evidence omits unknown or invalid counters and bounds resolved model identity", async () => {
|
|
261
|
+
for (const invalid of [undefined, NaN, Infinity, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, "12"]) {
|
|
262
|
+
const assistant = assistantText("", {
|
|
263
|
+
content: [], stopReason: "PRIVATE STOP" as never,
|
|
264
|
+
usage: Object.fromEntries(["input", "output", "reasoning", "cacheRead", "cacheWrite", "totalTokens"].map((key) => [key, invalid])) as never,
|
|
265
|
+
});
|
|
266
|
+
const refused = expectRefused(await runInProcessReviewer(baseRequest(), {
|
|
267
|
+
registry: { ...fakeRegistry([]), find: () => fakeModel({ id: "x".repeat(2_000) }) },
|
|
268
|
+
complete: async () => assistant,
|
|
269
|
+
}));
|
|
270
|
+
assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
|
|
271
|
+
assert.equal(String(refused.evidence?.reviewerModel).length, 512);
|
|
272
|
+
assert.ok(!Object.hasOwn(refused.evidence!, "usage"));
|
|
273
|
+
assert.ok(!Object.hasOwn(refused.evidence!, "stopReason"));
|
|
274
|
+
assert.doesNotMatch(JSON.stringify(refused), /PRIVATE STOP/);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("empty-output evidence does not invent usage or missing thinking lengths", async () => {
|
|
279
|
+
const refused = expectRefused(await runInProcessReviewer(baseRequest(), {
|
|
280
|
+
registry: fakeRegistry([fakeModel()]),
|
|
281
|
+
complete: async () => assistantText("", { content: [{ type: "thinking" } as never], usage: undefined }),
|
|
282
|
+
}));
|
|
283
|
+
assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
|
|
284
|
+
assert.ok(!Object.hasOwn(refused.evidence!, "usage"));
|
|
285
|
+
assert.deepEqual(refused.evidence?.content, { blocks: 1, textBlocks: 0, thinkingBlocks: 1, textChars: 0 });
|
|
233
286
|
});
|
|
234
287
|
|
|
235
288
|
test("refuses with PROVIDER_FAILED when the provider itself reports an aborted completion", async () => {
|
|
@@ -10,7 +10,13 @@ import { INSTALLER_ASSETS, buildInstallerBundles, bundleFiles, relativeReference
|
|
|
10
10
|
import { installerPaths } from "../scripts/verify-package-files.mjs";
|
|
11
11
|
|
|
12
12
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
-
|
|
13
|
+
// The builder shells out to the zip binary for the archives. Repository CI
|
|
14
|
+
// installs zip, but a dev machine without it cannot run the archive tests:
|
|
15
|
+
// skip with a clear reason instead of failing on spawnSync ENOENT.
|
|
16
|
+
const zipAvailable = spawnSync("zip", ["--version"], { stdio: "ignore" }).status === 0;
|
|
17
|
+
const bundleArchive = {
|
|
18
|
+
skip: process.platform === "win32" ? "Windows host" : zipAvailable ? false : "the zip binary is not on PATH",
|
|
19
|
+
};
|
|
14
20
|
// Independent of the builder's scanner on purpose: imports and file URLs next to the module.
|
|
15
21
|
const relativeImports = (text: string) => [...text.matchAll(/(?:from\s+|import\s*\(\s*|new URL\(\s*)["'](\.{1,2}\/[^"']+\.[a-z]+)["']/g)].map((match) => match[1]);
|
|
16
22
|
|
|
@@ -45,7 +51,7 @@ function build() {
|
|
|
45
51
|
}
|
|
46
52
|
const listing = (command: string, args: string[]) => spawnSync(command, args, { encoding: "utf8" }).stdout;
|
|
47
53
|
|
|
48
|
-
test("one download per system, with stable names and SHA-256 checksums",
|
|
54
|
+
test("one download per system, with stable names and SHA-256 checksums", bundleArchive, () => {
|
|
49
55
|
const b = build();
|
|
50
56
|
try {
|
|
51
57
|
assert.deepEqual(INSTALLER_ASSETS, ["gentle-shell-installer-macos.zip", "gentle-shell-installer-windows.zip", "gentle-shell-installer-linux.tar.gz"]);
|
|
@@ -58,7 +64,7 @@ test("one download per system, with stable names and SHA-256 checksums", posixOn
|
|
|
58
64
|
} finally { b.cleanup(); }
|
|
59
65
|
});
|
|
60
66
|
|
|
61
|
-
test("macOS and Linux double-click launchers are executable and start the bundled bootstrap",
|
|
67
|
+
test("macOS and Linux double-click launchers are executable and start the bundled bootstrap", bundleArchive, () => {
|
|
62
68
|
const b = build();
|
|
63
69
|
try {
|
|
64
70
|
const mac = listing("zipinfo", [join(b.out, "gentle-shell-installer-macos.zip")]);
|
|
@@ -74,7 +80,7 @@ test("macOS and Linux double-click launchers are executable and start the bundle
|
|
|
74
80
|
} finally { b.cleanup(); }
|
|
75
81
|
});
|
|
76
82
|
|
|
77
|
-
test("the Windows launcher is a CRLF batch file that calls the bundled bootstrap and keeps its window open",
|
|
83
|
+
test("the Windows launcher is a CRLF batch file that calls the bundled bootstrap and keeps its window open", bundleArchive, () => {
|
|
78
84
|
const b = build();
|
|
79
85
|
try {
|
|
80
86
|
const zip = join(b.out, "gentle-shell-installer-windows.zip");
|
|
@@ -88,7 +94,7 @@ test("the Windows launcher is a CRLF batch file that calls the bundled bootstrap
|
|
|
88
94
|
} finally { b.cleanup(); }
|
|
89
95
|
});
|
|
90
96
|
|
|
91
|
-
test("an extracted bundle runs on its own: real files only, and every wizard module loads",
|
|
97
|
+
test("an extracted bundle runs on its own: real files only, and every wizard module loads", bundleArchive, () => {
|
|
92
98
|
const b = build();
|
|
93
99
|
const target = realpathSync(mkdtempSync(join(tmpdir(), "installer-extract-")));
|
|
94
100
|
try {
|
|
@@ -111,7 +117,7 @@ test("an extracted bundle runs on its own: real files only, and every wizard mod
|
|
|
111
117
|
}
|
|
112
118
|
});
|
|
113
119
|
|
|
114
|
-
test("the builder command writes the downloads to the requested folder",
|
|
120
|
+
test("the builder command writes the downloads to the requested folder", bundleArchive, () => {
|
|
115
121
|
const out = realpathSync(mkdtempSync(join(tmpdir(), "installer-cli-")));
|
|
116
122
|
try {
|
|
117
123
|
mkdirSync(join(out, "dist"));
|
|
@@ -934,19 +934,19 @@ test("Go has no descriptor for other targets", () => {
|
|
|
934
934
|
// The Engram release the bundled install ships (S9), from its checksums.txt and
|
|
935
935
|
// the GitHub asset digests; every archive was downloaded and re-hashed.
|
|
936
936
|
const engramPins = {
|
|
937
|
-
"darwin-arm64": ["darwin_arm64.tar.gz", "
|
|
938
|
-
"darwin-x64": ["darwin_amd64.tar.gz", "
|
|
939
|
-
"linux-x64": ["linux_amd64.tar.gz", "
|
|
940
|
-
"linux-arm64": ["linux_arm64.tar.gz", "
|
|
941
|
-
"win32-x64": ["windows_amd64.zip", "
|
|
942
|
-
"win32-arm64": ["windows_arm64.zip", "
|
|
937
|
+
"darwin-arm64": ["darwin_arm64.tar.gz", "43d398b2fe3327a4a6436c26b2a17d3285ba80bf2f11a22d0796ea2a7fd2b78c", 7749520],
|
|
938
|
+
"darwin-x64": ["darwin_amd64.tar.gz", "aea28ebf461a4ea86c52b793c29311539d7123875354ed7411eae982fc82ec87", 8181300],
|
|
939
|
+
"linux-x64": ["linux_amd64.tar.gz", "106a43ca8f43a27c2c260a6787151c3be6d91c5ed3d5e454e6140527adf92e01", 8064598],
|
|
940
|
+
"linux-arm64": ["linux_arm64.tar.gz", "b0ba49cf5a7d5af436c9744a9a0d638b3f9ebb2a7321c8736d30e96305c2993c", 7472279],
|
|
941
|
+
"win32-x64": ["windows_amd64.zip", "ba56a36d37b2da85fcb1581bbdbed9ae1ee690e642aab8dbe1e18209bf264afe", 8272809],
|
|
942
|
+
"win32-arm64": ["windows_arm64.zip", "df5acc5dd6ee407d484074e570c77a79567c0b8dccf55e25ae25a644c1e7db5f", 7530282],
|
|
943
943
|
} as const;
|
|
944
944
|
test("fixed Engram descriptors are the GitHub release archives and checksums, and nothing else", () => {
|
|
945
945
|
for (const [target, [suffix, sha256, size]] of Object.entries(engramPins)) {
|
|
946
946
|
const [platform, arch] = target.split("-");
|
|
947
947
|
const artifact = artifactFor("engram", platform, arch);
|
|
948
|
-
assert.equal(artifact.version, "3.
|
|
949
|
-
assert.equal(artifact.url, `https://github.com/Gentleman-Programming/engram/releases/download/v3.
|
|
948
|
+
assert.equal(artifact.version, "3.5.2");
|
|
949
|
+
assert.equal(artifact.url, `https://github.com/Gentleman-Programming/engram/releases/download/v3.5.2/engram_3.5.2_${suffix}`);
|
|
950
950
|
assert.equal(artifact.integrity, `sha256-${sha256}`);
|
|
951
951
|
assert.deepEqual([artifact.size, artifact.maxBytes], [size, size]);
|
|
952
952
|
// GitHub serves release assets from its asset hosts.
|
|
@@ -172,6 +172,22 @@ test("Windows requires compatible Go only before a missing native binary", () =>
|
|
|
172
172
|
assert.deepEqual(older.actions.slice(0, 2), goActions);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
test("Windows acquires the pinned Go for a Shell install even when Gentle AI is already installed", () => {
|
|
176
|
+
// gentle-pi's postinstall always builds Gentle AI from source on Windows, so an
|
|
177
|
+
// existing compatible gentle-ai never removes the need for Go.
|
|
178
|
+
const compatible = { ...tool(requirements.gentleAi), compatible: true };
|
|
179
|
+
for (const inventory of [{ ...clean("win32"), gentleAi: compatible },
|
|
180
|
+
{ ...installed("win32"), shell: absent, setup: false, go: absent }]) {
|
|
181
|
+
const plan = planPreflight(inventory);
|
|
182
|
+
assert.deepEqual(plan.blockers, []);
|
|
183
|
+
assert.deepEqual(plan.tools.go, { status: "needs-acquire", required: requirements.go, version: goAcquisition.version });
|
|
184
|
+
assert.ok(ids(inventory).includes("install-shell"));
|
|
185
|
+
assert.deepEqual(plan.actions.filter((action: { target: string }) => action.target === "go"), goActions);
|
|
186
|
+
}
|
|
187
|
+
// A set-up Windows stack builds nothing, so it still needs no Go.
|
|
188
|
+
assert.equal(planPreflight({ ...installed("win32"), go: absent }).tools.go.status, "not-required");
|
|
189
|
+
});
|
|
190
|
+
|
|
175
191
|
for (const [name, version] of [["node", "22.18.0"], ["shell", "3.9.0"], ["node", "banana"], ["pi", "1.0.0-rc.1"]]) {
|
|
176
192
|
test(`${name} ${version} blocks rather than replacing an existing tool`, () => {
|
|
177
193
|
const inventory = { ...installed(), [name]: tool(version) };
|
|
@@ -306,8 +306,8 @@ test("/api/plan names the found and required version of an older Gentle Shell an
|
|
|
306
306
|
const view = await plan(port, await login());
|
|
307
307
|
assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["incompatible-tool", "shell"]]);
|
|
308
308
|
assert.equal(view.blockers[0].guidance,
|
|
309
|
-
"Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.
|
|
310
|
-
"Update it with `pnpm add -g gentle-pi@4.
|
|
309
|
+
"Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.4.0 or newer. Nothing was replaced. " +
|
|
310
|
+
"Update it with `pnpm add -g gentle-pi@4.4.0`, or remove it with `pnpm remove -g gentle-pi`, then select Check again.");
|
|
311
311
|
assert.deepEqual(view.actions, []);
|
|
312
312
|
} finally {
|
|
313
313
|
await host.close("test");
|
|
@@ -934,6 +934,10 @@ test("a failed setup passes only its bounded detail through, with rate-limit gui
|
|
|
934
934
|
for (const [step, detail, expected] of [
|
|
935
935
|
["install-global", ".../gentle-pi postinstall: root cause Error: go: open ~/x: The directory name is invalid.", guidance.failed["install-global"]],
|
|
936
936
|
["install-global", rateLimited, guidance.githubRateLimit],
|
|
937
|
+
// Windows code integrity (Smart App Control) refused the gentle-ai.exe the postinstall built.
|
|
938
|
+
["install-global", ".../node_modules/gentle-pi postinstall: caused by Error: spawn UNKNOWN (code UNKNOWN)", guidance.codeIntegrityBlocked],
|
|
939
|
+
["install-version", ".../node_modules/gentle-pi postinstall: caused by Error: spawn UNKNOWN (code UNKNOWN)", guidance.codeIntegrityBlocked],
|
|
940
|
+
["install-version", "ERR_PNPM_FETCH_401 GET https://npm.acme.dev/x: Unauthorized", guidance.failed["install-version"]],
|
|
937
941
|
["install-shell-main", mainLimited, guidance.githubRateLimit],
|
|
938
942
|
["install-shell-main", "main-commit-unavailable: GitHub did not return the latest main commit of Gentleman-Programming/gentle-shell (HTTP 500)", guidance.failed["install-shell-main"]],
|
|
939
943
|
["build-gentle-ai-main", "main-commit-unavailable: GitHub did not return the latest main commit of Gentleman-Programming/gentle-ai (HTTP 403)", guidance.githubRateLimit],
|
|
@@ -957,6 +961,10 @@ test("a failed setup passes only its bounded detail through, with rate-limit gui
|
|
|
957
961
|
assert.match(guidance.githubRateLimit, /GitHub/);
|
|
958
962
|
assert.match(guidance.githubRateLimit, /hour/);
|
|
959
963
|
assert.doesNotMatch(guidance.githubRateLimit, /gentle-shell setup|token|credential|password|log in|sign in/i);
|
|
964
|
+
assert.match(guidance.codeIntegrityBlocked, /Smart App Control/);
|
|
965
|
+
assert.match(guidance.codeIntegrityBlocked, /WSL/);
|
|
966
|
+
assert.match(guidance.codeIntegrityBlocked, /cannot be turned back on/);
|
|
967
|
+
assert.doesNotMatch(guidance.codeIntegrityBlocked, /network|registry/);
|
|
960
968
|
assert.match(guidance.goDestinationConflict, /earlier/);
|
|
961
969
|
assert.match(guidance.goDestinationConflict, /Remove that folder/);
|
|
962
970
|
assert.doesNotMatch(guidance.goDestinationConflict, /network/);
|