@gotgenes/pi-permission-system 10.0.0 → 10.1.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 +26 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent-prep-session.ts +28 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +11 -0
- package/src/forwarded-permissions/permission-forwarder.ts +549 -0
- package/src/forwarding-manager.ts +3 -7
- package/src/gate-handler-session.ts +13 -0
- package/src/gate-prompter.ts +14 -0
- package/src/handlers/before-agent-start.ts +2 -3
- package/src/handlers/gates/bash-command.ts +4 -18
- package/src/handlers/gates/bash-external-directory.ts +3 -15
- package/src/handlers/gates/bash-path.ts +3 -16
- package/src/handlers/gates/descriptor.ts +0 -28
- package/src/handlers/gates/path.ts +3 -15
- package/src/handlers/gates/runner.ts +142 -105
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +44 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
- package/src/handlers/lifecycle.ts +9 -9
- package/src/handlers/permission-gate-handler.ts +34 -238
- package/src/index.ts +49 -69
- package/src/mcp-targets.ts +56 -46
- package/src/permission-prompter.ts +7 -58
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +77 -9
- package/src/permissions-service.ts +53 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-lifecycle-session.ts +24 -0
- package/src/tool-input-preview.ts +0 -62
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +6 -4
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +62 -0
- package/test/forwarding-manager.test.ts +26 -44
- package/test/handlers/before-agent-start.test.ts +45 -21
- package/test/handlers/external-directory-integration.test.ts +86 -22
- package/test/handlers/external-directory-session-dedup.test.ts +102 -55
- package/test/handlers/gates/bash-command.test.ts +49 -90
- package/test/handlers/gates/bash-external-directory.test.ts +54 -95
- package/test/handlers/gates/bash-path.test.ts +63 -148
- package/test/handlers/gates/path.test.ts +38 -105
- package/test/handlers/gates/runner.test.ts +150 -93
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +128 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
- package/test/handlers/input.test.ts +1 -2
- package/test/handlers/lifecycle.test.ts +49 -33
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +147 -16
- package/test/helpers/handler-fixtures.ts +143 -27
- package/test/mcp-targets.test.ts +55 -0
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-forwarding.test.ts +0 -282
- package/test/permission-prompter.test.ts +33 -44
- package/test/permission-session.test.ts +160 -27
- package/test/permissions-service.test.ts +151 -0
- package/test/runtime.test.ts +0 -4
- package/test/service-lifecycle.test.ts +162 -0
- package/test/tool-input-preview.test.ts +0 -111
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/src/forwarded-permissions/polling.ts +0 -411
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
2
|
+
import { countTextLines, formatCount } from "./tool-input-preview";
|
|
3
|
+
|
|
4
|
+
export function getPromptPath(input: Record<string, unknown>): string | null {
|
|
5
|
+
return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatEditInputForPrompt(
|
|
9
|
+
input: Record<string, unknown>,
|
|
10
|
+
): string {
|
|
11
|
+
const path = getPromptPath(input);
|
|
12
|
+
const rawEdits = Array.isArray(input.edits)
|
|
13
|
+
? input.edits
|
|
14
|
+
: typeof input.oldText === "string" && typeof input.newText === "string"
|
|
15
|
+
? [{ oldText: input.oldText, newText: input.newText }]
|
|
16
|
+
: [];
|
|
17
|
+
|
|
18
|
+
const edits = rawEdits
|
|
19
|
+
.map((edit) => toRecord(edit))
|
|
20
|
+
.filter(
|
|
21
|
+
(edit) =>
|
|
22
|
+
typeof edit.oldText === "string" && typeof edit.newText === "string",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const pathPart = path ? `for '${path}'` : "";
|
|
26
|
+
if (edits.length === 0) {
|
|
27
|
+
return pathPart ? `${pathPart} with edit input` : "with edit input";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const firstEdit = edits[0];
|
|
31
|
+
const oldText = String(firstEdit.oldText);
|
|
32
|
+
const newText = String(firstEdit.newText);
|
|
33
|
+
const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
|
|
34
|
+
const extraEdits =
|
|
35
|
+
edits.length > 1
|
|
36
|
+
? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
|
|
37
|
+
: "";
|
|
38
|
+
const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
|
|
39
|
+
return pathPart ? `${pathPart} ${summary}` : summary;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatWriteInputForPrompt(
|
|
43
|
+
input: Record<string, unknown>,
|
|
44
|
+
): string {
|
|
45
|
+
const path = getPromptPath(input);
|
|
46
|
+
const content = typeof input.content === "string" ? input.content : "";
|
|
47
|
+
const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
|
|
48
|
+
return path ? `for '${path}' ${summary}` : summary;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatReadInputForPrompt(
|
|
52
|
+
input: Record<string, unknown>,
|
|
53
|
+
): string {
|
|
54
|
+
const path = getPromptPath(input);
|
|
55
|
+
const parts = path ? [`path '${path}'`] : [];
|
|
56
|
+
if (typeof input.offset === "number") {
|
|
57
|
+
parts.push(`offset ${input.offset}`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof input.limit === "number") {
|
|
60
|
+
parts.push(`limit ${input.limit}`);
|
|
61
|
+
}
|
|
62
|
+
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
63
|
+
}
|
|
@@ -2,16 +2,18 @@ import { getNonEmptyString, toRecord } from "./common";
|
|
|
2
2
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
3
|
import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
|
|
4
4
|
import {
|
|
5
|
-
formatEditInputForPrompt,
|
|
6
|
-
formatReadInputForPrompt,
|
|
7
|
-
formatWriteInputForPrompt,
|
|
8
|
-
getPromptPath,
|
|
9
5
|
serializeToolInputPreview,
|
|
10
6
|
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
11
7
|
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
12
8
|
TOOL_TEXT_SUMMARY_MAX_LENGTH,
|
|
13
9
|
truncateInlineText,
|
|
14
10
|
} from "./tool-input-preview";
|
|
11
|
+
import {
|
|
12
|
+
formatEditInputForPrompt,
|
|
13
|
+
formatReadInputForPrompt,
|
|
14
|
+
formatWriteInputForPrompt,
|
|
15
|
+
getPromptPath,
|
|
16
|
+
} from "./tool-input-prompt-formatters";
|
|
15
17
|
import type { PermissionCheckResult } from "./types";
|
|
16
18
|
|
|
17
19
|
export interface ToolPreviewFormatterOptions {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type DecisionReporter,
|
|
5
|
+
GateDecisionReporter,
|
|
6
|
+
} from "#src/decision-reporter";
|
|
7
|
+
import {
|
|
8
|
+
PERMISSIONS_DECISION_CHANNEL,
|
|
9
|
+
type PermissionDecisionEvent,
|
|
10
|
+
} from "#src/permission-events";
|
|
11
|
+
import type { SessionLogger } from "#src/session-logger";
|
|
12
|
+
|
|
13
|
+
// ── fixtures ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function makeLogger(): SessionLogger {
|
|
16
|
+
return {
|
|
17
|
+
debug: vi.fn(),
|
|
18
|
+
review: vi.fn(),
|
|
19
|
+
warn: vi.fn(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeEvents() {
|
|
24
|
+
return {
|
|
25
|
+
emit: vi.fn(),
|
|
26
|
+
on: vi.fn().mockReturnValue(() => undefined),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeDecisionEvent(
|
|
31
|
+
overrides: Partial<PermissionDecisionEvent> = {},
|
|
32
|
+
): PermissionDecisionEvent {
|
|
33
|
+
return {
|
|
34
|
+
surface: "read",
|
|
35
|
+
value: "read",
|
|
36
|
+
result: "allow",
|
|
37
|
+
resolution: "policy_allow",
|
|
38
|
+
origin: "global",
|
|
39
|
+
agentName: null,
|
|
40
|
+
matchedPattern: null,
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── tests ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("GateDecisionReporter", () => {
|
|
48
|
+
it("satisfies the DecisionReporter interface", () => {
|
|
49
|
+
const reporter: DecisionReporter = new GateDecisionReporter(
|
|
50
|
+
makeLogger(),
|
|
51
|
+
makeEvents(),
|
|
52
|
+
);
|
|
53
|
+
expect(reporter).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("writeReviewLog", () => {
|
|
57
|
+
it("delegates to logger.review with event and details", () => {
|
|
58
|
+
const logger = makeLogger();
|
|
59
|
+
const reporter = new GateDecisionReporter(logger, makeEvents());
|
|
60
|
+
reporter.writeReviewLog("permission_request.blocked", { tool: "bash" });
|
|
61
|
+
expect(logger.review).toHaveBeenCalledWith("permission_request.blocked", {
|
|
62
|
+
tool: "bash",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("delegates with an empty details object", () => {
|
|
67
|
+
const logger = makeLogger();
|
|
68
|
+
const reporter = new GateDecisionReporter(logger, makeEvents());
|
|
69
|
+
reporter.writeReviewLog("permission_request.session_approved", {});
|
|
70
|
+
expect(logger.review).toHaveBeenCalledWith(
|
|
71
|
+
"permission_request.session_approved",
|
|
72
|
+
{},
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not call emitDecision", () => {
|
|
77
|
+
const events = makeEvents();
|
|
78
|
+
const reporter = new GateDecisionReporter(makeLogger(), events);
|
|
79
|
+
reporter.writeReviewLog("some.event", { key: "val" });
|
|
80
|
+
expect(events.emit).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("emitDecision", () => {
|
|
85
|
+
it("emits on the PERMISSIONS_DECISION_CHANNEL with the event", () => {
|
|
86
|
+
const events = makeEvents();
|
|
87
|
+
const reporter = new GateDecisionReporter(makeLogger(), events);
|
|
88
|
+
const event = makeDecisionEvent();
|
|
89
|
+
reporter.emitDecision(event);
|
|
90
|
+
expect(events.emit).toHaveBeenCalledWith(
|
|
91
|
+
PERMISSIONS_DECISION_CHANNEL,
|
|
92
|
+
event,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not call writeReviewLog", () => {
|
|
97
|
+
const logger = makeLogger();
|
|
98
|
+
const reporter = new GateDecisionReporter(logger, makeEvents());
|
|
99
|
+
reporter.emitDecision(makeDecisionEvent());
|
|
100
|
+
expect(logger.review).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("does not propagate a throwing listener", () => {
|
|
104
|
+
const events = makeEvents();
|
|
105
|
+
events.emit.mockImplementation(() => {
|
|
106
|
+
throw new Error("listener boom");
|
|
107
|
+
});
|
|
108
|
+
const reporter = new GateDecisionReporter(makeLogger(), events);
|
|
109
|
+
expect(() => reporter.emitDecision(makeDecisionEvent())).not.toThrow();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -265,6 +265,31 @@ describe("formatDenyReason", () => {
|
|
|
265
265
|
);
|
|
266
266
|
});
|
|
267
267
|
});
|
|
268
|
+
|
|
269
|
+
describe("skill_input context", () => {
|
|
270
|
+
test("without agent", () => {
|
|
271
|
+
expect(
|
|
272
|
+
formatDenyReason({
|
|
273
|
+
kind: "skill_input",
|
|
274
|
+
skillName: "librarian",
|
|
275
|
+
}),
|
|
276
|
+
).toBe(
|
|
277
|
+
"[pi-permission-system] Current agent is not permitted to access skill 'librarian'.",
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("with agent", () => {
|
|
282
|
+
expect(
|
|
283
|
+
formatDenyReason({
|
|
284
|
+
kind: "skill_input",
|
|
285
|
+
skillName: "librarian",
|
|
286
|
+
agentName: "my-agent",
|
|
287
|
+
}),
|
|
288
|
+
).toBe(
|
|
289
|
+
"[pi-permission-system] Agent 'my-agent' is not permitted to access skill 'librarian'.",
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
268
293
|
});
|
|
269
294
|
|
|
270
295
|
// ── formatUnavailableReason ────────────────────────────────────────────────
|
|
@@ -353,6 +378,17 @@ describe("formatUnavailableReason", () => {
|
|
|
353
378
|
"[pi-permission-system] Accessing skill 'librarian' requires approval, but no interactive UI is available.",
|
|
354
379
|
);
|
|
355
380
|
});
|
|
381
|
+
|
|
382
|
+
test("skill_input", () => {
|
|
383
|
+
expect(
|
|
384
|
+
formatUnavailableReason({
|
|
385
|
+
kind: "skill_input",
|
|
386
|
+
skillName: "librarian",
|
|
387
|
+
}),
|
|
388
|
+
).toBe(
|
|
389
|
+
"[pi-permission-system] Accessing skill 'librarian' requires approval, but no interactive UI is available.",
|
|
390
|
+
);
|
|
391
|
+
});
|
|
356
392
|
});
|
|
357
393
|
|
|
358
394
|
// ── formatUserDeniedReason ─────────────────────────────────────────────────
|
|
@@ -530,4 +566,30 @@ describe("formatUserDeniedReason", () => {
|
|
|
530
566
|
);
|
|
531
567
|
});
|
|
532
568
|
});
|
|
569
|
+
|
|
570
|
+
describe("skill_input context", () => {
|
|
571
|
+
test("without agent and without reason", () => {
|
|
572
|
+
expect(
|
|
573
|
+
formatUserDeniedReason({
|
|
574
|
+
kind: "skill_input",
|
|
575
|
+
skillName: "librarian",
|
|
576
|
+
}),
|
|
577
|
+
).toBe("[pi-permission-system] User denied access to skill 'librarian'.");
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("with agent and with reason", () => {
|
|
581
|
+
expect(
|
|
582
|
+
formatUserDeniedReason(
|
|
583
|
+
{
|
|
584
|
+
kind: "skill_input",
|
|
585
|
+
skillName: "librarian",
|
|
586
|
+
agentName: "code-agent",
|
|
587
|
+
},
|
|
588
|
+
"not permitted",
|
|
589
|
+
),
|
|
590
|
+
).toBe(
|
|
591
|
+
"[pi-permission-system] User denied access to skill 'librarian'. Reason: not permitted.",
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
533
595
|
});
|
|
@@ -4,13 +4,11 @@ import { ForwardingManager } from "#src/forwarding-manager";
|
|
|
4
4
|
|
|
5
5
|
// ── Mocks ─────────────────────────────────────────────────────────────────
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const mockProcessInbox = vi.hoisted(() =>
|
|
8
|
+
vi.fn((): Promise<void> => Promise.resolve()),
|
|
9
|
+
);
|
|
8
10
|
const mockIsSubagentExecutionContext = vi.hoisted(() => vi.fn());
|
|
9
11
|
|
|
10
|
-
vi.mock("../src/forwarded-permissions/polling", () => ({
|
|
11
|
-
processForwardedPermissionRequests: mockProcessForwardedPermissionRequests,
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
12
|
vi.mock("../src/subagent-context", () => ({
|
|
15
13
|
isSubagentExecutionContext: mockIsSubagentExecutionContext,
|
|
16
14
|
}));
|
|
@@ -27,22 +25,12 @@ function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
|
|
|
27
25
|
} as unknown as import("@earendil-works/pi-coding-agent").ExtensionContext;
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
function
|
|
31
|
-
return {
|
|
32
|
-
forwardingDir: "/agent/sessions/permission-forwarding",
|
|
33
|
-
subagentSessionsDir: "/agent/subagent-sessions",
|
|
34
|
-
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
35
|
-
writeReviewLog: vi.fn(),
|
|
36
|
-
requestPermissionDecisionFromUi: vi.fn(),
|
|
37
|
-
shouldAutoApprove: vi.fn().mockReturnValue(false),
|
|
38
|
-
} as unknown as import("../src/forwarded-permissions/polling").PermissionForwardingDeps;
|
|
28
|
+
function makeForwarder() {
|
|
29
|
+
return { processInbox: mockProcessInbox };
|
|
39
30
|
}
|
|
40
31
|
|
|
41
32
|
function makeManager() {
|
|
42
|
-
return new ForwardingManager(
|
|
43
|
-
"/agent/subagent-sessions",
|
|
44
|
-
makeForwardingDeps(),
|
|
45
|
-
);
|
|
33
|
+
return new ForwardingManager("/agent/subagent-sessions", makeForwarder());
|
|
46
34
|
}
|
|
47
35
|
|
|
48
36
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
@@ -52,8 +40,8 @@ describe("ForwardingManager", () => {
|
|
|
52
40
|
vi.useFakeTimers();
|
|
53
41
|
mockIsSubagentExecutionContext.mockReset();
|
|
54
42
|
mockIsSubagentExecutionContext.mockReturnValue(false);
|
|
55
|
-
|
|
56
|
-
|
|
43
|
+
mockProcessInbox.mockReset();
|
|
44
|
+
mockProcessInbox.mockResolvedValue(undefined);
|
|
57
45
|
});
|
|
58
46
|
|
|
59
47
|
afterEach(() => {
|
|
@@ -73,9 +61,9 @@ describe("ForwardingManager", () => {
|
|
|
73
61
|
manager.stop();
|
|
74
62
|
|
|
75
63
|
// After stop, the timer fires no more callbacks.
|
|
76
|
-
|
|
64
|
+
mockProcessInbox.mockClear();
|
|
77
65
|
await vi.advanceTimersByTimeAsync(500);
|
|
78
|
-
expect(
|
|
66
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
79
67
|
});
|
|
80
68
|
});
|
|
81
69
|
|
|
@@ -86,7 +74,7 @@ describe("ForwardingManager", () => {
|
|
|
86
74
|
manager.start(ctx);
|
|
87
75
|
|
|
88
76
|
await vi.advanceTimersByTimeAsync(500);
|
|
89
|
-
expect(
|
|
77
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
90
78
|
});
|
|
91
79
|
|
|
92
80
|
it("stops any existing poll and does not start a new one when hasUI is false", async () => {
|
|
@@ -98,9 +86,9 @@ describe("ForwardingManager", () => {
|
|
|
98
86
|
// Now stop the polling by calling start() with no-UI ctx.
|
|
99
87
|
manager.start(noUiCtx);
|
|
100
88
|
|
|
101
|
-
|
|
89
|
+
mockProcessInbox.mockClear();
|
|
102
90
|
await vi.advanceTimersByTimeAsync(500);
|
|
103
|
-
expect(
|
|
91
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
104
92
|
});
|
|
105
93
|
|
|
106
94
|
it("does not start polling when isSubagentExecutionContext returns true", async () => {
|
|
@@ -110,7 +98,7 @@ describe("ForwardingManager", () => {
|
|
|
110
98
|
manager.start(ctx);
|
|
111
99
|
|
|
112
100
|
await vi.advanceTimersByTimeAsync(500);
|
|
113
|
-
expect(
|
|
101
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
114
102
|
});
|
|
115
103
|
|
|
116
104
|
it("stops any existing poll when called with a subagent context", async () => {
|
|
@@ -124,21 +112,18 @@ describe("ForwardingManager", () => {
|
|
|
124
112
|
const ctx2 = makeCtx();
|
|
125
113
|
manager.start(ctx2);
|
|
126
114
|
|
|
127
|
-
|
|
115
|
+
mockProcessInbox.mockClear();
|
|
128
116
|
await vi.advanceTimersByTimeAsync(500);
|
|
129
|
-
expect(
|
|
117
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
130
118
|
});
|
|
131
119
|
|
|
132
|
-
it("starts polling and calls
|
|
120
|
+
it("starts polling and calls processInbox on tick", async () => {
|
|
133
121
|
const manager = makeManager();
|
|
134
122
|
const ctx = makeCtx();
|
|
135
123
|
manager.start(ctx);
|
|
136
124
|
|
|
137
125
|
await vi.advanceTimersByTimeAsync(250);
|
|
138
|
-
expect(
|
|
139
|
-
ctx,
|
|
140
|
-
expect.anything(),
|
|
141
|
-
);
|
|
126
|
+
expect(mockProcessInbox).toHaveBeenCalledWith(ctx);
|
|
142
127
|
});
|
|
143
128
|
|
|
144
129
|
it("is idempotent — calling start() twice does not create a second timer", async () => {
|
|
@@ -149,7 +134,7 @@ describe("ForwardingManager", () => {
|
|
|
149
134
|
|
|
150
135
|
await vi.advanceTimersByTimeAsync(250);
|
|
151
136
|
// Only one tick should fire per interval, not two.
|
|
152
|
-
expect(
|
|
137
|
+
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
153
138
|
});
|
|
154
139
|
|
|
155
140
|
it("updates the context when called again while already running", async () => {
|
|
@@ -161,16 +146,13 @@ describe("ForwardingManager", () => {
|
|
|
161
146
|
|
|
162
147
|
await vi.advanceTimersByTimeAsync(250);
|
|
163
148
|
// The process call should use the newer context.
|
|
164
|
-
expect(
|
|
165
|
-
ctx2,
|
|
166
|
-
expect.anything(),
|
|
167
|
-
);
|
|
149
|
+
expect(mockProcessInbox).toHaveBeenCalledWith(ctx2);
|
|
168
150
|
});
|
|
169
151
|
|
|
170
152
|
it("skips a tick while processing is in progress", async () => {
|
|
171
|
-
// Make
|
|
153
|
+
// Make processInbox hang so processing=true persists.
|
|
172
154
|
let resolveProcess: () => void;
|
|
173
|
-
|
|
155
|
+
mockProcessInbox.mockReturnValue(
|
|
174
156
|
new Promise<void>((resolve) => {
|
|
175
157
|
resolveProcess = resolve;
|
|
176
158
|
}),
|
|
@@ -182,22 +164,22 @@ describe("ForwardingManager", () => {
|
|
|
182
164
|
|
|
183
165
|
// First tick starts processing.
|
|
184
166
|
await vi.advanceTimersByTimeAsync(250);
|
|
185
|
-
expect(
|
|
167
|
+
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
186
168
|
|
|
187
169
|
// Second tick is skipped because processing flag is still true.
|
|
188
170
|
await vi.advanceTimersByTimeAsync(250);
|
|
189
|
-
expect(
|
|
171
|
+
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
190
172
|
|
|
191
173
|
// Resolve and a third tick should fire.
|
|
192
174
|
resolveProcess!();
|
|
193
175
|
await vi.advanceTimersByTimeAsync(250);
|
|
194
|
-
expect(
|
|
176
|
+
expect(mockProcessInbox).toHaveBeenCalledTimes(2);
|
|
195
177
|
});
|
|
196
178
|
|
|
197
179
|
it("passes subagentSessionsDir from the constructor to isSubagentExecutionContext", () => {
|
|
198
180
|
const manager = new ForwardingManager(
|
|
199
181
|
"/custom/subagent-dir",
|
|
200
|
-
|
|
182
|
+
makeForwarder(),
|
|
201
183
|
);
|
|
202
184
|
const ctx = makeCtx();
|
|
203
185
|
manager.start(ctx);
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
import type { AgentPrepSession } from "#src/agent-prep-session";
|
|
3
4
|
import {
|
|
4
5
|
AgentPrepHandler,
|
|
5
6
|
shouldExposeTool,
|
|
6
7
|
} from "#src/handlers/before-agent-start";
|
|
7
|
-
import type { PermissionSession } from "#src/permission-session";
|
|
8
8
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
9
9
|
|
|
10
|
-
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
10
|
+
import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures";
|
|
11
11
|
|
|
12
12
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
13
13
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
@@ -26,24 +26,48 @@ function makeEvent(systemPrompt = "You are an assistant.") {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function makeSession(
|
|
29
|
-
overrides: Partial<
|
|
30
|
-
):
|
|
29
|
+
overrides: Partial<AgentPrepSession> = {},
|
|
30
|
+
): AgentPrepSession {
|
|
31
31
|
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
resolveAgentName:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
32
|
+
activate: overrides.activate ?? vi.fn<AgentPrepSession["activate"]>(),
|
|
33
|
+
refreshConfig:
|
|
34
|
+
overrides.refreshConfig ?? vi.fn<AgentPrepSession["refreshConfig"]>(),
|
|
35
|
+
resolveAgentName:
|
|
36
|
+
overrides.resolveAgentName ??
|
|
37
|
+
vi.fn<AgentPrepSession["resolveAgentName"]>().mockReturnValue(null),
|
|
38
|
+
checkPermission:
|
|
39
|
+
overrides.checkPermission ??
|
|
40
|
+
vi
|
|
41
|
+
.fn<AgentPrepSession["checkPermission"]>()
|
|
42
|
+
.mockReturnValue(makeCheckResult()),
|
|
43
|
+
getToolPermission:
|
|
44
|
+
overrides.getToolPermission ??
|
|
45
|
+
vi.fn<AgentPrepSession["getToolPermission"]>().mockReturnValue("allow"),
|
|
46
|
+
shouldUpdateActiveTools:
|
|
47
|
+
overrides.shouldUpdateActiveTools ??
|
|
48
|
+
vi
|
|
49
|
+
.fn<AgentPrepSession["shouldUpdateActiveTools"]>()
|
|
50
|
+
.mockReturnValue(true),
|
|
51
|
+
commitActiveToolsCacheKey:
|
|
52
|
+
overrides.commitActiveToolsCacheKey ??
|
|
53
|
+
vi.fn<AgentPrepSession["commitActiveToolsCacheKey"]>(),
|
|
54
|
+
getPolicyCacheStamp:
|
|
55
|
+
overrides.getPolicyCacheStamp ??
|
|
56
|
+
vi
|
|
57
|
+
.fn<AgentPrepSession["getPolicyCacheStamp"]>()
|
|
58
|
+
.mockReturnValue("stamp-1"),
|
|
59
|
+
shouldUpdatePromptState:
|
|
60
|
+
overrides.shouldUpdatePromptState ??
|
|
61
|
+
vi
|
|
62
|
+
.fn<AgentPrepSession["shouldUpdatePromptState"]>()
|
|
63
|
+
.mockReturnValue(true),
|
|
64
|
+
commitPromptStateCacheKey:
|
|
65
|
+
overrides.commitPromptStateCacheKey ??
|
|
66
|
+
vi.fn<AgentPrepSession["commitPromptStateCacheKey"]>(),
|
|
67
|
+
setActiveSkillEntries:
|
|
68
|
+
overrides.setActiveSkillEntries ??
|
|
69
|
+
vi.fn<AgentPrepSession["setActiveSkillEntries"]>(),
|
|
70
|
+
};
|
|
47
71
|
}
|
|
48
72
|
|
|
49
73
|
function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
@@ -55,11 +79,11 @@ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
|
55
79
|
}
|
|
56
80
|
|
|
57
81
|
function makeHandler(overrides?: {
|
|
58
|
-
session?: Partial<
|
|
82
|
+
session?: Partial<AgentPrepSession>;
|
|
59
83
|
toolRegistry?: Partial<ToolRegistry>;
|
|
60
84
|
}): {
|
|
61
85
|
handler: AgentPrepHandler;
|
|
62
|
-
session:
|
|
86
|
+
session: AgentPrepSession;
|
|
63
87
|
toolRegistry: ToolRegistry;
|
|
64
88
|
} {
|
|
65
89
|
const session = makeSession(overrides?.session);
|