@gotgenes/pi-permission-system 8.3.1 → 9.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/CHANGELOG.md +34 -0
- package/package.json +1 -1
- package/src/forwarded-permissions/polling.ts +1 -2
- package/src/handlers/lifecycle.ts +9 -0
- package/src/index.ts +30 -11
- package/src/permission-events.ts +3 -2
- package/src/permission-forwarding.ts +4 -4
- package/src/service.ts +17 -4
- package/src/subagent-context.ts +33 -5
- package/src/subagent-lifecycle-events.ts +6 -6
- package/src/subagent-registry.ts +29 -21
- package/test/composition-root.test.ts +398 -0
- package/test/handlers/lifecycle.test.ts +15 -2
- package/test/helpers/make-fake-pi.ts +95 -0
- package/test/permission-events.test.ts +32 -2
- package/test/permission-forwarding.test.ts +12 -15
- package/test/permission-system.test.ts +16 -34
- package/test/service.test.ts +25 -6
- package/test/subagent-context.test.ts +79 -17
- package/test/subagent-lifecycle-events.test.ts +37 -18
- package/test/subagent-registry.test.ts +51 -44
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "node:fs";
|
|
9
9
|
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import { expect, test } from "vitest";
|
|
12
13
|
import {
|
|
13
14
|
createActiveToolsCacheKey,
|
|
@@ -46,23 +47,16 @@ import {
|
|
|
46
47
|
canResolveAskPermissionRequest,
|
|
47
48
|
shouldAutoApprovePermissionState,
|
|
48
49
|
} from "#src/yolo-mode";
|
|
50
|
+
import { type FakePi, makeFakePi } from "#test/helpers/make-fake-pi";
|
|
49
51
|
import {
|
|
50
52
|
type CreateManagerOptions,
|
|
51
53
|
createManager,
|
|
52
54
|
} from "#test/helpers/manager-harness";
|
|
53
55
|
|
|
54
|
-
type MockHandler = (
|
|
55
|
-
event: Record<string, unknown>,
|
|
56
|
-
ctx: Record<string, unknown>,
|
|
57
|
-
) =>
|
|
58
|
-
| Promise<Record<string, unknown> | undefined>
|
|
59
|
-
| Record<string, unknown>
|
|
60
|
-
| undefined;
|
|
61
|
-
|
|
62
56
|
type ExtensionHarness = {
|
|
63
57
|
baseDir: string;
|
|
64
58
|
cwd: string;
|
|
65
|
-
|
|
59
|
+
pi: FakePi;
|
|
66
60
|
prompts: string[];
|
|
67
61
|
cleanup: () => Promise<void>;
|
|
68
62
|
};
|
|
@@ -112,7 +106,6 @@ function createToolCallHarness(
|
|
|
112
106
|
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-runtime-"));
|
|
113
107
|
const cwd = options.cwd ?? baseDir;
|
|
114
108
|
const prompts: string[] = [];
|
|
115
|
-
const handlers: Record<string, MockHandler> = {};
|
|
116
109
|
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
117
110
|
const globalConfigPath = getGlobalConfigPath(baseDir);
|
|
118
111
|
mkdirSync(join(baseDir, "agents"), { recursive: true });
|
|
@@ -124,22 +117,10 @@ function createToolCallHarness(
|
|
|
124
117
|
"utf8",
|
|
125
118
|
);
|
|
126
119
|
|
|
120
|
+
const pi = makeFakePi({ toolNames });
|
|
127
121
|
process.env.PI_CODING_AGENT_DIR = baseDir;
|
|
128
122
|
try {
|
|
129
|
-
piPermissionSystemExtension(
|
|
130
|
-
on: (name: string, handler: MockHandler): void => {
|
|
131
|
-
handlers[name] = handler;
|
|
132
|
-
},
|
|
133
|
-
registerCommand: (): void => {},
|
|
134
|
-
getAllTools: (): Array<{ name: string }> =>
|
|
135
|
-
toolNames.map((name) => ({ name })),
|
|
136
|
-
setActiveTools: (): void => {},
|
|
137
|
-
registerProvider: (): void => {},
|
|
138
|
-
events: {
|
|
139
|
-
emit: (): void => {},
|
|
140
|
-
on: (): (() => void) => () => undefined,
|
|
141
|
-
},
|
|
142
|
-
} as never);
|
|
123
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
143
124
|
} finally {
|
|
144
125
|
if (originalAgentDir === undefined) {
|
|
145
126
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
@@ -151,11 +132,13 @@ function createToolCallHarness(
|
|
|
151
132
|
return {
|
|
152
133
|
baseDir,
|
|
153
134
|
cwd,
|
|
154
|
-
|
|
135
|
+
pi,
|
|
155
136
|
prompts,
|
|
156
137
|
cleanup: async (): Promise<void> => {
|
|
157
|
-
await
|
|
158
|
-
|
|
138
|
+
await pi.fire(
|
|
139
|
+
"session_shutdown",
|
|
140
|
+
{},
|
|
141
|
+
createMockContext(cwd, prompts, options),
|
|
159
142
|
);
|
|
160
143
|
rmSync(baseDir, { recursive: true, force: true });
|
|
161
144
|
},
|
|
@@ -192,15 +175,14 @@ async function runToolCall(
|
|
|
192
175
|
event: Record<string, unknown>,
|
|
193
176
|
options: ExtensionHarnessOptions = {},
|
|
194
177
|
): Promise<Record<string, unknown>> {
|
|
195
|
-
const handler = harness.handlers.tool_call;
|
|
196
|
-
expect(handler).toBeTypeOf("function");
|
|
197
|
-
|
|
198
178
|
const result = await withIsolatedSubagentEnv(async () =>
|
|
199
|
-
|
|
200
|
-
|
|
179
|
+
harness.pi.fire(
|
|
180
|
+
"tool_call",
|
|
181
|
+
event,
|
|
182
|
+
createMockContext(harness.cwd, harness.prompts, options),
|
|
201
183
|
),
|
|
202
184
|
);
|
|
203
|
-
return result ?? {};
|
|
185
|
+
return (result as Record<string, unknown> | undefined) ?? {};
|
|
204
186
|
}
|
|
205
187
|
|
|
206
188
|
test("Yolo mode only auto-approves ask-state permissions", () => {
|
|
@@ -2335,7 +2317,7 @@ test("session approval: session_shutdown clears session approvals", async () =>
|
|
|
2335
2317
|
hasUI: true,
|
|
2336
2318
|
selectResponse: "Yes",
|
|
2337
2319
|
});
|
|
2338
|
-
await
|
|
2320
|
+
await harness.pi.fire("session_shutdown", {}, shutdownCtx);
|
|
2339
2321
|
|
|
2340
2322
|
// Access same path again — should prompt because cache was cleared
|
|
2341
2323
|
const result = await runToolCall(
|
package/test/service.test.ts
CHANGED
|
@@ -26,7 +26,10 @@ function makeService(
|
|
|
26
26
|
|
|
27
27
|
describe("globalThis accessor", () => {
|
|
28
28
|
afterEach(() => {
|
|
29
|
-
|
|
29
|
+
const current = getPermissionsService();
|
|
30
|
+
if (current) {
|
|
31
|
+
unpublishPermissionsService(current);
|
|
32
|
+
}
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
it("returns undefined when nothing has been published", () => {
|
|
@@ -47,15 +50,25 @@ describe("globalThis accessor", () => {
|
|
|
47
50
|
expect(getPermissionsService()).toBe(second);
|
|
48
51
|
});
|
|
49
52
|
|
|
50
|
-
it("
|
|
53
|
+
it("removes the slot when it still holds the given service", () => {
|
|
51
54
|
const service = makeService();
|
|
52
55
|
publishPermissionsService(service);
|
|
53
|
-
unpublishPermissionsService();
|
|
56
|
+
unpublishPermissionsService(service);
|
|
54
57
|
expect(getPermissionsService()).toBeUndefined();
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
it("does not remove the slot when a different service occupies it", () => {
|
|
61
|
+
const parent = makeService();
|
|
62
|
+
const child = makeService();
|
|
63
|
+
publishPermissionsService(parent);
|
|
64
|
+
// A child instance never published `parent`; unpublishing its own service
|
|
65
|
+
// must be a no-op that leaves the parent's slot intact.
|
|
66
|
+
unpublishPermissionsService(child);
|
|
67
|
+
expect(getPermissionsService()).toBe(parent);
|
|
68
|
+
});
|
|
69
|
+
|
|
57
70
|
it("unpublish is safe to call when nothing was published", () => {
|
|
58
|
-
expect(() => unpublishPermissionsService()).not.toThrow();
|
|
71
|
+
expect(() => unpublishPermissionsService(makeService())).not.toThrow();
|
|
59
72
|
expect(getPermissionsService()).toBeUndefined();
|
|
60
73
|
});
|
|
61
74
|
});
|
|
@@ -64,7 +77,10 @@ describe("globalThis accessor", () => {
|
|
|
64
77
|
|
|
65
78
|
describe("service adapter delegation", () => {
|
|
66
79
|
afterEach(() => {
|
|
67
|
-
|
|
80
|
+
const current = getPermissionsService();
|
|
81
|
+
if (current) {
|
|
82
|
+
unpublishPermissionsService(current);
|
|
83
|
+
}
|
|
68
84
|
});
|
|
69
85
|
|
|
70
86
|
const fakeResult: PermissionCheckResult = {
|
|
@@ -191,7 +207,10 @@ describe("service adapter delegation", () => {
|
|
|
191
207
|
|
|
192
208
|
describe("registerToolInputFormatter delegation", () => {
|
|
193
209
|
afterEach(() => {
|
|
194
|
-
|
|
210
|
+
const current = getPermissionsService();
|
|
211
|
+
if (current) {
|
|
212
|
+
unpublishPermissionsService(current);
|
|
213
|
+
}
|
|
195
214
|
});
|
|
196
215
|
|
|
197
216
|
it("delegates to the registry and returns its disposer", () => {
|
|
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
3
|
import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
|
|
4
4
|
import {
|
|
5
|
+
isRegisteredSubagentChild,
|
|
5
6
|
isSubagentExecutionContext,
|
|
6
7
|
normalizeFilesystemPath,
|
|
7
8
|
} from "#src/subagent-context";
|
|
@@ -12,14 +13,57 @@ afterEach(() => {
|
|
|
12
13
|
vi.restoreAllMocks();
|
|
13
14
|
});
|
|
14
15
|
|
|
15
|
-
function makeCtx(
|
|
16
|
+
function makeCtx(
|
|
17
|
+
sessionDir: string | null,
|
|
18
|
+
sessionId: string = "",
|
|
19
|
+
): ExtensionContext {
|
|
16
20
|
return {
|
|
17
21
|
sessionManager: {
|
|
18
22
|
getSessionDir: vi.fn(() => sessionDir),
|
|
23
|
+
getSessionId: vi.fn(() => sessionId),
|
|
19
24
|
},
|
|
20
25
|
} as unknown as ExtensionContext;
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
describe("isRegisteredSubagentChild", () => {
|
|
29
|
+
const childSessionId = "child-session-abc";
|
|
30
|
+
|
|
31
|
+
test("returns true when the session id is registered", () => {
|
|
32
|
+
const registry = new SubagentSessionRegistry();
|
|
33
|
+
registry.register(childSessionId, {});
|
|
34
|
+
expect(
|
|
35
|
+
isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns false when the session id is not registered", () => {
|
|
40
|
+
const registry = new SubagentSessionRegistry();
|
|
41
|
+
expect(
|
|
42
|
+
isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns false when the session id is empty", () => {
|
|
47
|
+
const registry = new SubagentSessionRegistry();
|
|
48
|
+
registry.register("", {});
|
|
49
|
+
expect(isRegisteredSubagentChild(makeCtx(null, ""), registry)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("returns false when getSessionId throws", () => {
|
|
53
|
+
const registry = new SubagentSessionRegistry();
|
|
54
|
+
registry.register(childSessionId, {});
|
|
55
|
+
const ctx = {
|
|
56
|
+
sessionManager: {
|
|
57
|
+
getSessionDir: vi.fn(() => null),
|
|
58
|
+
getSessionId: vi.fn(() => {
|
|
59
|
+
throw new Error("session id unavailable");
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
} as unknown as ExtensionContext;
|
|
63
|
+
expect(isRegisteredSubagentChild(ctx, registry)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
23
67
|
describe("normalizeFilesystemPath", () => {
|
|
24
68
|
test("normalizes a simple absolute path", () => {
|
|
25
69
|
expect(normalizeFilesystemPath("/projects/my-app")).toBe(
|
|
@@ -203,57 +247,75 @@ describe("isSubagentExecutionContext — registry detection", () => {
|
|
|
203
247
|
const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
|
|
204
248
|
const outsideDir =
|
|
205
249
|
"/home/user/projects/my-app/.pi/agent/sessions/parent/tasks";
|
|
250
|
+
const childSessionId = "child-session-abc";
|
|
206
251
|
|
|
207
|
-
test("returns true when session
|
|
252
|
+
test("returns true when session id is registered (no env vars, dir outside filesystem root)", () => {
|
|
208
253
|
const registry = new SubagentSessionRegistry();
|
|
209
|
-
registry.register(
|
|
254
|
+
registry.register(childSessionId, {});
|
|
210
255
|
expect(
|
|
211
|
-
isSubagentExecutionContext(
|
|
256
|
+
isSubagentExecutionContext(
|
|
257
|
+
makeCtx(outsideDir, childSessionId),
|
|
258
|
+
subagentRoot,
|
|
259
|
+
registry,
|
|
260
|
+
),
|
|
212
261
|
).toBe(true);
|
|
213
262
|
});
|
|
214
263
|
|
|
215
264
|
test("returns true when registered session has a parentSessionId", () => {
|
|
216
265
|
const registry = new SubagentSessionRegistry();
|
|
217
|
-
registry.register(
|
|
218
|
-
agentName: "Plan",
|
|
219
|
-
parentSessionId: "parent-123",
|
|
220
|
-
});
|
|
266
|
+
registry.register(childSessionId, { parentSessionId: "parent-123" });
|
|
221
267
|
expect(
|
|
222
|
-
isSubagentExecutionContext(
|
|
268
|
+
isSubagentExecutionContext(
|
|
269
|
+
makeCtx(outsideDir, childSessionId),
|
|
270
|
+
subagentRoot,
|
|
271
|
+
registry,
|
|
272
|
+
),
|
|
223
273
|
).toBe(true);
|
|
224
274
|
});
|
|
225
275
|
|
|
226
|
-
test("returns false when registry is provided but session
|
|
276
|
+
test("returns false when registry is provided but session id is not registered", () => {
|
|
227
277
|
const registry = new SubagentSessionRegistry();
|
|
228
278
|
expect(
|
|
229
|
-
isSubagentExecutionContext(
|
|
279
|
+
isSubagentExecutionContext(
|
|
280
|
+
makeCtx(outsideDir, childSessionId),
|
|
281
|
+
subagentRoot,
|
|
282
|
+
registry,
|
|
283
|
+
),
|
|
230
284
|
).toBe(false);
|
|
231
285
|
});
|
|
232
286
|
|
|
233
|
-
test("returns false when session
|
|
287
|
+
test("returns false when session id is empty and registry has no matching entry", () => {
|
|
234
288
|
const registry = new SubagentSessionRegistry();
|
|
235
289
|
expect(
|
|
236
|
-
isSubagentExecutionContext(makeCtx(null), subagentRoot, registry),
|
|
290
|
+
isSubagentExecutionContext(makeCtx(null, ""), subagentRoot, registry),
|
|
237
291
|
).toBe(false);
|
|
238
292
|
});
|
|
239
293
|
|
|
240
294
|
test("registry check takes priority over env var detection", () => {
|
|
241
295
|
// Registry says registered; env var not set — should still return true.
|
|
242
296
|
const registry = new SubagentSessionRegistry();
|
|
243
|
-
registry.register(
|
|
297
|
+
registry.register(childSessionId, {});
|
|
244
298
|
// Confirm no env var is set
|
|
245
299
|
expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
|
|
246
300
|
expect(
|
|
247
|
-
isSubagentExecutionContext(
|
|
301
|
+
isSubagentExecutionContext(
|
|
302
|
+
makeCtx(outsideDir, childSessionId),
|
|
303
|
+
subagentRoot,
|
|
304
|
+
registry,
|
|
305
|
+
),
|
|
248
306
|
).toBe(true);
|
|
249
307
|
});
|
|
250
308
|
|
|
251
309
|
test("unregistered session falls through to env var detection", () => {
|
|
252
310
|
vi.stubEnv("PI_IS_SUBAGENT", "true");
|
|
253
|
-
const registry = new SubagentSessionRegistry(); // empty —
|
|
311
|
+
const registry = new SubagentSessionRegistry(); // empty — childSessionId not registered
|
|
254
312
|
// Env var present → still true even without registry entry
|
|
255
313
|
expect(
|
|
256
|
-
isSubagentExecutionContext(
|
|
314
|
+
isSubagentExecutionContext(
|
|
315
|
+
makeCtx(outsideDir, childSessionId),
|
|
316
|
+
subagentRoot,
|
|
317
|
+
registry,
|
|
318
|
+
),
|
|
257
319
|
).toBe(true);
|
|
258
320
|
});
|
|
259
321
|
|
|
@@ -19,13 +19,11 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
19
19
|
subscribeSubagentLifecycle(bus, registry);
|
|
20
20
|
|
|
21
21
|
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
22
|
-
|
|
23
|
-
agentName: "Explore",
|
|
22
|
+
sessionId: "child-session-abc",
|
|
24
23
|
parentSessionId: "parent-42",
|
|
25
24
|
});
|
|
26
25
|
|
|
27
|
-
expect(registry.get("
|
|
28
|
-
agentName: "Explore",
|
|
26
|
+
expect(registry.get("child-session-abc")).toEqual({
|
|
29
27
|
parentSessionId: "parent-42",
|
|
30
28
|
});
|
|
31
29
|
});
|
|
@@ -40,12 +38,11 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
40
38
|
subscribeSubagentLifecycle(bus, registry);
|
|
41
39
|
|
|
42
40
|
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
43
|
-
|
|
44
|
-
agentName: "Explore",
|
|
41
|
+
sessionId: "child-session-sync",
|
|
45
42
|
});
|
|
46
43
|
|
|
47
44
|
// No await between emit and this assertion.
|
|
48
|
-
expect(registry.has("
|
|
45
|
+
expect(registry.has("child-session-sync")).toBe(true);
|
|
49
46
|
});
|
|
50
47
|
|
|
51
48
|
it("omits parentSessionId when the event does not carry one", () => {
|
|
@@ -53,12 +50,10 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
53
50
|
subscribeSubagentLifecycle(bus, registry);
|
|
54
51
|
|
|
55
52
|
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
56
|
-
|
|
57
|
-
agentName: "general-purpose",
|
|
53
|
+
sessionId: "child-session-xyz",
|
|
58
54
|
});
|
|
59
55
|
|
|
60
|
-
expect(registry.get("
|
|
61
|
-
agentName: "general-purpose",
|
|
56
|
+
expect(registry.get("child-session-xyz")).toEqual({
|
|
62
57
|
parentSessionId: undefined,
|
|
63
58
|
});
|
|
64
59
|
});
|
|
@@ -66,11 +61,11 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
66
61
|
it("unregisters a child session on disposed", () => {
|
|
67
62
|
const bus = createEventBus();
|
|
68
63
|
subscribeSubagentLifecycle(bus, registry);
|
|
69
|
-
registry.register("
|
|
64
|
+
registry.register("child-session-abc", { parentSessionId: "parent-42" });
|
|
70
65
|
|
|
71
|
-
bus.emit(SUBAGENT_CHILD_DISPOSED, {
|
|
66
|
+
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
|
|
72
67
|
|
|
73
|
-
expect(registry.has("
|
|
68
|
+
expect(registry.has("child-session-abc")).toBe(false);
|
|
74
69
|
});
|
|
75
70
|
|
|
76
71
|
it("detaches both handlers when the returned unsubscribe is called", () => {
|
|
@@ -80,12 +75,11 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
80
75
|
unsubscribe();
|
|
81
76
|
|
|
82
77
|
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
83
|
-
|
|
84
|
-
agentName: "Explore",
|
|
78
|
+
sessionId: "child-session-abc",
|
|
85
79
|
});
|
|
86
|
-
bus.emit(SUBAGENT_CHILD_DISPOSED, {
|
|
80
|
+
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
|
|
87
81
|
|
|
88
|
-
expect(registry.has("
|
|
82
|
+
expect(registry.has("child-session-abc")).toBe(false);
|
|
89
83
|
});
|
|
90
84
|
|
|
91
85
|
it("subscribes to a fake bus on the exact channel names", () => {
|
|
@@ -110,4 +104,29 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
110
104
|
);
|
|
111
105
|
expect(SUBAGENT_CHILD_DISPOSED).toBe("subagents:child:disposed");
|
|
112
106
|
});
|
|
107
|
+
|
|
108
|
+
// ── #298 regression: concurrent siblings must be independent ──────────────
|
|
109
|
+
|
|
110
|
+
it("disposing one sibling does not evict the other (collision regression)", () => {
|
|
111
|
+
const bus = createEventBus();
|
|
112
|
+
subscribeSubagentLifecycle(bus, registry);
|
|
113
|
+
|
|
114
|
+
// Two concurrent children of the same parent register under distinct ids.
|
|
115
|
+
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
116
|
+
sessionId: "child-A",
|
|
117
|
+
parentSessionId: "parent-P",
|
|
118
|
+
});
|
|
119
|
+
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
120
|
+
sessionId: "child-B",
|
|
121
|
+
parentSessionId: "parent-P",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Sibling A finishes first.
|
|
125
|
+
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-A" });
|
|
126
|
+
|
|
127
|
+
// B must still be detected as a registered subagent.
|
|
128
|
+
expect(registry.has("child-A")).toBe(false);
|
|
129
|
+
expect(registry.has("child-B")).toBe(true);
|
|
130
|
+
expect(registry.get("child-B")?.parentSessionId).toBe("parent-P");
|
|
131
|
+
});
|
|
113
132
|
});
|
|
@@ -12,89 +12,99 @@ const REGISTRY_KEY = Symbol.for(
|
|
|
12
12
|
function makeInfo(
|
|
13
13
|
overrides: Partial<SubagentSessionInfo> = {},
|
|
14
14
|
): SubagentSessionInfo {
|
|
15
|
-
return {
|
|
16
|
-
agentName: "Explore",
|
|
17
|
-
...overrides,
|
|
18
|
-
};
|
|
15
|
+
return { ...overrides };
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
describe("SubagentSessionRegistry", () => {
|
|
22
19
|
test("has() returns false for an unregistered key", () => {
|
|
23
20
|
const registry = new SubagentSessionRegistry();
|
|
24
|
-
expect(registry.has("
|
|
21
|
+
expect(registry.has("session-abc")).toBe(false);
|
|
25
22
|
});
|
|
26
23
|
|
|
27
24
|
test("get() returns undefined for an unregistered key", () => {
|
|
28
25
|
const registry = new SubagentSessionRegistry();
|
|
29
|
-
expect(registry.get("
|
|
26
|
+
expect(registry.get("session-abc")).toBeUndefined();
|
|
30
27
|
});
|
|
31
28
|
|
|
32
29
|
test("has() returns true after register()", () => {
|
|
33
30
|
const registry = new SubagentSessionRegistry();
|
|
34
|
-
registry.register("
|
|
35
|
-
expect(registry.has("
|
|
31
|
+
registry.register("session-abc", makeInfo());
|
|
32
|
+
expect(registry.has("session-abc")).toBe(true);
|
|
36
33
|
});
|
|
37
34
|
|
|
38
35
|
test("get() returns the registered info after register()", () => {
|
|
39
36
|
const registry = new SubagentSessionRegistry();
|
|
40
37
|
const info = makeInfo({ parentSessionId: "parent-123" });
|
|
41
|
-
registry.register("
|
|
42
|
-
expect(registry.get("
|
|
38
|
+
registry.register("session-abc", info);
|
|
39
|
+
expect(registry.get("session-abc")).toEqual(info);
|
|
43
40
|
});
|
|
44
41
|
|
|
45
|
-
test("register() stores
|
|
42
|
+
test("register() stores entry without parentSessionId", () => {
|
|
46
43
|
const registry = new SubagentSessionRegistry();
|
|
47
|
-
registry.register("
|
|
48
|
-
expect(registry.get("
|
|
49
|
-
agentName: "Explore",
|
|
50
|
-
});
|
|
44
|
+
registry.register("session-abc", makeInfo());
|
|
45
|
+
expect(registry.get("session-abc")).toEqual({});
|
|
51
46
|
});
|
|
52
47
|
|
|
53
48
|
test("has() returns false after unregister()", () => {
|
|
54
49
|
const registry = new SubagentSessionRegistry();
|
|
55
|
-
registry.register("
|
|
56
|
-
registry.unregister("
|
|
57
|
-
expect(registry.has("
|
|
50
|
+
registry.register("session-abc", makeInfo());
|
|
51
|
+
registry.unregister("session-abc");
|
|
52
|
+
expect(registry.has("session-abc")).toBe(false);
|
|
58
53
|
});
|
|
59
54
|
|
|
60
55
|
test("get() returns undefined after unregister()", () => {
|
|
61
56
|
const registry = new SubagentSessionRegistry();
|
|
62
|
-
registry.register("
|
|
63
|
-
registry.unregister("
|
|
64
|
-
expect(registry.get("
|
|
57
|
+
registry.register("session-abc", makeInfo());
|
|
58
|
+
registry.unregister("session-abc");
|
|
59
|
+
expect(registry.get("session-abc")).toBeUndefined();
|
|
65
60
|
});
|
|
66
61
|
|
|
67
62
|
test("unregister() is a no-op for an unknown key", () => {
|
|
68
63
|
const registry = new SubagentSessionRegistry();
|
|
69
|
-
expect(() => registry.unregister("
|
|
64
|
+
expect(() => registry.unregister("session-nonexistent")).not.toThrow();
|
|
70
65
|
});
|
|
71
66
|
|
|
72
67
|
test("register() overwrites a previous entry for the same key", () => {
|
|
68
|
+
const registry = new SubagentSessionRegistry();
|
|
69
|
+
registry.register("session-abc", makeInfo({ parentSessionId: "parent-1" }));
|
|
70
|
+
registry.register("session-abc", makeInfo({ parentSessionId: "parent-2" }));
|
|
71
|
+
expect(registry.get("session-abc")?.parentSessionId).toBe("parent-2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── #298 regression: concurrent siblings must be independent ──────────────
|
|
75
|
+
|
|
76
|
+
test("two sibling session ids are registered independently", () => {
|
|
73
77
|
const registry = new SubagentSessionRegistry();
|
|
74
78
|
registry.register(
|
|
75
|
-
"
|
|
76
|
-
makeInfo({ parentSessionId: "parent-
|
|
79
|
+
"child-session-A",
|
|
80
|
+
makeInfo({ parentSessionId: "parent-P" }),
|
|
77
81
|
);
|
|
78
82
|
registry.register(
|
|
79
|
-
"
|
|
80
|
-
makeInfo({ parentSessionId: "parent-
|
|
81
|
-
);
|
|
82
|
-
expect(registry.get("/sessions/task-abc")?.parentSessionId).toBe(
|
|
83
|
-
"parent-2",
|
|
83
|
+
"child-session-B",
|
|
84
|
+
makeInfo({ parentSessionId: "parent-P" }),
|
|
84
85
|
);
|
|
86
|
+
|
|
87
|
+
expect(registry.has("child-session-A")).toBe(true);
|
|
88
|
+
expect(registry.has("child-session-B")).toBe(true);
|
|
85
89
|
});
|
|
86
90
|
|
|
87
|
-
test("
|
|
91
|
+
test("disposing one sibling does not evict the other (collision regression)", () => {
|
|
88
92
|
const registry = new SubagentSessionRegistry();
|
|
89
|
-
registry.register(
|
|
90
|
-
|
|
93
|
+
registry.register(
|
|
94
|
+
"child-session-A",
|
|
95
|
+
makeInfo({ parentSessionId: "parent-P" }),
|
|
96
|
+
);
|
|
97
|
+
registry.register(
|
|
98
|
+
"child-session-B",
|
|
99
|
+
makeInfo({ parentSessionId: "parent-P" }),
|
|
100
|
+
);
|
|
91
101
|
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
// Sibling A finishes — should not affect B.
|
|
103
|
+
registry.unregister("child-session-A");
|
|
94
104
|
|
|
95
|
-
registry.
|
|
96
|
-
expect(registry.has("
|
|
97
|
-
expect(registry.
|
|
105
|
+
expect(registry.has("child-session-A")).toBe(false);
|
|
106
|
+
expect(registry.has("child-session-B")).toBe(true);
|
|
107
|
+
expect(registry.get("child-session-B")?.parentSessionId).toBe("parent-P");
|
|
98
108
|
});
|
|
99
109
|
});
|
|
100
110
|
|
|
@@ -119,20 +129,17 @@ describe("getSubagentSessionRegistry (process-global accessor)", () => {
|
|
|
119
129
|
|
|
120
130
|
test("state registered through one call is visible through another call", () => {
|
|
121
131
|
const writer = getSubagentSessionRegistry();
|
|
122
|
-
writer.register("
|
|
123
|
-
agentName: "Explore",
|
|
132
|
+
writer.register("child-session-xyz", {
|
|
124
133
|
parentSessionId: "parent-abc",
|
|
125
134
|
});
|
|
126
135
|
|
|
127
136
|
const reader = getSubagentSessionRegistry();
|
|
128
|
-
expect(reader.has("
|
|
129
|
-
expect(reader.get("
|
|
130
|
-
"parent-abc",
|
|
131
|
-
);
|
|
137
|
+
expect(reader.has("child-session-xyz")).toBe(true);
|
|
138
|
+
expect(reader.get("child-session-xyz")?.parentSessionId).toBe("parent-abc");
|
|
132
139
|
});
|
|
133
140
|
|
|
134
141
|
test("starts empty on first call", () => {
|
|
135
142
|
const registry = getSubagentSessionRegistry();
|
|
136
|
-
expect(registry.has("
|
|
143
|
+
expect(registry.has("any-session-id")).toBe(false);
|
|
137
144
|
});
|
|
138
145
|
});
|