@gotgenes/pi-permission-system 10.5.0 → 10.5.1
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 +7 -0
- package/package.json +1 -1
- package/src/handlers/before-agent-start.ts +11 -6
- package/src/handlers/lifecycle.ts +7 -4
- package/src/handlers/permission-gate-handler.ts +3 -3
- package/src/index.ts +8 -3
- package/src/permission-resolver.ts +0 -3
- package/src/permission-session.ts +8 -52
- package/src/session-rules.ts +3 -2
- package/src/skill-prompt-sanitizer.ts +1 -1
- package/test/handlers/before-agent-start.test.ts +56 -86
- package/test/handlers/external-directory-session-dedup.test.ts +79 -159
- package/test/handlers/input.test.ts +5 -4
- package/test/handlers/lifecycle.test.ts +79 -85
- package/test/handlers/tool-call.test.ts +3 -2
- package/test/helpers/handler-fixtures.ts +99 -102
- package/test/helpers/session-fixtures.ts +192 -0
- package/test/permission-resolver.test.ts +3 -1
- package/test/permission-session.test.ts +14 -198
- package/test/session-rules.test.ts +13 -5
- package/src/agent-prep-session.ts +0 -28
- package/src/gate-handler-session.ts +0 -13
- package/src/session-lifecycle-session.ts +0 -24
|
@@ -3,33 +3,30 @@
|
|
|
3
3
|
* external path only prompt once — the session-approval recorded by the
|
|
4
4
|
* first call covers the second.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the real interaction between PermissionSession, SessionRules, and
|
|
9
|
-
* PermissionManager.
|
|
6
|
+
* Uses real PermissionSession + PermissionResolver + SessionRules so the
|
|
7
|
+
* stateful approval-tracking path is exercised end-to-end.
|
|
10
8
|
*/
|
|
11
9
|
|
|
12
10
|
import { describe, expect, it, vi } from "vitest";
|
|
13
11
|
|
|
14
12
|
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
15
|
-
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
16
13
|
import type { GatePrompter } from "#src/gate-prompter";
|
|
17
14
|
import { GateRunner } from "#src/handlers/gates/runner";
|
|
18
15
|
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
19
16
|
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
20
17
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
21
|
-
import type { Rule } from "#src/rule";
|
|
22
|
-
import type { SessionApproval } from "#src/session-approval";
|
|
23
|
-
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
|
24
|
-
import type { ToolRegistry } from "#src/tool-registry";
|
|
25
18
|
import type { PermissionCheckResult } from "#src/types";
|
|
26
19
|
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
27
20
|
|
|
28
21
|
import {
|
|
29
|
-
type MockGateHandlerSession,
|
|
30
22
|
makeCtx,
|
|
31
23
|
makeEvents,
|
|
24
|
+
makeToolRegistry,
|
|
32
25
|
} from "#test/helpers/handler-fixtures";
|
|
26
|
+
import {
|
|
27
|
+
makeRealResolver,
|
|
28
|
+
makeRealSession,
|
|
29
|
+
} from "#test/helpers/session-fixtures";
|
|
33
30
|
|
|
34
31
|
// ── SDK stub ───────────────────────────────────────────────────────────────
|
|
35
32
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
@@ -41,177 +38,105 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
|
41
38
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
42
39
|
|
|
43
40
|
/**
|
|
44
|
-
* Build a
|
|
41
|
+
* Build a fully wired PermissionGateHandler for external-directory dedup
|
|
42
|
+
* tests.
|
|
45
43
|
*
|
|
46
|
-
* `checkPermission`
|
|
47
|
-
*
|
|
48
|
-
* it
|
|
49
|
-
* "allow"
|
|
44
|
+
* `permissionManager.checkPermission` is configured so that:
|
|
45
|
+
* - `external_directory` surface returns "ask" on first call
|
|
46
|
+
* - On subsequent calls it checks the shared `sessionRules` store; if a
|
|
47
|
+
* matching rule was recorded by the runner, it returns "allow" with
|
|
48
|
+
* `source: "session"`.
|
|
49
|
+
* - All other surfaces return "allow".
|
|
50
50
|
*/
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
state: "allow",
|
|
82
|
-
toolName: surface,
|
|
83
|
-
source: "session",
|
|
84
|
-
origin: "session",
|
|
85
|
-
matchedPattern: match.pattern,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
51
|
+
function makeDeduplicatingHandler(prompter?: GatePrompter): {
|
|
52
|
+
handler: PermissionGateHandler;
|
|
53
|
+
prompter: GatePrompter;
|
|
54
|
+
} {
|
|
55
|
+
const { session, permissionManager, sessionRules, logger } =
|
|
56
|
+
makeRealSession();
|
|
57
|
+
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
58
|
+
|
|
59
|
+
// Configure checkPermission to simulate config-level "ask" for external_directory
|
|
60
|
+
// but return "allow/session" when a session rule has been recorded.
|
|
61
|
+
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
62
|
+
(surface, input, _agentName, rules): PermissionCheckResult => {
|
|
63
|
+
if (surface === "external_directory") {
|
|
64
|
+
const record = (input ?? {}) as Record<string, unknown>;
|
|
65
|
+
const pathValue = typeof record.path === "string" ? record.path : null;
|
|
66
|
+
|
|
67
|
+
if (pathValue && rules && rules.length > 0) {
|
|
68
|
+
const match = rules.findLast(
|
|
69
|
+
(r) =>
|
|
70
|
+
r.surface === "external_directory" &&
|
|
71
|
+
wildcardMatch(r.pattern, pathValue),
|
|
72
|
+
);
|
|
73
|
+
if (match) {
|
|
74
|
+
return {
|
|
75
|
+
state: "allow",
|
|
76
|
+
toolName: surface,
|
|
77
|
+
source: "session",
|
|
78
|
+
origin: "session",
|
|
79
|
+
matchedPattern: match.pattern,
|
|
80
|
+
};
|
|
88
81
|
}
|
|
89
|
-
|
|
90
|
-
// No session match → config-level "ask"
|
|
91
|
-
return {
|
|
92
|
-
state: "ask",
|
|
93
|
-
toolName: surface,
|
|
94
|
-
source: "special",
|
|
95
|
-
origin: "global",
|
|
96
|
-
};
|
|
97
82
|
}
|
|
98
83
|
|
|
99
|
-
// All other surfaces: allow
|
|
100
84
|
return {
|
|
101
|
-
state: "
|
|
85
|
+
state: "ask",
|
|
102
86
|
toolName: surface,
|
|
103
|
-
source: "
|
|
104
|
-
origin: "
|
|
87
|
+
source: "special",
|
|
88
|
+
origin: "global",
|
|
105
89
|
};
|
|
106
|
-
},
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const recordSessionApproval = vi
|
|
110
|
-
.fn<MockGateHandlerSession["recordSessionApproval"]>()
|
|
111
|
-
.mockImplementation((approval: SessionApproval) => {
|
|
112
|
-
for (const pattern of approval.patterns) {
|
|
113
|
-
sessionRules.push({
|
|
114
|
-
surface: approval.surface,
|
|
115
|
-
pattern,
|
|
116
|
-
action: "allow",
|
|
117
|
-
layer: "session",
|
|
118
|
-
origin: "session",
|
|
119
|
-
});
|
|
120
90
|
}
|
|
121
|
-
});
|
|
122
91
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
debug: vi.fn(),
|
|
130
|
-
review: vi.fn(),
|
|
131
|
-
warn: vi.fn(),
|
|
92
|
+
return {
|
|
93
|
+
state: "allow",
|
|
94
|
+
toolName: surface,
|
|
95
|
+
source: "tool",
|
|
96
|
+
origin: "builtin",
|
|
97
|
+
};
|
|
132
98
|
},
|
|
133
|
-
|
|
134
|
-
resolveAgentName:
|
|
135
|
-
overrides.resolveAgentName ??
|
|
136
|
-
vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
|
|
137
|
-
checkPermission: overrides.checkPermission ?? checkPermission,
|
|
138
|
-
getSessionRuleset: overrides.getSessionRuleset ?? getSessionRuleset,
|
|
139
|
-
recordSessionApproval:
|
|
140
|
-
overrides.recordSessionApproval ?? recordSessionApproval,
|
|
141
|
-
getActiveSkillEntries:
|
|
142
|
-
overrides.getActiveSkillEntries ??
|
|
143
|
-
vi
|
|
144
|
-
.fn<MockGateHandlerSession["getActiveSkillEntries"]>()
|
|
145
|
-
.mockReturnValue([]),
|
|
146
|
-
getInfrastructureReadDirs:
|
|
147
|
-
overrides.getInfrastructureReadDirs ??
|
|
148
|
-
vi
|
|
149
|
-
.fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
|
|
150
|
-
.mockReturnValue([]),
|
|
151
|
-
getToolPreviewLimits:
|
|
152
|
-
overrides.getToolPreviewLimits ??
|
|
153
|
-
vi
|
|
154
|
-
.fn<MockGateHandlerSession["getToolPreviewLimits"]>()
|
|
155
|
-
.mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
|
|
156
|
-
};
|
|
157
|
-
return session;
|
|
158
|
-
}
|
|
99
|
+
);
|
|
159
100
|
|
|
160
|
-
function makeHandlerForSession(
|
|
161
|
-
session: MockGateHandlerSession,
|
|
162
|
-
prompter?: GatePrompter,
|
|
163
|
-
): { handler: PermissionGateHandler; prompter: GatePrompter } {
|
|
164
101
|
const events = makeEvents();
|
|
165
|
-
const reporter = new GateDecisionReporter(
|
|
102
|
+
const reporter = new GateDecisionReporter(logger, events);
|
|
166
103
|
const resolvedPrompter: GatePrompter = prompter ?? {
|
|
167
104
|
canConfirm: vi.fn().mockReturnValue(true),
|
|
168
105
|
prompt: vi
|
|
169
106
|
.fn<GatePrompter["prompt"]>()
|
|
170
107
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
171
108
|
};
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
input,
|
|
179
|
-
agentName,
|
|
180
|
-
session.getSessionRuleset(),
|
|
181
|
-
),
|
|
182
|
-
};
|
|
183
|
-
const runner = new GateRunner(resolver, session, resolvedPrompter, reporter);
|
|
109
|
+
const runner = new GateRunner(
|
|
110
|
+
resolver,
|
|
111
|
+
sessionRules,
|
|
112
|
+
resolvedPrompter,
|
|
113
|
+
reporter,
|
|
114
|
+
);
|
|
184
115
|
const handler = new PermissionGateHandler(
|
|
185
116
|
session,
|
|
186
|
-
makeToolRegistry(
|
|
117
|
+
makeToolRegistry({
|
|
118
|
+
getAll: vi
|
|
119
|
+
.fn()
|
|
120
|
+
.mockReturnValue([
|
|
121
|
+
{ name: "read" },
|
|
122
|
+
{ name: "write" },
|
|
123
|
+
{ name: "edit" },
|
|
124
|
+
{ name: "bash" },
|
|
125
|
+
]),
|
|
126
|
+
}),
|
|
187
127
|
new ToolCallGatePipeline(resolver, session),
|
|
188
|
-
new SkillInputGatePipeline(
|
|
128
|
+
new SkillInputGatePipeline(resolver),
|
|
189
129
|
runner,
|
|
190
130
|
);
|
|
191
131
|
return { handler, prompter: resolvedPrompter };
|
|
192
132
|
}
|
|
193
133
|
|
|
194
|
-
function makeToolRegistry(): ToolRegistry {
|
|
195
|
-
return {
|
|
196
|
-
getAll: vi
|
|
197
|
-
.fn()
|
|
198
|
-
.mockReturnValue([
|
|
199
|
-
{ name: "read" },
|
|
200
|
-
{ name: "write" },
|
|
201
|
-
{ name: "edit" },
|
|
202
|
-
{ name: "bash" },
|
|
203
|
-
]),
|
|
204
|
-
setActive: vi.fn(),
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
134
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
209
135
|
|
|
210
136
|
describe("external-directory session dedup", () => {
|
|
211
137
|
describe("path-bearing tools (read, write, edit)", () => {
|
|
212
138
|
it("does not re-prompt for the same external path after session approval", async () => {
|
|
213
|
-
const
|
|
214
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
139
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
215
140
|
const ctx = makeCtx();
|
|
216
141
|
const externalPath = "/outside/project/data.txt";
|
|
217
142
|
|
|
@@ -239,8 +164,7 @@ describe("external-directory session dedup", () => {
|
|
|
239
164
|
});
|
|
240
165
|
|
|
241
166
|
it("does not re-prompt for a different file in the same external directory", async () => {
|
|
242
|
-
const
|
|
243
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
167
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
244
168
|
const ctx = makeCtx();
|
|
245
169
|
|
|
246
170
|
// First call — prompt for /outside/project/a.txt
|
|
@@ -265,8 +189,7 @@ describe("external-directory session dedup", () => {
|
|
|
265
189
|
});
|
|
266
190
|
|
|
267
191
|
it("does prompt for a file in a different external directory", async () => {
|
|
268
|
-
const
|
|
269
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
192
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
270
193
|
const ctx = makeCtx();
|
|
271
194
|
|
|
272
195
|
// First call — /outside/alpha/file.txt
|
|
@@ -291,14 +214,13 @@ describe("external-directory session dedup", () => {
|
|
|
291
214
|
});
|
|
292
215
|
|
|
293
216
|
it("re-prompts when user approved once (not for session)", async () => {
|
|
294
|
-
const session = makeStatefulSession();
|
|
295
217
|
const approveOnce: GatePrompter = {
|
|
296
218
|
canConfirm: vi.fn().mockReturnValue(true),
|
|
297
219
|
prompt: vi
|
|
298
220
|
.fn<GatePrompter["prompt"]>()
|
|
299
221
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
300
222
|
};
|
|
301
|
-
const { handler, prompter } =
|
|
223
|
+
const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
|
|
302
224
|
const ctx = makeCtx();
|
|
303
225
|
const externalPath = "/outside/project/data.txt";
|
|
304
226
|
|
|
@@ -326,8 +248,7 @@ describe("external-directory session dedup", () => {
|
|
|
326
248
|
|
|
327
249
|
describe("bash commands with external paths", () => {
|
|
328
250
|
it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
|
|
329
|
-
const
|
|
330
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
251
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
331
252
|
const ctx = makeCtx();
|
|
332
253
|
|
|
333
254
|
// First call — bash referencing /tmp/out.txt
|
|
@@ -354,8 +275,7 @@ describe("external-directory session dedup", () => {
|
|
|
354
275
|
});
|
|
355
276
|
|
|
356
277
|
it("does not re-prompt for read after bash already approved the same directory", async () => {
|
|
357
|
-
const
|
|
358
|
-
const { handler, prompter } = makeHandlerForSession(session);
|
|
278
|
+
const { handler, prompter } = makeDeduplicatingHandler();
|
|
359
279
|
const ctx = makeCtx();
|
|
360
280
|
|
|
361
281
|
// First call — bash writes to /tmp/out.txt
|
|
@@ -49,9 +49,10 @@ describe("extractSkillNameFromInput", () => {
|
|
|
49
49
|
describe("handleInput", () => {
|
|
50
50
|
it("activates session with ctx", async () => {
|
|
51
51
|
const ctx = makeCtx();
|
|
52
|
-
const { handler,
|
|
52
|
+
const { handler, forwarding } = makeHandler();
|
|
53
53
|
await handler.handleInput(makeInputEvent("hello"), ctx);
|
|
54
|
-
|
|
54
|
+
// session.activate(ctx) calls forwarding.start(ctx) on the real session
|
|
55
|
+
expect(forwarding.start).toHaveBeenCalledWith(ctx);
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
it("returns continue for non-skill input", async () => {
|
|
@@ -64,9 +65,9 @@ describe("handleInput", () => {
|
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
it("does not check permissions for non-skill input", async () => {
|
|
67
|
-
const { handler,
|
|
68
|
+
const { handler, permissionManager } = makeHandler();
|
|
68
69
|
await handler.handleInput(makeInputEvent("just a message"), makeCtx());
|
|
69
|
-
expect(
|
|
70
|
+
expect(permissionManager.checkPermission).not.toHaveBeenCalled();
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
it("returns continue when skill is allowed", async () => {
|
|
@@ -2,9 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { SessionLifecycleHandler } from "#src/handlers/lifecycle";
|
|
4
4
|
import type { ServiceLifecycle } from "#src/service-lifecycle";
|
|
5
|
-
import type { SessionLifecycleSession } from "#src/session-lifecycle-session";
|
|
6
5
|
|
|
7
6
|
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
7
|
+
import {
|
|
8
|
+
makeRealResolver,
|
|
9
|
+
makeRealSession,
|
|
10
|
+
} from "#test/helpers/session-fixtures";
|
|
8
11
|
|
|
9
12
|
// ── status stub ────────────────────────────────────────────────────────────
|
|
10
13
|
vi.mock("../../src/status", () => ({
|
|
@@ -15,55 +18,40 @@ vi.mock("../../src/status", () => ({
|
|
|
15
18
|
|
|
16
19
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
17
20
|
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
logResolvedConfigPaths:
|
|
34
|
-
overrides.logResolvedConfigPaths ??
|
|
35
|
-
vi.fn<SessionLifecycleSession["logResolvedConfigPaths"]>(),
|
|
36
|
-
resolveAgentName:
|
|
37
|
-
overrides.resolveAgentName ??
|
|
38
|
-
vi
|
|
39
|
-
.fn<SessionLifecycleSession["resolveAgentName"]>()
|
|
40
|
-
.mockReturnValue(null),
|
|
41
|
-
getConfigIssues:
|
|
42
|
-
overrides.getConfigIssues ??
|
|
43
|
-
vi.fn<SessionLifecycleSession["getConfigIssues"]>().mockReturnValue([]),
|
|
44
|
-
reload: overrides.reload ?? vi.fn<SessionLifecycleSession["reload"]>(),
|
|
45
|
-
getRuntimeContext:
|
|
46
|
-
overrides.getRuntimeContext ??
|
|
47
|
-
vi
|
|
48
|
-
.fn<SessionLifecycleSession["getRuntimeContext"]>()
|
|
49
|
-
.mockReturnValue(null),
|
|
50
|
-
shutdown:
|
|
51
|
-
overrides.shutdown ?? vi.fn<SessionLifecycleSession["shutdown"]>(),
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function makeHandler(overrides?: Partial<SessionLifecycleSession>): {
|
|
56
|
-
handler: SessionLifecycleHandler;
|
|
57
|
-
session: SessionLifecycleSession;
|
|
58
|
-
serviceLifecycle: ServiceLifecycle;
|
|
59
|
-
} {
|
|
60
|
-
const session = makeSession(overrides);
|
|
21
|
+
function makeSetup(opts?: { configIssues?: string[] }) {
|
|
22
|
+
const {
|
|
23
|
+
session,
|
|
24
|
+
permissionManager,
|
|
25
|
+
sessionRules,
|
|
26
|
+
logger,
|
|
27
|
+
forwarding,
|
|
28
|
+
configStore,
|
|
29
|
+
} = makeRealSession();
|
|
30
|
+
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
31
|
+
if (opts?.configIssues) {
|
|
32
|
+
vi.mocked(permissionManager.getConfigIssues).mockReturnValue(
|
|
33
|
+
opts.configIssues,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
61
36
|
const serviceLifecycle: ServiceLifecycle = {
|
|
62
37
|
activate: vi.fn<ServiceLifecycle["activate"]>(),
|
|
63
38
|
teardown: vi.fn<ServiceLifecycle["teardown"]>(),
|
|
64
39
|
};
|
|
65
|
-
const handler = new SessionLifecycleHandler(
|
|
66
|
-
|
|
40
|
+
const handler = new SessionLifecycleHandler(
|
|
41
|
+
session,
|
|
42
|
+
resolver,
|
|
43
|
+
serviceLifecycle,
|
|
44
|
+
);
|
|
45
|
+
return {
|
|
46
|
+
handler,
|
|
47
|
+
session,
|
|
48
|
+
resolver,
|
|
49
|
+
permissionManager,
|
|
50
|
+
logger,
|
|
51
|
+
forwarding,
|
|
52
|
+
configStore,
|
|
53
|
+
serviceLifecycle,
|
|
54
|
+
};
|
|
67
55
|
}
|
|
68
56
|
|
|
69
57
|
// ── handleSessionStart ─────────────────────────────────────────────────────
|
|
@@ -71,51 +59,53 @@ function makeHandler(overrides?: Partial<SessionLifecycleSession>): {
|
|
|
71
59
|
describe("handleSessionStart", () => {
|
|
72
60
|
it("refreshes config with ctx", async () => {
|
|
73
61
|
const ctx = makeCtx();
|
|
74
|
-
const { handler,
|
|
62
|
+
const { handler, configStore } = makeSetup();
|
|
75
63
|
await handler.handleSessionStart({ reason: "startup" }, ctx);
|
|
76
|
-
expect(
|
|
64
|
+
expect(configStore.refresh).toHaveBeenCalledWith(ctx);
|
|
77
65
|
});
|
|
78
66
|
|
|
79
67
|
it("calls resetForNewSession with ctx", async () => {
|
|
80
68
|
const ctx = makeCtx();
|
|
81
|
-
const { handler, session } =
|
|
69
|
+
const { handler, session } = makeSetup();
|
|
70
|
+
const spy = vi.spyOn(session, "resetForNewSession");
|
|
82
71
|
await handler.handleSessionStart({ reason: "startup" }, ctx);
|
|
83
|
-
expect(
|
|
72
|
+
expect(spy).toHaveBeenCalledWith(ctx);
|
|
84
73
|
});
|
|
85
74
|
|
|
86
75
|
it("logs resolved config paths", async () => {
|
|
87
|
-
const { handler,
|
|
76
|
+
const { handler, configStore } = makeSetup();
|
|
88
77
|
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
|
|
89
|
-
expect(
|
|
78
|
+
expect(configStore.logResolvedPaths).toHaveBeenCalledOnce();
|
|
90
79
|
});
|
|
91
80
|
|
|
92
81
|
it("resolves agent name from ctx", async () => {
|
|
93
82
|
const ctx = makeCtx();
|
|
94
|
-
const { handler, session } =
|
|
83
|
+
const { handler, session } = makeSetup();
|
|
84
|
+
const spy = vi.spyOn(session, "resolveAgentName");
|
|
95
85
|
await handler.handleSessionStart({ reason: "startup" }, ctx);
|
|
96
|
-
expect(
|
|
86
|
+
expect(spy).toHaveBeenCalledWith(ctx);
|
|
97
87
|
});
|
|
98
88
|
|
|
99
89
|
it("notifies each policy issue", async () => {
|
|
100
|
-
const { handler,
|
|
101
|
-
|
|
90
|
+
const { handler, logger } = makeSetup({
|
|
91
|
+
configIssues: ["issue A", "issue B"],
|
|
102
92
|
});
|
|
103
93
|
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
|
|
104
|
-
expect(
|
|
105
|
-
expect(
|
|
94
|
+
expect(logger.warn).toHaveBeenCalledWith("issue A");
|
|
95
|
+
expect(logger.warn).toHaveBeenCalledWith("issue B");
|
|
106
96
|
});
|
|
107
97
|
|
|
108
98
|
it("does not warn when there are no policy issues", async () => {
|
|
109
|
-
const { handler,
|
|
99
|
+
const { handler, logger } = makeSetup();
|
|
110
100
|
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
|
|
111
|
-
expect(
|
|
101
|
+
expect(logger.warn).not.toHaveBeenCalled();
|
|
112
102
|
});
|
|
113
103
|
|
|
114
104
|
it("writes lifecycle.reload debug log when reason is reload", async () => {
|
|
115
105
|
const ctx = makeCtx({ cwd: "/proj" });
|
|
116
|
-
const { handler,
|
|
106
|
+
const { handler, logger } = makeSetup();
|
|
117
107
|
await handler.handleSessionStart({ reason: "reload" }, ctx);
|
|
118
|
-
expect(
|
|
108
|
+
expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
|
|
119
109
|
triggeredBy: "session_start",
|
|
120
110
|
reason: "reload",
|
|
121
111
|
cwd: "/proj",
|
|
@@ -123,23 +113,26 @@ describe("handleSessionStart", () => {
|
|
|
123
113
|
});
|
|
124
114
|
|
|
125
115
|
it("does not write lifecycle.reload debug log for non-reload reasons", async () => {
|
|
126
|
-
const { handler,
|
|
116
|
+
const { handler, logger } = makeSetup();
|
|
127
117
|
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
|
|
128
|
-
expect(
|
|
118
|
+
expect(logger.debug).not.toHaveBeenCalled();
|
|
129
119
|
});
|
|
130
120
|
|
|
131
121
|
it("activates the service for the session with ctx", async () => {
|
|
132
122
|
const ctx = makeCtx();
|
|
133
|
-
const { handler, serviceLifecycle } =
|
|
123
|
+
const { handler, serviceLifecycle } = makeSetup();
|
|
134
124
|
await handler.handleSessionStart({ reason: "startup" }, ctx);
|
|
135
125
|
expect(serviceLifecycle.activate).toHaveBeenCalledWith(ctx);
|
|
136
126
|
});
|
|
137
127
|
|
|
138
128
|
it("calls refreshConfig before resetForNewSession", async () => {
|
|
139
129
|
const callOrder: string[] = [];
|
|
140
|
-
const { handler } =
|
|
141
|
-
|
|
142
|
-
|
|
130
|
+
const { handler, session, configStore } = makeSetup();
|
|
131
|
+
vi.spyOn(configStore, "refresh").mockImplementation(() => {
|
|
132
|
+
callOrder.push("refreshConfig");
|
|
133
|
+
});
|
|
134
|
+
vi.spyOn(session, "resetForNewSession").mockImplementation(() => {
|
|
135
|
+
callOrder.push("resetForNewSession");
|
|
143
136
|
});
|
|
144
137
|
await handler.handleSessionStart({ reason: "startup" }, makeCtx());
|
|
145
138
|
expect(callOrder).toEqual(["refreshConfig", "resetForNewSession"]);
|
|
@@ -150,24 +143,25 @@ describe("handleSessionStart", () => {
|
|
|
150
143
|
|
|
151
144
|
describe("handleResourcesDiscover", () => {
|
|
152
145
|
it("does nothing when reason is not reload", async () => {
|
|
153
|
-
const { handler, session } =
|
|
146
|
+
const { handler, session } = makeSetup();
|
|
147
|
+
const spy = vi.spyOn(session, "reload");
|
|
154
148
|
await handler.handleResourcesDiscover({ reason: "startup" });
|
|
155
|
-
expect(
|
|
149
|
+
expect(spy).not.toHaveBeenCalled();
|
|
156
150
|
});
|
|
157
151
|
|
|
158
152
|
it("calls reload on the session on reload", async () => {
|
|
159
|
-
const { handler, session } =
|
|
153
|
+
const { handler, session } = makeSetup();
|
|
154
|
+
const spy = vi.spyOn(session, "reload");
|
|
160
155
|
await handler.handleResourcesDiscover({ reason: "reload" });
|
|
161
|
-
expect(
|
|
156
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
162
157
|
});
|
|
163
158
|
|
|
164
159
|
it("writes lifecycle.reload debug log on reload", async () => {
|
|
165
160
|
const ctx = makeCtx({ cwd: "/proj" });
|
|
166
|
-
const { handler, session } =
|
|
167
|
-
|
|
168
|
-
});
|
|
161
|
+
const { handler, session, logger } = makeSetup();
|
|
162
|
+
session.activate(ctx);
|
|
169
163
|
await handler.handleResourcesDiscover({ reason: "reload" });
|
|
170
|
-
expect(
|
|
164
|
+
expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
|
|
171
165
|
triggeredBy: "resources_discover",
|
|
172
166
|
reason: "reload",
|
|
173
167
|
cwd: "/proj",
|
|
@@ -175,9 +169,9 @@ describe("handleResourcesDiscover", () => {
|
|
|
175
169
|
});
|
|
176
170
|
|
|
177
171
|
it("logs cwd as null when runtimeContext is null on reload", async () => {
|
|
178
|
-
const { handler,
|
|
172
|
+
const { handler, logger } = makeSetup();
|
|
179
173
|
await handler.handleResourcesDiscover({ reason: "reload" });
|
|
180
|
-
expect(
|
|
174
|
+
expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
|
|
181
175
|
triggeredBy: "resources_discover",
|
|
182
176
|
reason: "reload",
|
|
183
177
|
cwd: null,
|
|
@@ -190,9 +184,8 @@ describe("handleResourcesDiscover", () => {
|
|
|
190
184
|
describe("handleSessionShutdown", () => {
|
|
191
185
|
it("clears UI status when runtime context is present", async () => {
|
|
192
186
|
const ctx = makeCtx();
|
|
193
|
-
const { handler } =
|
|
194
|
-
|
|
195
|
-
});
|
|
187
|
+
const { handler, session } = makeSetup();
|
|
188
|
+
session.activate(ctx);
|
|
196
189
|
await handler.handleSessionShutdown();
|
|
197
190
|
expect(ctx.ui.setStatus).toHaveBeenCalledWith(
|
|
198
191
|
"permission-system",
|
|
@@ -201,18 +194,19 @@ describe("handleSessionShutdown", () => {
|
|
|
201
194
|
});
|
|
202
195
|
|
|
203
196
|
it("does not throw when runtime context is null", async () => {
|
|
204
|
-
const { handler } =
|
|
197
|
+
const { handler } = makeSetup();
|
|
205
198
|
await expect(handler.handleSessionShutdown()).resolves.not.toThrow();
|
|
206
199
|
});
|
|
207
200
|
|
|
208
201
|
it("calls shutdown on the session", async () => {
|
|
209
|
-
const { handler, session } =
|
|
202
|
+
const { handler, session } = makeSetup();
|
|
203
|
+
const spy = vi.spyOn(session, "shutdown");
|
|
210
204
|
await handler.handleSessionShutdown();
|
|
211
|
-
expect(
|
|
205
|
+
expect(spy).toHaveBeenCalledOnce();
|
|
212
206
|
});
|
|
213
207
|
|
|
214
208
|
it("calls serviceLifecycle.teardown", async () => {
|
|
215
|
-
const { handler, serviceLifecycle } =
|
|
209
|
+
const { handler, serviceLifecycle } = makeSetup();
|
|
216
210
|
await handler.handleSessionShutdown();
|
|
217
211
|
expect(serviceLifecycle.teardown).toHaveBeenCalledOnce();
|
|
218
212
|
});
|
|
@@ -49,9 +49,10 @@ describe("getEventInput", () => {
|
|
|
49
49
|
describe("handleToolCall", () => {
|
|
50
50
|
it("activates session with ctx", async () => {
|
|
51
51
|
const ctx = makeCtx();
|
|
52
|
-
const { handler,
|
|
52
|
+
const { handler, forwarding } = makeHandler();
|
|
53
53
|
await handler.handleToolCall(makeToolCallEvent("read"), ctx);
|
|
54
|
-
|
|
54
|
+
// session.activate(ctx) calls forwarding.start(ctx) on the real session
|
|
55
|
+
expect(forwarding.start).toHaveBeenCalledWith(ctx);
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
it("blocks when tool name cannot be resolved", async () => {
|