gentle-pi 4.3.0 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { EventEmitter } from "node:events";
|
|
3
4
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { createServer, type Server } from "node:net";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
7
9
|
import { test } from "node:test";
|
|
8
10
|
import { NativePulsePlayer, NATIVE_PULSE_SCHEMA, type NativePulseOptions, type NativePulseSpawnOptions } from "../lib/notification-audio-native.ts";
|
|
9
11
|
|
|
@@ -75,9 +77,12 @@ async function startFakeServer(): Promise<FakeServer> {
|
|
|
75
77
|
class FakeChild extends EventEmitter {
|
|
76
78
|
readonly stdout = new EventEmitter();
|
|
77
79
|
readonly stderr = new EventEmitter();
|
|
80
|
+
readonly stdin = { writes: [] as string[], write(chunk: string): boolean { this.writes.push(chunk); return true; } };
|
|
78
81
|
readonly kills: string[] = [];
|
|
79
82
|
kill(signal: string): boolean { this.kills.push(signal); return true; }
|
|
80
83
|
}
|
|
84
|
+
const okPlay = (): string => `${JSON.stringify({ schema: NATIVE_PULSE_SCHEMA, ok: true, played: true })}\n`;
|
|
85
|
+
const failPlay = (): string => `${JSON.stringify({ schema: NATIVE_PULSE_SCHEMA, ok: false })}\n`;
|
|
81
86
|
function recorder(platform = "linux", overrides: Partial<NativePulseOptions> = {}) {
|
|
82
87
|
const spawned: Array<{ executable: string; args: string[]; options: NativePulseSpawnOptions }> = [];
|
|
83
88
|
const children: FakeChild[] = [];
|
|
@@ -135,11 +140,77 @@ test("play gate false prevents spawning and no async runs after it", async () =>
|
|
|
135
140
|
const { player: second, spawned: secondSpawned, children } = recorder();
|
|
136
141
|
const playing = second.play("/tmp/s.wav", undefined, { permit: () => true });
|
|
137
142
|
assert.equal(secondSpawned.length, 1);
|
|
138
|
-
children[0]!.stdout.emit("data",
|
|
139
|
-
children[0]!.emit("close");
|
|
143
|
+
children[0]!.stdout.emit("data", okPlay());
|
|
140
144
|
await playing;
|
|
141
145
|
});
|
|
142
146
|
|
|
147
|
+
/** N plays must reuse ONE persistent `serve` worker: spawn once, queue, answer one envelope per play. */
|
|
148
|
+
test("persistent serve worker: N plays spawn exactly one worker and each play succeeds", async () => {
|
|
149
|
+
const { player, spawned, children } = recorder();
|
|
150
|
+
const paths = ["/tmp/a.wav", "/tmp/b.wav", "/tmp/c.wav"];
|
|
151
|
+
const plays = paths.map(path => player.play(path));
|
|
152
|
+
assert.equal(spawned.length, 1, "N plays must spawn exactly one serve worker");
|
|
153
|
+
assert.equal(spawned[0]!.args[3], "serve");
|
|
154
|
+
assert.deepEqual(spawned[0]!.options.stdio, ["pipe", "pipe", "pipe"]);
|
|
155
|
+
assert.equal(children[0]!.stdin.writes.length, 1, "queued plays must not write until the active play settles");
|
|
156
|
+
for (const write of children[0]!.stdin.writes) {
|
|
157
|
+
const request = JSON.parse(write) as { schema: string; op: string; path: string };
|
|
158
|
+
assert.equal(request.schema, NATIVE_PULSE_SCHEMA); assert.equal(request.op, "play"); assert.equal(request.path, paths[0]);
|
|
159
|
+
}
|
|
160
|
+
for (let i = 0; i < plays.length; i++) children[0]!.stdout.emit("data", okPlay());
|
|
161
|
+
await Promise.all(plays);
|
|
162
|
+
assert.equal(spawned.length, 1);
|
|
163
|
+
assert.equal(children[0]!.stdin.writes.length, 3);
|
|
164
|
+
assert.deepEqual(children[0]!.stdin.writes.map(write => (JSON.parse(write) as { path: string }).path), paths);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a crashed serve worker is replaced by a fresh spawn on the next play", async () => {
|
|
168
|
+
const { player, spawned, children } = recorder();
|
|
169
|
+
const first = player.play("/tmp/a.wav");
|
|
170
|
+
children[0]!.stdout.emit("data", okPlay());
|
|
171
|
+
await first;
|
|
172
|
+
children[0]!.emit("close"); // crash while idle
|
|
173
|
+
const second = player.play("/tmp/b.wav");
|
|
174
|
+
assert.equal(spawned.length, 2, "the next play must spawn a fresh worker after a crash");
|
|
175
|
+
assert.equal(spawned[1]!.args[3], "serve");
|
|
176
|
+
children[1]!.stdout.emit("data", okPlay());
|
|
177
|
+
await second;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("a crash with a play in flight fails that play privately and the next play spawns fresh", async () => {
|
|
181
|
+
const { player, spawned, children } = recorder();
|
|
182
|
+
const failing = player.play("/tmp/a.wav");
|
|
183
|
+
children[0]!.emit("close");
|
|
184
|
+
await assert.rejects(failing, /Native pulse/);
|
|
185
|
+
const next = player.play("/tmp/b.wav");
|
|
186
|
+
assert.equal(spawned.length, 2);
|
|
187
|
+
children[1]!.stdout.emit("data", okPlay());
|
|
188
|
+
await next;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("dispose kills the persistent worker, fails pending plays and the next play spawns fresh", async () => {
|
|
192
|
+
const { player, spawned, children } = recorder();
|
|
193
|
+
const playing = player.play("/tmp/a.wav");
|
|
194
|
+
player.dispose();
|
|
195
|
+
assert.deepEqual(children[0]!.kills, ["SIGKILL"]);
|
|
196
|
+
await assert.rejects(playing, /Native pulse/);
|
|
197
|
+
const next = player.play("/tmp/b.wav");
|
|
198
|
+
assert.equal(spawned.length, 2);
|
|
199
|
+
children[1]!.stdout.emit("data", okPlay());
|
|
200
|
+
await next;
|
|
201
|
+
player.dispose(); // a second dispose is a no-op
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("pending plays are bounded fail-closed and never grow without bound", async () => {
|
|
205
|
+
const { player, spawned, children } = recorder();
|
|
206
|
+
const plays = Array.from({ length: 10 }, (_, i) => player.play(`/tmp/${i}.wav`).catch(error => error));
|
|
207
|
+
assert.equal(spawned.length, 1);
|
|
208
|
+
assert.match(String(await plays[9]), /too many pending/);
|
|
209
|
+
for (let i = 0; i < 9; i++) children[0]!.stdout.emit("data", okPlay()); // settle active + queued
|
|
210
|
+
const settled = await Promise.all(plays);
|
|
211
|
+
assert.equal(settled.filter(value => value instanceof Error).length, 1);
|
|
212
|
+
});
|
|
213
|
+
|
|
143
214
|
test("malformed, oversize and error output fail privately and kill at most once", async () => {
|
|
144
215
|
const malformed = recorder();
|
|
145
216
|
const malformedProbe = malformed.player.probe();
|
|
@@ -158,9 +229,11 @@ test("malformed, oversize and error output fail privately and kill at most once"
|
|
|
158
229
|
|
|
159
230
|
const failure = recorder();
|
|
160
231
|
const playing = failure.player.play("/private/snapshot.wav");
|
|
161
|
-
failure.children[0]!.stdout.emit("data",
|
|
162
|
-
|
|
232
|
+
failure.children[0]!.stdout.emit("data", failPlay());
|
|
233
|
+
// A private ok:false leaves the healthy worker serviceable; no kill, no restart needed.
|
|
163
234
|
await assert.rejects(playing, (error: Error) => /Native pulse/.test(error.message) && !/snapshot|cookie|\/private/.test(error.message));
|
|
235
|
+
assert.equal(failure.children[0]!.kills.length, 0);
|
|
236
|
+
assert.equal(failure.spawned.length, 1);
|
|
164
237
|
});
|
|
165
238
|
|
|
166
239
|
test("timeout and abort SIGKILL once and settle only on close", async () => {
|
|
@@ -181,6 +254,11 @@ test("timeout and abort SIGKILL once and settle only on close", async () => {
|
|
|
181
254
|
const playing = aborted.player.play("/tmp/s.wav", controller.signal);
|
|
182
255
|
controller.abort();
|
|
183
256
|
assert.deepEqual(aborted.children[0]!.kills, ["SIGKILL"]);
|
|
257
|
+
let abortSettled = false; void playing.then(() => { abortSettled = true; }, () => { abortSettled = true; });
|
|
258
|
+
aborted.children[0]!.emit("exit", null);
|
|
259
|
+
aborted.children[0]!.emit("error", new Error("kill race")); // Node emits close after error
|
|
260
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
261
|
+
assert.equal(abortSettled, false);
|
|
184
262
|
aborted.children[0]!.emit("close");
|
|
185
263
|
await assert.rejects(playing, /aborted|Native pulse/);
|
|
186
264
|
});
|
|
@@ -210,7 +288,8 @@ test("probe never throws for offline/malformed IPC while play rejects and cleans
|
|
|
210
288
|
assert.deepEqual(await probing, { available: false, formats: [] });
|
|
211
289
|
const { player: playPlayer, children: playChildren } = recorder();
|
|
212
290
|
const playing = playPlayer.play("/tmp/s.wav");
|
|
213
|
-
playChildren[0]!.stdout.emit("data", JSON.stringify({ schema: "wrong", ok: true, played: true }));
|
|
291
|
+
playChildren[0]!.stdout.emit("data", `${JSON.stringify({ schema: "wrong", ok: true, played: true })}\n`);
|
|
292
|
+
assert.deepEqual(playChildren[0]!.kills, ["SIGKILL"], "a malformed serve line is a protocol violation: kill once");
|
|
214
293
|
playChildren[0]!.emit("close");
|
|
215
294
|
await assert.rejects(playing, /Native pulse/);
|
|
216
295
|
});
|
|
@@ -259,13 +338,13 @@ test("real worker plays a fake PCM snapshot to a fake socket with no OS audio",
|
|
|
259
338
|
const snapshot = join(dir, "sound.wav");
|
|
260
339
|
writeFileSync(snapshot, bytes, { mode: 0o400 });
|
|
261
340
|
const server = await startFakeServer();
|
|
341
|
+
const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 });
|
|
262
342
|
try {
|
|
263
|
-
const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 });
|
|
264
343
|
await player.play(snapshot);
|
|
265
344
|
assert.deepEqual(Buffer.concat(server.pcm), dataOf(bytes));
|
|
266
345
|
assert.ok(server.seen.includes(3) && server.seen.includes(12) && server.seen.includes(4));
|
|
267
346
|
assert.ok(existsSync(snapshot), "the bridge/worker must not delete the snapshot; the owner does");
|
|
268
|
-
} finally { await server.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
347
|
+
} finally { player.dispose(); await server.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
269
348
|
});
|
|
270
349
|
|
|
271
350
|
test("real worker rejects invalid WAV before opening the socket", async () => {
|
|
@@ -273,9 +352,49 @@ test("real worker rejects invalid WAV before opening the socket", async () => {
|
|
|
273
352
|
const snapshot = join(dir, "sound.wav");
|
|
274
353
|
writeFileSync(snapshot, Buffer.alloc(64, 0x00), { mode: 0o400 });
|
|
275
354
|
const server = await startFakeServer();
|
|
355
|
+
const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 });
|
|
276
356
|
try {
|
|
277
|
-
const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 });
|
|
278
357
|
await assert.rejects(player.play(snapshot), /Native pulse/);
|
|
279
358
|
assert.deepEqual(server.seen, []);
|
|
280
|
-
} finally { await server.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
359
|
+
} finally { player.dispose(); await server.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
/** Direct serve-protocol check: bounded fail-closed requests, private per-play failures, stdin EOF teardown. */
|
|
363
|
+
test("real serve worker fails closed on protocol violations and exits on stdin EOF", async () => {
|
|
364
|
+
const workerPath = fileURLToPath(new URL("../lib/notification-pulse-worker.ts", import.meta.url));
|
|
365
|
+
const serveEnv = { ...process.env, NODE_NO_WARNINGS: "1", GENTLE_PI_AGENTS_CHILD: "1" };
|
|
366
|
+
const spawnServe = () => spawn(process.execPath, ["--experimental-strip-types", workerPath, "serve"],
|
|
367
|
+
{ stdio: ["pipe", "pipe", "pipe"], env: serveEnv });
|
|
368
|
+
const collect = (child: ReturnType<typeof spawn>): string[] => {
|
|
369
|
+
const lines: string[] = []; let text = "";
|
|
370
|
+
child.stdout!.setEncoding("utf8");
|
|
371
|
+
child.stdout!.on("data", (chunk: string) => {
|
|
372
|
+
text += chunk;
|
|
373
|
+
for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n")) { lines.push(text.slice(0, index)); text = text.slice(index + 1); }
|
|
374
|
+
});
|
|
375
|
+
return lines;
|
|
376
|
+
};
|
|
377
|
+
const exited = (child: ReturnType<typeof spawn>): Promise<number | null> => new Promise(resolve => {
|
|
378
|
+
if (child.exitCode !== null || child.signalCode !== null) { resolve(child.exitCode); return; }
|
|
379
|
+
child.once("exit", code => resolve(code));
|
|
380
|
+
});
|
|
381
|
+
// (a) A malformed line is a protocol violation: one generic envelope, then a crash the owner restarts.
|
|
382
|
+
const crashing = spawnServe();
|
|
383
|
+
const crashingLines = collect(crashing);
|
|
384
|
+
crashing.stdin!.write("not json\n");
|
|
385
|
+
assert.equal(await exited(crashing), 1);
|
|
386
|
+
assert.deepEqual(await crashingLines, [JSON.stringify({ schema: "gentle.audio.pulse/v1", ok: false })]);
|
|
387
|
+
// (b) A schema-valid request for a missing snapshot fails privately and the worker keeps serving.
|
|
388
|
+
const serving = spawnServe();
|
|
389
|
+
const servingLines = collect(serving);
|
|
390
|
+
for (const path of ["/nonexistent/gentle-a.wav", "/nonexistent/gentle-b.wav"])
|
|
391
|
+
serving.stdin!.write(`${JSON.stringify({ schema: "gentle.audio.pulse/v1", op: "play", path })}\n`);
|
|
392
|
+
await new Promise(resolve => setTimeout(resolve, 1200));
|
|
393
|
+
assert.deepEqual(servingLines, [
|
|
394
|
+
JSON.stringify({ schema: "gentle.audio.pulse/v1", ok: false }),
|
|
395
|
+
JSON.stringify({ schema: "gentle.audio.pulse/v1", ok: false }),
|
|
396
|
+
]);
|
|
397
|
+
// (c) stdin EOF (owner death) is a clean exit.
|
|
398
|
+
serving.stdin!.end();
|
|
399
|
+
assert.equal(await exited(serving), 0);
|
|
281
400
|
});
|
|
@@ -10,6 +10,15 @@ import { basename } from "node:path";
|
|
|
10
10
|
|
|
11
11
|
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
12
12
|
|
|
13
|
+
/** UI chains settle across a load-dependent number of turns: wait on the
|
|
14
|
+
* observable state instead of assuming a fixed tick count landed the async
|
|
15
|
+
* step (gentle-shell#2498). The deadline keeps a broken chain failing fast. */
|
|
16
|
+
const settleUntil = async (predicate: () => boolean, what: string): Promise<void> => {
|
|
17
|
+
const deadline = Date.now() + 5000;
|
|
18
|
+
while (!predicate() && Date.now() < deadline) await tick();
|
|
19
|
+
assert.ok(predicate(), `timed out waiting for ${what}`);
|
|
20
|
+
};
|
|
21
|
+
|
|
13
22
|
/** Real packaged WAVs so a valid path uses an existing fixture, never a synthetic name. */
|
|
14
23
|
const WAV_FIXTURE = fileURLToPath(new URL("../assets/sounds/success.wav", import.meta.url));
|
|
15
24
|
const WAV_FIXTURES = {
|
|
@@ -223,7 +232,7 @@ test("an in-flight local sound validation keeps busy, blocking a second field an
|
|
|
223
232
|
assert.match(view.render(160).join("\n"), /Local audio path/);
|
|
224
233
|
view.handleInput(WAV_FIXTURE);
|
|
225
234
|
view.handleInput("\r");
|
|
226
|
-
await
|
|
235
|
+
await settleUntil(() => validated.length === 1, "the submitted value to start validating");
|
|
227
236
|
assert.deepEqual(validated, [`file:${WAV_FIXTURE}`], "the submitted value is validated exactly once");
|
|
228
237
|
// Validation is still pending: `f` must not open a second field and Enter must not write.
|
|
229
238
|
view.handleInput("f");
|
|
@@ -233,11 +242,11 @@ test("an in-flight local sound validation keeps busy, blocking a second field an
|
|
|
233
242
|
assert.equal(h.writes, 0, "no write before the deferred validation resolves");
|
|
234
243
|
// Resolve: a single write, then busy releases for the next edit.
|
|
235
244
|
resolveValidation(true);
|
|
236
|
-
await
|
|
245
|
+
await settleUntil(() => h.writes === 1, "the resolved validation to write exactly once");
|
|
237
246
|
assert.equal(h.writes, 1, "the resolved validation writes exactly once");
|
|
238
247
|
assert.equal(getNotificationService()!.getState().settings.audio.events["agent.failed"], `file:${WAV_FIXTURE}`);
|
|
239
248
|
view.handleInput("f");
|
|
240
|
-
|
|
249
|
+
await settleUntil(() => view.render(160).join("\n").includes("Local audio path"), "busy released so the next edit opens a fresh field");
|
|
241
250
|
} finally { h.owner.retire(); }
|
|
242
251
|
});
|
|
243
252
|
|
|
@@ -262,14 +271,17 @@ test("disposing the card while validation is in flight never writes the stale ch
|
|
|
262
271
|
const h = harness(); try {
|
|
263
272
|
const rows = h.rows();
|
|
264
273
|
let resolveValidation!: (value: boolean) => void;
|
|
265
|
-
|
|
274
|
+
let validationStarted = false;
|
|
275
|
+
getNotificationService()!.validateFile = () => { validationStarted = true; return new Promise<boolean>(resolve => { resolveValidation = resolve; }); };
|
|
266
276
|
const view = customizeView(rows);
|
|
267
277
|
await expandAndSelect(view, "agent.failed:");
|
|
268
278
|
view.handleInput("f");
|
|
269
279
|
view.render(160);
|
|
270
280
|
view.handleInput(WAV_FIXTURE);
|
|
271
281
|
view.handleInput("\r");
|
|
272
|
-
|
|
282
|
+
// Disposing before the validation even starts would never observe the
|
|
283
|
+
// stale-choice guard, so wait until the deferred validation is pending.
|
|
284
|
+
await settleUntil(() => validationStarted, "the in-flight validation to start");
|
|
273
285
|
view.dispose();
|
|
274
286
|
resolveValidation(true);
|
|
275
287
|
await tick(); await tick();
|
|
@@ -149,7 +149,8 @@ const ANCHORS: readonly RoutingAnchor[] = [
|
|
|
149
149
|
canonical: "run a differential check",
|
|
150
150
|
mirrors: [
|
|
151
151
|
{ surface: DELEGATION, includes: "**Differential check (PRESERVE):**" },
|
|
152
|
-
|
|
152
|
+
// weak-model-economy S2: the harness gates the check on shared code an unnamed command uses.
|
|
153
|
+
{ surface: EXTENSION, includes: "run one differential check" },
|
|
153
154
|
],
|
|
154
155
|
},
|
|
155
156
|
{
|
|
@@ -33,7 +33,10 @@ const FIXTURE_PATH = join(import.meta.dirname, "fixtures", "orchestrator.pre-die
|
|
|
33
33
|
// gentle-shell#1731 T10 (user decision): 8,192 -> 8,400 B for the narrowed high-risk items 1 and 3.
|
|
34
34
|
// Verification by named risk (escalate-only rule 3) and the writer-rule fix (triggers hold only when
|
|
35
35
|
// their condition holds; a unit is independent behavior over a size floor), user decision 2026-10-14: 8,400 -> 9,100 B.
|
|
36
|
-
|
|
36
|
+
// Weak-model economy (small path reads one batch with no todo; item 1 excludes rebuildable dev/test data), measured in
|
|
37
|
+
// gentle-shell-bench 1321c60 (DeepSeek -24-30% time on x2/x5, -70% on a dev-database rename; Sol unchanged), user decision
|
|
38
|
+
// 2026-10-22: 9,100 -> 9,250 B.
|
|
39
|
+
const BUDGET_BYTES = 9250;
|
|
37
40
|
const MIN_CONTROLLED_LONG_ASSETS_ROOT_CHARS = 93;
|
|
38
41
|
|
|
39
42
|
const LAZY_ASSET_NAMES = [
|
|
@@ -126,7 +129,7 @@ function measureOrchestratorPromptBytes(assetsDir: string): number {
|
|
|
126
129
|
// measuring a shorter render again.
|
|
127
130
|
const RDD_WORST_CASE_LINE = "Receipt-driven development: unknown (native status unavailable)";
|
|
128
131
|
|
|
129
|
-
test("getOrchestratorPrompt return value stays within the canonical 9,
|
|
132
|
+
test("getOrchestratorPrompt return value stays within the canonical 9,250 B budget at a short assets root", () => {
|
|
130
133
|
const rendered = __testing.renderOrchestratorPrompt(representativeProductionAssetsDir);
|
|
131
134
|
assert.ok(
|
|
132
135
|
rendered.includes(RDD_WORST_CASE_LINE),
|
|
@@ -2,8 +2,10 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { fork } from "node:child_process";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
|
+
import fsSync from "node:fs";
|
|
6
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
5
7
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
9
|
import test from "node:test";
|
|
8
10
|
import { historyDir, saveTask } from "../lib/agents-history.ts";
|
|
9
11
|
import { TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
@@ -20,7 +22,7 @@ function fixture(t: test.TestContext) {
|
|
|
20
22
|
if (fs.existsSync(root) && !fs.lstatSync(root).isSymbolicLink()) {
|
|
21
23
|
for (const name of fs.readdirSync(root)) {
|
|
22
24
|
const path = join(root, name);
|
|
23
|
-
if (["tasks", "presence"].includes(name) && fs.lstatSync(path).isDirectory()) {
|
|
25
|
+
if (["tasks", "presence", "catalog"].includes(name) && fs.lstatSync(path).isDirectory()) {
|
|
24
26
|
for (const file of fs.readdirSync(path)) fs.unlinkSync(join(path, file));
|
|
25
27
|
fs.rmdirSync(path);
|
|
26
28
|
} else fs.unlinkSync(path);
|
|
@@ -56,6 +58,102 @@ test("heartbeat refreshes a bounded canonical label and stops an invalid source"
|
|
|
56
58
|
assert.deepEqual(listPresence(profile).entries, []);
|
|
57
59
|
});
|
|
58
60
|
|
|
61
|
+
test("warm-cache flush performs no synchronous ancestor validation", (t) => {
|
|
62
|
+
t.mock.timers.enable({ apis: ["Date", "setInterval", "setTimeout"], now: 100_000 });
|
|
63
|
+
const profile = fixture(t);
|
|
64
|
+
const pub = publisher(t, profile);
|
|
65
|
+
const absolute = resolve(profile);
|
|
66
|
+
const ancestors = new Set<string>();
|
|
67
|
+
for (let path = absolute;; path = dirname(path)) {
|
|
68
|
+
ancestors.add(path);
|
|
69
|
+
if (dirname(path) === path) break;
|
|
70
|
+
}
|
|
71
|
+
ancestors.add(join(absolute, "gentle-agents"));
|
|
72
|
+
ancestors.add(join(absolute, "gentle-agents", "presence"));
|
|
73
|
+
const probes: string[] = [];
|
|
74
|
+
const originalLstat = fsSync.lstatSync;
|
|
75
|
+
const spy = t.mock.method(fsSync, "lstatSync", (path: Parameters<typeof fsSync.lstatSync>[0], ...args: unknown[]) => {
|
|
76
|
+
if (typeof path === "string" && ancestors.has(path)) probes.push(path);
|
|
77
|
+
return (originalLstat as (...args: unknown[]) => fs.Stats)(path, ...args);
|
|
78
|
+
});
|
|
79
|
+
syncBuiltinESMExports();
|
|
80
|
+
t.after(() => { spy.mock.restore(); syncBuiltinESMExports(); });
|
|
81
|
+
pub.update(rows("warm cache"));
|
|
82
|
+
t.mock.timers.tick(400);
|
|
83
|
+
assert.deepEqual(probes, [], "writes must consume the validated cache, not re-probe ancestors");
|
|
84
|
+
assert.equal(listPresence(profile).entries[0].generation, 2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a vanished storage tree skips publication without crashing and revalidates asynchronously", async (t) => {
|
|
88
|
+
const profile = fixture(t);
|
|
89
|
+
let heartbeat: (() => void) | undefined;
|
|
90
|
+
const interval = globalThis.setInterval;
|
|
91
|
+
t.mock.method(globalThis, "setInterval", (callback: () => void, ms: number) => {
|
|
92
|
+
if (ms === 5000) heartbeat = callback;
|
|
93
|
+
return interval(callback, ms);
|
|
94
|
+
});
|
|
95
|
+
const pub = publisher(t, profile);
|
|
96
|
+
const root = join(profile, "gentle-agents", "presence");
|
|
97
|
+
for (const name of fs.readdirSync(root)) fs.unlinkSync(join(root, name));
|
|
98
|
+
fs.rmdirSync(root);
|
|
99
|
+
heartbeat!();
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
101
|
+
assert.equal(pub.error, undefined, "a vanished tree skips writes instead of failing the publisher");
|
|
102
|
+
assert.deepEqual(listPresence(profile).entries, []);
|
|
103
|
+
fs.mkdirSync(root, { mode: 0o700 });
|
|
104
|
+
heartbeat!(); // cache is still cold: skip this tick while the heartbeat revalidates
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
106
|
+
heartbeat!(); // warm cache again: publication resumes
|
|
107
|
+
assert.equal(listPresence(profile).entries.length, 1);
|
|
108
|
+
assert.equal(pub.error, undefined);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("heartbeat path validations never overlap", async (t) => {
|
|
112
|
+
const profile = fixture(t);
|
|
113
|
+
let heartbeat: (() => void) | undefined;
|
|
114
|
+
const interval = globalThis.setInterval;
|
|
115
|
+
t.mock.method(globalThis, "setInterval", (callback: () => void, ms: number) => {
|
|
116
|
+
if (ms === 5000) heartbeat = callback;
|
|
117
|
+
return interval(callback, ms);
|
|
118
|
+
});
|
|
119
|
+
const pub = publisher(t, profile);
|
|
120
|
+
let chains = 0;
|
|
121
|
+
let release!: () => void;
|
|
122
|
+
const gate = new Promise<void>((done) => { release = done; });
|
|
123
|
+
const original = fs.promises.lstat;
|
|
124
|
+
const spy = t.mock.method(fs.promises, "lstat", async (path: Parameters<typeof fs.promises.lstat>[0], ...args: unknown[]) => {
|
|
125
|
+
if (String(path) === resolve(profile)) { chains++; await gate; }
|
|
126
|
+
return (original as (...args: unknown[]) => Promise<fs.Stats>).apply(fs.promises, [path, ...args]);
|
|
127
|
+
});
|
|
128
|
+
t.after(() => spy.mock.restore());
|
|
129
|
+
heartbeat!();
|
|
130
|
+
heartbeat!(); // while the first validation waits, this tick must not start another
|
|
131
|
+
release();
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
133
|
+
assert.equal(chains, 1, "overlapping heartbeat validations must be skipped");
|
|
134
|
+
heartbeat!();
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
136
|
+
assert.equal(chains, 2, "the next heartbeat validates again");
|
|
137
|
+
assert.equal(pub.error, undefined);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("first publication lands immediately after start() with no heartbeat wait", (t) => {
|
|
141
|
+
const profile = fixture(t);
|
|
142
|
+
const pub = publisher(t, profile);
|
|
143
|
+
// The first updateDiscovery lands in the same tick: no heartbeat may be
|
|
144
|
+
// required for the first header, discovery, or catalog publication.
|
|
145
|
+
pub.updateDiscovery({ sessionId: "same session", endpoint: "/fixture/first.sock", createdAt: 1 },
|
|
146
|
+
{ workspace: "/repo", tasks: [{ id: "child", label: "Check auth", status: "waiting", cwd: "/repo-child" }] });
|
|
147
|
+
const header = listPresence(profile).entries[0];
|
|
148
|
+
assert.equal(header.label, "Workspace");
|
|
149
|
+
assert.equal(presence.readDiscovery(profile, header)?.workspace, "/repo");
|
|
150
|
+
assert.equal(presence.readDiscovery(profile, header)?.tasks[0].id, "child");
|
|
151
|
+
const catalog = JSON.parse(fs.readFileSync(
|
|
152
|
+
join(profile, "gentle-agents", "catalog", `${header.sessionHash}.${header.incarnation}.json`), "utf8"));
|
|
153
|
+
assert.equal(catalog.schema, 1);
|
|
154
|
+
assert.deepEqual(catalog.catalog.tasks.map((row: { id: string }) => row.id), ["child"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
59
157
|
function rows(text = "full retained output") {
|
|
60
158
|
return [{ task: { id: "t", agent: "worker", label: "summary", status: "running", model: "model",
|
|
61
159
|
createdAt: 1000, startedAt: 1100, endedAt: null, lastActivityAt: 1200,
|
|
@@ -66,13 +66,13 @@ test("rendered parent prompt keeps the RDD boundary while omitting lifecycle mir
|
|
|
66
66
|
// the three renderable forms -- so this IS the worst-case render the core
|
|
67
67
|
// budget below must cover, not a smaller placeholder production later
|
|
68
68
|
// exceeds. gentle-shell#1731 T10 (user decision): budget 8,192 -> 8,400 B.
|
|
69
|
-
// Verification by named risk and the writer-rule fix: 8,400 -> 9,100 B
|
|
69
|
+
// Verification by named risk and the writer-rule fix: 8,400 -> 9,100 B; weak-model economy: 9,100 -> 9,250 B
|
|
70
70
|
// (the canonical budget in orchestrator-budget.test.ts).
|
|
71
71
|
assert.ok(
|
|
72
72
|
rendered.includes("Receipt-driven development: unknown (native status unavailable)"),
|
|
73
73
|
"the default render must include the worst-case RDD status line",
|
|
74
74
|
);
|
|
75
|
-
assert.ok(Buffer.byteLength(rendered, "utf8") <=
|
|
75
|
+
assert.ok(Buffer.byteLength(rendered, "utf8") <= 9250, "the rendered parent prompt must stay within the 9,250 B core budget");
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
test("static prompts retain ODD and delegated-work guidance without SDD", () => {
|
|
@@ -8,6 +8,20 @@ import { OrchestratorScopeCache, validRecordedScope } from "../lib/orchestrator-
|
|
|
8
8
|
import { PresencePublisher, listPresence, readDiscovery } from "../lib/orchestrator-presence.ts";
|
|
9
9
|
import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
|
|
10
10
|
|
|
11
|
+
test("cachedScope serves deferred publications from the snapshot without probing", () => {
|
|
12
|
+
let probes = 0;
|
|
13
|
+
const cache = new OrchestratorScopeCache(() => { probes++; return { root: "/repo", commonDir: "/clone" }; });
|
|
14
|
+
assert.equal(cache.cachedScope(), undefined, "nothing is cached before a projection");
|
|
15
|
+
const snapshot = cache.project("/repo", [], []);
|
|
16
|
+
assert.equal(probes, 1, "the event-time projection resolves eagerly");
|
|
17
|
+
assert.equal(cache.cachedScope(), snapshot);
|
|
18
|
+
assert.equal(cache.cachedScope(), snapshot, "repeated deferred reads never probe");
|
|
19
|
+
assert.equal(probes, 1);
|
|
20
|
+
cache.clear();
|
|
21
|
+
assert.equal(cache.cachedScope(), undefined, "a cleared cache publishes no stale scope");
|
|
22
|
+
assert.equal(probes, 1);
|
|
23
|
+
});
|
|
24
|
+
|
|
11
25
|
test("recorded host/owned child/registered scope correlates clones, isolates ambient Git and caches lifecycle", (t) => {
|
|
12
26
|
const base = realpathSync(mkdtempSync(join(tmpdir(), "orchestrator-scope-")));
|
|
13
27
|
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
@@ -74,6 +74,35 @@ test("alias identity preserves status age, validates data, and rejects stale bra
|
|
|
74
74
|
assert.deepEqual(cache.get(manager)?.aliases, valid.aliases);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
test("explicit work topic refreshes the current alias when no subject is declared (#2553)", () => {
|
|
78
|
+
const cache = new OrchestratorStateCache();
|
|
79
|
+
const entries: { type: string; customType: string; data: unknown }[] = [];
|
|
80
|
+
const manager = { getSessionId: () => "owner", getCwd: () => "/repo", getBranch: () => entries };
|
|
81
|
+
const append = (customType: string, data: unknown) => entries.push({ type: "custom", customType, data });
|
|
82
|
+
cache.load(manager);
|
|
83
|
+
const work = { area: "gentle-shell", topic: "pr-2509-followup" };
|
|
84
|
+
cache.publish(manager, { work }, append, 10);
|
|
85
|
+
assert.deepEqual(cache.get(manager)?.aliases, { initialAlias: "pr-2509-followup", currentAlias: "pr-2509-followup" });
|
|
86
|
+
// Fresh topic on a later publication updates Current; Initial is retained.
|
|
87
|
+
cache.publish(manager, { work: { area: "gentle-shell", topic: "ci-watch" } }, append, 20);
|
|
88
|
+
assert.deepEqual(cache.get(manager)?.aliases, { initialAlias: "pr-2509-followup", currentAlias: "ci-watch" });
|
|
89
|
+
// A state-only refresh never re-derives the alias from stored work.
|
|
90
|
+
cache.publish(manager, undefined, append, 30);
|
|
91
|
+
assert.deepEqual(cache.get(manager)?.aliases, { initialAlias: "pr-2509-followup", currentAlias: "ci-watch" });
|
|
92
|
+
// An explicit subject in the same publication wins over the topic.
|
|
93
|
+
cache.publish(manager, { work }, append, 40, "declared-task");
|
|
94
|
+
assert.deepEqual(cache.get(manager)?.aliases, { initialAlias: "pr-2509-followup", currentAlias: "declared-task" });
|
|
95
|
+
// Legacy records without aliases: topic refreshes Current, Initial stays unknown.
|
|
96
|
+
const legacy = [{ type: "custom", customType: ORCHESTRATOR_STATE_ENTRY, data: {
|
|
97
|
+
schema: 1, sessionId: "owner", recordedAt: 1, cwd: "/repo", source: "owner-curated",
|
|
98
|
+
ownerReply: false, authority: "none", state: null } }];
|
|
99
|
+
const legacyManager = { getSessionId: () => "owner", getCwd: () => "/repo", getBranch: () => legacy };
|
|
100
|
+
const legacyCache = new OrchestratorStateCache();
|
|
101
|
+
legacyCache.load(legacyManager);
|
|
102
|
+
legacyCache.publish(legacyManager, { work }, append, 50);
|
|
103
|
+
assert.deepEqual(legacyCache.get(legacyManager)?.aliases, { initialAlias: null, currentAlias: "pr-2509-followup" });
|
|
104
|
+
});
|
|
105
|
+
|
|
77
106
|
test("recorded cwd preserves only unambiguous native absolute paths", () => {
|
|
78
107
|
const separators = [0x00a0, ...Array.from({ length: 11 }, (_, i) => 0x2000 + i), 0x202f, 0x205f, 0x3000];
|
|
79
108
|
const invalid = ["relative/repo", "/repo\nother", "/repo\ud800", "/" + "x".repeat(1024),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { missingRequiredPaths, requiredPaths } from "../scripts/verify-package-files.mjs";
|
|
5
|
+
|
|
6
|
+
// The voice worker is spawned by path and never imported, so only the package
|
|
7
|
+
// check keeps it in the published tarball next to the modules that launch it.
|
|
8
|
+
|
|
9
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
10
|
+
|
|
11
|
+
test("the package check requires the voice command, its modules and the worker script", () => {
|
|
12
|
+
const voice = ["extensions/gentle-voice.ts", "lib/voice-command.ts", "lib/voice-session.ts", "lib/voice-ui.ts", "scripts/voice-worker.cjs", "docs/voice.md"];
|
|
13
|
+
for (const path of voice) assert.ok(requiredPaths.includes(path), `${path} is required`);
|
|
14
|
+
assert.deepEqual(missingRequiredPaths(root, voice), []);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// The canvas server reads its page and scripts from assets/ by path, never by import.
|
|
18
|
+
const designCanvas = [
|
|
19
|
+
"lib/design-canvas-server.ts",
|
|
20
|
+
"lib/design-open-browser.ts",
|
|
21
|
+
"lib/design-comments.ts",
|
|
22
|
+
"lib/design-handoffs.ts",
|
|
23
|
+
"assets/design-canvas/index.html",
|
|
24
|
+
"assets/design-canvas/canvas.js",
|
|
25
|
+
"assets/design-canvas/canvas.css",
|
|
26
|
+
"assets/design-canvas/inspector.js",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// The design command names its skill file by path when Pi has not loaded it.
|
|
30
|
+
test("the package check requires the design command, its helpers and its skill", () => {
|
|
31
|
+
const design = ["extensions/gentle-design.ts", "lib/gentle-design.ts", "lib/design-sources.ts", "skills/design/SKILL.md", ...designCanvas];
|
|
32
|
+
for (const path of design) assert.ok(requiredPaths.includes(path), `${path} is required`);
|
|
33
|
+
assert.deepEqual(missingRequiredPaths(root, design), []);
|
|
34
|
+
});
|
|
@@ -431,20 +431,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
|
|
|
431
431
|
);
|
|
432
432
|
});
|
|
433
433
|
|
|
434
|
-
test("package verification binds the published Gentle AI v4.
|
|
434
|
+
test("package verification binds the published Gentle AI v4.3.1 runtime pin", () => {
|
|
435
435
|
const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
|
|
436
436
|
const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
|
|
437
437
|
const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
|
|
438
438
|
|
|
439
|
-
assert.match(installer, /INSTALLER_VERSION = "4\.
|
|
439
|
+
assert.match(installer, /INSTALLER_VERSION = "4\.3\.1"/);
|
|
440
440
|
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
|
|
441
|
-
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:
|
|
441
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:00p8dnjJrhYFq1Qikk2oY6K\/UXIty9A\/IBUUOUiX9XA="/);
|
|
442
442
|
assert.match(installer, /GOTOOLCHAIN: "local"/);
|
|
443
443
|
assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
|
|
444
444
|
assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
|
|
445
445
|
assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
|
|
446
446
|
assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
|
|
447
|
-
assert.match(verifier, /v4\.
|
|
447
|
+
assert.match(verifier, /v4\.3\.1/);
|
|
448
448
|
});
|
|
449
449
|
|
|
450
450
|
|
|
@@ -1793,9 +1793,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
|
|
|
1793
1793
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1794
1794
|
});
|
|
1795
1795
|
|
|
1796
|
-
test("Gentle Shell v4.
|
|
1796
|
+
test("Gentle Shell v4.4.0 package manifest declares the release version", () => {
|
|
1797
1797
|
const packageJson = readPackageJson();
|
|
1798
|
-
assert.equal(packageJson.version, "4.
|
|
1798
|
+
assert.equal(packageJson.version, "4.4.0", "the release manifest must be explicitly pinned to v4.4.0");
|
|
1799
1799
|
assert.equal(packageJson.scripts?.test, "node scripts/run-test-suite.mjs");
|
|
1800
1800
|
assert.ok(packageJson.files?.includes("assets/"));
|
|
1801
1801
|
assert.ok(packageJson.files?.includes("contracts/"));
|
|
@@ -7,7 +7,7 @@ import { syncBuiltinESMExports } from "node:module";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { registerYoloSessionPolicy, discoverYoloUiAdapter } from "../lib/yolo-session-policy.ts";
|
|
10
|
-
import { parsePermissionModePolicyFile, resolvePermissionModePolicy, writePermissionModePolicy, permissionModePolicyFile, PERMISSION_MODE_SCHEMA } from "../lib/permission-mode-policy.ts";
|
|
10
|
+
import { isPermissionMode, parsePermissionModePolicyFile, resolvePermissionModePolicy, writePermissionModePolicy, permissionModePolicyFile, PERMISSION_MODE_SCHEMA } from "../lib/permission-mode-policy.ts";
|
|
11
11
|
|
|
12
12
|
function fixture(t: test.TestContext) {
|
|
13
13
|
const home = mkdtempSync(join(tmpdir(), "gentle-mode-"));
|
|
@@ -192,3 +192,12 @@ test("stale pending activation, scope loss and malformed preference remain close
|
|
|
192
192
|
assert.equal(await h.controller.saveDefault("yolo", h.context), false);
|
|
193
193
|
assert.equal(readFileSync(permissionModePolicyFile(options), "utf8"), "{");
|
|
194
194
|
});
|
|
195
|
+
|
|
196
|
+
test("super-yolo is a valid saved and parsed permission mode", t => {
|
|
197
|
+
const options = fixture(t);
|
|
198
|
+
assert.equal(isPermissionMode("super-yolo"), true);
|
|
199
|
+
assert.equal(isPermissionMode("superyolo"), false);
|
|
200
|
+
const path = writePermissionModePolicy("super-yolo", options);
|
|
201
|
+
assert.equal(parsePermissionModePolicyFile(readFileSync(path, "utf8")), "super-yolo");
|
|
202
|
+
assert.equal(resolvePermissionModePolicy(options).mode, "super-yolo");
|
|
203
|
+
});
|