gentle-pi 2.7.0 → 3.0.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 +24 -6
- package/assets/agents/gentle-ai-worker.md +5 -1
- package/assets/agents/sdd-apply.md +9 -7
- package/assets/agents/sdd-archive.md +42 -23
- package/assets/agents/sdd-proposal.md +2 -2
- package/assets/agents/sdd-remediate.md +4 -4
- package/assets/agents/sdd-research.md +20 -48
- package/assets/agents/sdd-tasks.md +5 -5
- package/assets/agents/sdd-verify.md +6 -28
- package/assets/chains/sdd-full.chain.md +4 -22
- package/assets/chains/sdd-verify.chain.md +3 -12
- package/assets/orchestrator-delegation.md +33 -3
- package/assets/orchestrator-memory.md +20 -7
- package/assets/orchestrator.md +5 -3
- package/assets/sdd-orchestrator-workflow.md +25 -58
- package/assets/support/sdd-status-contract.md +9 -12
- package/docs/gentle-shell.md +14 -4
- package/docs/readme-reference.md +171 -32
- package/extensions/codegraph-tools.ts +2 -0
- package/extensions/gentle-agents.ts +281 -361
- package/extensions/gentle-ai.ts +588 -117
- package/extensions/gentle-shell.ts +74 -30
- package/extensions/pi-pretty.ts +63 -14
- package/extensions/quiet-tools.ts +1 -2
- package/extensions/startup-banner.ts +10 -9
- package/lib/agent-home.ts +8 -0
- package/lib/agent-profile-pin.ts +336 -0
- package/lib/agent-profiles.ts +28 -8
- package/lib/agents-config.ts +24 -2
- package/lib/agents-history.ts +3 -97
- package/lib/agents-keys.ts +27 -0
- package/lib/agents-protocol.ts +2 -15
- package/lib/agents-runner.ts +67 -111
- package/lib/agents-session-transport.ts +691 -0
- package/lib/command-palette-catalog.ts +87 -0
- package/lib/command-palette.ts +346 -0
- package/lib/native-choice-list.ts +5 -0
- package/lib/native-review-cli.ts +10 -97
- package/lib/review-publication-gate.ts +11 -1
- package/lib/review-repository.ts +1 -1
- package/lib/review-snapshot.ts +1 -0
- package/lib/review-transaction.ts +4 -2
- package/lib/sdd-preflight.ts +2 -1
- package/lib/sdd-research-capabilities.ts +18 -152
- package/lib/sdd-status.ts +7 -779
- package/lib/session-change-capture.ts +2 -1
- package/lib/session-changes.ts +8 -1
- package/lib/shell-bar.ts +21 -12
- package/lib/shell-card.ts +8 -12
- package/lib/shell-changes.ts +3 -2
- package/lib/shell-prompt.ts +25 -8
- package/lib/shell-sidebar-banner.ts +2 -2
- package/lib/shell-sidebar-layout.ts +5 -2
- package/lib/windows-session-transport.ts +877 -0
- package/package.json +3 -3
- package/runtime/native-review-cli.mjs +9 -96
- package/runtime/windows-session-transport.ps1 +791 -0
- package/scripts/test-packed-runner.mjs +1668 -20
- package/scripts/verify-package-files.mjs +0 -1
- package/tests/agent-home.test.ts +52 -0
- package/tests/agent-profiles.test.ts +30 -1
- package/tests/agents-config.test.ts +44 -0
- package/tests/agents-history.test.ts +12 -24
- package/tests/agents-runner.test.ts +307 -58
- package/tests/agents-session-transport-process.test.ts +249 -0
- package/tests/agents-session-transport.test.ts +823 -0
- package/tests/artifact-language.test.ts +10 -7
- package/tests/command-palette.test.ts +378 -0
- package/tests/delegated-key-learnings-contract.test.ts +2 -2
- package/tests/fixtures/agents-session-transport-process.mjs +108 -0
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
- package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
- package/tests/fixtures/windows-session-compile.ps1 +110 -0
- package/tests/gentle-agents.test.ts +849 -356
- package/tests/gentle-ai.test.ts +472 -4
- package/tests/gentle-shell.test.ts +201 -8
- package/tests/native-choice-list.test.ts +13 -0
- package/tests/native-review-cli.test.ts +0 -33
- package/tests/odd-routing-contract.test.ts +208 -0
- package/tests/orchestrator-budget.test.ts +17 -2
- package/tests/package-manifest.test.ts +115 -27
- package/tests/persona-single-channel.test.ts +3 -3
- package/tests/pi-pretty.test.ts +45 -0
- package/tests/profile-pin.test.ts +370 -0
- package/tests/quiet-tool-rendering.test.ts +32 -5
- package/tests/review-contract-prompt.test.ts +9 -0
- package/tests/review-controller.test.ts +0 -44
- package/tests/review-session-standing-permission-ipc.test.ts +427 -13
- package/tests/runtime-harness.mjs +4 -4
- package/tests/sdd-agent-tools.test.ts +15 -36
- package/tests/sdd-archive-replay.test.ts +82 -0
- package/tests/sdd-classical-continuation.test.ts +74 -0
- package/tests/sdd-execution-routing-contract.test.ts +18 -2
- package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
- package/tests/sdd-native-managed-uptake.test.ts +11 -21
- package/tests/sdd-no-attempts-contract.test.ts +15 -0
- package/tests/sdd-odd-integration.test.ts +33 -0
- package/tests/sdd-optional-research.test.ts +124 -0
- package/tests/sdd-planning-routing-contract.test.ts +1 -1
- package/tests/sdd-preflight-rpc-input.test.ts +125 -0
- package/tests/sdd-preflight.test.ts +1 -1
- package/tests/sdd-research-capabilities.test.ts +20 -162
- package/tests/sdd-selection-transport.test.ts +180 -88
- package/tests/sdd-status.test.ts +5 -778
- package/tests/sdd-task-truth.test.ts +43 -0
- package/tests/session-change-capture.test.ts +20 -2
- package/tests/session-changes.test.ts +11 -0
- package/tests/shell-bar.test.ts +21 -0
- package/tests/shell-card.test.ts +8 -6
- package/tests/shell-changes.test.ts +8 -0
- package/tests/shell-prompt.test.ts +41 -7
- package/tests/shell-sidebar-banner.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +97 -13
- package/tests/startup-banner.test.ts +55 -2
- package/tests/windows-hidden-processes.test.ts +303 -0
- package/tests/windows-session-bootstrap.test.ts +1772 -0
- package/tests/windows-session-compile.test.ts +170 -0
- package/tests/windows-session-transport.test.ts +754 -0
- package/assets/agents/sdd-sync.md +0 -146
- package/lib/openspec-guardrails.ts +0 -99
- package/tests/native-sdd-attempt-authority.test.ts +0 -240
- package/tests/openspec-guardrails.test.ts +0 -71
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
3
4
|
import { homedir, tmpdir } from "node:os";
|
|
4
5
|
import { createRequire, syncBuiltinESMExports } from "node:module";
|
|
5
|
-
import { join, relative, resolve } from "node:path";
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
6
7
|
import { pendingReviewMutation, REVIEW_REMINDER_RECEIPT } from "../lib/review-reminder-receipt.ts";
|
|
7
8
|
import { SESSION_WORKTREE_ENTRY, SESSION_WORKTREE_CHANGED } from "../lib/session-worktree-registry.ts";
|
|
8
|
-
import test, { after, mock } from "node:test";
|
|
9
|
+
import test, { after, afterEach, mock } from "node:test";
|
|
10
|
+
import type { TestContext } from "node:test";
|
|
9
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
12
|
import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
11
|
-
import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, legacySubagentsInstalled, type AgentsDeps } from "../extensions/gentle-agents.ts";
|
|
13
|
+
import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, createDefaultSessionTransport, legacySubagentsInstalled, type AgentsDeps, type SessionTransportFactory } from "../extensions/gentle-agents.ts";
|
|
14
|
+
import { ActiveSessionClient, ActiveSessionListener, SessionPresenceRegistry } from "../lib/agents-session-transport.ts";
|
|
15
|
+
import { WindowsActiveSessionClient, WindowsActiveSessionListener } from "../lib/windows-session-transport.ts";
|
|
12
16
|
import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
|
|
13
17
|
import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
|
|
14
18
|
import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
|
|
@@ -44,6 +48,21 @@ const PARENT_CONFIRMED_SDD_CONTEXT = renderSddPreflightPrompt({
|
|
|
44
48
|
prompted: true,
|
|
45
49
|
});
|
|
46
50
|
|
|
51
|
+
const inertSessionTransport: SessionTransportFactory = {
|
|
52
|
+
createRegistry: async () => ({ list: async () => [], listActivations: async () => [] }),
|
|
53
|
+
createListener: (registry) => ({ registry, start: async () => {}, close: async () => {} }),
|
|
54
|
+
createClient: () => ({ close() {}, sendNotification: async () => { throw new Error("inert session transport must not send notifications"); } }),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function containsResolvedPath(
|
|
58
|
+
candidate: string,
|
|
59
|
+
path: string,
|
|
60
|
+
paths: Pick<typeof win32, "isAbsolute" | "relative" | "sep"> = { isAbsolute, relative, sep },
|
|
61
|
+
): boolean {
|
|
62
|
+
const fromCandidate = paths.relative(candidate, path);
|
|
63
|
+
return fromCandidate === "" || (!paths.isAbsolute(fromCandidate) && fromCandidate !== ".." && !fromCandidate.startsWith(`..${paths.sep}`));
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
type Overlay = {
|
|
48
67
|
render(width: number): string[];
|
|
49
68
|
handleInput(data: string): void;
|
|
@@ -60,8 +79,14 @@ function mouse(
|
|
|
60
79
|
): TuiMouseEvent {
|
|
61
80
|
return { type, button, x, y, screenX: x, screenY: y, width, height, shift: false, alt: false, ctrl: false };
|
|
62
81
|
}
|
|
63
|
-
const root = mkdtempSync(join(tmpdir(), "gentle-agents-ext-"));
|
|
64
|
-
|
|
82
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "gentle-agents-ext-")));
|
|
83
|
+
const activeSessionTeardowns = new Set<() => Promise<void>>();
|
|
84
|
+
const stopActiveSessions = () => Promise.all([...activeSessionTeardowns].map((shutdown) => shutdown()));
|
|
85
|
+
afterEach(stopActiveSessions);
|
|
86
|
+
after(async () => {
|
|
87
|
+
try { await stopActiveSessions(); }
|
|
88
|
+
finally { rmSync(root, { recursive: true, force: true }); }
|
|
89
|
+
});
|
|
65
90
|
const home = join(root, "home");
|
|
66
91
|
const cwd = join(root, "project");
|
|
67
92
|
const nonGitCwd = join(root, "non-git-project");
|
|
@@ -99,8 +124,24 @@ function fakePi() {
|
|
|
99
124
|
registerShortcut: (key: string, registration: { description: string; handler(ctx: ExtensionContext): Promise<void> }) => shortcuts.set(key, registration),
|
|
100
125
|
registerCommand: (name: string, registration: { handler(args: string, ctx: ExtensionContext): Promise<void> }) => commands.set(name, registration),
|
|
101
126
|
} as unknown as ExtensionAPI;
|
|
127
|
+
let activeSession: ExtensionContext | undefined;
|
|
128
|
+
const teardown = async () => {
|
|
129
|
+
const ctx = activeSession;
|
|
130
|
+
if (ctx === undefined) return;
|
|
131
|
+
await fire("session_shutdown", ctx, { reason: "quit" });
|
|
132
|
+
};
|
|
102
133
|
const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
|
|
103
|
-
|
|
134
|
+
try {
|
|
135
|
+
for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
|
|
136
|
+
} finally {
|
|
137
|
+
if (event === "session_start") {
|
|
138
|
+
activeSession = ctx;
|
|
139
|
+
activeSessionTeardowns.add(teardown);
|
|
140
|
+
} else if (event === "session_shutdown" && activeSession === ctx) {
|
|
141
|
+
activeSession = undefined;
|
|
142
|
+
activeSessionTeardowns.delete(teardown);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
104
145
|
};
|
|
105
146
|
return { pi, tools, shortcuts, commands, fire, sent, renderers, entryRenderers, entries, events, listeners };
|
|
106
147
|
}
|
|
@@ -175,16 +216,91 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
|
|
|
175
216
|
home,
|
|
176
217
|
resolveWorktree: (path, base) => ({ root: resolve(base, path), commonDir: "/fixture/common" }),
|
|
177
218
|
env: { PATH: "/bin" },
|
|
219
|
+
sessionTransport: inertSessionTransport,
|
|
178
220
|
},
|
|
179
221
|
};
|
|
180
222
|
}
|
|
181
223
|
|
|
182
|
-
|
|
224
|
+
const PRINT_BACKGROUND_ERROR = "Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
|
|
225
|
+
|
|
226
|
+
for (const continuation of [false, true]) {
|
|
227
|
+
test(`print mode rejects background ${continuation ? "continuation" : "launch"} before allocating a task`, async (t) => {
|
|
228
|
+
const h = fakePi();
|
|
229
|
+
const runtime = deps();
|
|
230
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
231
|
+
const { ctx } = fakeContext();
|
|
232
|
+
Object.assign(ctx, { mode: "print", hasUI: false });
|
|
233
|
+
await h.fire("session_start", ctx);
|
|
234
|
+
let taskId: string | undefined;
|
|
235
|
+
if (continuation) {
|
|
236
|
+
const pending = h.tools.get("subagent_run")!.execute("seed", { agent: "explore", task: "Map", mode: "task" }, undefined, undefined, ctx);
|
|
237
|
+
await tick();
|
|
238
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
|
|
239
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
240
|
+
const result = await pending;
|
|
241
|
+
taskId = (result.details.gentleAgents as { taskId: string }).taskId;
|
|
242
|
+
}
|
|
243
|
+
const run = t.mock.method(AgentRunner.prototype, "run");
|
|
244
|
+
const spawnedBefore = runtime.spawned.length;
|
|
245
|
+
const entriesBefore = [...h.entries];
|
|
246
|
+
const historyBefore = await loadHistory(home);
|
|
247
|
+
const listBefore = await h.tools.get("subagent_list_tasks")!.execute("before", {}, undefined, undefined, ctx);
|
|
248
|
+
const tool = h.tools.get(continuation ? "subagent_continue" : "subagent_run")!;
|
|
249
|
+
await assert.rejects(tool.execute("denied", continuation
|
|
250
|
+
? { task_id: taskId, prompt: "Follow up", mode: "background" }
|
|
251
|
+
: { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx), { message: PRINT_BACKGROUND_ERROR });
|
|
252
|
+
await tick();
|
|
253
|
+
assert.equal(run.mock.callCount(), 0, "rejection must precede runner task ID allocation");
|
|
254
|
+
assert.equal(runtime.spawned.length, spawnedBefore, "no child spawned");
|
|
255
|
+
assert.deepEqual(await h.tools.get("subagent_list_tasks")!.execute("after", {}, undefined, undefined, ctx), listBefore, "no new task record");
|
|
256
|
+
assert.deepEqual(await loadHistory(home), historyBefore, "no history write");
|
|
257
|
+
assert.deepEqual(h.entries, entriesBefore, "no worktree registration");
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const mode of ["print", "tui", "rpc"] as const) {
|
|
262
|
+
test(`${mode} preserves ${mode === "print" ? "bounded task" : "background"} execution`, async () => {
|
|
263
|
+
const h = fakePi();
|
|
264
|
+
const runtime = deps();
|
|
265
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
266
|
+
const { ctx } = fakeContext();
|
|
267
|
+
Object.assign(ctx, { mode, hasUI: mode === "tui" });
|
|
268
|
+
await h.fire("session_start", ctx);
|
|
269
|
+
let resolved = false;
|
|
270
|
+
const pending = h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: mode === "print" ? "task" : "background" }, undefined, undefined, ctx).then(result => { resolved = true; return result; });
|
|
271
|
+
await tick();
|
|
272
|
+
assert.equal(runtime.spawned.length, 1);
|
|
273
|
+
assert.equal(resolved, mode !== "print", "only task mode waits for completion");
|
|
274
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
|
|
275
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
276
|
+
const result = await pending;
|
|
277
|
+
if (mode === "print") assert.match(result.content[0].text, /mapped/);
|
|
278
|
+
const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
|
|
279
|
+
assert.ok(taskId);
|
|
280
|
+
if (mode !== "print") {
|
|
281
|
+
await tick();
|
|
282
|
+
assert.match((await h.tools.get("subagent_status")!.execute("status", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, /completed · background/);
|
|
283
|
+
assert.equal(h.sent.length, 1, "settlement delivers exactly one completion");
|
|
284
|
+
assert.equal(h.sent[0].message.customType, "gentle-agents.result");
|
|
285
|
+
assert.equal(h.sent[0].message.content, `Subagent explore (task ${taskId}, "Map") finished.\n\nmapped`);
|
|
286
|
+
assert.equal(h.sent[0].message.display, true);
|
|
287
|
+
assert.deepEqual(h.sent[0].options, { deliverAs: "steer", triggerTurn: true });
|
|
288
|
+
await h.fire("turn_end", ctx);
|
|
289
|
+
await h.fire("turn_end", ctx);
|
|
290
|
+
await tick();
|
|
291
|
+
assert.equal(h.sent.length, 1, "later turns must not redeliver the completion");
|
|
292
|
+
assert.equal((await h.tools.get("subagent_result")!.execute("result", { task_id: taskId }, undefined, undefined, ctx)).content[0].text, "mapped");
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
test("all nine subagent registrations own their transcript shell", () => {
|
|
183
298
|
const { pi, tools } = fakePi();
|
|
184
299
|
gentleAgents(pi, {}, deps().deps);
|
|
185
|
-
|
|
186
|
-
assert.
|
|
187
|
-
|
|
300
|
+
const subagentTools = [...tools.values()].filter((tool) => tool.name.startsWith("subagent_"));
|
|
301
|
+
assert.equal(subagentTools.length, 9);
|
|
302
|
+
assert.equal(tools.has("subagent_reconcile"), false);
|
|
303
|
+
for (const tool of subagentTools) assert.equal(tool.renderShell, "self", tool.name);
|
|
188
304
|
});
|
|
189
305
|
|
|
190
306
|
test("host query delivery exposes correlation and accepts one current-session reply", async () => {
|
|
@@ -319,6 +435,359 @@ test("foreground handoff survives settlement before its original await resumes",
|
|
|
319
435
|
});
|
|
320
436
|
|
|
321
437
|
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
438
|
+
type LifecycleOutcome = { status: "fulfilled" } | { status: "rejected"; error: unknown };
|
|
439
|
+
type LifecycleState = { status: "pending" | "fulfilled" | "rejected"; outcome?: LifecycleOutcome };
|
|
440
|
+
const observeLifecycle = <T>(promise: Promise<T>, state: LifecycleState): Promise<LifecycleOutcome> => promise.then(() => { const outcome = { status: "fulfilled" as const }; state.status = outcome.status; state.outcome = outcome; return outcome; }, (error) => { const outcome = { status: "rejected" as const, error }; state.status = outcome.status; state.outcome = outcome; return outcome; });
|
|
441
|
+
const boundedLifecycle = async <T>(promise: Promise<T>, label: string) => {
|
|
442
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
443
|
+
try { return await Promise.race([promise, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 2000); })]); }
|
|
444
|
+
finally { if (timer) clearTimeout(timer); }
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const drainLifecycle = async (label: string, promise: Promise<LifecycleOutcome>) => {
|
|
448
|
+
try { return { label, outcome: await boundedLifecycle(promise, label) }; }
|
|
449
|
+
catch (error) { return { label, error }; }
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
test("overlapping session transport startups preserve ownership and shutdown waits for every pending operation", async (t: TestContext) => {
|
|
453
|
+
const h = fakePi();
|
|
454
|
+
const runtime = deps();
|
|
455
|
+
let sessionId = "old";
|
|
456
|
+
const registryGates: Array<() => void> = [];
|
|
457
|
+
const listenerStarts: string[] = [], listenerCloses: string[] = [], clientCloses: string[] = [], registryCloseCalls: string[] = [], registryCloseEffects: string[] = [];
|
|
458
|
+
const registries = new Map<string, { closed: boolean; close(): Promise<void> }>();
|
|
459
|
+
const transport: SessionTransportFactory = {
|
|
460
|
+
createRegistry: async () => {
|
|
461
|
+
const id = sessionId;
|
|
462
|
+
await new Promise<void>((resolve) => registryGates.push(resolve));
|
|
463
|
+
const registry = { sessionId: id, closed: false, list: async () => [], listActivations: async () => [], close: async () => { registryCloseCalls.push(id); if (!registry.closed) { registry.closed = true; registryCloseEffects.push(id); } } };
|
|
464
|
+
registries.set(id, registry);
|
|
465
|
+
return registry;
|
|
466
|
+
},
|
|
467
|
+
createListener: (registry, id) => ({ registry, closesRegistry: true, start: async () => { listenerStarts.push(id); }, close: async () => { listenerCloses.push(id); await registry.close?.(); } }),
|
|
468
|
+
createClient: (_registry, id) => ({ close: () => { clientCloses.push(id); }, sendNotification: async () => ({ id: "unused", accepted: true }) }),
|
|
469
|
+
};
|
|
470
|
+
runtime.deps.sessionTransport = transport;
|
|
471
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
472
|
+
const { ctx } = fakeContext();
|
|
473
|
+
(ctx.sessionManager as { getSessionId(): string }).getSessionId = () => sessionId;
|
|
474
|
+
const launched: Array<{ state: LifecycleState; outcome: Promise<LifecycleOutcome> }> = [];
|
|
475
|
+
const launch = <T>(promise: Promise<T>) => { const state: LifecycleState = { status: "pending" }; const outcome = observeLifecycle(promise, state); launched.push({ state, outcome }); return { state, outcome }; };
|
|
476
|
+
let shutdownRecord: ReturnType<typeof launch> | undefined;
|
|
477
|
+
let primary: unknown;
|
|
478
|
+
try {
|
|
479
|
+
const first = h.fire("session_start", ctx, { reason: "startup" });
|
|
480
|
+
launch(first);
|
|
481
|
+
await tick();
|
|
482
|
+
sessionId = "new";
|
|
483
|
+
const second = h.fire("session_start", ctx, { reason: "new" });
|
|
484
|
+
const secondRecord = launch(second);
|
|
485
|
+
await tick();
|
|
486
|
+
assert.equal(registryGates.length, 2, "both starts must own pending registry creation");
|
|
487
|
+
registryGates[1]!();
|
|
488
|
+
await eventually(() => listenerStarts.includes("new"), "current startup must publish after its registry is ready");
|
|
489
|
+
const shutdown = h.fire("session_shutdown", ctx, { reason: "quit" });
|
|
490
|
+
shutdownRecord = launch(shutdown);
|
|
491
|
+
await tick();
|
|
492
|
+
assert.equal(secondRecord.state.status, "fulfilled", "the current startup settles before shutdown begins");
|
|
493
|
+
assert.equal(shutdownRecord?.state.status, "pending", "shutdown must wait for the older pending startup, not only the current startup");
|
|
494
|
+
registryGates[0]!();
|
|
495
|
+
assert.equal(registries.has("old"), false, "the released old registry gate has not yet completed acquisition");
|
|
496
|
+
assert.equal(registries.get("old")?.closed, undefined, "the retained old registry cannot be closed before acquisition completes");
|
|
497
|
+
await eventually(() => launched.every(({ state }) => state.status !== "pending"), "all launched lifecycle operations must settle after gate release");
|
|
498
|
+
const outcomes = await Promise.all(launched.map(({ outcome }) => outcome));
|
|
499
|
+
assert.ok(outcomes.every((outcome) => outcome.status === "fulfilled"), "all launched lifecycle operations must fulfill");
|
|
500
|
+
assert.deepEqual(listenerStarts, ["new"], "the stale startup must never publish");
|
|
501
|
+
assert.deepEqual(clientCloses, ["new"], "the current client closes exactly once");
|
|
502
|
+
assert.deepEqual(listenerCloses, ["new"], "the current listener closes exactly once");
|
|
503
|
+
assert.deepEqual(registryCloseCalls.sort(), ["new", "old"], "each owned registry close is invoked exactly once");
|
|
504
|
+
assert.deepEqual(registryCloseEffects.sort(), ["new", "old"], "each owned registry closes exactly once");
|
|
505
|
+
assert.equal(registries.get("old")?.closed, true);
|
|
506
|
+
assert.equal(registries.get("new")?.closed, true);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
primary = error;
|
|
509
|
+
} finally {
|
|
510
|
+
for (const release of registryGates) release();
|
|
511
|
+
if (shutdownRecord === undefined) shutdownRecord = launch(h.fire("session_shutdown", ctx, { reason: "cleanup" }));
|
|
512
|
+
if (launched.length > 0) {
|
|
513
|
+
try {
|
|
514
|
+
await eventually(() => launched.every(({ state }) => state.status !== "pending"), "all launched lifecycle operations must settle during cleanup");
|
|
515
|
+
const outcomes = await Promise.all(launched.map(({ outcome }) => outcome));
|
|
516
|
+
const cleanupFailure = outcomes.find((outcome) => outcome.status === "rejected");
|
|
517
|
+
if (cleanupFailure?.status === "rejected") {
|
|
518
|
+
if (primary === undefined) primary = cleanupFailure.error;
|
|
519
|
+
else t.diagnostic(`Lifecycle cleanup secondary failure: ${cleanupFailure.error instanceof Error ? cleanupFailure.error.message : String(cleanupFailure.error)}`);
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (primary === undefined) primary = error;
|
|
523
|
+
else t.diagnostic(`Lifecycle cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (primary !== undefined) throw primary;
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("session_start does not block a subsequently registered handler on registry creation", async (t: TestContext) => {
|
|
531
|
+
const h = fakePi();
|
|
532
|
+
const runtime = deps();
|
|
533
|
+
let registryEntered = false;
|
|
534
|
+
let listenerCreated = false;
|
|
535
|
+
let clientCreated = false;
|
|
536
|
+
let listenerClosed = 0;
|
|
537
|
+
let clientClosed = 0;
|
|
538
|
+
let releaseRegistry!: () => void;
|
|
539
|
+
const registryGate = new Promise<void>((resolve) => { releaseRegistry = resolve; });
|
|
540
|
+
const transport: SessionTransportFactory = {
|
|
541
|
+
createRegistry: async () => {
|
|
542
|
+
registryEntered = true;
|
|
543
|
+
await registryGate;
|
|
544
|
+
return { list: async () => [], listActivations: async () => [] };
|
|
545
|
+
},
|
|
546
|
+
createListener: (registry) => { listenerCreated = true; return { registry, start: async () => {}, close: async () => { listenerClosed++; } }; },
|
|
547
|
+
createClient: () => { clientCreated = true; return { close() { clientClosed++; }, sendNotification: async () => ({ id: "unused", accepted: true }) }; },
|
|
548
|
+
};
|
|
549
|
+
runtime.deps.sessionTransport = transport;
|
|
550
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
551
|
+
let laterFinished = false;
|
|
552
|
+
h.pi.on("session_start", async () => { laterFinished = true; });
|
|
553
|
+
const { ctx } = fakeContext();
|
|
554
|
+
let outcome: Promise<LifecycleOutcome> | undefined;
|
|
555
|
+
let lifecycleState: LifecycleState | undefined;
|
|
556
|
+
let primary: unknown;
|
|
557
|
+
try {
|
|
558
|
+
const started = h.fire("session_start", ctx, { reason: "startup" });
|
|
559
|
+
lifecycleState = { status: "pending" };
|
|
560
|
+
outcome = observeLifecycle(started, lifecycleState);
|
|
561
|
+
await eventually(() => registryEntered, "registry gate must be entered before the bounded handler assertion");
|
|
562
|
+
await eventually(() => laterFinished, "a subsequently registered session_start handler must finish while registry creation is gated");
|
|
563
|
+
releaseRegistry();
|
|
564
|
+
assert.ok(outcome);
|
|
565
|
+
assert.equal((await outcome).status, "fulfilled");
|
|
566
|
+
} catch (error) {
|
|
567
|
+
primary = error;
|
|
568
|
+
} finally {
|
|
569
|
+
releaseRegistry();
|
|
570
|
+
try {
|
|
571
|
+
if (outcome !== undefined) {
|
|
572
|
+
await eventually(() => listenerCreated && clientCreated, "registry-gated startup must acquire owned resources before cleanup shutdown");
|
|
573
|
+
const shutdownState: LifecycleState = { status: "pending" };
|
|
574
|
+
const shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "cleanup" }), shutdownState);
|
|
575
|
+
await eventually(() => lifecycleState?.status !== "pending" && shutdownState.status !== "pending", "registry-gated startup cleanup must settle all launched operations");
|
|
576
|
+
const cleanupOutcomes = await Promise.all([outcome, shutdownOutcome]);
|
|
577
|
+
assert.equal(listenerClosed, 1, "cleanup closes the owned listener");
|
|
578
|
+
assert.equal(clientClosed, 1, "cleanup closes the owned client");
|
|
579
|
+
const cleanupFailure = cleanupOutcomes.find((value) => value.status === "rejected");
|
|
580
|
+
if (cleanupFailure?.status === "rejected") {
|
|
581
|
+
if (primary === undefined) primary = cleanupFailure.error;
|
|
582
|
+
else t.diagnostic(`Registry-gate cleanup secondary failure: ${cleanupFailure.error instanceof Error ? cleanupFailure.error.message : String(cleanupFailure.error)}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
} catch (error) {
|
|
586
|
+
if (primary === undefined) primary = error;
|
|
587
|
+
else t.diagnostic(`Registry-gate cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (primary !== undefined) throw primary;
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("session_start does not block a subsequently registered handler on listener publication", async (t: TestContext) => {
|
|
594
|
+
const h = fakePi();
|
|
595
|
+
const runtime = deps();
|
|
596
|
+
let listenerEntered = false;
|
|
597
|
+
let listenerClosed = 0;
|
|
598
|
+
let clientClosed = 0;
|
|
599
|
+
let releaseListener!: () => void;
|
|
600
|
+
const listenerGate = new Promise<void>((resolve) => { releaseListener = resolve; });
|
|
601
|
+
const registry = { list: async () => [], listActivations: async () => [] };
|
|
602
|
+
const transport: SessionTransportFactory = {
|
|
603
|
+
createRegistry: async () => registry,
|
|
604
|
+
createListener: (ownedRegistry) => ({
|
|
605
|
+
registry: ownedRegistry,
|
|
606
|
+
start: async () => { listenerEntered = true; await listenerGate; },
|
|
607
|
+
close: async () => { listenerClosed++; },
|
|
608
|
+
}),
|
|
609
|
+
createClient: () => ({ close() { clientClosed++; }, sendNotification: async () => ({ id: "unused", accepted: true }) }),
|
|
610
|
+
};
|
|
611
|
+
runtime.deps.sessionTransport = transport;
|
|
612
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
613
|
+
let laterFinished = false;
|
|
614
|
+
h.pi.on("session_start", async () => { laterFinished = true; });
|
|
615
|
+
const { ctx } = fakeContext();
|
|
616
|
+
let outcome: Promise<LifecycleOutcome> | undefined;
|
|
617
|
+
let lifecycleState: LifecycleState | undefined;
|
|
618
|
+
let primary: unknown;
|
|
619
|
+
try {
|
|
620
|
+
const started = h.fire("session_start", ctx, { reason: "startup" });
|
|
621
|
+
lifecycleState = { status: "pending" };
|
|
622
|
+
outcome = observeLifecycle(started, lifecycleState);
|
|
623
|
+
await eventually(() => listenerEntered, "listener gate must be entered after registry creation");
|
|
624
|
+
await eventually(() => laterFinished, "a subsequently registered session_start handler must finish while listener publication is gated");
|
|
625
|
+
releaseListener();
|
|
626
|
+
assert.ok(outcome);
|
|
627
|
+
assert.equal((await outcome).status, "fulfilled");
|
|
628
|
+
} catch (error) {
|
|
629
|
+
primary = error;
|
|
630
|
+
} finally {
|
|
631
|
+
releaseListener();
|
|
632
|
+
try {
|
|
633
|
+
if (outcome !== undefined) {
|
|
634
|
+
const shutdownState: LifecycleState = { status: "pending" };
|
|
635
|
+
const shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "cleanup" }), shutdownState);
|
|
636
|
+
await eventually(() => lifecycleState?.status !== "pending" && shutdownState.status !== "pending", "listener-gated startup cleanup must settle all launched operations");
|
|
637
|
+
const cleanupOutcomes = await Promise.all([outcome, shutdownOutcome]);
|
|
638
|
+
assert.equal(listenerClosed, 1, "cleanup closes the owned listener");
|
|
639
|
+
assert.equal(clientClosed, 1, "cleanup closes the owned client");
|
|
640
|
+
const cleanupFailure = cleanupOutcomes.find((value) => value.status === "rejected");
|
|
641
|
+
if (cleanupFailure?.status === "rejected") {
|
|
642
|
+
if (primary === undefined) primary = cleanupFailure.error;
|
|
643
|
+
else t.diagnostic(`Listener-gate cleanup secondary failure: ${cleanupFailure.error instanceof Error ? cleanupFailure.error.message : String(cleanupFailure.error)}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
} catch (error) {
|
|
647
|
+
if (primary === undefined) primary = error;
|
|
648
|
+
else t.diagnostic(`Listener-gate cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (primary !== undefined) throw primary;
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("replacement closes a gated stale listener once and leaves the successor owned until shutdown", async (t: TestContext) => {
|
|
655
|
+
const h = fakePi();
|
|
656
|
+
const runtime = deps();
|
|
657
|
+
let sessionId = "alpha";
|
|
658
|
+
let releaseAlpha!: () => void;
|
|
659
|
+
const alphaGate = new Promise<void>((resolve) => { releaseAlpha = resolve; });
|
|
660
|
+
let alphaEntered = false, betaStarted = false, betaClientCreated = false;
|
|
661
|
+
let alphaCallback: ((notification: { id: string; senderSessionId: string; message: string }) => Promise<void>) | undefined;
|
|
662
|
+
let alphaCloses = 0, betaCloses = 0, alphaClientCloses = 0, betaClientCloses = 0;
|
|
663
|
+
const registryCloseCalls: string[] = [], registryCloseEffects: string[] = [];
|
|
664
|
+
const transport: SessionTransportFactory = {
|
|
665
|
+
createRegistry: async () => {
|
|
666
|
+
const id = sessionId;
|
|
667
|
+
const ownedRegistry = { list: async () => [], listActivations: async () => [], closed: false, close: async () => { registryCloseCalls.push(id); if (!ownedRegistry.closed) { ownedRegistry.closed = true; registryCloseEffects.push(id); } } };
|
|
668
|
+
return ownedRegistry;
|
|
669
|
+
},
|
|
670
|
+
createListener: (ownedRegistry, id, callback) => {
|
|
671
|
+
if (id === "alpha") alphaCallback = callback;
|
|
672
|
+
return { registry: ownedRegistry, closesRegistry: true, start: async () => { if (id === "alpha") { alphaEntered = true; await alphaGate; } else betaStarted = true; }, close: async () => { if (id === "alpha") alphaCloses++; else betaCloses++; await ownedRegistry.close?.(); } };
|
|
673
|
+
},
|
|
674
|
+
createClient: (_registry, id) => { if (id === "beta") betaClientCreated = true; return { close: () => { if (id === "alpha") alphaClientCloses++; else betaClientCloses++; }, sendNotification: async () => ({ id: "unused", accepted: true }) }; },
|
|
675
|
+
};
|
|
676
|
+
runtime.deps.sessionTransport = transport;
|
|
677
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
678
|
+
const { ctx } = fakeContext();
|
|
679
|
+
(ctx.sessionManager as { getSessionId(): string }).getSessionId = () => sessionId;
|
|
680
|
+
let primary: unknown;
|
|
681
|
+
let alphaOutcome: Promise<LifecycleOutcome> | undefined;
|
|
682
|
+
let betaOutcome: Promise<LifecycleOutcome> | undefined;
|
|
683
|
+
let shutdownOutcome: Promise<LifecycleOutcome> | undefined;
|
|
684
|
+
let shutdownState: LifecycleState | undefined;
|
|
685
|
+
try {
|
|
686
|
+
alphaOutcome = observeLifecycle(h.fire("session_start", ctx, { reason: "alpha" }), { status: "pending" });
|
|
687
|
+
await eventually(() => alphaEntered, "replacement test must enter the alpha listener gate");
|
|
688
|
+
sessionId = "beta";
|
|
689
|
+
betaOutcome = observeLifecycle(h.fire("session_start", ctx, { reason: "beta" }), { status: "pending" });
|
|
690
|
+
assert.ok(betaOutcome);
|
|
691
|
+
assert.equal((await boundedLifecycle(betaOutcome, "successor startup")).status, "fulfilled");
|
|
692
|
+
await eventually(() => betaClientCreated && betaStarted, "replacement must acquire and publish the successor before ownership assertions");
|
|
693
|
+
assert.equal(betaClientCloses, 0, "replacement must not close the successor client");
|
|
694
|
+
assert.equal(betaCloses, 0, "replacement must not close the successor listener");
|
|
695
|
+
assert.ok(alphaCallback, "the gated listener registered its callback before start");
|
|
696
|
+
await boundedLifecycle(assert.rejects(alphaCallback!({ id: "late", senderSessionId: "peer", message: "late" }), /stale session transport/), "stale callback rejection");
|
|
697
|
+
shutdownState = { status: "pending" };
|
|
698
|
+
shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "quit" }), shutdownState);
|
|
699
|
+
await boundedLifecycle(tick(), "replacement shutdown scheduling");
|
|
700
|
+
assert.equal(shutdownState.status, "pending", "shutdown waits while the replaced listener startup remains gated");
|
|
701
|
+
releaseAlpha();
|
|
702
|
+
assert.ok(alphaOutcome);
|
|
703
|
+
assert.equal((await boundedLifecycle(alphaOutcome, "stale alpha startup")).status, "fulfilled");
|
|
704
|
+
assert.equal(alphaClientCloses, 1, "stale alpha client closes once after its gate releases");
|
|
705
|
+
assert.equal(alphaCloses, 1, "stale alpha listener closes once after its gate releases");
|
|
706
|
+
assert.ok(shutdownOutcome);
|
|
707
|
+
assert.equal((await boundedLifecycle(shutdownOutcome, "successor shutdown")).status, "fulfilled");
|
|
708
|
+
assert.equal(betaClientCloses, 1, "shutdown closes the successor client once");
|
|
709
|
+
assert.equal(betaCloses, 1, "shutdown closes the successor listener once");
|
|
710
|
+
assert.deepEqual(registryCloseCalls.sort(), ["alpha", "beta"], "each owned registry close is invoked once");
|
|
711
|
+
assert.deepEqual(registryCloseEffects.sort(), ["alpha", "beta"], "each owned registry closes once");
|
|
712
|
+
} catch (error) {
|
|
713
|
+
primary = error;
|
|
714
|
+
} finally {
|
|
715
|
+
releaseAlpha();
|
|
716
|
+
try {
|
|
717
|
+
if (shutdownOutcome === undefined) shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "cleanup" }), { status: "pending" });
|
|
718
|
+
const cleanup = await Promise.all([
|
|
719
|
+
...(alphaOutcome === undefined ? [] : [drainLifecycle("alpha cleanup", alphaOutcome)]),
|
|
720
|
+
...(betaOutcome === undefined ? [] : [drainLifecycle("beta cleanup", betaOutcome)]),
|
|
721
|
+
...(shutdownOutcome === undefined ? [] : [drainLifecycle("shutdown cleanup", shutdownOutcome)]),
|
|
722
|
+
]);
|
|
723
|
+
for (const result of cleanup) {
|
|
724
|
+
if ("error" in result) {
|
|
725
|
+
if (primary === undefined) primary = result.error;
|
|
726
|
+
else t.diagnostic(`${result.label} secondary failure: ${result.error instanceof Error ? result.error.message : String(result.error)}`);
|
|
727
|
+
} else if (result.outcome.status === "rejected") {
|
|
728
|
+
if (primary === undefined) primary = result.outcome.error;
|
|
729
|
+
else t.diagnostic(`${result.label} secondary rejection: ${result.outcome.error instanceof Error ? result.outcome.error.message : String(result.outcome.error)}`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (primary === undefined) primary = error;
|
|
734
|
+
else t.diagnostic(`replacement cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (primary !== undefined) throw primary;
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
test("startup cleanup contains client-close failure and still closes the remaining owned resources", async (t: TestContext) => {
|
|
741
|
+
const h = fakePi();
|
|
742
|
+
const runtime = deps();
|
|
743
|
+
const startupError = new Error("listener unavailable");
|
|
744
|
+
const clientError = new Error("client close failed");
|
|
745
|
+
let listenerCloses = 0, registryCloses = 0, clientCloseAttempts = 0;
|
|
746
|
+
const registry = { list: async () => [], listActivations: async () => [], close: async () => { registryCloses++; } };
|
|
747
|
+
runtime.deps.sessionTransport = {
|
|
748
|
+
createRegistry: async () => registry,
|
|
749
|
+
createListener: () => ({ registry, start: async () => { throw startupError; }, close: async () => { listenerCloses++; } }),
|
|
750
|
+
createClient: () => ({ close: () => { clientCloseAttempts++; throw clientError; }, sendNotification: async () => ({ id: "unused", accepted: true }) }),
|
|
751
|
+
};
|
|
752
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
753
|
+
const { ctx } = fakeContext();
|
|
754
|
+
const outcome = observeLifecycle(h.fire("session_start", ctx, { reason: "startup" }), { status: "pending" });
|
|
755
|
+
let shutdownOutcome: Promise<LifecycleOutcome> | undefined;
|
|
756
|
+
let primary: unknown;
|
|
757
|
+
try {
|
|
758
|
+
assert.equal((await boundedLifecycle(outcome, "startup failure settlement")).status, "fulfilled", "startup failure remains contained");
|
|
759
|
+
await eventually(() => clientCloseAttempts === 1 && listenerCloses === 1 && registryCloses === 1, "startup cleanup attempts all owned resources before counter assertions");
|
|
760
|
+
assert.equal(clientCloseAttempts, 1, "startup cleanup attempts the owned client once");
|
|
761
|
+
assert.equal(listenerCloses, 1, "startup cleanup attempts the owned listener once");
|
|
762
|
+
assert.equal(registryCloses, 1, "startup cleanup attempts the owned registry once");
|
|
763
|
+
shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "verified-cleanup" }), { status: "pending" });
|
|
764
|
+
assert.equal((await boundedLifecycle(shutdownOutcome, "verified startup shutdown")).status, "fulfilled");
|
|
765
|
+
} catch (error) {
|
|
766
|
+
primary = error;
|
|
767
|
+
} finally {
|
|
768
|
+
try {
|
|
769
|
+
if (shutdownOutcome === undefined) shutdownOutcome = observeLifecycle(h.fire("session_shutdown", ctx, { reason: "cleanup" }), { status: "pending" });
|
|
770
|
+
assert.ok(shutdownOutcome);
|
|
771
|
+
const cleanup = await Promise.all([
|
|
772
|
+
drainLifecycle("startup cleanup", outcome),
|
|
773
|
+
drainLifecycle("startup shutdown", shutdownOutcome),
|
|
774
|
+
]);
|
|
775
|
+
for (const result of cleanup) {
|
|
776
|
+
if ("error" in result) {
|
|
777
|
+
if (primary === undefined) primary = result.error;
|
|
778
|
+
else t.diagnostic(`${result.label} secondary failure: ${result.error instanceof Error ? result.error.message : String(result.error)}`);
|
|
779
|
+
} else if (result.outcome.status === "rejected") {
|
|
780
|
+
if (primary === undefined) primary = result.outcome.error;
|
|
781
|
+
else t.diagnostic(`${result.label} secondary rejection: ${result.outcome.error instanceof Error ? result.outcome.error.message : String(result.outcome.error)}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
} catch (error) {
|
|
785
|
+
if (primary === undefined) primary = error;
|
|
786
|
+
else t.diagnostic(`startup cleanup secondary failure: ${error instanceof Error ? error.message : String(error)}`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (primary !== undefined) throw primary;
|
|
790
|
+
});
|
|
322
791
|
|
|
323
792
|
test("child parent-message tooling admits notifications and the active parent preserves raw model text", async () => {
|
|
324
793
|
const child = fakePi();
|
|
@@ -748,20 +1217,18 @@ test("research launch transports selected grants and only matching existing exte
|
|
|
748
1217
|
fake.pi.getActiveTools = () => ["fetch_content", "web_search", "mcp", "bash"];
|
|
749
1218
|
fake.pi.getAllTools = () => fake.pi.getActiveTools().map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) as never;
|
|
750
1219
|
const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
|
|
751
|
-
const artifact = { store: "openspec", worktree: cwd, changeName: "demo", retainedIntent: "preserve denied documentation questions", locators: [{ artifact: "research", path: join(cwd, "openspec/changes/demo/research.md"), revision: 1, digest: "a".repeat(64) }] };
|
|
752
1220
|
let childEnv: NodeJS.ProcessEnv = {};
|
|
753
1221
|
gentleAgents(fake.pi, {}, { ...runtime.deps, home: fixtureHome, spawn: (command, args, options) => {
|
|
754
1222
|
childEnv = options.env!;
|
|
755
1223
|
return runtime.deps.spawn!(command, args, options);
|
|
756
1224
|
} });
|
|
757
|
-
const result = await fake.tools.get("subagent_run")!.execute("research", { agent: "sdd-research", task: "Research docs", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", research_selection: selection
|
|
1225
|
+
const result = await fake.tools.get("subagent_run")!.execute("research", { agent: "sdd-research", task: "Research docs", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", research_selection: selection }, undefined, undefined, ctx);
|
|
758
1226
|
await tick();
|
|
759
1227
|
const argv = runtime.spawned[0];
|
|
760
|
-
assert.equal(argv[argv.indexOf("--tools") + 1], "
|
|
1228
|
+
assert.equal(argv[argv.indexOf("--tools") + 1], "fetch_content,subagent_parent_message");
|
|
761
1229
|
assert.equal(argv[argv.indexOf("--extension") + 1], "/installed/web.ts");
|
|
762
1230
|
assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), selection);
|
|
763
|
-
assert.
|
|
764
|
-
assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_artifact);
|
|
1231
|
+
assert.equal(fake.tools.get("subagent_continue")!.parameters.properties.research_artifact, undefined);
|
|
765
1232
|
assert.ok(fake.tools.get("subagent_continue")!.parameters.properties.research_selection, "fresh selection must be expressible on continuation");
|
|
766
1233
|
assert.ok(JSON.parse(childEnv.GENTLE_PI_RESEARCH_TOOLS!).includes("subagent_parent_message"));
|
|
767
1234
|
assert.match(argv[argv.indexOf("--append-system-prompt") + 1], /documentation: available/);
|
|
@@ -769,33 +1236,46 @@ test("research launch transports selected grants and only matching existing exte
|
|
|
769
1236
|
runtime.children[0].emit({ type: "agent_settled" });
|
|
770
1237
|
await tick();
|
|
771
1238
|
const taskId = (result.details.gentleAgents as { taskId: string }).taskId;
|
|
772
|
-
|
|
773
|
-
assert.match(rejected.content[0].text, /scope/);
|
|
774
|
-
assert.equal(runtime.spawned.length, 1);
|
|
775
|
-
await fake.tools.get("subagent_continue")!.execute("resume", { task_id: taskId, prompt: "Inspect", mode: "background", research_artifact: artifact }, undefined, undefined, ctx);
|
|
1239
|
+
await fake.tools.get("subagent_continue")!.execute("resume", { task_id: taskId, prompt: "Inspect", mode: "background" }, undefined, undefined, ctx);
|
|
776
1240
|
await tick();
|
|
777
1241
|
assert.equal(runtime.spawned.length, 2);
|
|
778
1242
|
assert.ok(!runtime.spawned[1].includes("--extension"), "no inherited research selection");
|
|
779
|
-
assert.equal(runtime.spawned[1][runtime.spawned[1].indexOf("--tools") + 1], "
|
|
1243
|
+
assert.equal(runtime.spawned[1][runtime.spawned[1].indexOf("--tools") + 1], "subagent_parent_message");
|
|
780
1244
|
assert.equal(JSON.parse(childEnv.GENTLE_PI_RESEARCH_SELECTION!), null);
|
|
781
|
-
assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact, "denial intent and exact store/path survive re-entry");
|
|
782
1245
|
fake.pi.getActiveTools = () => ["web_search"];
|
|
783
1246
|
runtime.children[1].emit({ type: "agent_settled" });
|
|
784
1247
|
await tick();
|
|
785
|
-
const denied = await fake.tools.get("subagent_continue")!.execute("missing-tool", { task_id: taskId, prompt: "Retry same scope", mode: "background", research_selection: selection
|
|
1248
|
+
const denied = await fake.tools.get("subagent_continue")!.execute("missing-tool", { task_id: taskId, prompt: "Retry same scope", mode: "background", research_selection: selection }, undefined, undefined, ctx);
|
|
786
1249
|
await tick();
|
|
787
1250
|
assert.ok(!runtime.spawned[2].includes("--extension"));
|
|
788
1251
|
runtime.children[2].emit({ type: "agent_settled" });
|
|
789
1252
|
await tick();
|
|
790
1253
|
fake.pi.getActiveTools = () => ["fetch_content", "web_search"];
|
|
791
|
-
await fake.tools.get("subagent_continue")!.execute("corrected", { task_id: (denied.details.gentleAgents as { taskId: string }).taskId, prompt: "Retry same scope", mode: "background", research_selection: selection
|
|
1254
|
+
await fake.tools.get("subagent_continue")!.execute("corrected", { task_id: (denied.details.gentleAgents as { taskId: string }).taskId, prompt: "Retry same scope", mode: "background", research_selection: selection }, undefined, undefined, ctx);
|
|
792
1255
|
await tick();
|
|
793
1256
|
assert.equal(runtime.spawned[3][runtime.spawned[3].indexOf("--extension") + 1], "/installed/web.ts");
|
|
794
|
-
assert.deepEqual(JSON.parse(childEnv.GENTLE_PI_RESEARCH_ARTIFACT!), artifact);
|
|
795
1257
|
await fake.fire("session_shutdown", ctx);
|
|
796
1258
|
});
|
|
797
1259
|
|
|
798
|
-
test("research
|
|
1260
|
+
test("research registered continuation needs no prior artifact identity", async () => {
|
|
1261
|
+
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
1262
|
+
const home = join(root, "optional-research-home");
|
|
1263
|
+
mkdirSync(join(home, ".pi/agent/agents"), { recursive: true });
|
|
1264
|
+
writeFileSync(join(home, ".pi/agent/agents/sdd-research.md"), "---\nname: sdd-research\ntools: [read]\n---\nExplore a question.");
|
|
1265
|
+
gentleAgents(h.pi, {}, { ...runtime.deps, home });
|
|
1266
|
+
await h.fire("session_start", ctx);
|
|
1267
|
+
const first = await h.tools.get("subagent_run")!.execute("first", { agent: "sdd-research", task: "Inspect a question", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background" }, undefined, undefined, ctx);
|
|
1268
|
+
await tick();
|
|
1269
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
1270
|
+
await tick();
|
|
1271
|
+
const taskId = (first.details.gentleAgents as { taskId: string }).taskId;
|
|
1272
|
+
const next = await h.tools.get("subagent_continue")!.execute("next", { task_id: taskId, prompt: "Investigate the remaining question", mode: "background" }, undefined, undefined, ctx);
|
|
1273
|
+
await tick();
|
|
1274
|
+
assert.equal(runtime.spawned.length, 2, JSON.stringify(next));
|
|
1275
|
+
await h.fire("session_shutdown", ctx);
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test("research child inventory exposes remaining authorized tools", () => {
|
|
799
1279
|
const required = ["web_search", "source_check", "fetch_content", "get_search_content"];
|
|
800
1280
|
for (const missing of [undefined, ...required]) {
|
|
801
1281
|
const hooks = new Map<string, (event: any) => any>();
|
|
@@ -803,7 +1283,7 @@ test("research child inventory requires every canonical open-web tool", () => {
|
|
|
803
1283
|
const pi = { on: (name: string, handler: (event: any) => any) => hooks.set(name, handler), getActiveTools: () => active, getAllTools: () => required.map(name => ({ name, sourceInfo: { source: "extension", path: "/installed/web.ts" } })) } as never;
|
|
804
1284
|
gentleAgents(pi, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(required), GENTLE_PI_RESEARCH_SELECTION: JSON.stringify({ documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } }, "open-web": { tools: required, extensions: Object.fromEntries(required.map(name => [name, "/installed/web.ts"])) } }) });
|
|
805
1285
|
const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt;
|
|
806
|
-
assert.match(prompt,
|
|
1286
|
+
assert.match(prompt, /open-web: available/);
|
|
807
1287
|
assert.match(prompt, new RegExp(`documentation: ${missing === "fetch_content" ? "blocked" : "available"}`));
|
|
808
1288
|
assert.match(prompt, /Availability is not evidence/);
|
|
809
1289
|
}
|
|
@@ -816,7 +1296,7 @@ test("research child rechecks local inventory and blocks gateway calls", async (
|
|
|
816
1296
|
assert.match(hooks.get("before_agent_start")!({ systemPrompt: "research" }).systemPrompt, /documentation: blocked/);
|
|
817
1297
|
assert.equal(hooks.get("tool_call")!({ toolName: "mcp" }).block, true);
|
|
818
1298
|
assert.equal(hooks.get("tool_call")!({ toolName: "fetch_content" }).block, true);
|
|
819
|
-
assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true, "missing
|
|
1299
|
+
assert.equal(hooks.get("tool_call")!({ toolName: "read" }).block, true, "missing path context cannot authorize a read");
|
|
820
1300
|
});
|
|
821
1301
|
|
|
822
1302
|
async function shutdownAndRestoreNativeSpawn(
|
|
@@ -879,7 +1359,7 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
|
|
|
879
1359
|
ctx.sessionManager.getBranch = (() => h.entries) as typeof ctx.sessionManager.getBranch;
|
|
880
1360
|
d.deps.resolveWorktree = (path, base) => {
|
|
881
1361
|
const absolute = resolve(base, path);
|
|
882
|
-
const worktree = [cwd, sibling].find((candidate) =>
|
|
1362
|
+
const worktree = [cwd, sibling].find((candidate) => containsResolvedPath(candidate, absolute));
|
|
883
1363
|
return worktree ? { root: worktree, commonDir: "/fixture/common" } : undefined;
|
|
884
1364
|
};
|
|
885
1365
|
const spawn = d.deps.spawn!;
|
|
@@ -911,6 +1391,14 @@ for (const scenario of ["own", "other-root", "escaped", "sibling", "session-swit
|
|
|
911
1391
|
});
|
|
912
1392
|
}
|
|
913
1393
|
|
|
1394
|
+
test("worktree attribution containment respects Windows path boundaries", () => {
|
|
1395
|
+
const candidate = win32.resolve("C:\\fixture", "project");
|
|
1396
|
+
assert.equal(containsResolvedPath(candidate, win32.resolve(candidate), win32), true, "the worktree root itself is contained");
|
|
1397
|
+
assert.equal(containsResolvedPath(candidate, win32.resolve(candidate, "nested", "file.ts"), win32), true, "Windows descendants are contained");
|
|
1398
|
+
assert.equal(containsResolvedPath(candidate, win32.resolve(candidate, ".."), win32), false, "the parent is excluded");
|
|
1399
|
+
assert.equal(containsResolvedPath(candidate, win32.resolve("C:\\fixture", "project-sibling", "file.ts"), win32), false, "a sibling prefix is excluded");
|
|
1400
|
+
});
|
|
1401
|
+
|
|
914
1402
|
test("default Node spawn adapter distinguishes IPC-only and permission-capable canonical Git children", async () => {
|
|
915
1403
|
const childProcess = createRequire(import.meta.url)("node:child_process") as typeof import("node:child_process");
|
|
916
1404
|
const originalSpawn = childProcess.spawn;
|
|
@@ -918,23 +1406,16 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
918
1406
|
const captured: Array<{ command: string; args: readonly string[]; options: CapturedSpawnOptions }> = [];
|
|
919
1407
|
const children: FakeChild[] = [];
|
|
920
1408
|
const shutdown: Array<() => Promise<void>> = [];
|
|
921
|
-
const
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
try {
|
|
932
|
-
return action();
|
|
933
|
-
} finally {
|
|
934
|
-
if (previousPath === undefined) delete process.env.PATH;
|
|
935
|
-
else process.env.PATH = previousPath;
|
|
936
|
-
}
|
|
937
|
-
};
|
|
1409
|
+
const canonicalGitFixture = mkdtempSync(join(tmpdir(), "gentle-agents-canonical-git-"));
|
|
1410
|
+
const canonicalGitCwd = join(canonicalGitFixture, "project");
|
|
1411
|
+
const gitTemplate = join(canonicalGitFixture, "template");
|
|
1412
|
+
try {
|
|
1413
|
+
mkdirSync(gitTemplate);
|
|
1414
|
+
execFileSync("git", ["init", "--quiet", `--template=${gitTemplate}`, canonicalGitCwd]);
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
rmSync(canonicalGitFixture, { recursive: true, force: true });
|
|
1417
|
+
throw error;
|
|
1418
|
+
}
|
|
938
1419
|
childProcess.spawn = ((command: string, args: readonly string[], options: Record<string, unknown>) => {
|
|
939
1420
|
captured.push({ command, args, options: options as unknown as CapturedSpawnOptions });
|
|
940
1421
|
const child = fakeChild();
|
|
@@ -945,12 +1426,12 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
945
1426
|
try {
|
|
946
1427
|
const launch = async (mode: "task" | "background", env: NodeJS.ProcessEnv, sessionCwd = nonGitCwd) => {
|
|
947
1428
|
const h = fakePi();
|
|
948
|
-
gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined });
|
|
1429
|
+
gentleAgents(h.pi, env, { home, agentHome: join(home, ".pi", "agent"), env, pi: { command: "/fixture/pi", args: ["--host-flag"] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport });
|
|
949
1430
|
const { ctx } = fakeContext();
|
|
950
1431
|
(ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
|
|
951
1432
|
await h.fire("session_start", ctx);
|
|
952
1433
|
shutdown.push(() => h.fire("session_shutdown", ctx));
|
|
953
|
-
return { h, ctx, result:
|
|
1434
|
+
return { h, ctx, result: h.tools.get("subagent_run")!.execute(`spawn-${mode}`, { agent: "explore", task: `Capture ${mode}`, mode }, undefined, undefined, ctx) };
|
|
954
1435
|
};
|
|
955
1436
|
const task = await launch("task", { PATH: "/bin", FIXTURE: "task" });
|
|
956
1437
|
await tick();
|
|
@@ -968,6 +1449,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
968
1449
|
|
|
969
1450
|
const args = ["--host-flag", "--mode", "rpc", "--session-dir", join(home, ".pi", "agent", "gentle-agents", "sessions"), "--model", "openai-codex/gpt-5.6-terra:low", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."];
|
|
970
1451
|
assert.equal(captured.length, 3, "the extension reaches Node's spawn boundary for IPC-only and permission-channel launches");
|
|
1452
|
+
const permissionChannelStdio = process.platform === "win32" ? "overlapped" : "pipe";
|
|
971
1453
|
for (const [index, fixture] of ["task", "background", "permission"].entries()) {
|
|
972
1454
|
const permissionChannel = index === 2;
|
|
973
1455
|
const ownedIpc = captured[index]?.options.env.GENTLE_PI_AGENTS_OWNED_IPC;
|
|
@@ -979,13 +1461,17 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
|
|
|
979
1461
|
assert.equal(captured[index]?.options.shell, undefined, "the adapter does not invoke a shell");
|
|
980
1462
|
assert.equal(captured[index]?.options.windowsHide, true, "the adapter always hides a Windows console");
|
|
981
1463
|
assert.equal(captured[index]?.options.detached, process.platform !== "win32", "the adapter forwards the runner's platform selection");
|
|
982
|
-
assert.deepEqual(captured[index]?.options.stdio, permissionChannel ? ["pipe", "pipe", "pipe",
|
|
1464
|
+
assert.deepEqual(captured[index]?.options.stdio, permissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"] : ["pipe", "pipe", "pipe", "ipc"], permissionChannel ? "canonical repository children retain an fd3 permission channel and receive messaging IPC at fd4" : "IPC-only children have no inherited permission fd");
|
|
983
1465
|
}
|
|
984
1466
|
await Promise.all(shutdown.map((close) => close()));
|
|
985
1467
|
assert.deepEqual(children[1]?.killed, ["SIGTERM"], "session shutdown cleans up an active background child");
|
|
986
1468
|
shutdown.length = 0;
|
|
987
1469
|
} finally {
|
|
988
|
-
|
|
1470
|
+
try {
|
|
1471
|
+
await shutdownAndRestoreNativeSpawn(childProcess, originalSpawn, () => Promise.all(shutdown.map((close) => close())));
|
|
1472
|
+
} finally {
|
|
1473
|
+
rmSync(canonicalGitFixture, { recursive: true, force: true });
|
|
1474
|
+
}
|
|
989
1475
|
}
|
|
990
1476
|
});
|
|
991
1477
|
|
|
@@ -1010,7 +1496,7 @@ test("native spawn interception restores CommonJS and ESM exports after rejected
|
|
|
1010
1496
|
return undefined;
|
|
1011
1497
|
};
|
|
1012
1498
|
}
|
|
1013
|
-
gentleAgents(h.pi, {}, { home, agentHome: join(home, ".pi", "agent"), env: { PATH: "/bin" }, pi: { command: "/fixture/pi", args: [] }, resolveWorktree: () => undefined });
|
|
1499
|
+
gentleAgents(h.pi, {}, { home, agentHome: join(home, ".pi", "agent"), env: { PATH: "/bin" }, pi: { command: "/fixture/pi", args: [] }, resolveWorktree: () => undefined, sessionTransport: inertSessionTransport });
|
|
1014
1500
|
const { ctx } = fakeContext();
|
|
1015
1501
|
await h.fire("session_start", ctx);
|
|
1016
1502
|
await h.tools.get("subagent_run")!.execute("cleanup", { agent: "explore", task: "Keep cleanup live", mode: "background" }, undefined, undefined, ctx);
|
|
@@ -1190,16 +1676,16 @@ test("delayed child spawn retains the originating session and cannot append into
|
|
|
1190
1676
|
|
|
1191
1677
|
test("agentRuntimePaths isolates sessions and transcripts by profile and retains the explicit-home fallback", () => {
|
|
1192
1678
|
assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-principal/agent"), {
|
|
1193
|
-
sessions: "/profiles/pi-principal/agent
|
|
1194
|
-
transcripts: "/profiles/pi-principal/agent
|
|
1679
|
+
sessions: join("/profiles/pi-principal/agent", "gentle-agents", "sessions"),
|
|
1680
|
+
transcripts: join("/profiles/pi-principal/agent", "gentle-agents", "transcripts"),
|
|
1195
1681
|
});
|
|
1196
1682
|
assert.deepEqual(agentRuntimePaths("/home/x", "/profiles/pi-lab/agent"), {
|
|
1197
|
-
sessions: "/profiles/pi-lab/agent
|
|
1198
|
-
transcripts: "/profiles/pi-lab/agent
|
|
1683
|
+
sessions: join("/profiles/pi-lab/agent", "gentle-agents", "sessions"),
|
|
1684
|
+
transcripts: join("/profiles/pi-lab/agent", "gentle-agents", "transcripts"),
|
|
1199
1685
|
});
|
|
1200
1686
|
assert.deepEqual(agentRuntimePaths("/home/x"), {
|
|
1201
|
-
sessions: "/home/x
|
|
1202
|
-
transcripts: "/home/x
|
|
1687
|
+
sessions: join("/home/x", ".pi", "agent", "gentle-agents", "sessions"),
|
|
1688
|
+
transcripts: join("/home/x", ".pi", "agent", "gentle-agents", "transcripts"),
|
|
1203
1689
|
});
|
|
1204
1690
|
});
|
|
1205
1691
|
|
|
@@ -1258,6 +1744,108 @@ for (const [key, tilde] of [["GENTLE_PI_AGENT_HOME", false], ["PI_CODING_AGENT_D
|
|
|
1258
1744
|
});
|
|
1259
1745
|
}
|
|
1260
1746
|
|
|
1747
|
+
// A per-repository profile pin is resolved at launch against the global profiles
|
|
1748
|
+
// store. The fixture writes both layers into a sandbox instead of a real clone and
|
|
1749
|
+
// binds them to the launch through the worktree resolver the launch already uses,
|
|
1750
|
+
// so these tests never depend on the ambient Git state.
|
|
1751
|
+
function pinFixture(name: string) {
|
|
1752
|
+
const base = join(root, `pin-${name}`);
|
|
1753
|
+
const worktreeRoot = join(base, "worktree");
|
|
1754
|
+
const commonDir = join(base, "git-common");
|
|
1755
|
+
const configHome = join(base, "config");
|
|
1756
|
+
for (const dir of [worktreeRoot, commonDir, configHome]) mkdirSync(dir, { recursive: true });
|
|
1757
|
+
const writePinText = (path: string, profile: string) => {
|
|
1758
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1759
|
+
writeFileSync(path, `${JSON.stringify({ kind: "gentle-pi.agent_model_profile_pin", version: 1, profile }, null, 2)}\n`);
|
|
1760
|
+
};
|
|
1761
|
+
const localPinPath = join(commonDir, "gentle-ai", "profile-pin.json");
|
|
1762
|
+
const declarationPath = join(worktreeRoot, ".pi", "gentle-ai", "profile.json");
|
|
1763
|
+
return {
|
|
1764
|
+
root: worktreeRoot,
|
|
1765
|
+
commonDir,
|
|
1766
|
+
configHome,
|
|
1767
|
+
localPinPath,
|
|
1768
|
+
declarationPath,
|
|
1769
|
+
writePin: (profile: string) => writePinText(localPinPath, profile),
|
|
1770
|
+
writeDeclaration: (profile: string) => writePinText(declarationPath, profile),
|
|
1771
|
+
writeStore: (profiles: Record<string, unknown>) => {
|
|
1772
|
+
writeFileSync(join(configHome, "profiles.json"), `${JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, profiles }, null, 2)}\n`);
|
|
1773
|
+
},
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// The model the child was actually spawned with, resolved for the repository the
|
|
1778
|
+
// pin fixture binds to the launch.
|
|
1779
|
+
async function launchPinned(base: ReturnType<typeof pinFixture>): Promise<string> {
|
|
1780
|
+
const harness = deps();
|
|
1781
|
+
harness.deps.resolveWorktree = () => ({ root: base.root, commonDir: base.commonDir });
|
|
1782
|
+
harness.deps.env = { PATH: "/bin", GENTLE_PI_CONFIG_HOME: base.configHome };
|
|
1783
|
+
const { pi, tools, fire } = fakePi();
|
|
1784
|
+
gentleAgents(pi, {}, harness.deps);
|
|
1785
|
+
const { ctx } = fakeContext();
|
|
1786
|
+
await fire("session_start", ctx);
|
|
1787
|
+
try {
|
|
1788
|
+
await tools.get("subagent_run")!.execute("pin", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
|
|
1789
|
+
await tick();
|
|
1790
|
+
const args = harness.spawned[0];
|
|
1791
|
+
return args[args.indexOf("--model") + 1];
|
|
1792
|
+
} finally {
|
|
1793
|
+
await fire("session_shutdown", ctx);
|
|
1794
|
+
await tick();
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
test("a local pin routes the repository's subagent launches through the pinned profile", async () => {
|
|
1799
|
+
const base = pinFixture("local");
|
|
1800
|
+
base.writeStore({
|
|
1801
|
+
pinned: {
|
|
1802
|
+
// The reserved orchestrator key travels inside the profile but is never
|
|
1803
|
+
// subagent routing.
|
|
1804
|
+
orchestrator: { model: "nan/glm5.3", thinking: "max" },
|
|
1805
|
+
explore: { model: "openai/alpha", thinking: "minimal" },
|
|
1806
|
+
},
|
|
1807
|
+
});
|
|
1808
|
+
base.writePin("pinned");
|
|
1809
|
+
assert.equal(await launchPinned(base), "openai/alpha:minimal");
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
test("a pinned profile replaces global subagent routing instead of merging it", async () => {
|
|
1813
|
+
const base = pinFixture("replace");
|
|
1814
|
+
// The global subagents.json routes explore at a lower effort and the pinned
|
|
1815
|
+
// profile does not mention explore at all: wholesale replacement returns it to
|
|
1816
|
+
// its definition routing instead of inheriting the global profile.
|
|
1817
|
+
base.writeStore({ pinned: { helper: { model: "openai/beta" } } });
|
|
1818
|
+
base.writePin("pinned");
|
|
1819
|
+
assert.equal(await launchPinned(base), "openai-codex/gpt-5.6-terra:high");
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
test("a committed repository declaration pins the worktree when no local pin exists", async () => {
|
|
1823
|
+
const base = pinFixture("declaration");
|
|
1824
|
+
base.writeStore({ declared: { explore: { model: "openai/alpha" } } });
|
|
1825
|
+
base.writeDeclaration("declared");
|
|
1826
|
+
assert.equal(await launchPinned(base), "openai/alpha:high");
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
test("a local pin takes precedence over the worktree's repository declaration", async () => {
|
|
1830
|
+
const base = pinFixture("precedence");
|
|
1831
|
+
base.writeStore({
|
|
1832
|
+
declared: { explore: { model: "openai/alpha" } },
|
|
1833
|
+
local: { explore: { model: "openai/beta" } },
|
|
1834
|
+
});
|
|
1835
|
+
base.writeDeclaration("declared");
|
|
1836
|
+
base.writePin("local");
|
|
1837
|
+
assert.equal(await launchPinned(base), "openai/beta:high");
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
test("a stale or unreadable pin degrades to the global routing instead of failing the launch", async () => {
|
|
1841
|
+
const base = pinFixture("stale");
|
|
1842
|
+
base.writeStore({ other: { explore: { model: "openai/alpha" } } });
|
|
1843
|
+
base.writePin("deleted-profile");
|
|
1844
|
+
assert.equal(await launchPinned(base), "openai-codex/gpt-5.6-terra:low");
|
|
1845
|
+
writeFileSync(base.localPinPath, "{ not json\n");
|
|
1846
|
+
assert.equal(await launchPinned(base), "openai-codex/gpt-5.6-terra:low");
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1261
1849
|
test("agentsEnabled and agentsCollapseKey read their flags and stay off inside a child", () => {
|
|
1262
1850
|
assert.equal(agentsEnabled({}), true);
|
|
1263
1851
|
assert.equal(agentsEnabled({ GENTLE_PI_AGENTS: "off" }), false);
|
|
@@ -1296,7 +1884,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
|
|
|
1296
1884
|
gentleAgents(pi, {}, harness.deps);
|
|
1297
1885
|
const { ctx, widget } = fakeContext();
|
|
1298
1886
|
await fire("session_start", ctx);
|
|
1299
|
-
assert.deepEqual([...tools.keys()].sort(), ["subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "
|
|
1887
|
+
assert.deepEqual([...tools.keys()].sort(), ["orchestrator_list", "orchestrator_send_message", "orchestrator_session_id", "subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
|
|
1300
1888
|
const listed = await tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
|
|
1301
1889
|
assert.match(listed.content[0].text, /- explore \(global\): maps things/);
|
|
1302
1890
|
|
|
@@ -1825,6 +2413,153 @@ test("the production overlay reads terminal rows at render time without a minimu
|
|
|
1825
2413
|
await opened;
|
|
1826
2414
|
});
|
|
1827
2415
|
|
|
2416
|
+
|
|
2417
|
+
test("default session transport selects Windows or POSIX classes without mutating the process platform", () => {
|
|
2418
|
+
const registry = {} as never;
|
|
2419
|
+
const onNotification = async () => {};
|
|
2420
|
+
const windows = createDefaultSessionTransport("win32");
|
|
2421
|
+
assert.ok(windows.createListener(registry, "s1", onNotification) instanceof WindowsActiveSessionListener);
|
|
2422
|
+
assert.ok(windows.createClient(registry, "s1") instanceof WindowsActiveSessionClient);
|
|
2423
|
+
const posix = createDefaultSessionTransport("linux");
|
|
2424
|
+
assert.ok(posix.createListener(registry, "s1", onNotification) instanceof ActiveSessionListener);
|
|
2425
|
+
assert.ok(posix.createClient(registry, "s1") instanceof ActiveSessionClient);
|
|
2426
|
+
assert.equal(posix.createRegistry, createDefaultSessionTransport("darwin").createRegistry);
|
|
2427
|
+
assert.equal(windows.createRegistry, createDefaultSessionTransport("win32").createRegistry);
|
|
2428
|
+
assert.notEqual(windows.createRegistry, posix.createRegistry);
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2431
|
+
test("session transport startup failure cleans the constructed Windows-capable transport and stays unavailable", async () => {
|
|
2432
|
+
const h = fakePi();
|
|
2433
|
+
const runtime = deps();
|
|
2434
|
+
let registryCloses = 0;
|
|
2435
|
+
let listenerCloses = 0;
|
|
2436
|
+
let clientCloses = 0;
|
|
2437
|
+
const registry = {
|
|
2438
|
+
list: async () => [],
|
|
2439
|
+
listActivations: async () => [],
|
|
2440
|
+
close: async () => { registryCloses += 1; },
|
|
2441
|
+
};
|
|
2442
|
+
const transport: SessionTransportFactory = {
|
|
2443
|
+
createRegistry: async () => registry,
|
|
2444
|
+
createListener: () => ({
|
|
2445
|
+
registry,
|
|
2446
|
+
start: async () => { throw new Error("unavailable"); },
|
|
2447
|
+
close: async () => { listenerCloses += 1; },
|
|
2448
|
+
}),
|
|
2449
|
+
createClient: () => ({
|
|
2450
|
+
close: () => { clientCloses += 1; },
|
|
2451
|
+
sendNotification: async () => ({ id: "unused", accepted: true }),
|
|
2452
|
+
}),
|
|
2453
|
+
};
|
|
2454
|
+
runtime.deps.sessionTransport = transport;
|
|
2455
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2456
|
+
const { ctx } = fakeContext();
|
|
2457
|
+
await h.fire("session_start", ctx);
|
|
2458
|
+
await eventually(() => clientCloses === 1 && listenerCloses === 1 && registryCloses === 1, "startup failure cleanup completes");
|
|
2459
|
+
assert.equal(clientCloses, 1);
|
|
2460
|
+
assert.equal(listenerCloses, 1);
|
|
2461
|
+
assert.equal(registryCloses, 1);
|
|
2462
|
+
assert.match((await h.tools.get("orchestrator_session_id")!.execute("id", {}, undefined, undefined, ctx)).content[0].text, /not ready/);
|
|
2463
|
+
});
|
|
2464
|
+
|
|
2465
|
+
test("session transport adds host tools, forwards notifications, and closes on shutdown", async () => {
|
|
2466
|
+
const h = fakePi();
|
|
2467
|
+
const runtime = deps();
|
|
2468
|
+
let callback: ((notification: { id: string; senderSessionId: string; message: string }) => Promise<void>) | undefined;
|
|
2469
|
+
let listenerStarts = 0;
|
|
2470
|
+
let listenerCloses = 0;
|
|
2471
|
+
let clientCloses = 0;
|
|
2472
|
+
const registry = { list: async () => [{ sessionId: "peer", reachability: "unknown" }], listActivations: async () => [] };
|
|
2473
|
+
runtime.deps.sessionTransport = {
|
|
2474
|
+
createRegistry: async () => registry,
|
|
2475
|
+
createListener: (_registry, _sessionId, received) => {
|
|
2476
|
+
callback = received;
|
|
2477
|
+
return { registry, start: async () => { listenerStarts += 1; }, close: async () => { listenerCloses += 1; } };
|
|
2478
|
+
},
|
|
2479
|
+
createClient: () => ({ close: () => { clientCloses += 1; } }),
|
|
2480
|
+
} as never;
|
|
2481
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2482
|
+
const { ctx } = fakeContext();
|
|
2483
|
+
await h.fire("session_start", ctx);
|
|
2484
|
+
await eventually(() => listenerStarts === 1, "session transport listener starts");
|
|
2485
|
+
assert.equal(listenerStarts, 1);
|
|
2486
|
+
assert.ok(h.tools.has("orchestrator_session_id"));
|
|
2487
|
+
assert.ok(h.tools.has("orchestrator_list"));
|
|
2488
|
+
assert.ok(h.tools.has("orchestrator_send_message"));
|
|
2489
|
+
assert.match((await h.tools.get("orchestrator_list")!.execute("list", {}, undefined, undefined, ctx)).content[0].text, /peer/);
|
|
2490
|
+
assert.ok(callback, "listener receives the inbound callback");
|
|
2491
|
+
await callback!({ id: "message-1", senderSessionId: "peer", message: "\u001b[31mraw model content" });
|
|
2492
|
+
assert.equal(h.sent.at(-1)?.message.customType, "gentle-agents.orchestrator-message");
|
|
2493
|
+
assert.match(String(h.sent.at(-1)?.message.content), /\u001b\[31mraw model content/);
|
|
2494
|
+
assert.deepEqual(h.sent.at(-1)?.options, { deliverAs: "followUp", triggerTurn: true });
|
|
2495
|
+
await h.fire("session_shutdown", ctx);
|
|
2496
|
+
assert.equal(clientCloses, 1);
|
|
2497
|
+
assert.equal(listenerCloses, 1);
|
|
2498
|
+
});
|
|
2499
|
+
|
|
2500
|
+
test("session transport accepts a notification while listener publication is still starting", async () => {
|
|
2501
|
+
const h = fakePi();
|
|
2502
|
+
const runtime = deps();
|
|
2503
|
+
let callback: ((notification: { id: string; senderSessionId: string; message: string }) => Promise<void>) | undefined;
|
|
2504
|
+
const registry = { list: async () => [], listActivations: async () => [] };
|
|
2505
|
+
runtime.deps.sessionTransport = {
|
|
2506
|
+
createRegistry: async () => registry,
|
|
2507
|
+
createListener: (_registry, _sessionId, received) => {
|
|
2508
|
+
callback = received;
|
|
2509
|
+
return { registry, start: async () => { await callback!({ id: "published", senderSessionId: "peer", message: "during publication" }); }, close: async () => {} };
|
|
2510
|
+
},
|
|
2511
|
+
createClient: () => ({ close: () => {} }),
|
|
2512
|
+
} as never;
|
|
2513
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2514
|
+
const { ctx } = fakeContext();
|
|
2515
|
+
await h.fire("session_start", ctx);
|
|
2516
|
+
await eventually(() => h.sent.at(-1)?.message.customType === "gentle-agents.orchestrator-message", "publication callback completes");
|
|
2517
|
+
assert.equal(h.sent.at(-1)?.message.customType, "gentle-agents.orchestrator-message");
|
|
2518
|
+
assert.match(String(h.sent.at(-1)?.message.content), /during publication/);
|
|
2519
|
+
});
|
|
2520
|
+
|
|
2521
|
+
test("session transport selects a peer for outbound delivery and rejects stale callbacks after replacement", async () => {
|
|
2522
|
+
const h = fakePi();
|
|
2523
|
+
const runtime = deps();
|
|
2524
|
+
const callbacks: Array<(notification: { id: string; senderSessionId: string; message: string }) => Promise<void>> = [];
|
|
2525
|
+
const sent: Array<{ recipient: string; message: string; expectedActivation?: unknown }> = [];
|
|
2526
|
+
let closed = 0;
|
|
2527
|
+
const records = [
|
|
2528
|
+
{ version: 1, sessionId: "alpha", endpoint: "/alpha.sock", createdAt: 1 },
|
|
2529
|
+
{ version: 1, sessionId: "beta", endpoint: "/beta.sock", createdAt: 2 },
|
|
2530
|
+
];
|
|
2531
|
+
const registry = { list: async () => [], listActivations: async () => records };
|
|
2532
|
+
runtime.deps.sessionTransport = {
|
|
2533
|
+
createRegistry: async () => registry,
|
|
2534
|
+
createListener: (_registry, _sessionId, received) => {
|
|
2535
|
+
callbacks.push(received);
|
|
2536
|
+
return { registry, start: async () => {}, close: async () => { closed += 1; } };
|
|
2537
|
+
},
|
|
2538
|
+
createClient: () => ({
|
|
2539
|
+
close: () => { closed += 1; },
|
|
2540
|
+
sendNotification: async (recipient: string, message: string, options: { expectedActivation?: unknown }) => {
|
|
2541
|
+
sent.push({ recipient, message, expectedActivation: options.expectedActivation });
|
|
2542
|
+
return { id: "accepted-1", accepted: true };
|
|
2543
|
+
},
|
|
2544
|
+
}),
|
|
2545
|
+
} as never;
|
|
2546
|
+
gentleAgents(h.pi, {}, runtime.deps);
|
|
2547
|
+
const { ctx, dialogs } = fakeContext();
|
|
2548
|
+
await h.fire("session_start", ctx);
|
|
2549
|
+
await eventually(() => callbacks.length === 1, "initial transport callback registration");
|
|
2550
|
+
const result = await h.tools.get("orchestrator_send_message")!.execute("send", { message: "hello peer" }, undefined, undefined, ctx);
|
|
2551
|
+
assert.deepEqual(dialogs, ["select:Select recipient orchestrator:Orchestrator alpha|Orchestrator beta"]);
|
|
2552
|
+
assert.deepEqual(sent, [{ recipient: "alpha", message: "hello peer", expectedActivation: records[0] }]);
|
|
2553
|
+
assert.match(result.content[0].text, /accepted for delivery; it is not a delivery or read receipt/);
|
|
2554
|
+
const original = callbacks[0]!;
|
|
2555
|
+
(ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "s2";
|
|
2556
|
+
await h.fire("session_start", ctx);
|
|
2557
|
+
await eventually(() => closed === 2, "replacement closes the old client and listener");
|
|
2558
|
+
assert.equal(closed, 2, "replacement closes the old client and listener before activating its successor");
|
|
2559
|
+
await assert.rejects(original({ id: "late", senderSessionId: "alpha", message: "late callback" }), /stale session transport/);
|
|
2560
|
+
await h.fire("session_shutdown", ctx);
|
|
2561
|
+
});
|
|
2562
|
+
|
|
1828
2563
|
// Issue #867: a completion settling while the parent agent run is active must
|
|
1829
2564
|
// be held by the extension and flushed at the next turn boundary, not parked
|
|
1830
2565
|
// in the host's followUp queue until the whole orchestrator run stops calling
|
|
@@ -1965,9 +2700,10 @@ test("aborting the caller's signal cancels the subagent, records it, and says wh
|
|
|
1965
2700
|
"a warning names the abort and the cancellation",
|
|
1966
2701
|
);
|
|
1967
2702
|
assert.equal(harness.children[0].killed.length > 0, true, "the runner terminated the child");
|
|
2703
|
+
|
|
1968
2704
|
});
|
|
1969
2705
|
|
|
1970
|
-
test("selected child routes recheck provenance and
|
|
2706
|
+
test("selected child routes recheck provenance and deny research local tools", () => {
|
|
1971
2707
|
const names = ["fetch_content", "web_search", "read", "write", "mem_save", "subagent_parent_message"];
|
|
1972
2708
|
const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/web.ts" } } };
|
|
1973
2709
|
const path = join(root, "openspec/changes/demo/research.md");
|
|
@@ -1982,313 +2718,70 @@ test("selected child routes recheck provenance and keep separately authorized lo
|
|
|
1982
2718
|
const call = hooks.get("tool_call")!;
|
|
1983
2719
|
assert.equal(call({ toolName: "fetch_content" })?.block, mismatch === "none" ? undefined : true, mismatch);
|
|
1984
2720
|
assert.equal(call({ toolName: "web_search" })?.block, true, "available but unselected");
|
|
1985
|
-
for (const toolName of names.slice(2)) assert.equal(call({ toolName, input: toolName === "mem_save" ? { project: "pi", topic_key: "sdd/demo/research", content: '{"revision":2}' } : { path, content: '{"revision":2}' } }, { cwd: root })?.block,
|
|
1986
|
-
}
|
|
1987
|
-
});
|
|
1988
|
-
|
|
1989
|
-
test("research child narrows artifact arguments and observes actual dual-store readbacks", async () => {
|
|
1990
|
-
const { createHash } = await import("node:crypto");
|
|
1991
|
-
const cwd = join(root, "bounded-child");
|
|
1992
|
-
mkdirSync(cwd, { recursive: true });
|
|
1993
|
-
const bytes = '{"revision":1,"outcome":"blocked"}';
|
|
1994
|
-
const locator = { artifact: "research", path: join(cwd, "openspec/changes/demo/research.md"), revision: 1, digest: createHash("sha256").update(bytes).digest("hex"), engram: { id: 12, project: "pi", topic_key: "sdd/demo/research", revision_count: 1 } };
|
|
1995
|
-
const scope = { store: "both", worktree: cwd, changeName: "demo", retainedIntent: "fetch missing; preserve questions", locators: [locator] };
|
|
1996
|
-
const hooks = new Map<string, (...args: unknown[]) => unknown>();
|
|
1997
|
-
let active = ["read", "write", "mem_get_observation", "mem_save", "subagent_parent_message"];
|
|
1998
|
-
const journal = join(cwd, "session.jsonl"); writeFileSync(journal, "");
|
|
1999
|
-
const pi = { appendEntry: (customType, data) => appendFileSync(journal, JSON.stringify({ type: "custom", customType, data }) + "\n"), on: (name: string, fn: (...args: unknown[]) => unknown) => hooks.set(name, fn), getActiveTools: () => active, getAllTools: () => active.map(name => ({ name })) };
|
|
2000
|
-
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
|
|
2001
|
-
const ctx = { cwd, sessionManager: { getEntries: () => [], getSessionFile: () => journal } };
|
|
2002
|
-
const prompt = hooks.get("before_agent_start")!({ systemPrompt: "research" }, ctx) as { systemPrompt: string };
|
|
2003
|
-
assert.match(prompt.systemPrompt, /retainedIntent/);
|
|
2004
|
-
assert.match(prompt.systemPrompt, /never authority/);
|
|
2005
|
-
const call = (toolName: string, input: object, toolCallId = "c") => hooks.get("tool_call")!({ toolName, input, toolCallId }, ctx) as { block: boolean } | undefined;
|
|
2006
|
-
const result = (toolName: string, input: object, content: string, isError = false) => hooks.get("tool_result")!({ toolName, input, toolCallId: "c", content: [{ type: "text", text: content }], isError }, ctx) as { content: { text: string }[]; isError?: boolean };
|
|
2007
|
-
assert.equal(call("write", { path: locator.path, content: '{"revision":2,"outcome":"blocked"}' })?.block, true, "initial readback must precede mutation");
|
|
2008
|
-
assert.equal(call("write", { path: join(cwd, "outside.md") })?.block, true);
|
|
2009
|
-
assert.equal(call("mem_get_observation", { id: 13 })?.block, true);
|
|
2010
|
-
assert.equal(call("mem_save", { project: "pi", topic_key: "sdd/other/research" })?.block, true);
|
|
2011
|
-
assert.equal(call("read", { path: locator.path }), undefined);
|
|
2012
|
-
assert.match(result("read", { path: locator.path }, bytes).content.at(-1)!.text, /incomplete/);
|
|
2013
|
-
assert.equal(call("mem_get_observation", { id: 12 }), undefined);
|
|
2014
|
-
const observed = { ...locator.engram, content: bytes };
|
|
2015
|
-
assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify(observed)).content.at(-1)!.text, /all selected stores/);
|
|
2016
|
-
hooks.get("before_agent_start")!({ systemPrompt: "fresh generation" }, ctx);
|
|
2017
|
-
assert.equal(call("write", { path: locator.path, content: '{"revision":5}' })?.block, true, "new generation cannot reuse initial authorization");
|
|
2018
|
-
call("read", { path: locator.path }); result("read", { path: locator.path }, bytes);
|
|
2019
|
-
call("mem_get_observation", { id: 12 }); result("mem_get_observation", { id: 12 }, JSON.stringify(observed));
|
|
2020
|
-
const next = '{"revision":5,"outcome":"partial"}';
|
|
2021
|
-
assert.equal(call("write", { path: locator.path, content: next }), undefined);
|
|
2022
|
-
call("read", { path: locator.path }, "pending-read");
|
|
2023
|
-
const pendingRead = hooks.get("tool_result")!({ toolName: "read", input: { path: locator.path }, toolCallId: "pending-read", content: [{ type: "text", text: bytes }], isError: false }, ctx) as { content: { text: string }[] };
|
|
2024
|
-
assert.match(pendingRead.content.at(-1)!.text, /incomplete/, "old bytes cannot complete a pending mutation");
|
|
2025
|
-
result("write", { path: locator.path, content: next }, "written");
|
|
2026
|
-
call("read", { path: locator.path });
|
|
2027
|
-
assert.match(result("read", { path: locator.path }, next).content.at(-1)!.text, /incomplete/);
|
|
2028
|
-
assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next }), undefined);
|
|
2029
|
-
result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next }, "saved");
|
|
2030
|
-
call("read", { path: locator.path });
|
|
2031
|
-
result("read", { path: locator.path }, next);
|
|
2032
|
-
call("mem_get_observation", { id: 12 });
|
|
2033
|
-
assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: next, revision_count: 2 })).content.at(-1)!.text, /all selected stores/);
|
|
2034
|
-
assert.equal(call("write", { path: locator.path, content: '{"revision":2}' })?.block, true, "revision 1 to 5 to 2 is refused");
|
|
2035
|
-
const newer = '{"revision":6}';
|
|
2036
|
-
assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: newer }), undefined);
|
|
2037
|
-
result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: newer }, "saved");
|
|
2038
|
-
call("mem_get_observation", { id: 12 });
|
|
2039
|
-
assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: newer, revision_count: 2 })).isError, true, "each save must advance the accepted Engram revision count");
|
|
2040
|
-
call("write", { path: locator.path, content: '{"revision":3,"outcome":"partial"}' });
|
|
2041
|
-
result("write", { path: locator.path }, "permission denied", true);
|
|
2042
|
-
call("mem_get_observation", { id: 12 });
|
|
2043
|
-
assert.match(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: next, revision_count: 2 })).content.at(-1)!.text, /proposal_ready=false/, "write attempt invalidates prior readback even when denied");
|
|
2044
|
-
call("mem_get_observation", { id: 12 });
|
|
2045
|
-
assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, project: "wrong" })).isError, true);
|
|
2046
|
-
assert.equal(call("write", { path: locator.path, content: '{"revision":4}' })?.block, true, "stale/divergent readback must refuse recovery writes");
|
|
2047
|
-
assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: '{"revision":4}' })?.block, true);
|
|
2048
|
-
active = active.filter(name => name !== "write");
|
|
2049
|
-
assert.equal(call("write", { path: locator.path })?.block, true);
|
|
2050
|
-
assert.equal((hooks.get("tool_call")!({ toolName: "read", input: { path: locator.path } }, { cwd: root }) as { block: boolean }).block, true);
|
|
2051
|
-
active.push("write");
|
|
2052
|
-
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify(scope) });
|
|
2053
|
-
call("read", { path: locator.path });
|
|
2054
|
-
result("read", { path: locator.path }, bytes);
|
|
2055
|
-
call("mem_get_observation", { id: 12 });
|
|
2056
|
-
result("mem_get_observation", { id: 12 }, JSON.stringify(observed));
|
|
2057
|
-
call("write", { path: locator.path, content: next });
|
|
2058
|
-
result("write", { path: locator.path, content: next }, "written");
|
|
2059
|
-
const divergent = '{"revision":2,"outcome":"done"}';
|
|
2060
|
-
call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: divergent });
|
|
2061
|
-
result("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: divergent }, "saved");
|
|
2062
|
-
call("read", { path: locator.path });
|
|
2063
|
-
result("read", { path: locator.path }, next);
|
|
2064
|
-
call("mem_get_observation", { id: 12 });
|
|
2065
|
-
assert.equal(result("mem_get_observation", { id: 12 }, JSON.stringify({ ...observed, content: divergent, revision_count: 2 })).isError, true, "individually matching but divergent hybrid writes never converge");
|
|
2066
|
-
for (const completion of [[], [{ type: "text", text: "" }], [{ type: "text", text: "denied" }]]) {
|
|
2067
|
-
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify({ ...scope, store: "openspec", locators: [{ ...locator, engram: undefined }] }) });
|
|
2068
|
-
call("read", { path: locator.path }); result("read", { path: locator.path }, bytes);
|
|
2069
|
-
assert.equal(call("write", { path: locator.path, content: next }), undefined);
|
|
2070
|
-
assert.equal(call("write", { path: locator.path, content: next }, "overlap")?.block, true);
|
|
2071
|
-
hooks.get("tool_result")!({ toolName: "write", input: { path: locator.path, content: next }, toolCallId: "c", content: completion, isError: completion.length > 0 && completion[0].text === "denied" }, ctx);
|
|
2072
|
-
call("read", { path: locator.path });
|
|
2073
|
-
assert.match(result("read", { path: locator.path }, next).content.at(-1)!.text, /proposal_ready=false/, "failed or malformed write cannot establish completion");
|
|
2074
|
-
assert.equal(call("write", { path: locator.path, content: '{"revision":6}' })?.block, true);
|
|
2721
|
+
for (const toolName of names.slice(2)) assert.equal(call({ toolName, input: toolName === "mem_save" ? { project: "pi", topic_key: "sdd/demo/research", content: '{"revision":2}' } : { path, content: '{"revision":2}' } }, { cwd: root })?.block, toolName === "subagent_parent_message" ? undefined : true, toolName);
|
|
2075
2722
|
}
|
|
2076
|
-
for (const tool of ["write", "mem_save"]) {
|
|
2077
|
-
const memory = tool === "mem_save", readTool = memory ? "mem_get_observation" : "read";
|
|
2078
|
-
const input = memory ? { id: 12 } : { path: locator.path };
|
|
2079
|
-
const mutation = memory ? { project: "pi", topic_key: locator.engram.topic_key, content: next } : { path: locator.path, content: next };
|
|
2080
|
-
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify({ ...scope, store: memory ? "engram" : "openspec", locators: [{ ...locator, path: memory ? undefined : locator.path, engram: memory ? locator.engram : undefined }] }) });
|
|
2081
|
-
call(readTool, input); result(readTool, input, memory ? JSON.stringify(observed) : bytes);
|
|
2082
|
-
assert.equal(call(tool, mutation), undefined);
|
|
2083
|
-
assert.doesNotThrow(() => hooks.get("tool_result")!({ toolName: tool, input: mutation, toolCallId: "c", content: [null], isError: false }, ctx));
|
|
2084
|
-
call(readTool, input);
|
|
2085
|
-
assert.match(result(readTool, input, memory ? JSON.stringify({ ...observed, content: next, revision_count: 2 }) : next).content.at(-1)!.text, /proposal_ready=false/);
|
|
2086
|
-
assert.equal(call(tool, { ...mutation, content: '{"revision":6}' })?.block, true);
|
|
2087
|
-
}
|
|
2088
|
-
for (const store of ["openspec", "engram", "both"]) {
|
|
2089
|
-
for (const bad of ["missing", "malformed", "revision", "digest", "project", "topic", "id", "worktree"]) {
|
|
2090
|
-
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_RESEARCH_TOOLS: JSON.stringify(active), GENTLE_PI_RESEARCH_ARTIFACT: JSON.stringify({ ...scope, store, locators: [{ ...locator, path: store === "engram" ? undefined : locator.path, engram: store === "openspec" ? undefined : locator.engram }] }) });
|
|
2091
|
-
const memory = store !== "openspec";
|
|
2092
|
-
const tool = memory ? "mem_get_observation" : "read";
|
|
2093
|
-
const input = memory ? { id: 12 } : { path: locator.path };
|
|
2094
|
-
if (store === "both") {
|
|
2095
|
-
call("read", { path: locator.path });
|
|
2096
|
-
result("read", { path: locator.path }, bytes);
|
|
2097
|
-
assert.equal(call("write", { path: locator.path, content: next })?.block, true, "both initial stores must match before either mutation");
|
|
2098
|
-
}
|
|
2099
|
-
if (bad !== "missing") {
|
|
2100
|
-
const content = bad === "revision" ? '{"revision":0}' : bad === "digest" ? '{"revision":1,"different":true}' : bytes;
|
|
2101
|
-
const value = { ...observed, content, ...(bad === "project" ? { project: "wrong" } : bad === "topic" ? { topic_key: "wrong" } : bad === "id" ? { id: 13 } : {}) };
|
|
2102
|
-
if (bad === "worktree") {
|
|
2103
|
-
assert.equal((hooks.get("tool_call")!({ toolName: tool, input, toolCallId: "c" }, { cwd: root }) as { block: boolean }).block, true);
|
|
2104
|
-
} else if (memory || !["project", "topic", "id"].includes(bad)) {
|
|
2105
|
-
call(tool, input);
|
|
2106
|
-
assert.equal(result(tool, input, bad === "malformed" ? "{" : memory ? JSON.stringify(value) : content).isError, true);
|
|
2107
|
-
}
|
|
2108
|
-
}
|
|
2109
|
-
if (store !== "engram") assert.equal(call("write", { path: locator.path, content: next })?.block, true, `${store}/${bad}: zero writes`);
|
|
2110
|
-
if (memory) assert.equal(call("mem_save", { project: "pi", topic_key: locator.engram.topic_key, content: next })?.block, true, `${store}/${bad}: zero saves`);
|
|
2111
|
-
}
|
|
2112
|
-
}
|
|
2113
|
-
|
|
2114
2723
|
});
|
|
2115
2724
|
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
const h = fakePi(), runtime = deps(), fixtureHome = join(root,
|
|
2119
|
-
|
|
2725
|
+
for (const condition of ["granted", "declined", "native-denied", "asset-drift"] as const) test(`managed dispatch needs real consent but no attempt command (${condition})`, async () => {
|
|
2726
|
+
const consent = condition === "granted";
|
|
2727
|
+
const h = fakePi(), runtime = deps(), fixtureHome = join(root, `no-attempt-${condition}`);
|
|
2728
|
+
mkdirSync(join(fixtureHome, ".pi/agent/agents"), { recursive: true });
|
|
2729
|
+
writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
|
|
2120
2730
|
const spawn = runtime.deps.spawn;
|
|
2121
|
-
runtime.deps.spawn = (...args) => {
|
|
2122
|
-
const retained = JSON.parse(readFileSync(join(historyDir(fixtureHome), `${retainedId}.json`), "utf8")).task.sddRemediation;
|
|
2123
|
-
assert.equal(retained.token, "admitted-fixture"); assert.equal(retained.actorClaimed, true);
|
|
2124
|
-
const child = spawn(...args); child.pid = 123; return child; };
|
|
2731
|
+
runtime.deps.spawn = (...args) => { const child = spawn(...args); child.pid = 123; return child; };
|
|
2125
2732
|
runtime.deps.process = { platform: "win32", kill() {} };
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
const
|
|
2733
|
+
if (condition === "asset-drift") writeFileSync(join(fixtureHome, ".pi/agent/agents/sdd-remediate.md"), "---\nname: sdd-remediate\ntools: [bash]\n---\nUnowned instructions.");
|
|
2734
|
+
const revision = `sha256:${"a".repeat(64)}`;
|
|
2735
|
+
let attempts = 0, confirmations = 0;
|
|
2736
|
+
const obsolete = async () => { attempts++; throw new Error("Unknown command: sdd-attempt acquire/settle"); };
|
|
2737
|
+
const nativeSdd = {
|
|
2738
|
+
sddStatus: async () => ({ schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: "openspec", planningHome: { mode: "repo-local", path: join(cwd, "openspec") }, changeRoot: join(cwd, "openspec/changes/alpha"), actionContext: { mode: "repo-local", workspaceRoot: cwd, allowedEditRoots: [cwd] }, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])), phaseInstructions: { apply: [], verify: [], remediate: ["Correct evidence"], archive: [] }, blockedReasons: condition === "native-denied" ? ["edit_authority_missing"] : [], nextRecommended: "remediate", remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }),
|
|
2739
|
+
sddAttemptAcquire: obsolete, sddAttemptSettle: obsolete,
|
|
2740
|
+
} as unknown as NativeReviewCli;
|
|
2130
2741
|
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd });
|
|
2131
|
-
const { ctx } = fakeContext(
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
assert.equal(
|
|
2139
|
-
|
|
2140
|
-
assert.equal(
|
|
2141
|
-
assert.equal(
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
});
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
test("managed remediation tools publish the typed exact evidence plan and bracket input", () => {
|
|
2158
|
-
const h = fakePi(); gentleAgents(h.pi, {}, deps().deps);
|
|
2159
|
-
for (const name of ["subagent_run", "subagent_continue"]) {
|
|
2160
|
-
const schema = h.tools.get(name)!.parameters.properties.remediation as unknown as { required: string[]; properties: { plan: { required: string[] }; attempt: { properties: { token: unknown } } } };
|
|
2161
|
-
assert.deepEqual(schema.required, ["plan", "attempt"]);
|
|
2162
|
-
assert.deepEqual(schema.properties.plan.required, ["cwd", "commands", "runtimeHarness", "rollback"]);
|
|
2163
|
-
assert.ok(schema.properties.attempt.properties.token);
|
|
2742
|
+
const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return consent; });
|
|
2743
|
+
await h.fire("session_start", ctx);
|
|
2744
|
+
const launch = h.tools.get("subagent_run")!.execute("run", { agent: "sdd-remediate", task: "Correct alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture tests registered dispatch only." }, rollback: { boundary: "Remove isolated fixture", command: "git diff --check" } } } }, undefined, undefined, ctx);
|
|
2745
|
+
let result: Awaited<typeof launch> | undefined;
|
|
2746
|
+
if (consent) result = await launch;
|
|
2747
|
+
else await assert.rejects(launch, condition === "native-denied" ? /Stale remediation selection/ : condition === "asset-drift" ? /Unsupported remediation actor content/ : /fresh human authorization/);
|
|
2748
|
+
await tick();
|
|
2749
|
+
assert.equal(confirmations, ["native-denied", "asset-drift"].includes(condition) ? 0 : 1);
|
|
2750
|
+
assert.equal(runtime.spawned.length, consent ? 1 : 0);
|
|
2751
|
+
assert.equal(attempts, 0);
|
|
2752
|
+
assert.equal(h.tools.has("subagent_reconcile"), false);
|
|
2753
|
+
if (consent) {
|
|
2754
|
+
assert.ok(runtime.children[0].written.some((command) => command.type === "prompt"), "the registered actor receives its prompt");
|
|
2755
|
+
const repeat = h.tools.get("subagent_run")!.execute("repeat", { agent: "sdd-remediate", task: "Correct alpha again", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture tests registered dispatch only." }, rollback: { boundary: "Remove isolated fixture", command: "git diff --check" } } } }, undefined, undefined, ctx);
|
|
2756
|
+
await assert.rejects(repeat, /Remediation already queued or running/);
|
|
2757
|
+
assert.equal(confirmations, 2, "independent human consent does not permit overlapping managed actors");
|
|
2758
|
+
assert.equal(runtime.spawned.length, 1);
|
|
2759
|
+
|
|
2760
|
+
runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "Work stopped; no verification success is claimed." }], stopReason: "stop" }] });
|
|
2761
|
+
runtime.children[0].emit({ type: "agent_settled" });
|
|
2762
|
+
await tick();
|
|
2763
|
+
const id = (result!.details.gentleAgents as { taskId: string }).taskId;
|
|
2764
|
+
const status = await h.tools.get("subagent_status")!.execute("status", { task_id: id }, undefined, undefined, ctx);
|
|
2765
|
+
assert.equal((status.details.gentleAgents as { status: string }).status, "completed", "ordinary task completion is not native verification approval");
|
|
2164
2766
|
}
|
|
2165
|
-
});
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
test("R1 malformed child grant denies tools even before/after failed session initialization", () => {
|
|
2169
|
-
const hooks = new Map(), registered = [];
|
|
2170
|
-
const pi = { on: (name, fn) => hooks.set(name, fn), registerTool: tool => registered.push(tool), getFlag: () => "{}" };
|
|
2171
|
-
gentleAgents(pi as never, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_SDD_REMEDIATION_PLAN: "malformed" });
|
|
2172
|
-
const denied = () => hooks.get("tool_call")?.({ toolName: "bash", input: { command: "touch outside" } }, { cwd })?.block;
|
|
2173
|
-
assert.equal(denied(), true);
|
|
2174
|
-
assert.doesNotThrow(() => hooks.get("session_start")({}, { cwd }));
|
|
2175
|
-
assert.equal(denied(), true); assert.equal(registered.length, 0);
|
|
2176
|
-
});
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
test("public reconciliation replays retained authority, persists closure, and never exposes or starts the actor", async () => {
|
|
2180
|
-
const fixtureHome = join(root, "remediation-reconcile");
|
|
2181
|
-
const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "retained-acquire", workUnit: "correct", evidenceGoal: "Observed correction", remediatesEvidenceRevision: `sha256:${"a".repeat(64)}` };
|
|
2182
|
-
await saveTask(historyDir(fixtureHome), { id: "retained", agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as never, emptyThread());
|
|
2183
|
-
const h = fakePi(), runtime = deps(), calls = [];
|
|
2184
|
-
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: {
|
|
2185
|
-
sddAttemptAcquire: async input => { calls.push(["acquire", structuredClone(input)]); return { state: "proceed", token: "private-token" }; },
|
|
2186
|
-
sddAttemptSettle: async input => { calls.push(["settle", structuredClone(input)]); return { state: "complete" }; },
|
|
2187
|
-
} as unknown as NativeReviewCli });
|
|
2188
|
-
const { ctx } = fakeContext(); await h.fire("session_start", ctx);
|
|
2189
|
-
const output = await h.tools.get("subagent_reconcile").execute("reconcile", { task_id: "retained" }, undefined, undefined, ctx);
|
|
2190
|
-
assert.match(output.content[0].text, /reconciled/i);
|
|
2191
|
-
assert.equal(JSON.stringify(output).includes("private-token"), false);
|
|
2192
|
-
assert.deepEqual(calls[0], ["acquire", acquire]); assert.equal(calls[1][0], "settle");
|
|
2193
|
-
assert.equal(runtime.spawned.length, 0);
|
|
2194
|
-
const retained = (await loadHistory(historyDir(fixtureHome)))[0].task;
|
|
2195
|
-
assert.equal(retained.sddRemediation.acquireUncertain, undefined);
|
|
2196
|
-
assert.deepEqual(retained.sddRemediation.settlement, { state: "complete" });
|
|
2197
|
-
});
|
|
2198
|
-
|
|
2199
|
-
test("durable reconciliation locks serialize independent extension instances sharing one tasksDir", async () => {
|
|
2200
|
-
const fixtureHome = join(root, "remediation-reconcile-independent");
|
|
2201
|
-
const id = "retained-independent";
|
|
2202
|
-
const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: id, workUnit: "correct", evidenceGoal: "Observed correction" };
|
|
2203
|
-
await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as never, emptyThread());
|
|
2204
|
-
const first = fakePi(), second = fakePi(), runtime = deps();
|
|
2205
|
-
let calls = 0, release!: (result: { state: "blocked" }) => void;
|
|
2206
|
-
const pending = new Promise<{ state: "blocked" }>(resolve => { release = resolve; });
|
|
2207
|
-
const native = { sddAttemptAcquire: async () => { calls++; return calls === 1 ? pending : { state: "blocked" }; } } as unknown as NativeReviewCli;
|
|
2208
|
-
gentleAgents(first.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: native });
|
|
2209
|
-
gentleAgents(second.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: native });
|
|
2210
|
-
const firstContext = fakeContext(), secondContext = fakeContext();
|
|
2211
|
-
await first.fire("session_start", firstContext.ctx); await second.fire("session_start", secondContext.ctx);
|
|
2212
|
-
const running = first.tools.get("subagent_reconcile")!.execute("first", { task_id: id }, undefined, undefined, firstContext.ctx);
|
|
2213
|
-
await eventually(() => calls === 1, "the first instance must reach native acquire while holding the lock");
|
|
2214
|
-
await assert.rejects(second.tools.get("subagent_reconcile")!.execute("second", { task_id: id }, undefined, undefined, secondContext.ctx), /busy|active|already being reconciled/i);
|
|
2215
|
-
assert.equal(calls, 1, "a busy filesystem lock fails before native acquire");
|
|
2216
|
-
release({ state: "blocked" }); await running;
|
|
2217
|
-
await first.fire("session_shutdown", firstContext.ctx); await second.fire("session_shutdown", secondContext.ctx);
|
|
2218
|
-
});
|
|
2219
|
-
|
|
2220
|
-
test("reconciliation reloads a stale local task and preserves the retained disk thread", async () => {
|
|
2221
|
-
const fixtureHome = join(root, "remediation-reconcile-reload");
|
|
2222
|
-
const id = "retained-reload";
|
|
2223
|
-
const oldAcquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "old", workUnit: "old", evidenceGoal: "old" };
|
|
2224
|
-
const freshAcquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "fresh", workUnit: "fresh", evidenceGoal: "fresh" };
|
|
2225
|
-
await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire: oldAcquire, acquireUncertain: true } } as never, applyTaskEvent(emptyThread(), { type: TASK_EVENT.NOTE, text: "old thread" }));
|
|
2226
|
-
const h = fakePi(), runtime = deps(), seen: unknown[] = [];
|
|
2227
|
-
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddAttemptAcquire: async input => { seen.push(structuredClone(input)); return { state: "blocked" }; } } as unknown as NativeReviewCli });
|
|
2228
|
-
const { ctx } = fakeContext(); await h.fire("session_start", ctx);
|
|
2229
|
-
await h.tools.get("subagent_status")!.execute("status", { task_id: id }, undefined, undefined, ctx);
|
|
2230
|
-
const freshThread = applyTaskEvent(emptyThread(), { type: TASK_EVENT.NOTE, text: "fresh thread" });
|
|
2231
|
-
await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire: freshAcquire, acquireUncertain: true } } as never, freshThread);
|
|
2232
|
-
await h.tools.get("subagent_reconcile")!.execute("reconcile", { task_id: id }, undefined, undefined, ctx);
|
|
2233
|
-
assert.deepEqual(seen, [freshAcquire], "native receives the force-reloaded retained request");
|
|
2234
|
-
const stored = (await loadHistory(historyDir(fixtureHome))).find(entry => entry.task.id === id)!;
|
|
2235
|
-
assert.deepEqual(stored.thread.items, freshThread.items, "persistence retains the exact disk thread, not the stale store thread");
|
|
2236
|
-
await h.fire("session_shutdown", ctx);
|
|
2237
|
-
});
|
|
2238
|
-
|
|
2239
|
-
test("reconciliation releases the durable lock after native failure", async () => {
|
|
2240
|
-
const fixtureHome = join(root, "remediation-reconcile-failure");
|
|
2241
|
-
const id = "retained-failure";
|
|
2242
|
-
const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: id, workUnit: "correct", evidenceGoal: "Observed correction" };
|
|
2243
|
-
await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as never, emptyThread());
|
|
2244
|
-
const h = fakePi(), runtime = deps(); let calls = 0;
|
|
2245
|
-
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddAttemptAcquire: async () => { calls++; if (calls === 1) throw new TypeError("native failure"); return { state: "blocked" }; } } as unknown as NativeReviewCli });
|
|
2246
|
-
const { ctx } = fakeContext(); await h.fire("session_start", ctx);
|
|
2247
|
-
await assert.rejects(h.tools.get("subagent_reconcile")!.execute("failed", { task_id: id }, undefined, undefined, ctx), /native failure/);
|
|
2248
|
-
const recovered = await h.tools.get("subagent_reconcile")!.execute("retry", { task_id: id }, undefined, undefined, ctx);
|
|
2249
|
-
assert.match(recovered.content[0].text, /reconciled/i);
|
|
2250
|
-
assert.equal(calls, 2, "the second attempt acquires after finally released the first lock");
|
|
2251
2767
|
await h.fire("session_shutdown", ctx);
|
|
2768
|
+
assert.equal(attempts, 0, "terminal cleanup must not invoke settlement");
|
|
2252
2769
|
});
|
|
2253
2770
|
|
|
2254
|
-
test("R3/R4 host reload refuses retained acquire/actor uncertainty without another launch", async () => {
|
|
2255
|
-
for (const actorClaimed of [false, true, "blocked", "complete"]) {
|
|
2256
|
-
const normal = typeof actorClaimed === "string";
|
|
2257
|
-
const h = fakePi(), runtime = deps(), fixtureHome = join(root, `remediation-reload-${actorClaimed}`);
|
|
2258
|
-
mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
|
|
2259
|
-
writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
|
|
2260
|
-
const revision = `sha256:${"a".repeat(64)}`;
|
|
2261
|
-
const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "retained", workUnit: "correct", evidenceGoal: "Observed correction", remediatesEvidenceRevision: revision };
|
|
2262
|
-
await saveTask(historyDir(fixtureHome), { id: "retained", agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: normal ? false : !actorClaimed, actorClaimed: normal ? false : actorClaimed, ...(normal ? { acquireResult: { state: actorClaimed } } : actorClaimed ? { token: "retained-token" } : {}) } } as never, emptyThread());
|
|
2263
|
-
let acquisitions = 0, confirmations = 0;
|
|
2264
|
-
gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddStatus: async () => { throw new Error("fresh status reached"); }, sddAttemptSettle: async () => ({ state: "proceed" as const }), sddAttemptAcquire: async () => { acquisitions++; return { state: "proceed", token: "unsafe" }; } } as unknown as NativeReviewCli });
|
|
2265
|
-
const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return true; });
|
|
2266
|
-
await h.fire("session_start", ctx);
|
|
2267
|
-
await assert.rejects(h.tools.get("subagent_run").execute("again", { agent: "sdd-remediate", task: "Correct alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { attempt: { ...acquire, requestId: "different" }, plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture has no runtime boundary." }, rollback: { boundary: "Revert fixture", command: "git diff --check" } } } }, undefined, undefined, ctx), normal ? /fresh status reached/ : /reconcile exact history without actor replay/);
|
|
2268
|
-
assert.equal(acquisitions, 0); assert.equal(confirmations, 0); assert.equal(runtime.spawned.length, 0);
|
|
2269
|
-
assert.deepEqual((await loadHistory(historyDir(fixtureHome)))[0].task.sddRemediation.acquire, acquire);
|
|
2270
|
-
await h.fire("session_shutdown", ctx);
|
|
2271
|
-
}
|
|
2272
|
-
});
|
|
2273
2771
|
|
|
2274
|
-
test("
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
assert.equal(acquisitions, uncertain ? 0 : 1, "native admission is consulted once local unresolved history is not uncertain");
|
|
2290
|
-
assert.equal(confirmations, uncertain ? 0 : 1);
|
|
2291
|
-
assert.equal(runtime.spawned.length, 0);
|
|
2292
|
-
await h.fire("session_shutdown", ctx);
|
|
2293
|
-
}
|
|
2772
|
+
test("registered task actor receives ordinary checkbox and configured TDD guidance", async () => {
|
|
2773
|
+
const h = fakePi(), runtime = deps(), { ctx } = fakeContext();
|
|
2774
|
+
const home = join(root, "task-truth-home");
|
|
2775
|
+
mkdirSync(join(home, ".pi/agent/agents"), { recursive: true });
|
|
2776
|
+
writeFileSync(join(home, ".pi/agent/agents/sdd-tasks.md"), readFileSync("assets/agents/sdd-tasks.md"));
|
|
2777
|
+
gentleAgents(h.pi, {}, { ...runtime.deps, home });
|
|
2778
|
+
await h.fire("session_start", ctx);
|
|
2779
|
+
await h.tools.get("subagent_run")!.execute("tasks", { agent: "sdd-tasks", task: "Plan ordinary tasks", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background" }, undefined, undefined, ctx);
|
|
2780
|
+
await tick();
|
|
2781
|
+
assert.equal(runtime.spawned.length, 1);
|
|
2782
|
+
const args = runtime.spawned[0], instructions = args[args.indexOf("--append-system-prompt") + 1];
|
|
2783
|
+
assert.match(instructions, /Only when configured strict TDD is active/);
|
|
2784
|
+
assert.match(instructions, /- \[ \] 1\. Implement and verify the behavior\./);
|
|
2785
|
+
assert.doesNotMatch(instructions, /<!-- sdd-owner:/);
|
|
2786
|
+
await h.fire("session_shutdown", ctx);
|
|
2294
2787
|
});
|