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
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
// The voice worker (S1-S3) runs in its own process; here its entry function is
|
|
12
|
+
// driven in-process with fake capture and recognizer modules, so no native code
|
|
13
|
+
// or microphone is ever touched. The installed files it validates are real
|
|
14
|
+
// files in an owned scratch directory under tmp/.
|
|
15
|
+
|
|
16
|
+
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
17
|
+
const { runVoiceWorker } = createRequire(import.meta.url)("../scripts/voice-worker.cjs");
|
|
18
|
+
|
|
19
|
+
const SCHEMA = "gentle.voice/v1";
|
|
20
|
+
const SHERPA_ENTRY = "node_modules/sherpa-onnx-node/non-streaming-asr.js";
|
|
21
|
+
const CPAL_ENTRY = "node_modules/node-cpal/index.js";
|
|
22
|
+
const NATIVE = "node_modules/sherpa-onnx-linux-x64/sherpa-onnx.node";
|
|
23
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
24
|
+
|
|
25
|
+
interface Install {
|
|
26
|
+
runtimeRoot: string;
|
|
27
|
+
modelRoot: string;
|
|
28
|
+
modelFiles: Record<string, { name: string; size: number; sha256: string }>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function install(t: test.TestContext): Install {
|
|
32
|
+
const base = join(repoRoot, "tmp");
|
|
33
|
+
mkdirSync(base, { recursive: true });
|
|
34
|
+
const dir = mkdtempSync(join(base, "voice-worker-"));
|
|
35
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
36
|
+
const runtimeRoot = join(dir, "runtime");
|
|
37
|
+
for (const file of [SHERPA_ENTRY, CPAL_ENTRY, NATIVE]) {
|
|
38
|
+
mkdirSync(dirname(join(runtimeRoot, file)), { recursive: true });
|
|
39
|
+
writeFileSync(join(runtimeRoot, file), `// ${file}\n`);
|
|
40
|
+
}
|
|
41
|
+
const modelRoot = join(dir, "model");
|
|
42
|
+
mkdirSync(modelRoot);
|
|
43
|
+
const modelFiles: Install["modelFiles"] = {};
|
|
44
|
+
for (const [role, name] of [["encoder", "encoder.int8.onnx"], ["decoder", "decoder.int8.onnx"], ["joiner", "joiner.int8.onnx"], ["tokens", "tokens.txt"]]) {
|
|
45
|
+
const data = Buffer.from(`${role} weights`);
|
|
46
|
+
writeFileSync(join(modelRoot, name), data);
|
|
47
|
+
modelFiles[role] = { name, size: data.length, sha256: createHash("sha256").update(data).digest("hex") };
|
|
48
|
+
}
|
|
49
|
+
return { runtimeRoot, modelRoot, modelFiles };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function startRequest(paths: Install, overrides: Record<string, unknown> = {}) {
|
|
53
|
+
return {
|
|
54
|
+
schema: SCHEMA,
|
|
55
|
+
type: "start",
|
|
56
|
+
target: "linux-x64",
|
|
57
|
+
maxRecordingMs: 120_000,
|
|
58
|
+
numThreads: 2,
|
|
59
|
+
runtime: { root: paths.runtimeRoot, files: [SHERPA_ENTRY, CPAL_ENTRY, NATIVE] },
|
|
60
|
+
model: { root: paths.modelRoot, files: paths.modelFiles },
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface FakeStream {
|
|
66
|
+
options: { config: { sampleRate: number; channels: number; sampleFormat: string }; onData(data: ArrayBufferView): void; onError(error: unknown): void; autoStart: boolean };
|
|
67
|
+
closes: number;
|
|
68
|
+
close(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface FakeOptions {
|
|
72
|
+
device?: unknown;
|
|
73
|
+
defaults?: { sampleRate: number; channels: number; sampleFormat: string };
|
|
74
|
+
formats?: string[];
|
|
75
|
+
loadError?: string;
|
|
76
|
+
recognizerError?: string;
|
|
77
|
+
text?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function harness(t: test.TestContext, fake: FakeOptions = {}, io: Record<string, unknown> = {}) {
|
|
81
|
+
const paths = install(t);
|
|
82
|
+
const input = new EventEmitter();
|
|
83
|
+
const messages: Array<Record<string, unknown>> = [];
|
|
84
|
+
const raw: string[] = [];
|
|
85
|
+
const loaded: string[] = [];
|
|
86
|
+
const streams: FakeStream[] = [];
|
|
87
|
+
const recognizers: Array<{ config: any; waves: Array<{ sampleRate: number; samples: Float32Array }>; decodes: number }> = [];
|
|
88
|
+
let exitCode: number | undefined;
|
|
89
|
+
const convenience = {
|
|
90
|
+
getDefaultInputDevice: () => ("device" in fake ? fake.device : { deviceId: "default-input" }),
|
|
91
|
+
getDefaultInputConfig: () => fake.defaults ?? { sampleRate: 48000, channels: 2, sampleFormat: "i16" },
|
|
92
|
+
getSupportedFormats: () => fake.formats ?? ["i16"],
|
|
93
|
+
createInputStream: async (options: FakeStream["options"]) => {
|
|
94
|
+
const stream: FakeStream = { options, closes: 0, close: async () => void stream.closes++ };
|
|
95
|
+
streams.push(stream);
|
|
96
|
+
return stream;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
class OfflineRecognizer {
|
|
100
|
+
config: any;
|
|
101
|
+
waves: Array<{ sampleRate: number; samples: Float32Array }> = [];
|
|
102
|
+
decodes = 0;
|
|
103
|
+
constructor(config: unknown) {
|
|
104
|
+
if (fake.recognizerError) throw new Error(fake.recognizerError);
|
|
105
|
+
this.config = config;
|
|
106
|
+
recognizers.push(this);
|
|
107
|
+
}
|
|
108
|
+
createStream() {
|
|
109
|
+
return { acceptWaveform: (wave: { sampleRate: number; samples: Float32Array }) => this.waves.push(wave) };
|
|
110
|
+
}
|
|
111
|
+
decode() {
|
|
112
|
+
this.decodes++;
|
|
113
|
+
}
|
|
114
|
+
getResult() {
|
|
115
|
+
return { text: fake.text ?? " hello world " };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const done: Promise<void> = runVoiceWorker({
|
|
119
|
+
input,
|
|
120
|
+
write: (line: string) => {
|
|
121
|
+
raw.push(line);
|
|
122
|
+
messages.push(JSON.parse(line));
|
|
123
|
+
},
|
|
124
|
+
exit: (code: number) => {
|
|
125
|
+
exitCode = code;
|
|
126
|
+
},
|
|
127
|
+
loadModule: (file: string) => {
|
|
128
|
+
loaded.push(file);
|
|
129
|
+
if (fake.loadError) throw new Error(fake.loadError);
|
|
130
|
+
if (file === join(paths.runtimeRoot, ...SHERPA_ENTRY.split("/"))) return { OfflineRecognizer };
|
|
131
|
+
if (file === join(paths.runtimeRoot, ...CPAL_ENTRY.split("/"))) return { convenience };
|
|
132
|
+
throw new Error(`unexpected module ${file}`);
|
|
133
|
+
},
|
|
134
|
+
platform: "linux",
|
|
135
|
+
arch: "x64",
|
|
136
|
+
nodeVersion: "22.12.0",
|
|
137
|
+
uid,
|
|
138
|
+
...io,
|
|
139
|
+
});
|
|
140
|
+
const send = (message: unknown) => input.emit("data", `${typeof message === "string" ? message : JSON.stringify(message)}\n`);
|
|
141
|
+
const waitFor = async (predicate: () => boolean, label = "condition") => {
|
|
142
|
+
for (let i = 0; i < 500 && !predicate(); i++) await new Promise((resolve) => setTimeout(resolve, 2));
|
|
143
|
+
assert.ok(predicate(), `timed out waiting for ${label}; messages: ${JSON.stringify(messages)}`);
|
|
144
|
+
};
|
|
145
|
+
const types = () => messages.map((message) => message.type);
|
|
146
|
+
return { paths, input, messages, raw, loaded, streams, recognizers, done, send, waitFor, types, exitCode: () => exitCode };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const interleaved = (frames: number, channels: number, value: (frame: number, channel: number) => number) => {
|
|
150
|
+
const data = new Int16Array(frames * channels);
|
|
151
|
+
for (let frame = 0; frame < frames; frame++) for (let channel = 0; channel < channels; channel++) data[frame * channels + channel] = value(frame, channel);
|
|
152
|
+
return data;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
test("records multichannel audio, resamples it to 16 kHz mono and closes its retained stream once on stop", async (t) => {
|
|
156
|
+
const worker = harness(t);
|
|
157
|
+
worker.send(startRequest(worker.paths));
|
|
158
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
159
|
+
const [stream] = worker.streams;
|
|
160
|
+
assert.deepEqual(stream!.options.config, { sampleRate: 48000, channels: 2, sampleFormat: "i16" });
|
|
161
|
+
assert.equal(stream!.options.autoStart, true);
|
|
162
|
+
|
|
163
|
+
// Left at half scale, right silent: the mono downmix is a quarter scale.
|
|
164
|
+
stream!.options.onData(interleaved(4800, 2, (_frame, channel) => (channel === 0 ? 16384 : 0)));
|
|
165
|
+
stream!.options.onData(interleaved(4800, 2, (_frame, channel) => (channel === 0 ? 16384 : 0)));
|
|
166
|
+
worker.send({ schema: SCHEMA, type: "stop" });
|
|
167
|
+
await worker.done;
|
|
168
|
+
stream!.options.onData(interleaved(4800, 2, () => 32767));
|
|
169
|
+
|
|
170
|
+
assert.deepEqual(worker.types(), ["recording", "transcribing", "result"]);
|
|
171
|
+
assert.deepEqual(worker.messages.at(-1), { schema: SCHEMA, type: "result", text: "hello world", truncated: false });
|
|
172
|
+
assert.equal(stream!.closes, 1);
|
|
173
|
+
assert.equal(worker.exitCode(), 0);
|
|
174
|
+
const config = worker.recognizers[0]!.config;
|
|
175
|
+
assert.equal(config.modelConfig.modelType, "nemo_transducer");
|
|
176
|
+
assert.equal(config.modelConfig.transducer.encoder, join(worker.paths.modelRoot, "encoder.int8.onnx"));
|
|
177
|
+
assert.equal(config.modelConfig.tokens, join(worker.paths.modelRoot, "tokens.txt"));
|
|
178
|
+
assert.deepEqual(config.featConfig, { sampleRate: 16000, featureDim: 80 });
|
|
179
|
+
const [wave] = worker.recognizers[0]!.waves;
|
|
180
|
+
assert.equal(wave!.sampleRate, 16000);
|
|
181
|
+
assert.equal(wave!.samples.length, 3200);
|
|
182
|
+
assert.ok(wave!.samples.every((sample) => Math.abs(sample - 0.25) < 1e-6));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("prefers float input and transcribes on its own when the recording limit is reached", async (t) => {
|
|
186
|
+
const worker = harness(t, { defaults: { sampleRate: 8000, channels: 1, sampleFormat: "i16" }, formats: ["i16", "f32"] });
|
|
187
|
+
worker.send(startRequest(worker.paths, { maxRecordingMs: 1000 }));
|
|
188
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
189
|
+
const [stream] = worker.streams;
|
|
190
|
+
assert.equal(stream!.options.config.sampleFormat, "f32");
|
|
191
|
+
stream!.options.onData(new Float32Array(6000).fill(0.5));
|
|
192
|
+
stream!.options.onData(new Float32Array(6000).fill(0.5));
|
|
193
|
+
await worker.done;
|
|
194
|
+
assert.deepEqual(worker.types(), ["recording", "transcribing", "result"]);
|
|
195
|
+
assert.equal(worker.messages.at(-1)!.truncated, true);
|
|
196
|
+
// 8000 samples kept at 8 kHz, upsampled to one second at 16 kHz.
|
|
197
|
+
assert.equal(worker.recognizers[0]!.waves[0]!.samples.length, 16000);
|
|
198
|
+
assert.equal(stream!.closes, 1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("cancel closes capture, discards the audio and reports nothing", async (t) => {
|
|
202
|
+
const worker = harness(t);
|
|
203
|
+
worker.send(startRequest(worker.paths));
|
|
204
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
205
|
+
const [stream] = worker.streams;
|
|
206
|
+
stream!.options.onData(interleaved(9600, 2, () => 1000));
|
|
207
|
+
worker.send({ schema: SCHEMA, type: "cancel" });
|
|
208
|
+
await worker.done;
|
|
209
|
+
stream!.options.onData(interleaved(9600, 2, () => 1000));
|
|
210
|
+
worker.send({ schema: SCHEMA, type: "stop" });
|
|
211
|
+
assert.deepEqual(worker.types(), ["recording"]);
|
|
212
|
+
assert.equal(worker.recognizers.length, 0, "the recognizer is never created for a cancelled session");
|
|
213
|
+
assert.ok(!worker.loaded.some((file) => file.endsWith("non-streaming-asr.js")));
|
|
214
|
+
assert.equal(stream!.closes, 1);
|
|
215
|
+
assert.equal(worker.exitCode(), 0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a closed stdin ends the session like a cancel", async (t) => {
|
|
219
|
+
const worker = harness(t);
|
|
220
|
+
worker.send(startRequest(worker.paths));
|
|
221
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
222
|
+
worker.input.emit("end");
|
|
223
|
+
await worker.done;
|
|
224
|
+
assert.deepEqual(worker.types(), ["recording"]);
|
|
225
|
+
assert.equal(worker.streams[0]!.closes, 1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("rejects malformed and unknown requests before loading anything", async (t) => {
|
|
229
|
+
const cases: Array<(paths: Install) => unknown> = [
|
|
230
|
+
() => "not json",
|
|
231
|
+
(paths) => ({ ...startRequest(paths), extra: true }),
|
|
232
|
+
(paths) => startRequest(paths, { runtime: { root: "relative/runtime", files: [SHERPA_ENTRY, CPAL_ENTRY] } }),
|
|
233
|
+
(paths) => startRequest(paths, { runtime: { root: paths.runtimeRoot, files: [SHERPA_ENTRY] } }),
|
|
234
|
+
(paths) => startRequest(paths, { runtime: { root: paths.runtimeRoot, files: [SHERPA_ENTRY, CPAL_ENTRY, "../outside.js"] } }),
|
|
235
|
+
(paths) => startRequest(paths, { target: "win32-arm64" }),
|
|
236
|
+
(paths) => startRequest(paths, { maxRecordingMs: 10 }),
|
|
237
|
+
() => ({ schema: SCHEMA, type: "stop" }),
|
|
238
|
+
];
|
|
239
|
+
for (const [index, request] of cases.entries()) {
|
|
240
|
+
await t.test(`case ${index}`, async (st) => {
|
|
241
|
+
const worker = harness(st);
|
|
242
|
+
worker.send(request(worker.paths));
|
|
243
|
+
await worker.done;
|
|
244
|
+
assert.deepEqual(worker.messages, [{ schema: SCHEMA, type: "error", code: "invalid-request" }]);
|
|
245
|
+
assert.deepEqual(worker.loaded, []);
|
|
246
|
+
assert.equal(worker.exitCode(), 1);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("refuses runtime links, missing runtime files and a foreign target before native loading", async (t) => {
|
|
252
|
+
const cases: Array<[string, (paths: Install) => Record<string, unknown> | void, string]> = [
|
|
253
|
+
["symlinked runtime file", (paths) => {
|
|
254
|
+
rmSync(join(paths.runtimeRoot, NATIVE));
|
|
255
|
+
symlinkSync(join(paths.modelRoot, "tokens.txt"), join(paths.runtimeRoot, NATIVE));
|
|
256
|
+
}, "runtime-invalid"],
|
|
257
|
+
["missing runtime file", (paths) => rmSync(join(paths.runtimeRoot, CPAL_ENTRY)), "runtime-invalid"],
|
|
258
|
+
["other target", () => ({ target: "darwin-arm64" }), "runtime-invalid"],
|
|
259
|
+
];
|
|
260
|
+
for (const [name, tamper, code] of cases) {
|
|
261
|
+
await t.test(name, async (st) => {
|
|
262
|
+
const worker = harness(st);
|
|
263
|
+
const overrides = (tamper(worker.paths) as Record<string, unknown> | undefined) ?? {};
|
|
264
|
+
worker.send(startRequest(worker.paths, overrides));
|
|
265
|
+
await worker.done;
|
|
266
|
+
assert.deepEqual(worker.messages, [{ schema: SCHEMA, type: "error", code }]);
|
|
267
|
+
assert.deepEqual(worker.loaded, []);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("reports native load failures by code only, never their private detail", async (t) => {
|
|
273
|
+
for (const [fake, code] of [[{ loadError: "dlopen failed: /home/someone/.secret/voice/sherpa-onnx.node TOKEN=abc" }, "runtime-load"], [{ recognizerError: "cannot read /home/someone/model" }, "model-load"]] as const) {
|
|
274
|
+
await t.test(code, async (st) => {
|
|
275
|
+
const worker = harness(st, fake);
|
|
276
|
+
worker.send(startRequest(worker.paths));
|
|
277
|
+
if (code === "model-load") {
|
|
278
|
+
// The recognizer is loaded only after stop.
|
|
279
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
280
|
+
worker.streams[0]!.options.onData(interleaved(4800, 2, () => 1000));
|
|
281
|
+
worker.send({ schema: SCHEMA, type: "stop" });
|
|
282
|
+
}
|
|
283
|
+
await worker.done;
|
|
284
|
+
assert.deepEqual(worker.messages.at(-1), { schema: SCHEMA, type: "error", code });
|
|
285
|
+
assert.deepEqual(worker.types(), code === "model-load" ? ["recording", "transcribing", "error"] : ["error"]);
|
|
286
|
+
assert.ok(!worker.raw.join("").includes("someone"));
|
|
287
|
+
assert.ok(!worker.raw.join("").includes(worker.paths.runtimeRoot));
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("reports a missing microphone, an unusable format and empty audio", async (t) => {
|
|
293
|
+
const cases: Array<[FakeOptions, string, boolean]> = [
|
|
294
|
+
[{ device: null }, "no-input-device", false],
|
|
295
|
+
[{ defaults: { sampleRate: 48000, channels: 1, sampleFormat: "i64" }, formats: ["i64"] }, "unsupported-input", false],
|
|
296
|
+
[{}, "empty-audio", true],
|
|
297
|
+
];
|
|
298
|
+
for (const [fake, code, stop] of cases) {
|
|
299
|
+
await t.test(code, async (st) => {
|
|
300
|
+
const worker = harness(st, fake);
|
|
301
|
+
worker.send(startRequest(worker.paths));
|
|
302
|
+
if (stop) {
|
|
303
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
304
|
+
worker.streams[0]!.options.onData(interleaved(100, 2, () => 1000));
|
|
305
|
+
worker.send({ schema: SCHEMA, type: "stop" });
|
|
306
|
+
}
|
|
307
|
+
await worker.done;
|
|
308
|
+
assert.deepEqual(worker.messages.at(-1), { schema: SCHEMA, type: "error", code });
|
|
309
|
+
assert.equal(worker.exitCode(), 1);
|
|
310
|
+
for (const stream of worker.streams) assert.equal(stream.closes, 1);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("refuses to run on Node older than 22", async (t) => {
|
|
316
|
+
const worker = harness(t, {}, { nodeVersion: "20.18.0" });
|
|
317
|
+
await worker.done;
|
|
318
|
+
assert.deepEqual(worker.messages, [{ schema: SCHEMA, type: "error", code: "node-version" }]);
|
|
319
|
+
assert.deepEqual(worker.loaded, []);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// --- capture before model verification (T4) -----------------------------------
|
|
323
|
+
|
|
324
|
+
const isSherpa = (file: string) => file.endsWith("non-streaming-asr.js");
|
|
325
|
+
const isCpal = (file: string) => file.endsWith(join("node-cpal", "index.js"));
|
|
326
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
|
327
|
+
|
|
328
|
+
// Reads model files through streams that deliver nothing until released, so a
|
|
329
|
+
// test controls exactly when model verification can finish.
|
|
330
|
+
function gatedReads() {
|
|
331
|
+
let release!: () => void;
|
|
332
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
333
|
+
const opened: string[] = [];
|
|
334
|
+
const openRead = (file: string) => {
|
|
335
|
+
opened.push(file);
|
|
336
|
+
return Readable.from((async function* () {
|
|
337
|
+
await gate;
|
|
338
|
+
yield readFileSync(file);
|
|
339
|
+
})());
|
|
340
|
+
};
|
|
341
|
+
return { openRead, opened, release };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
test("capture opens and reports recording before the model is verified; sherpa loads only after stop and verification", async (t) => {
|
|
345
|
+
const reads = gatedReads();
|
|
346
|
+
const worker = harness(t, {}, { openRead: reads.openRead });
|
|
347
|
+
worker.send(startRequest(worker.paths));
|
|
348
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
349
|
+
assert.equal(worker.streams.length, 1, "capture is open");
|
|
350
|
+
assert.equal(worker.loaded.length, 1);
|
|
351
|
+
assert.ok(isCpal(worker.loaded[0]!), "only node-cpal is loaded while recording");
|
|
352
|
+
// Recording is reported before verification starts; the first digest read
|
|
353
|
+
// opens asynchronously while recording, so wait for it instead of racing
|
|
354
|
+
// the model tree walk (#2435).
|
|
355
|
+
await worker.waitFor(() => reads.opened.length >= 1, "model digests are streamed while recording");
|
|
356
|
+
// Audio captured during verification is kept.
|
|
357
|
+
worker.streams[0]!.options.onData(interleaved(4800, 2, (_frame, channel) => (channel === 0 ? 16384 : 0)));
|
|
358
|
+
worker.send({ schema: SCHEMA, type: "stop" });
|
|
359
|
+
await worker.waitFor(() => worker.types().includes("transcribing"));
|
|
360
|
+
await settle();
|
|
361
|
+
assert.equal(worker.streams[0]!.closes, 1, "capture stops at once on stop");
|
|
362
|
+
assert.ok(!worker.loaded.some(isSherpa), "no sherpa load before every digest matched");
|
|
363
|
+
assert.equal(worker.recognizers.length, 0);
|
|
364
|
+
reads.release();
|
|
365
|
+
await worker.done;
|
|
366
|
+
assert.deepEqual(worker.types(), ["recording", "transcribing", "result"]);
|
|
367
|
+
assert.ok(isSherpa(worker.loaded[1]!));
|
|
368
|
+
assert.equal(reads.opened.length, 4, "every model file was hashed");
|
|
369
|
+
assert.equal(worker.recognizers[0]!.waves[0]!.samples.length, 1600);
|
|
370
|
+
assert.equal(worker.exitCode(), 0);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a model digest mismatch found while recording closes capture, reports model-invalid and never loads sherpa", async (t) => {
|
|
374
|
+
const worker = harness(t);
|
|
375
|
+
writeFileSync(join(worker.paths.modelRoot, "joiner.int8.onnx"), "JOINER weights");
|
|
376
|
+
worker.send(startRequest(worker.paths));
|
|
377
|
+
await worker.done;
|
|
378
|
+
assert.deepEqual(worker.messages, [{ schema: SCHEMA, type: "recording" }, { schema: SCHEMA, type: "error", code: "model-invalid" }]);
|
|
379
|
+
assert.equal(worker.streams[0]!.closes, 1);
|
|
380
|
+
assert.ok(!worker.loaded.some(isSherpa));
|
|
381
|
+
assert.equal(worker.recognizers.length, 0);
|
|
382
|
+
assert.equal(worker.exitCode(), 1);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("an invalid model tree is reported while recording and never loads sherpa", async (t) => {
|
|
386
|
+
const worker = harness(t);
|
|
387
|
+
symlinkSync(worker.paths.runtimeRoot, join(worker.paths.modelRoot, "linked"));
|
|
388
|
+
worker.send(startRequest(worker.paths));
|
|
389
|
+
await worker.done;
|
|
390
|
+
assert.deepEqual(worker.types(), ["recording", "error"]);
|
|
391
|
+
assert.equal(worker.messages.at(-1)!.code, "model-invalid");
|
|
392
|
+
assert.ok(!worker.loaded.some(isSherpa));
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("a mismatch found after stop reports model-invalid instead of a transcript", async (t) => {
|
|
396
|
+
const reads = gatedReads();
|
|
397
|
+
const worker = harness(t, {}, { openRead: reads.openRead });
|
|
398
|
+
writeFileSync(join(worker.paths.modelRoot, "joiner.int8.onnx"), "JOINER weights");
|
|
399
|
+
worker.send(startRequest(worker.paths));
|
|
400
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
401
|
+
worker.streams[0]!.options.onData(interleaved(4800, 2, () => 1000));
|
|
402
|
+
worker.send({ schema: SCHEMA, type: "stop" });
|
|
403
|
+
await worker.waitFor(() => worker.types().includes("transcribing"));
|
|
404
|
+
reads.release();
|
|
405
|
+
await worker.done;
|
|
406
|
+
assert.deepEqual(worker.messages.at(-1), { schema: SCHEMA, type: "error", code: "model-invalid" });
|
|
407
|
+
assert.ok(!worker.loaded.some(isSherpa));
|
|
408
|
+
assert.equal(worker.recognizers.length, 0);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("cancel during model verification discards everything, stops hashing and never loads sherpa", async (t) => {
|
|
412
|
+
const reads = gatedReads();
|
|
413
|
+
const worker = harness(t, {}, { openRead: reads.openRead });
|
|
414
|
+
worker.send(startRequest(worker.paths));
|
|
415
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
416
|
+
worker.streams[0]!.options.onData(interleaved(4800, 2, () => 1000));
|
|
417
|
+
// Cancel must interrupt a digest that is actually in flight: wait for the
|
|
418
|
+
// first model read to open, otherwise an early cancel is indistinguishable
|
|
419
|
+
// from verification that never started (#2435).
|
|
420
|
+
await worker.waitFor(() => reads.opened.length === 1, "the first digest read is in flight");
|
|
421
|
+
worker.send({ schema: SCHEMA, type: "cancel" });
|
|
422
|
+
await worker.done;
|
|
423
|
+
reads.release();
|
|
424
|
+
await settle();
|
|
425
|
+
worker.send({ schema: SCHEMA, type: "stop" });
|
|
426
|
+
await settle();
|
|
427
|
+
assert.deepEqual(worker.types(), ["recording"]);
|
|
428
|
+
assert.equal(worker.streams[0]!.closes, 1);
|
|
429
|
+
assert.ok(!worker.loaded.some(isSherpa));
|
|
430
|
+
assert.equal(reads.opened.length, 1, "no further model file is opened after cancel");
|
|
431
|
+
assert.equal(worker.exitCode(), 0);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("the recording limit during verification transcribes once the model is verified", async (t) => {
|
|
435
|
+
const reads = gatedReads();
|
|
436
|
+
const worker = harness(t, { defaults: { sampleRate: 8000, channels: 1, sampleFormat: "f32" }, formats: ["f32"] }, { openRead: reads.openRead });
|
|
437
|
+
worker.send(startRequest(worker.paths, { maxRecordingMs: 1000 }));
|
|
438
|
+
await worker.waitFor(() => worker.types().includes("recording"));
|
|
439
|
+
worker.streams[0]!.options.onData(new Float32Array(9000).fill(0.5));
|
|
440
|
+
await worker.waitFor(() => worker.types().includes("transcribing"));
|
|
441
|
+
assert.ok(!worker.loaded.some(isSherpa));
|
|
442
|
+
reads.release();
|
|
443
|
+
await worker.done;
|
|
444
|
+
assert.deepEqual(worker.messages.at(-1), { schema: SCHEMA, type: "result", text: "hello world", truncated: true });
|
|
445
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { __testing } from "../extensions/gentle-ai.ts";
|
|
6
|
+
|
|
7
|
+
// odd/tasks/weak-model-economy.md S2-S4: bench C-econ and C-risk wording, asserted in the
|
|
8
|
+
// rendered primary-session prompt (S1, the todo block, is covered in gentle-todo.test.ts).
|
|
9
|
+
|
|
10
|
+
const read = (relative: string): string => readFileSync(join(import.meta.dirname, "..", relative), "utf8");
|
|
11
|
+
|
|
12
|
+
test("S2: the differential check runs only when shared code an unnamed command uses changes", () => {
|
|
13
|
+
const gated = "Only when your diff changes shared code (options, parsers, helpers) that an existing command the request did not name also uses, run one differential check before finishing, as a single script: for each such command,";
|
|
14
|
+
for (const persona of ["gentleman", "neutral"] as const) {
|
|
15
|
+
const prompt = __testing.buildGentlePrompt(persona);
|
|
16
|
+
assert.ok(prompt.includes(gated), `${persona} prompt must gate the differential check`);
|
|
17
|
+
assert.ok(!prompt.includes("Before finishing, run a differential check"), `${persona} prompt keeps the ungated check`);
|
|
18
|
+
}
|
|
19
|
+
const worker = read("assets/agents/gentle-ai-worker.md");
|
|
20
|
+
assert.ok(worker.includes(gated), "worker PRESERVE step must gate the differential check");
|
|
21
|
+
assert.ok(!worker.includes("Before finishing, run a differential check"), "worker keeps the ungated check");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("S3: the small path reads one batch and runs its checks in one command", () => {
|
|
25
|
+
const prompt = __testing.buildGentlePrompt("gentleman");
|
|
26
|
+
assert.ok(prompt.includes("Small path: inline (parent; `bash_background` policy); read only what you change and its helpers, in one batch; observe RED inline before the fix; run the focused test and the suite inline in one command."));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("S4: high-risk item 1 covers only data someone cannot rebuild", () => {
|
|
30
|
+
const prompt = __testing.buildGentlePrompt("gentleman");
|
|
31
|
+
assert.ok(prompt.includes("(1) data or irreversible effects that cannot be rebuilt (migrations, rewriting or deleting stored data, format changes, writing data without validation; not saving new records, nor a dev or test database a seed or backup rebuilds);"));
|
|
32
|
+
});
|
|
@@ -37,6 +37,8 @@ function fakeGitResult(command: string, args: readonly string[], cwd: string): s
|
|
|
37
37
|
if (args.includes("--git-common-dir")) return `${cwd}/.git`;
|
|
38
38
|
if (args.includes("--show-object-format")) return "sha1";
|
|
39
39
|
if (args.includes("--is-shallow-repository")) return "false";
|
|
40
|
+
if (args.includes("--is-inside-work-tree")) return "true";
|
|
41
|
+
if (args.includes("info/exclude")) return join(cwd, ".git", "info", "exclude");
|
|
40
42
|
if (args.includes("--git-path")) return join(cwd, ".git", "gentle-ai", "reviews");
|
|
41
43
|
if (args.includes("--max-parents=0")) return "a".repeat(40);
|
|
42
44
|
if (args[0] === "cat-file" && args[1] === "-t") return "tree";
|
|
@@ -161,6 +163,9 @@ async function captureOwnedRoutes(t: TestContext): Promise<{ captures: Capture[]
|
|
|
161
163
|
const fixtures = await import(`./review-test-fixtures.ts${query}`);
|
|
162
164
|
const publication = await import(`../lib/review-publication-gate.ts${query}`);
|
|
163
165
|
const gentleAi = await import(`../extensions/gentle-ai.ts${query}`);
|
|
166
|
+
const sliceSignal = await import(`../lib/commit-slice-signal.ts${query}`);
|
|
167
|
+
const scratch = await import(`../lib/session-scratch.ts${query}`);
|
|
168
|
+
const skills = await import(`../extensions/skill-registry.ts${query}`);
|
|
164
169
|
|
|
165
170
|
const traces: Trace[] = [];
|
|
166
171
|
const graph = codegraph.createCodeGraphTool();
|
|
@@ -169,6 +174,9 @@ async function captureOwnedRoutes(t: TestContext): Promise<{ captures: Capture[]
|
|
|
169
174
|
{ kind: "execFileSync", command: "git", args: ["rev-parse", "--show-toplevel"] },
|
|
170
175
|
{ kind: "execFile", command: "codegraph", args: ["init", cwd] },
|
|
171
176
|
...fallbackCalls,
|
|
177
|
+
// A successful init keeps .codegraph/ out of git through the local exclude file.
|
|
178
|
+
{ kind: "execFileSync", command: "git", args: ["rev-parse", "--is-inside-work-tree"] },
|
|
179
|
+
{ kind: "execFileSync", command: "git", args: ["rev-parse", "--git-path", "info/exclude"] },
|
|
172
180
|
]);
|
|
173
181
|
const graphText = String((graphResult as { content?: Array<{ text?: string }> }).content?.[0]?.text ?? "");
|
|
174
182
|
assert.match(graphText, /indexed/, "CodeGraph must return the fixture command result");
|
|
@@ -251,6 +259,22 @@ async function captureOwnedRoutes(t: TestContext): Promise<{ captures: Capture[]
|
|
|
251
259
|
{ kind: "spawnSync", command: "gh", args: ["api", "repos/{owner}/{repo}/commits/ffffffffffffffffffffffffffffffffffffffff/status", "--jq", ".state"] },
|
|
252
260
|
]);
|
|
253
261
|
assert.deepEqual(ci, { proven: false, status: null });
|
|
262
|
+
// Routes reached through their default runners, so the launch options under
|
|
263
|
+
// test are the ones production actually uses.
|
|
264
|
+
await trace(captures, traces, "commit slice report", () => sliceSignal.commitSliceReport(cwd), [
|
|
265
|
+
{ kind: "execFile", command: "git", args: ["symbolic-ref", "-q", "--short", "HEAD"] },
|
|
266
|
+
{ kind: "execFile", command: "git", args: ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"] },
|
|
267
|
+
]);
|
|
268
|
+
const artifact = await trace(captures, traces, "scratch artifact check", () => scratch.checkArtifact({ kind: "clone", path: cwd, command: "git clone", recordedAt: "2026-01-01T00:00:00.000Z" }, []), [
|
|
269
|
+
{ kind: "execFile", command: "git", args: ["rev-parse", "--show-toplevel"] },
|
|
270
|
+
]);
|
|
271
|
+
assert.equal(artifact.state, "kept", "an unrelated top-level keeps the recorded directory");
|
|
272
|
+
await trace(captures, traces, "profile declaration sharing", () => gentleAi.repoDeclarationSharing(cwd, join(cwd, "profile.json")), [
|
|
273
|
+
{ kind: "execFileSync", command: "git", args: ["check-ignore", "--quiet", "profile.json"] },
|
|
274
|
+
]);
|
|
275
|
+
await trace(captures, traces, "skill registry tracking probe", () => skills.automaticWriteWarning(cwd, [".atl/.gitignore"]), [
|
|
276
|
+
{ kind: "execFile", command: "git", args: ["ls-files", "--cached", "-z", "--", ".atl/.gitignore"] },
|
|
277
|
+
]);
|
|
254
278
|
return { captures, traces };
|
|
255
279
|
} finally {
|
|
256
280
|
cp.execFileSync = original.execFileSync;
|
|
@@ -281,7 +305,11 @@ test("owned public adapters pass windowsHide at every mapped Node child-process
|
|
|
281
305
|
"publication advertised object",
|
|
282
306
|
"publication remote head",
|
|
283
307
|
"publication GH CI",
|
|
284
|
-
|
|
308
|
+
"commit slice report",
|
|
309
|
+
"scratch artifact check",
|
|
310
|
+
"profile declaration sharing",
|
|
311
|
+
"skill registry tracking probe",
|
|
312
|
+
], "all sixteen mapped public actions must complete before flag assertions");
|
|
285
313
|
for (const trace of traces) {
|
|
286
314
|
for (const capture of trace.captures) {
|
|
287
315
|
assert.equal(capture.options.windowsHide, true, `${trace.label}: ${capture.kind} ${capture.command} must hide Windows consoles`);
|
|
@@ -297,7 +325,7 @@ test("external editor remains the explicit interactive exemption", async () => {
|
|
|
297
325
|
received = options;
|
|
298
326
|
return { status: 0 };
|
|
299
327
|
}) as never, process.cwd()), true);
|
|
300
|
-
assert.equal(received?.shell,
|
|
328
|
+
assert.equal(received?.shell, false, "interactive editors must not interpret paths through a shell");
|
|
301
329
|
assert.equal(received?.stdio, "inherit");
|
|
302
330
|
assert.equal(received?.windowsHide, undefined, "the interactive editor intentionally remains unhidden");
|
|
303
331
|
});
|
|
@@ -54,10 +54,13 @@ function dispatchWriter(input: Record<string, unknown>) {
|
|
|
54
54
|
assert.equal(typeof toolCall, "function");
|
|
55
55
|
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-writer-surfaces-"));
|
|
56
56
|
scratchRoots.push(cwd);
|
|
57
|
+
// S16: these tests cover the surface rule; a measured context at the backstop admits the launch so the
|
|
58
|
+
// feature-document admission (tests/writer-feature-document-admission.test.ts) does not decide here.
|
|
57
59
|
return toolCall!({ toolName: "subagent_run", input }, {
|
|
58
60
|
cwd,
|
|
59
61
|
hasUI: false,
|
|
60
62
|
ui: { confirm: async () => true },
|
|
63
|
+
getContextUsage: () => ({ tokens: 150_000, contextWindow: 272_000, percent: 55 }),
|
|
61
64
|
} as unknown as ExtensionContext);
|
|
62
65
|
}
|
|
63
66
|
|