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
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
parseFrontmatter,
|
|
15
15
|
parseModelRef,
|
|
16
16
|
resolveAgentProfile,
|
|
17
|
+
unmatchedProfileKeys,
|
|
17
18
|
withPinnedModelProfiles,
|
|
18
19
|
} from "../lib/agents-config.ts";
|
|
19
20
|
import { THINKING_LEVELS } from "../lib/model-routing-authority.ts";
|
|
@@ -247,3 +248,63 @@ test("a pinned profile replaces subagent routing and leaves every other default
|
|
|
247
248
|
// repository can pin "everything inherits" without touching the global store.
|
|
248
249
|
assert.deepEqual(withPinnedModelProfiles(global, {}).modelProfiles, {});
|
|
249
250
|
});
|
|
251
|
+
|
|
252
|
+
// Issue #2196 rework (PR #2233 maintainer decision): role keys that name no
|
|
253
|
+
// discovered agent must be reported, with a did-you-mean suggestion, instead of
|
|
254
|
+
// being silently ignored while the child inherits the orchestrator model.
|
|
255
|
+
test("unmatchedProfileKeys reports profile keys that match no discovered agent", () => {
|
|
256
|
+
const worker = {
|
|
257
|
+
name: "gentle-ai-worker",
|
|
258
|
+
description: "",
|
|
259
|
+
filePath: "/x/worker.md",
|
|
260
|
+
scope: "global" as const,
|
|
261
|
+
instructions: "",
|
|
262
|
+
model: undefined,
|
|
263
|
+
thinking: undefined,
|
|
264
|
+
mode: undefined,
|
|
265
|
+
tools: [],
|
|
266
|
+
};
|
|
267
|
+
const explore = { ...worker, name: "gentle-ai-explore" };
|
|
268
|
+
const profiles = {
|
|
269
|
+
worker: { model: { provider: "nan", id: "glm5.3" }, thinking: undefined },
|
|
270
|
+
WORKER: { model: { provider: "nan", id: "glm5.3" }, thinking: undefined },
|
|
271
|
+
review_risk: { model: undefined, thinking: "high" as const },
|
|
272
|
+
explore: { model: undefined, thinking: undefined },
|
|
273
|
+
};
|
|
274
|
+
assert.deepEqual(unmatchedProfileKeys(profiles, [worker, explore]), [
|
|
275
|
+
{ key: "worker", suggestion: "gentle-ai-worker" },
|
|
276
|
+
{ key: "WORKER", suggestion: "gentle-ai-worker" },
|
|
277
|
+
{ key: "review_risk", suggestion: undefined },
|
|
278
|
+
{ key: "explore", suggestion: "gentle-ai-explore" },
|
|
279
|
+
]);
|
|
280
|
+
// A key that exactly matches an agent name never reports, and an empty
|
|
281
|
+
// profile set reports nothing.
|
|
282
|
+
assert.deepEqual(unmatchedProfileKeys({ "gentle-ai-worker": { model: undefined, thinking: undefined } }, [worker]), []);
|
|
283
|
+
assert.deepEqual(unmatchedProfileKeys({}, [worker]), []);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Cortex QA adversarial follow-up (R5 pin): the did-you-mean mapping exists to
|
|
287
|
+
// make a misconfiguration visible, never to resolve; a short key must keep
|
|
288
|
+
// yielding an unresolved profile so the role inherits frontmatter and default
|
|
289
|
+
// routing exactly as before the fix.
|
|
290
|
+
test("resolveAgentProfile never resolves through the suggestion mapping", () => {
|
|
291
|
+
const worker = {
|
|
292
|
+
name: "gentle-ai-worker",
|
|
293
|
+
description: "",
|
|
294
|
+
filePath: "/x/worker.md",
|
|
295
|
+
scope: "global" as const,
|
|
296
|
+
instructions: "",
|
|
297
|
+
model: { provider: "openai-codex", id: "gpt-5.6-terra" },
|
|
298
|
+
thinking: undefined,
|
|
299
|
+
mode: undefined,
|
|
300
|
+
tools: [],
|
|
301
|
+
};
|
|
302
|
+
const config = parseAgentsConfig(
|
|
303
|
+
{ model_profiles: { worker: { model: "nan/glm5.3", effort: "high" } } },
|
|
304
|
+
undefined,
|
|
305
|
+
);
|
|
306
|
+
const resolved = resolveAgentProfile(worker, config);
|
|
307
|
+
assert.equal(resolved.model?.provider, "openai-codex", "the short key must not route; the agent frontmatter model wins");
|
|
308
|
+
assert.equal(resolved.source.model, "definition");
|
|
309
|
+
assert.ok(unmatchedProfileKeys(config.modelProfiles, [worker]).some((entry) => entry.key === "worker" && entry.suggestion === "gentle-ai-worker"), "the same short key is still reported with its suggestion");
|
|
310
|
+
});
|
|
@@ -17,7 +17,7 @@ export interface FakeChild {
|
|
|
17
17
|
message(event: Record<string, unknown>): void;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export function fakeChild(options: { exitOnKill?: boolean; pid?: number } = {}): FakeChild {
|
|
20
|
+
export function fakeChild(options: { exitOnKill?: boolean; exitOnStdinEnd?: boolean; pid?: number } = {}): FakeChild {
|
|
21
21
|
const emitter = new EventEmitter();
|
|
22
22
|
const stdin = new PassThrough();
|
|
23
23
|
const stdout = new PassThrough();
|
|
@@ -38,6 +38,13 @@ export function fakeChild(options: { exitOnKill?: boolean; pid?: number } = {}):
|
|
|
38
38
|
stdout.write(`${JSON.stringify({ type: "response", id: command.id, command: command.type, success: true, data })}\n`);
|
|
39
39
|
}
|
|
40
40
|
});
|
|
41
|
+
const origEnd = stdin.end.bind(stdin);
|
|
42
|
+
stdin.end = ((...args: unknown[]) => {
|
|
43
|
+
const res = (origEnd as Function)(...args);
|
|
44
|
+
// Preserve existing fixtures while allowing EOF and signal exits to vary independently.
|
|
45
|
+
if (options.exitOnStdinEnd ?? options.exitOnKill ?? true) queueMicrotask(() => emitter.emit("exit", 0, null));
|
|
46
|
+
return res;
|
|
47
|
+
}) as typeof stdin.end;
|
|
41
48
|
const child: ChildLike = {
|
|
42
49
|
pid: options.pid,
|
|
43
50
|
stdin,
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
TASK_STATUS,
|
|
11
11
|
taskLabel,
|
|
12
12
|
TaskStore,
|
|
13
|
+
TERMINAL_RETENTION,
|
|
13
14
|
THREAD_ITEM,
|
|
15
|
+
isFinished,
|
|
14
16
|
type TaskRecord,
|
|
15
17
|
} from "../lib/agents-protocol.ts";
|
|
16
18
|
|
|
@@ -274,6 +276,36 @@ test("TaskStore notifies only the listeners of the task that changed and keeps s
|
|
|
274
276
|
assert.deepEqual(store.thread("missing"), emptyThread());
|
|
275
277
|
});
|
|
276
278
|
|
|
279
|
+
// Finished tasks must release their weight: a bounded window of the most
|
|
280
|
+
// recent ones stays readable, older ones leave the store entirely, and a
|
|
281
|
+
// terminal task's listeners go quiet after the one final transition.
|
|
282
|
+
test("TaskStore keeps a bounded window of finished tasks and silences terminal listeners", () => {
|
|
283
|
+
const store = new TaskStore();
|
|
284
|
+
const seen: string[] = [];
|
|
285
|
+
store.add(record({ id: "watched", status: TASK_STATUS.RUNNING, lastActivityAt: 1 }));
|
|
286
|
+
store.add(record({ id: "filler", status: TASK_STATUS.RUNNING, lastActivityAt: 2 }));
|
|
287
|
+
store.subscribe("watched", (task) => seen.push(task.status));
|
|
288
|
+
// `watched` completes the window once it finishes, so the next terminal
|
|
289
|
+
// task evicts exactly the oldest one.
|
|
290
|
+
for (let index = 0; index < TERMINAL_RETENTION - 1; index += 1) {
|
|
291
|
+
store.add(record({ id: `done-${index}`, status: TASK_STATUS.COMPLETED, lastActivityAt: 10 + index }));
|
|
292
|
+
}
|
|
293
|
+
assert.equal(store.list().filter((task) => isFinished(task.status)).length, TERMINAL_RETENTION - 1, "retained finished tasks stay at the cap");
|
|
294
|
+
assert.ok(store.get("watched") && store.get("filler"), "running tasks are never evicted");
|
|
295
|
+
store.update("watched", { status: TASK_STATUS.COMPLETED, endedAt: 50 });
|
|
296
|
+
assert.deepEqual(seen, ["completed"], "the terminal transition itself still notifies exactly once");
|
|
297
|
+
store.apply("watched", { type: TASK_EVENT.TEXT, text: "late" }, 60);
|
|
298
|
+
assert.deepEqual(seen, ["completed"], "a terminal task's listener stays quiet afterwards");
|
|
299
|
+
store.add(record({ id: "newest", status: TASK_STATUS.COMPLETED, lastActivityAt: 1_000 }));
|
|
300
|
+
assert.equal(store.get("done-0"), undefined, "the oldest finished task is evicted");
|
|
301
|
+
assert.ok(store.get("done-1") && store.get("newest"), "recent finished tasks remain readable");
|
|
302
|
+
assert.ok(store.get("filler"), "running tasks survive eviction");
|
|
303
|
+
assert.equal(store.list().filter((task) => isFinished(task.status)).length, TERMINAL_RETENTION);
|
|
304
|
+
assert.deepEqual(store.thread("done-0"), emptyThread(), "an evicted task's thread weight is released");
|
|
305
|
+
assert.ok(store.thread("done-1").limits.maxItems > 0, "a retained finished task stays renderable");
|
|
306
|
+
assert.equal(store.summary().finished, TERMINAL_RETENTION, "summaries reflect the bounded window");
|
|
307
|
+
});
|
|
308
|
+
|
|
277
309
|
test("TaskStore.apply moves the task to waiting on ask, back to running on any later event, and counts turns", () => {
|
|
278
310
|
const store = new TaskStore();
|
|
279
311
|
store.add(record({ id: "a", status: TASK_STATUS.RUNNING }));
|
|
@@ -34,7 +34,7 @@ interface Harness {
|
|
|
34
34
|
advance(ms: number): void;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boolean; process?: RunnerDeps["process"]; pid?: number; maxConcurrency?: number; stallTimeoutMs?: number; toolStallTimeoutMs?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
|
|
37
|
+
function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boolean; process?: RunnerDeps["process"]; pid?: number; maxConcurrency?: number; stallTimeoutMs?: number; toolStallTimeoutMs?: number; cleanupTimeoutMs?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; exitOnStdinEnd?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
|
|
38
38
|
const children: FakeChild[] = [];
|
|
39
39
|
const timers: Harness["timers"] = [];
|
|
40
40
|
const asks: Harness["asks"] = [];
|
|
@@ -48,7 +48,7 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
|
|
|
48
48
|
spawn: (command, args, launchOptions) => {
|
|
49
49
|
if (options.failStart) throw new Error("fixture spawn failed");
|
|
50
50
|
spawnOptions.push({ command, args, env: launchOptions.env, stdio: launchOptions.stdio });
|
|
51
|
-
const fake = fakeChild({ exitOnKill: options.exitOnKill, pid: options.pid });
|
|
51
|
+
const fake = fakeChild({ exitOnKill: options.exitOnKill, exitOnStdinEnd: options.exitOnStdinEnd, pid: options.pid });
|
|
52
52
|
if (options.state !== undefined) {
|
|
53
53
|
fake.child.stdin.removeAllListeners("data");
|
|
54
54
|
fake.child.stdin.on("data", (chunk) => {
|
|
@@ -73,7 +73,7 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
|
|
|
73
73
|
pi: { command: "pi", args: [] },
|
|
74
74
|
};
|
|
75
75
|
const store = new TaskStore();
|
|
76
|
-
const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: options.stallTimeoutMs ?? 10_000, toolStallTimeoutMs: options.toolStallTimeoutMs }, deps, {
|
|
76
|
+
const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: options.stallTimeoutMs ?? 10_000, toolStallTimeoutMs: options.toolStallTimeoutMs, cleanupTimeoutMs: options.cleanupTimeoutMs }, deps, {
|
|
77
77
|
askUser: async (taskId, ask) => {
|
|
78
78
|
asks.push({ taskId, method: ask.method });
|
|
79
79
|
return options.answer ?? { value: "yes" };
|
|
@@ -95,6 +95,87 @@ function harness(options: { resolvePi?: RunnerDeps["resolvePi"]; failStart?: boo
|
|
|
95
95
|
|
|
96
96
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
97
97
|
|
|
98
|
+
for (const mode of [AGENT_MODE.TASK, AGENT_MODE.BACKGROUND]) {
|
|
99
|
+
for (const policy of [undefined, "off", "malformed"] as const) {
|
|
100
|
+
test(`background capability admission rejects ${mode} with ${policy ?? "default"} policy`, async (t) => {
|
|
101
|
+
const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
|
|
102
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
103
|
+
const config = join(root, "config");
|
|
104
|
+
const file = join(config, "background-subagents.json");
|
|
105
|
+
fs.mkdirSync(config);
|
|
106
|
+
const raw = policy === "malformed" ? "{broken" : JSON.stringify({ schema: "gentle-pi.background-subagents/v1", policy });
|
|
107
|
+
if (policy) fs.writeFileSync(file, raw);
|
|
108
|
+
const h = harness({ maxConcurrency: 1 });
|
|
109
|
+
let launches = 0;
|
|
110
|
+
const env = { GENTLE_PI_CONFIG_HOME: config, ...(policy === "malformed" ? { GENTLE_PI_BACKGROUND_SUBAGENTS: "on" } : {}) };
|
|
111
|
+
const task = h.runner.run(request({ cwd: root, env, mode, agent: { ...explorer, tools: ["read", "bash_background"] }, onLaunch: () => { launches++; } }));
|
|
112
|
+
await tick();
|
|
113
|
+
assert.equal(h.store.get(task.id)?.status, TASK_STATUS.FAILED);
|
|
114
|
+
const target = policy ? file : join(root, ".pi", "gentle-ai", "background-subagents.json");
|
|
115
|
+
assert.equal(h.store.get(task.id)?.error, `could not start pi: explicit tool bash_background requires background-subagents policy on. Set ${target} to {"schema":"gentle-pi.background-subagents/v1","policy":"on"}; inspect or change policy with /gentle:background-subagents enable|disable|status.`);
|
|
116
|
+
assert.equal(h.children.length, 0);
|
|
117
|
+
assert.equal(h.spawnOptions.length, 0);
|
|
118
|
+
assert.equal(h.timers.length, 0);
|
|
119
|
+
assert.equal(launches, 0);
|
|
120
|
+
assert.deepEqual(h.finishes, [task.id]);
|
|
121
|
+
assert.equal((await h.runner.waitFor(task.id)).status, TASK_STATUS.FAILED);
|
|
122
|
+
assert.equal(existsSync(file), policy !== undefined);
|
|
123
|
+
if (policy) assert.equal(readFileSync(file, "utf8"), raw);
|
|
124
|
+
const next = h.runner.run(request({ cwd: root, env }));
|
|
125
|
+
await tick();
|
|
126
|
+
assert.equal(h.store.get(next.id)?.status, TASK_STATUS.RUNNING);
|
|
127
|
+
h.runner.cancel(next.id);
|
|
128
|
+
await tick();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const { tools, policy } of [
|
|
134
|
+
...[undefined, "off"].flatMap(policy => [["read", "unknown_tool"], []].map(tools => ({ tools, policy }))),
|
|
135
|
+
{ tools: ["read", "bash_background", "grep"], policy: "on" },
|
|
136
|
+
]) {
|
|
137
|
+
test(`background capability admission preserves launch for ${tools.join(",") || "default tools"} with ${policy ?? "default off"} policy`, async (t) => {
|
|
138
|
+
const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
|
|
139
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
140
|
+
const h = harness();
|
|
141
|
+
const env = { GENTLE_PI_CONFIG_HOME: join(root, "config"), ...(policy === undefined ? {} : { GENTLE_PI_BACKGROUND_SUBAGENTS: policy }), KEEP: "yes" };
|
|
142
|
+
const task = h.runner.run(request({ cwd: root, env, agent: { ...explorer, tools } }));
|
|
143
|
+
await tick();
|
|
144
|
+
assert.equal(h.store.get(task.id)?.status, TASK_STATUS.RUNNING);
|
|
145
|
+
assert.equal(h.store.get(task.id)?.error, null);
|
|
146
|
+
assert.equal(h.children.length, 1);
|
|
147
|
+
const expected = tools.length ? [...tools, "subagent_parent_message"].join(",") : undefined;
|
|
148
|
+
assert.equal(h.spawnOptions[0].env[REQUESTED_TOOLS_ENV], expected);
|
|
149
|
+
assert.equal(h.spawnOptions[0].env.KEEP, "yes");
|
|
150
|
+
assert.deepEqual(h.spawnOptions[0].args, childArguments(request({ cwd: root, env, agent: { ...explorer, tools } })));
|
|
151
|
+
assert.equal(h.timers.filter(timer => !timer.cancelled).length, 1);
|
|
152
|
+
h.runner.cancel(task.id);
|
|
153
|
+
await tick();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
test("background capability admission refreshes project policy when queued work dispatches", async (t) => {
|
|
158
|
+
const root = fs.mkdtempSync(join(tmpdir(), "admission-"));
|
|
159
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
160
|
+
const dir = join(root, ".pi", "gentle-ai");
|
|
161
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
const file = join(dir, "background-subagents.json");
|
|
163
|
+
const h = harness({ maxConcurrency: 1 });
|
|
164
|
+
const env = { GENTLE_PI_CONFIG_HOME: join(root, "config"), GENTLE_PI_BACKGROUND_SUBAGENTS: "on" };
|
|
165
|
+
const first = h.runner.run(request({ cwd: root, env }));
|
|
166
|
+
const queued = h.runner.run(request({ cwd: root, env, agent: { ...explorer, tools: ["bash_background"] } }));
|
|
167
|
+
await tick();
|
|
168
|
+
assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.QUEUED);
|
|
169
|
+
const raw = '{"schema":"gentle-pi.background-subagents/v1","policy":"off"}';
|
|
170
|
+
fs.writeFileSync(file, raw);
|
|
171
|
+
h.runner.cancel(first.id);
|
|
172
|
+
await tick();
|
|
173
|
+
assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.FAILED);
|
|
174
|
+
assert.equal(h.store.get(queued.id)?.error, `could not start pi: explicit tool bash_background requires background-subagents policy on. Set ${file} to {"schema":"gentle-pi.background-subagents/v1","policy":"on"}; inspect or change policy with /gentle:background-subagents enable|disable|status.`);
|
|
175
|
+
assert.equal(h.children.length, 1);
|
|
176
|
+
assert.equal(readFileSync(file, "utf8"), raw);
|
|
177
|
+
});
|
|
178
|
+
|
|
98
179
|
const FOUR_MIN_MS = 4 * 60_000;
|
|
99
180
|
|
|
100
181
|
function argumentUpdate(type: string, fields: Record<string, unknown> = {}): Record<string, unknown> {
|
|
@@ -923,7 +1004,8 @@ test("AgentRunner runs a task end to end: prompt, deltas into the store, complet
|
|
|
923
1004
|
assert.equal(finished?.toolCalls, 1);
|
|
924
1005
|
assert.equal(finished?.sessionPath, "/sessions/child.jsonl");
|
|
925
1006
|
assert.equal(finished?.label, "Map the repo");
|
|
926
|
-
assert.ok(children[0].
|
|
1007
|
+
assert.ok(children[0].child.stdin.writableEnded, "the child is stopped via orderly shutdown once the answer is in");
|
|
1008
|
+
assert.equal(children[0].killed.length, 0, "no kill signals sent during orderly settlement");
|
|
927
1009
|
assert.equal(store.thread(task.id).items.length, 2);
|
|
928
1010
|
assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
|
|
929
1011
|
});
|
|
@@ -973,6 +1055,143 @@ test("AgentRunner queues beyond max concurrency and starts the next task when on
|
|
|
973
1055
|
assert.equal(store.get(second.id)?.status, TASK_STATUS.RUNNING);
|
|
974
1056
|
});
|
|
975
1057
|
|
|
1058
|
+
test("AgentRunner requests orderly shutdown via stdin on settlement and does not send SIGTERM immediately (#1740)", async () => {
|
|
1059
|
+
const { store, runner, children } = harness({ exitOnKill: false });
|
|
1060
|
+
const task = runner.run(request());
|
|
1061
|
+
await tick();
|
|
1062
|
+
assert.equal(children.length, 1);
|
|
1063
|
+
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
|
|
1064
|
+
await tick();
|
|
1065
|
+
assert.equal(children[0].killed.length, 0);
|
|
1066
|
+
children[0].emit({ type: "agent_settled" });
|
|
1067
|
+
await tick();
|
|
1068
|
+
|
|
1069
|
+
// Orderly shutdown must have requested stdin.end()
|
|
1070
|
+
assert.equal(children[0].child.stdin.writableEnded, true, "stdin must be ended on settlement to trigger Pi orderly shutdown");
|
|
1071
|
+
// Must NOT send SIGTERM immediately
|
|
1072
|
+
assert.deepEqual(children[0].killed, [], "SIGTERM must not be sent immediately upon settlement");
|
|
1073
|
+
// Task completion and cleanup in flight should be distinguishable
|
|
1074
|
+
assert.equal(store.get(task.id)?.lastStep, "cleaning up", "lastStep distinguishes in-flight cleanup");
|
|
1075
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING, "task waits for cleanup exit");
|
|
1076
|
+
|
|
1077
|
+
// Child exits cleanly upon session_shutdown completion
|
|
1078
|
+
children[0].exit(0);
|
|
1079
|
+
await tick();
|
|
1080
|
+
await tick();
|
|
1081
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.COMPLETED);
|
|
1082
|
+
assert.equal(store.get(task.id)?.lastStep, "done");
|
|
1083
|
+
assert.deepEqual(children[0].killed, [], "clean shutdown completes with zero kill signals sent");
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("AgentRunner allows delayed cleanup and cancels escalation after clean exit (#1740)", async () => {
|
|
1087
|
+
// EOF must be independent of kill behavior: this child honors signals but
|
|
1088
|
+
// needs time to finish asynchronous shutdown after its input closes.
|
|
1089
|
+
const { store, runner, children, timers, advance, finishes } = harness({ exitOnStdinEnd: false });
|
|
1090
|
+
const task = runner.run(request());
|
|
1091
|
+
await tick();
|
|
1092
|
+
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
|
|
1093
|
+
children[0].emit({ type: "agent_settled" });
|
|
1094
|
+
await tick();
|
|
1095
|
+
assert.equal(children[0].child.stdin.writableEnded, true);
|
|
1096
|
+
advance(2_000);
|
|
1097
|
+
await tick();
|
|
1098
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING);
|
|
1099
|
+
assert.equal(store.get(task.id)?.lastStep, "cleaning up");
|
|
1100
|
+
assert.deepEqual(children[0].killed, []);
|
|
1101
|
+
assert.deepEqual(finishes, []);
|
|
1102
|
+
children[0].exit(0);
|
|
1103
|
+
await tick();
|
|
1104
|
+
assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
|
|
1105
|
+
assert.equal(store.get(task.id)?.lastStep, "done");
|
|
1106
|
+
assert.deepEqual(finishes, [task.id]);
|
|
1107
|
+
assert.ok(timers.filter((timer) => timer.ms === 5_000).every((timer) => timer.cancelled));
|
|
1108
|
+
advance(5_000);
|
|
1109
|
+
await tick();
|
|
1110
|
+
assert.deepEqual(children[0].killed, []);
|
|
1111
|
+
assert.deepEqual(finishes, [task.id]);
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
test("AgentRunner escalates to SIGTERM and SIGKILL if orderly cleanup exceeds cleanupTimeoutMs (#1740)", async () => {
|
|
1115
|
+
const { store, runner, children, timers } = harness({ exitOnKill: false, cleanupTimeoutMs: 3000 });
|
|
1116
|
+
const task = runner.run(request());
|
|
1117
|
+
await tick();
|
|
1118
|
+
assert.equal(children.length, 1);
|
|
1119
|
+
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
|
|
1120
|
+
await tick();
|
|
1121
|
+
children[0].emit({ type: "agent_settled" });
|
|
1122
|
+
await tick();
|
|
1123
|
+
|
|
1124
|
+
assert.equal(children[0].child.stdin.writableEnded, true);
|
|
1125
|
+
assert.deepEqual(children[0].killed, []);
|
|
1126
|
+
|
|
1127
|
+
// Find the cleanup timeout timer (3000 ms) and trigger it
|
|
1128
|
+
const cleanupTimer = timers.find((timer) => !timer.cancelled && timer.ms === 3000);
|
|
1129
|
+
assert.ok(cleanupTimer, "cleanup timeout timer must be scheduled");
|
|
1130
|
+
cleanupTimer.fn();
|
|
1131
|
+
await tick();
|
|
1132
|
+
|
|
1133
|
+
// Escalation has fired SIGTERM
|
|
1134
|
+
assert.deepEqual(children[0].killed, ["SIGTERM"]);
|
|
1135
|
+
|
|
1136
|
+
// And termination grace timer (250 ms) is scheduled for SIGKILL
|
|
1137
|
+
const killTimer = timers.find((timer) => !timer.cancelled && timer.ms === 250);
|
|
1138
|
+
assert.ok(killTimer, "SIGKILL escalation timer must be scheduled");
|
|
1139
|
+
killTimer.fn();
|
|
1140
|
+
await tick();
|
|
1141
|
+
|
|
1142
|
+
assert.deepEqual(children[0].killed, ["SIGTERM", "SIGKILL"]);
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
test("orderly cleanup escalation retains the full exit confirmation deadline after SIGKILL (#1740)", async () => {
|
|
1146
|
+
let groupAlive = true;
|
|
1147
|
+
const { store, runner, children, timers, advance } = harness({
|
|
1148
|
+
exitOnKill: false,
|
|
1149
|
+
pid: 42,
|
|
1150
|
+
cleanupTimeoutMs: 5_000,
|
|
1151
|
+
process: {
|
|
1152
|
+
platform: "linux",
|
|
1153
|
+
kill: () => {
|
|
1154
|
+
if (!groupAlive) throw Object.assign(new Error(), { code: "ESRCH" });
|
|
1155
|
+
return true;
|
|
1156
|
+
},
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
const task = runner.run(request());
|
|
1160
|
+
await tick();
|
|
1161
|
+
assert.equal(children.length, 1);
|
|
1162
|
+
children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Done" }] }] });
|
|
1163
|
+
await tick();
|
|
1164
|
+
children[0].emit({ type: "agent_settled" });
|
|
1165
|
+
await tick();
|
|
1166
|
+
|
|
1167
|
+
// Advance time by the cleanup window (5000 ms)
|
|
1168
|
+
const cleanupTimer = timers.find((t) => !t.cancelled && t.ms === 5_000);
|
|
1169
|
+
assert.ok(cleanupTimer, "cleanup timeout timer must be scheduled");
|
|
1170
|
+
advance(5_000);
|
|
1171
|
+
await tick();
|
|
1172
|
+
|
|
1173
|
+
// Advance time by termination grace (250 ms)
|
|
1174
|
+
const killTimer = timers.find((t) => !t.cancelled && t.ms === 250);
|
|
1175
|
+
assert.ok(killTimer, "SIGKILL timer must be scheduled");
|
|
1176
|
+
advance(250);
|
|
1177
|
+
await tick();
|
|
1178
|
+
|
|
1179
|
+
// After SIGKILL, confirmGroupExit checks the process group. Because cleanupDeadlineAt was set
|
|
1180
|
+
// at SIGKILL time, it must NOT immediately fail with capacity quarantined!
|
|
1181
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.RUNNING, "must not prematurely quarantine after cleanup window");
|
|
1182
|
+
const probeTimer = timers.find((t) => !t.cancelled && t.ms === 25);
|
|
1183
|
+
assert.ok(probeTimer, "exit confirmation polling timer must be scheduled");
|
|
1184
|
+
|
|
1185
|
+
// If the group exits during the confirmation window:
|
|
1186
|
+
groupAlive = false;
|
|
1187
|
+
children[0].exit(0);
|
|
1188
|
+
advance(25);
|
|
1189
|
+
await tick();
|
|
1190
|
+
|
|
1191
|
+
assert.equal(store.get(task.id)?.status, TASK_STATUS.COMPLETED);
|
|
1192
|
+
assert.equal(store.get(task.id)?.error, null);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
976
1195
|
test("AgentRunner classifies terminal assistant outcomes only after settlement", async () => {
|
|
977
1196
|
const scenarios = [
|
|
978
1197
|
{ name: "error", messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: "WebSocket error: secret=never-copy" }], status: TASK_STATUS.FAILED, error: /assistant reported an error/ },
|
|
@@ -1136,7 +1355,9 @@ test("AgentRunner retains permission broker fd3 and assigns messaging IPC to fd4
|
|
|
1136
1355
|
const launch = spawnOptions[0];
|
|
1137
1356
|
const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
|
|
1138
1357
|
assert.match(launch?.env.GENTLE_PI_AGENTS_OWNED_IPC ?? "", /^\d+-[a-z0-9]+$/, "the owned-IPC marker has the runner's opaque shape");
|
|
1139
|
-
|
|
1358
|
+
const scratch = launch?.env.TMPDIR;
|
|
1359
|
+
const scratchEnv = process.platform === "win32" ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : { TMPDIR: scratch };
|
|
1360
|
+
assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3", [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message", ...scratchEnv });
|
|
1140
1361
|
assert.match(basename(launch?.env.TMPDIR ?? ""), /^ga[A-Za-z0-9]{6}$/);
|
|
1141
1362
|
assert.deepEqual(launch?.stdio, ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"]);
|
|
1142
1363
|
assert.equal(launch?.stdio?.length, 5);
|
|
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { once } from "node:events";
|
|
5
5
|
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
6
|
-
import { createConnection, createServer, Socket } from "node:net";
|
|
6
|
+
import { createConnection, createServer, Socket, type Server } from "node:net";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import nodeTest, { type TestContext } from "node:test";
|
|
@@ -124,7 +124,12 @@ test("lists only advertised peer IDs without mutating the private registry", asy
|
|
|
124
124
|
assert.equal(await snapshot(), before, "listing leaves presence files, directory modes, and socket entries unchanged");
|
|
125
125
|
assert.equal(alphaNew.createdAt >= alphaOld.createdAt, true);
|
|
126
126
|
for (let index = 0; index < 65; index++) await transport.publish(await transport.record(`overflow-${index}`, index));
|
|
127
|
-
|
|
127
|
+
// The over-cap scan sweeps provably dead records once instead of failing
|
|
128
|
+
// busy: the overflow endpoints were never bound, and the missing/malformed/
|
|
129
|
+
// symlinked fixtures are debris too. Live peers stay listed.
|
|
130
|
+
assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "alpha", reachability: "unknown" }]);
|
|
131
|
+
const remaining = (await readdir(transport.paths.presence)).sort();
|
|
132
|
+
assert.equal(remaining.length, 3, "only the live records (self, alpha twice) survive the sweep");
|
|
128
133
|
});
|
|
129
134
|
|
|
130
135
|
test("keeps same-session activations distinct and old cleanup preserves the newest", async (t) => {
|
|
@@ -161,7 +166,52 @@ test("isolates two sessions and bounds registry discovery at 65 entries", async
|
|
|
161
166
|
assert.deepEqual(await transport.resolve("left"), left);
|
|
162
167
|
assert.deepEqual(await transport.resolve("right"), right);
|
|
163
168
|
for (let index = 0; index < 65; index++) await transport.publish(await transport.record("capped", index));
|
|
164
|
-
|
|
169
|
+
// The dead-endpoint overflow is swept on the first over-cap scan, so the
|
|
170
|
+
// retry resolves the truth (nothing left) instead of failing busy.
|
|
171
|
+
await rejected(transport.resolve("capped"), "not_found");
|
|
172
|
+
assert.equal((await readdir(transport.paths.presence)).length, 0, "dead overflow records are swept, never retained");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("foreign-schema presence files never count toward the activation cap", async (t) => {
|
|
176
|
+
const transport = await registry(t);
|
|
177
|
+
// The orchestrator publisher shares this directory with a different naming
|
|
178
|
+
// schema (<sessionHash>.<incarnation>.<kind>.json, three files per peer),
|
|
179
|
+
// and long-lived profiles accumulate hundreds of those records. They are
|
|
180
|
+
// never transport activations, so they must not trip the spam guard.
|
|
181
|
+
const incarnation = (index: number) => `00000000-0000-4000-8000-${index.toString().padStart(12, "0")}`;
|
|
182
|
+
for (let index = 0; index < 80; index++) {
|
|
183
|
+
for (const kind of ["header", "activity", "discovery"]) {
|
|
184
|
+
await writeFile(join(transport.paths.presence, `${"b".repeat(64)}.${incarnation(index)}.${kind}.json`), "{}");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const self = await raw(t, transport, "self", null);
|
|
188
|
+
const peer = await raw(t, transport, "peer", null);
|
|
189
|
+
assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "peer", reachability: "unknown" }], "listing survives a foreign-schema flood and still sees real activations");
|
|
190
|
+
assert.deepEqual(await transport.resolve("peer"), peer);
|
|
191
|
+
// The cap still bounds real transport activations exactly as before: the
|
|
192
|
+
// 65 advertised dead-endpoint records overflow the guard, the sweep removes
|
|
193
|
+
// them, and the live peers remain discoverable afterwards.
|
|
194
|
+
for (let index = 0; index < 65; index++) await transport.publish(await transport.record("capped", index + 10));
|
|
195
|
+
assert.deepEqual(await transport.list(self.sessionId), [{ sessionId: "peer", reachability: "unknown" }], "dead overflow is swept once and the live peer stays discoverable");
|
|
196
|
+
await rejected(transport.resolve("capped"), "not_found");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// The sweep must never trade the spam guard for liveness probing: a genuine
|
|
200
|
+
// flood of records backed by real listening sockets stays over the cap, keeps
|
|
201
|
+
// failing busy, and its records are never removed.
|
|
202
|
+
test("a flood of live listeners stays over the cap and keeps failing busy", async (t) => {
|
|
203
|
+
const transport = await registry(t);
|
|
204
|
+
const servers: Server[] = [];
|
|
205
|
+
t.after(() => { for (const server of servers) server.close(); });
|
|
206
|
+
for (let index = 0; index < 66; index++) {
|
|
207
|
+
const record = await transport.record(`flood-${index}`, index);
|
|
208
|
+
const server = createServer(() => {});
|
|
209
|
+
await new Promise<void>((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen(record.endpoint, resolveListen); });
|
|
210
|
+
servers.push(server);
|
|
211
|
+
await transport.publish(record);
|
|
212
|
+
}
|
|
213
|
+
await rejected(transport.list(), "busy");
|
|
214
|
+
assert.equal((await readdir(transport.paths.presence)).length, 66, "live records are never swept");
|
|
165
215
|
});
|
|
166
216
|
|
|
167
217
|
test("rejects unsafe IDs, v1 schema violations, prototypes, and content-leaking errors", async (t) => {
|
|
@@ -33,6 +33,7 @@ interface ChoiceTool {
|
|
|
33
33
|
promptGuidelines?: string[];
|
|
34
34
|
parameters: ChoiceParameters;
|
|
35
35
|
execute: (...args: unknown[]) => Promise<ChoiceResult>;
|
|
36
|
+
renderResult?: (result: unknown, options: unknown, theme: ChoiceTheme) => { render(width: number): string[] };
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
interface ChoiceLifecycleEvent {
|
|
@@ -711,3 +712,32 @@ test("ask_user_choice is offered on an interactive rpc host and withdrawn once t
|
|
|
711
712
|
for (const hook of registration.hooks) await hook({}, { mode: "rpc" });
|
|
712
713
|
assert.deepEqual(registration.activeTools(), ["read"]);
|
|
713
714
|
});
|
|
715
|
+
|
|
716
|
+
test("ask_user_choice renderResult renders selected, custom response with text, and cancelled states (#1692)", () => {
|
|
717
|
+
const registration = registerChoiceTool();
|
|
718
|
+
const theme: ChoiceTheme = { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text };
|
|
719
|
+
|
|
720
|
+
// 1. Selected option
|
|
721
|
+
const selected = registration.tool.renderResult!(
|
|
722
|
+
{ details: { selection: { index: 1, label: "Deploy to production" } } },
|
|
723
|
+
{},
|
|
724
|
+
theme,
|
|
725
|
+
).render(80).join("\n");
|
|
726
|
+
assert.match(selected, /✓ 1\. Deploy to production/);
|
|
727
|
+
|
|
728
|
+
// 2. Custom response with submitted text (#1692)
|
|
729
|
+
const custom = registration.tool.renderResult!(
|
|
730
|
+
{ details: { customResponse: "use the staging bucket" } },
|
|
731
|
+
{},
|
|
732
|
+
theme,
|
|
733
|
+
).render(80).join("\n");
|
|
734
|
+
assert.match(custom, /✓ Custom response — use the staging bucket/);
|
|
735
|
+
|
|
736
|
+
// 3. Cancelled
|
|
737
|
+
const cancelled = registration.tool.renderResult!(
|
|
738
|
+
{ details: { cancelled: true } },
|
|
739
|
+
{},
|
|
740
|
+
theme,
|
|
741
|
+
).render(80).join("\n");
|
|
742
|
+
assert.match(cancelled, /Cancelled/);
|
|
743
|
+
});
|
package/tests/auto-mode.test.ts
CHANGED
|
@@ -42,6 +42,23 @@ test("Auto cannot bypass data-loss, configured confirmation, protected paths or
|
|
|
42
42
|
assert.equal(h.requests.length,0);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test("production hook classifies companion updates without granting permission to change authority",async t=>{
|
|
46
|
+
const h=harness(t);await h.enable();await h.input("Update the installed companion tools from main; preserve permissions.");
|
|
47
|
+
for(const toolName of ["write","edit"]) {
|
|
48
|
+
assert.equal(await h.call(toolName,{path:join(h.home,"dev-binary.json"),content:"fixture companion update"}),undefined);
|
|
49
|
+
}
|
|
50
|
+
assert.equal(h.requests.length,2,"updates reach the ordinary Auto classifier");
|
|
51
|
+
h.setDecision("deny");
|
|
52
|
+
assert.equal((await h.call("write",{path:join(h.home,"channel.json"),content:"unapproved"})).block,true);
|
|
53
|
+
assert.equal(h.requests.length,3);
|
|
54
|
+
h.setDecision("allow");
|
|
55
|
+
for(const name of ["permission-mode.json","runtime-guardrails.json","models.json","persona.json"]) {
|
|
56
|
+
assert.equal((await h.call("write",{path:join(h.home,name),content:"{}"})).block,true);
|
|
57
|
+
}
|
|
58
|
+
assert.equal(h.requests.length,3,"a classifier allow cannot override protected authority");
|
|
59
|
+
assert.equal(h.confirmations(),0);
|
|
60
|
+
});
|
|
61
|
+
|
|
45
62
|
test("normal mode preserves previous guard behavior and named children do not inherit Auto",async t=>{
|
|
46
63
|
const h=harness(t);assert.equal((await h.call("bash",{command:"git push origin feature"})).block,true);assert.equal(h.requests.length,0);
|
|
47
64
|
await h.enable();await h.emit("before_agent_start",{agent:{name:"worker"},systemPromptOptions:{}});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { AutoPermissionMemory } from "../lib/auto-permission-memory.ts";
|
|
4
|
+
test("binder diagnostics retain rejection cause without destination or content",()=>{
|
|
5
|
+
const memory=new AutoPermissionMemory(()=>[]);
|
|
6
|
+
const ctx:any={cwd:"PRIVATE_CWD",sessionManager:{getSessionId:()=>"PRIVATE_SESSION",getBranch:()=>[{id:"PRIVATE_ANCHOR"}]}};
|
|
7
|
+
memory.admitted({toolName:"mem_current_project",toolCallId:"PRIVATE_CALL",input:{cwd:ctx.cwd}},ctx,1);
|
|
8
|
+
memory.context({toolName:"mem_save",input:{cwd:ctx.cwd,project:"PRIVATE_PROJECT",content:"PRIVATE_CONTENT"}},ctx,1);
|
|
9
|
+
assert.deepEqual(memory.diagnostic(),{stage:"admission",reason:"untrusted-source",sourceRejection:"tool-missing"});
|
|
10
|
+
assert.doesNotMatch(JSON.stringify(memory.diagnostic()),/PRIVATE/);
|
|
11
|
+
memory.clear();
|
|
12
|
+
memory.context({toolName:"mem_session_summary",input:{}},ctx,1);
|
|
13
|
+
assert.deepEqual(memory.diagnostic(),{stage:"clear",reason:"binding-cleared"});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("source rejection reports only the first failed provenance predicate",()=>{
|
|
17
|
+
const sourceInfo={path:"/installed/engram.ts",source:"npm:gentle-engram@latest",scope:"user",origin:"package"};
|
|
18
|
+
const tool={name:"mem_current_project",sourceInfo};
|
|
19
|
+
const cases:[string,any[]][]=[
|
|
20
|
+
["tool-missing",[]],["tool-ambiguous",[tool,tool]],
|
|
21
|
+
["metadata-missing",[{name:tool.name}]],
|
|
22
|
+
["origin",[{...tool,sourceInfo:{...sourceInfo,origin:"top-level",source:"local"}}]],
|
|
23
|
+
["source-format",[{...tool,sourceInfo:{...sourceInfo,source:"PRIVATE_SOURCE"}}]],
|
|
24
|
+
["path",[{...tool,sourceInfo:{...sourceInfo,path:"<PRIVATE_PATH>"}}]],
|
|
25
|
+
];
|
|
26
|
+
const ctx:any={cwd:"/project",sessionManager:{getSessionId:()=>"session",getBranch:()=>[{id:"human"}]}};
|
|
27
|
+
const call={toolName:tool.name,toolCallId:"resolve",input:{cwd:ctx.cwd}};
|
|
28
|
+
for(const [sourceRejection,tools] of cases){
|
|
29
|
+
const memory=new AutoPermissionMemory(()=>tools);
|
|
30
|
+
memory.admitted(call,ctx,1);
|
|
31
|
+
assert.deepEqual(memory.diagnostic(),{stage:"admission",reason:"untrusted-source",sourceRejection});
|
|
32
|
+
memory.context({toolName:"mem_save",input:{}},ctx,1);
|
|
33
|
+
assert.equal(memory.diagnostic()?.sourceRejection,sourceRejection);
|
|
34
|
+
assert.doesNotMatch(JSON.stringify(memory.diagnostic()),/PRIVATE/);
|
|
35
|
+
}
|
|
36
|
+
const memory=new AutoPermissionMemory(()=>[tool] as any);
|
|
37
|
+
memory.admitted(call,ctx,1);
|
|
38
|
+
assert.deepEqual(memory.diagnostic(),{stage:"admission",reason:"admitted"});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("registry exceptions replace successful attachment diagnostics",()=>{
|
|
42
|
+
let fail=false;
|
|
43
|
+
const sourceInfo={path:"/installed/engram.ts",source:"npm:gentle-engram",scope:"user",origin:"package"};
|
|
44
|
+
const memory=new AutoPermissionMemory(()=>{if(fail)throw Error("PRIVATE_ERROR");return ["mem_current_project","mem_save"].map(name=>({name,sourceInfo})) as any;});
|
|
45
|
+
const ctx:any={cwd:"/project",sessionManager:{getSessionId:()=>"session",getBranch:()=>[{id:"human"}]}};
|
|
46
|
+
const call={toolName:"mem_current_project",toolCallId:"resolve",input:{cwd:ctx.cwd}};
|
|
47
|
+
memory.admitted(call,ctx,1);
|
|
48
|
+
memory.result({...call,isError:false,details:{data:{cwd:ctx.cwd,project:"project",project_source:"git_remote"}}} as any,ctx,1);
|
|
49
|
+
const save={toolName:"mem_save",input:{cwd:ctx.cwd,project:"project"}};
|
|
50
|
+
assert.ok(memory.context(save,ctx,1));
|
|
51
|
+
assert.deepEqual(memory.diagnostic(),{stage:"write",reason:"attached"});
|
|
52
|
+
fail=true;
|
|
53
|
+
assert.equal(memory.context(save,ctx,1),undefined);
|
|
54
|
+
assert.deepEqual(memory.diagnostic(),{stage:"write",reason:"unexpected-error"});
|
|
55
|
+
assert.doesNotMatch(JSON.stringify(memory.diagnostic()),/PRIVATE/);
|
|
56
|
+
});
|