@gotgenes/pi-permission-system 3.7.0 → 3.8.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 +18 -0
- package/package.json +1 -1
- package/src/forwarded-permissions/io.ts +47 -12
- package/src/forwarded-permissions/polling.ts +33 -11
- package/src/handlers/before-agent-start.ts +7 -7
- package/src/handlers/input.ts +7 -5
- package/src/handlers/lifecycle.ts +25 -24
- package/src/handlers/tool-call.ts +32 -22
- package/src/handlers/types.ts +7 -30
- package/src/index.ts +46 -413
- package/src/runtime.ts +484 -0
- package/tests/forwarded-permissions/io.test.ts +135 -0
- package/tests/handlers/before-agent-start.test.ts +46 -30
- package/tests/handlers/input.test.ts +69 -39
- package/tests/handlers/lifecycle.test.ts +86 -65
- package/tests/handlers/tool-call.test.ts +92 -69
- package/tests/runtime.test.ts +618 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// ── logger stub ────────────────────────────────────────────────────────────
|
|
5
|
+
const {
|
|
6
|
+
mockLoggerDebug,
|
|
7
|
+
mockLoggerReview,
|
|
8
|
+
mockCreateLogger,
|
|
9
|
+
mockLoadAndMergeConfigs,
|
|
10
|
+
mockSyncPermissionSystemStatus,
|
|
11
|
+
mockGetActiveAgentName,
|
|
12
|
+
mockGetActiveAgentNameFromSystemPrompt,
|
|
13
|
+
mockBuildResolvedConfigLogEntry,
|
|
14
|
+
} = vi.hoisted(() => ({
|
|
15
|
+
mockLoggerDebug:
|
|
16
|
+
vi.fn<
|
|
17
|
+
(event: string, details?: Record<string, unknown>) => string | undefined
|
|
18
|
+
>(),
|
|
19
|
+
mockLoggerReview:
|
|
20
|
+
vi.fn<
|
|
21
|
+
(event: string, details?: Record<string, unknown>) => string | undefined
|
|
22
|
+
>(),
|
|
23
|
+
mockCreateLogger: vi.fn(),
|
|
24
|
+
mockLoadAndMergeConfigs: vi.fn(),
|
|
25
|
+
mockSyncPermissionSystemStatus: vi.fn(),
|
|
26
|
+
mockGetActiveAgentName: vi.fn<() => string | null>(),
|
|
27
|
+
mockGetActiveAgentNameFromSystemPrompt:
|
|
28
|
+
vi.fn<(prompt?: string) => string | null>(),
|
|
29
|
+
mockBuildResolvedConfigLogEntry: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("../src/logging", () => ({
|
|
33
|
+
createPermissionSystemLogger: mockCreateLogger,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock("../src/permission-manager", () => ({
|
|
37
|
+
PermissionManager: vi.fn(),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock("../src/config-loader", () => ({
|
|
41
|
+
loadAndMergeConfigs: mockLoadAndMergeConfigs,
|
|
42
|
+
loadUnifiedConfig: vi.fn().mockReturnValue({ config: {} }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
vi.mock("../src/status", () => ({
|
|
46
|
+
PERMISSION_SYSTEM_STATUS_KEY: "permission-system",
|
|
47
|
+
syncPermissionSystemStatus: mockSyncPermissionSystemStatus,
|
|
48
|
+
getPermissionSystemStatus: vi.fn(),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
vi.mock("../src/active-agent", () => ({
|
|
52
|
+
getActiveAgentName: mockGetActiveAgentName,
|
|
53
|
+
getActiveAgentNameFromSystemPrompt: mockGetActiveAgentNameFromSystemPrompt,
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("../src/config-reporter", () => ({
|
|
57
|
+
buildResolvedConfigLogEntry: mockBuildResolvedConfigLogEntry,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock("../src/forwarded-permissions/polling", () => ({
|
|
61
|
+
processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
|
|
62
|
+
confirmPermission: vi
|
|
63
|
+
.fn()
|
|
64
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
vi.mock("../src/subagent-context", () => ({
|
|
68
|
+
isSubagentExecutionContext: vi.fn().mockReturnValue(false),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
vi.mock("../src/session-approval-cache", () => ({
|
|
72
|
+
SessionApprovalCache: vi.fn(),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
76
|
+
import {
|
|
77
|
+
getGlobalConfigPath,
|
|
78
|
+
getGlobalLogsDir,
|
|
79
|
+
getProjectConfigPath,
|
|
80
|
+
} from "../src/config-paths";
|
|
81
|
+
import { DEFAULT_EXTENSION_CONFIG } from "../src/extension-config";
|
|
82
|
+
import { PermissionManager } from "../src/permission-manager";
|
|
83
|
+
import {
|
|
84
|
+
createExtensionRuntime,
|
|
85
|
+
createPermissionManagerForCwd,
|
|
86
|
+
derivePiProjectPaths,
|
|
87
|
+
refreshExtensionConfig,
|
|
88
|
+
resolveAgentName,
|
|
89
|
+
} from "../src/runtime";
|
|
90
|
+
|
|
91
|
+
// ── test suite ─────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe("createExtensionRuntime", () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
mockLoggerDebug.mockReset();
|
|
96
|
+
mockLoggerDebug.mockReturnValue(undefined);
|
|
97
|
+
mockLoggerReview.mockReset();
|
|
98
|
+
mockLoggerReview.mockReturnValue(undefined);
|
|
99
|
+
mockCreateLogger.mockReset();
|
|
100
|
+
mockCreateLogger.mockReturnValue({
|
|
101
|
+
debug: mockLoggerDebug,
|
|
102
|
+
review: mockLoggerReview,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── Path derivation ──────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
it("sets agentDir from provided option", () => {
|
|
109
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
110
|
+
expect(runtime.agentDir).toBe("/test/agent");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("derives sessionsDir from agentDir", () => {
|
|
114
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
115
|
+
expect(runtime.sessionsDir).toBe("/test/agent/sessions");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("derives subagentSessionsDir from agentDir", () => {
|
|
119
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
120
|
+
expect(runtime.subagentSessionsDir).toBe("/test/agent/subagent-sessions");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("derives forwardingDir as sessions/permission-forwarding", () => {
|
|
124
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
125
|
+
expect(runtime.forwardingDir).toBe(
|
|
126
|
+
"/test/agent/sessions/permission-forwarding",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("derives globalLogsDir via getGlobalLogsDir", () => {
|
|
131
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
132
|
+
expect(runtime.globalLogsDir).toBe(getGlobalLogsDir("/test/agent"));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── Default mutable state ────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
it("initializes config to DEFAULT_EXTENSION_CONFIG", () => {
|
|
138
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
139
|
+
expect(runtime.config).toEqual(DEFAULT_EXTENSION_CONFIG);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("initializes runtimeContext to null", () => {
|
|
143
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
144
|
+
expect(runtime.runtimeContext).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("initializes activeSkillEntries to empty array", () => {
|
|
148
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
149
|
+
expect(runtime.activeSkillEntries).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("initializes lastKnownActiveAgentName to null", () => {
|
|
153
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
154
|
+
expect(runtime.lastKnownActiveAgentName).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("initializes lastActiveToolsCacheKey to null", () => {
|
|
158
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
159
|
+
expect(runtime.lastActiveToolsCacheKey).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("initializes lastPromptStateCacheKey to null", () => {
|
|
163
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
164
|
+
expect(runtime.lastPromptStateCacheKey).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("initializes lastConfigWarning to null", () => {
|
|
168
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
169
|
+
expect(runtime.lastConfigWarning).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("initializes permissionForwardingContext to null", () => {
|
|
173
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
174
|
+
expect(runtime.permissionForwardingContext).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("initializes permissionForwardingTimer to null", () => {
|
|
178
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
179
|
+
expect(runtime.permissionForwardingTimer).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("initializes isProcessingForwardedRequests to false", () => {
|
|
183
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
184
|
+
expect(runtime.isProcessingForwardedRequests).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("creates a sessionApprovalCache instance", () => {
|
|
188
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
189
|
+
expect(runtime.sessionApprovalCache).toBeDefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Mutable state is writable ──────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
it("allows config to be updated", () => {
|
|
195
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
196
|
+
const newConfig = {
|
|
197
|
+
debugLog: true,
|
|
198
|
+
permissionReviewLog: false,
|
|
199
|
+
yoloMode: false,
|
|
200
|
+
};
|
|
201
|
+
runtime.config = newConfig;
|
|
202
|
+
expect(runtime.config).toEqual(newConfig);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("allows runtimeContext to be updated", () => {
|
|
206
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
207
|
+
const mockCtx = { hasUI: false } as never;
|
|
208
|
+
runtime.runtimeContext = mockCtx;
|
|
209
|
+
expect(runtime.runtimeContext).toBe(mockCtx);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ── Logger is created with runtime-derived paths ─────────────────────────
|
|
213
|
+
|
|
214
|
+
it("creates the logger with derived debugLogPath and reviewLogPath", () => {
|
|
215
|
+
const agentDir = "/test/agent";
|
|
216
|
+
const expectedLogsDir = getGlobalLogsDir(agentDir);
|
|
217
|
+
createExtensionRuntime({ agentDir });
|
|
218
|
+
expect(mockCreateLogger).toHaveBeenCalledOnce();
|
|
219
|
+
const opts = mockCreateLogger.mock.calls[0][0] as {
|
|
220
|
+
debugLogPath: string;
|
|
221
|
+
reviewLogPath: string;
|
|
222
|
+
};
|
|
223
|
+
expect(opts.debugLogPath).toContain(expectedLogsDir);
|
|
224
|
+
expect(opts.reviewLogPath).toContain(expectedLogsDir);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("passes getConfig that reads current runtime.config", () => {
|
|
228
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
229
|
+
const opts = mockCreateLogger.mock.calls[0][0] as {
|
|
230
|
+
getConfig: () => typeof DEFAULT_EXTENSION_CONFIG;
|
|
231
|
+
};
|
|
232
|
+
const updatedConfig = {
|
|
233
|
+
debugLog: true,
|
|
234
|
+
permissionReviewLog: false,
|
|
235
|
+
yoloMode: false,
|
|
236
|
+
};
|
|
237
|
+
runtime.config = updatedConfig;
|
|
238
|
+
// getConfig() should reflect the updated value
|
|
239
|
+
expect(opts.getConfig()).toEqual(updatedConfig);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ── writeDebugLog delegates to logger.debug ──────────────────────────────
|
|
243
|
+
|
|
244
|
+
it("writeDebugLog calls logger.debug with event and details", () => {
|
|
245
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
246
|
+
runtime.writeDebugLog("test.event", { key: "value" });
|
|
247
|
+
expect(mockLoggerDebug).toHaveBeenCalledWith("test.event", {
|
|
248
|
+
key: "value",
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("writeDebugLog uses empty object as default details", () => {
|
|
253
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
254
|
+
runtime.writeDebugLog("test.event");
|
|
255
|
+
expect(mockLoggerDebug).toHaveBeenCalledWith("test.event", {});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ── writeReviewLog delegates to logger.review ────────────────────────────
|
|
259
|
+
|
|
260
|
+
it("writeReviewLog calls logger.review with event and details", () => {
|
|
261
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
262
|
+
runtime.writeReviewLog("test.event", { key: "value" });
|
|
263
|
+
expect(mockLoggerReview).toHaveBeenCalledWith("test.event", {
|
|
264
|
+
key: "value",
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("writeReviewLog uses empty object as default details", () => {
|
|
269
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
270
|
+
runtime.writeReviewLog("test.event");
|
|
271
|
+
expect(mockLoggerReview).toHaveBeenCalledWith("test.event", {});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ── Logging warning reporter ──────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
it("notifies runtimeContext.ui when logger returns a warning", () => {
|
|
277
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
278
|
+
const mockNotify = vi.fn();
|
|
279
|
+
runtime.runtimeContext = {
|
|
280
|
+
hasUI: true,
|
|
281
|
+
ui: { notify: mockNotify },
|
|
282
|
+
} as never;
|
|
283
|
+
mockLoggerDebug.mockReturnValueOnce("log dir not writable");
|
|
284
|
+
runtime.writeDebugLog("some.event");
|
|
285
|
+
expect(mockNotify).toHaveBeenCalledWith("log dir not writable", "warning");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("does not notify when runtimeContext is null", () => {
|
|
289
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
290
|
+
mockLoggerDebug.mockReturnValueOnce("a warning");
|
|
291
|
+
// runtimeContext is null, should not throw
|
|
292
|
+
expect(() => runtime.writeDebugLog("some.event")).not.toThrow();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("deduplicates logging warnings (same warning not reported twice)", () => {
|
|
296
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
297
|
+
const mockNotify = vi.fn();
|
|
298
|
+
runtime.runtimeContext = {
|
|
299
|
+
hasUI: true,
|
|
300
|
+
ui: { notify: mockNotify },
|
|
301
|
+
} as never;
|
|
302
|
+
mockLoggerDebug
|
|
303
|
+
.mockReturnValueOnce("duplicate warning")
|
|
304
|
+
.mockReturnValueOnce("duplicate warning");
|
|
305
|
+
runtime.writeDebugLog("event.one");
|
|
306
|
+
runtime.writeDebugLog("event.two");
|
|
307
|
+
// The same warning should only be notified once
|
|
308
|
+
expect(mockNotify).toHaveBeenCalledTimes(1);
|
|
309
|
+
expect(mockNotify).toHaveBeenCalledWith("duplicate warning", "warning");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("reports a different warning even after a duplicate has been suppressed", () => {
|
|
313
|
+
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
314
|
+
const mockNotify = vi.fn();
|
|
315
|
+
runtime.runtimeContext = {
|
|
316
|
+
hasUI: true,
|
|
317
|
+
ui: { notify: mockNotify },
|
|
318
|
+
} as never;
|
|
319
|
+
mockLoggerDebug
|
|
320
|
+
.mockReturnValueOnce("warning A")
|
|
321
|
+
.mockReturnValueOnce("warning A")
|
|
322
|
+
.mockReturnValueOnce("warning B");
|
|
323
|
+
runtime.writeDebugLog("event.one");
|
|
324
|
+
runtime.writeDebugLog("event.two");
|
|
325
|
+
runtime.writeDebugLog("event.three");
|
|
326
|
+
expect(mockNotify).toHaveBeenCalledTimes(2);
|
|
327
|
+
expect(mockNotify).toHaveBeenNthCalledWith(1, "warning A", "warning");
|
|
328
|
+
expect(mockNotify).toHaveBeenNthCalledWith(2, "warning B", "warning");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ── Multiple independent runtimes ─────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
it("two runtimes have independent state", () => {
|
|
334
|
+
const rt1 = createExtensionRuntime({ agentDir: "/agent/a" });
|
|
335
|
+
const rt2 = createExtensionRuntime({ agentDir: "/agent/b" });
|
|
336
|
+
rt1.lastKnownActiveAgentName = "agent-a";
|
|
337
|
+
expect(rt2.lastKnownActiveAgentName).toBeNull();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ── derivePiProjectPaths ───────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
describe("derivePiProjectPaths", () => {
|
|
344
|
+
it("returns null for null cwd", () => {
|
|
345
|
+
expect(derivePiProjectPaths(null)).toBeNull();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("returns null for undefined cwd", () => {
|
|
349
|
+
expect(derivePiProjectPaths(undefined)).toBeNull();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("returns null for empty string cwd", () => {
|
|
353
|
+
expect(derivePiProjectPaths("")).toBeNull();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("returns projectGlobalConfigPath via getProjectConfigPath", () => {
|
|
357
|
+
const result = derivePiProjectPaths("/my/project");
|
|
358
|
+
expect(result?.projectGlobalConfigPath).toBe(
|
|
359
|
+
getProjectConfigPath("/my/project"),
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("returns projectAgentsDir as .pi/agent/agents under cwd", () => {
|
|
364
|
+
const result = derivePiProjectPaths("/my/project");
|
|
365
|
+
expect(result?.projectAgentsDir).toBe(
|
|
366
|
+
join("/my/project", ".pi", "agent", "agents"),
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ── createPermissionManagerForCwd ─────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
describe("createPermissionManagerForCwd", () => {
|
|
374
|
+
beforeEach(() => {
|
|
375
|
+
// PermissionManager is already mocked as vi.fn() at module scope.
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("creates a PermissionManager with globalConfigPath from agentDir", () => {
|
|
379
|
+
const MockPM = PermissionManager as ReturnType<typeof vi.fn>;
|
|
380
|
+
MockPM.mockClear();
|
|
381
|
+
createPermissionManagerForCwd("/test/agent", null);
|
|
382
|
+
expect(MockPM).toHaveBeenCalledWith(
|
|
383
|
+
expect.objectContaining({
|
|
384
|
+
globalConfigPath: getGlobalConfigPath("/test/agent"),
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("includes projectGlobalConfigPath when cwd is provided", () => {
|
|
390
|
+
const MockPM = PermissionManager as ReturnType<typeof vi.fn>;
|
|
391
|
+
MockPM.mockClear();
|
|
392
|
+
createPermissionManagerForCwd("/test/agent", "/my/project");
|
|
393
|
+
expect(MockPM).toHaveBeenCalledWith(
|
|
394
|
+
expect.objectContaining({
|
|
395
|
+
globalConfigPath: getGlobalConfigPath("/test/agent"),
|
|
396
|
+
projectGlobalConfigPath: getProjectConfigPath("/my/project"),
|
|
397
|
+
}),
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("excludes projectGlobalConfigPath when cwd is null", () => {
|
|
402
|
+
const MockPM = PermissionManager as ReturnType<typeof vi.fn>;
|
|
403
|
+
MockPM.mockClear();
|
|
404
|
+
createPermissionManagerForCwd("/test/agent", null);
|
|
405
|
+
const callArg = MockPM.mock.calls[0][0] as Record<string, unknown>;
|
|
406
|
+
expect(callArg.projectGlobalConfigPath).toBeUndefined();
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ── refreshExtensionConfig ────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
describe("refreshExtensionConfig", () => {
|
|
413
|
+
function makeRuntime() {
|
|
414
|
+
mockCreateLogger.mockReturnValue({
|
|
415
|
+
debug: mockLoggerDebug,
|
|
416
|
+
review: mockLoggerReview,
|
|
417
|
+
});
|
|
418
|
+
return createExtensionRuntime({ agentDir: "/test/agent" });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function makeCtx(
|
|
422
|
+
overrides: Partial<ExtensionContext> = {},
|
|
423
|
+
): ExtensionContext {
|
|
424
|
+
return {
|
|
425
|
+
cwd: "/test/project",
|
|
426
|
+
hasUI: false,
|
|
427
|
+
ui: { notify: vi.fn(), setStatus: vi.fn() },
|
|
428
|
+
sessionManager: { getEntries: vi.fn(), addEntry: vi.fn() },
|
|
429
|
+
...overrides,
|
|
430
|
+
} as unknown as ExtensionContext;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
beforeEach(() => {
|
|
434
|
+
mockLoggerDebug.mockReset().mockReturnValue(undefined);
|
|
435
|
+
mockLoggerReview.mockReset().mockReturnValue(undefined);
|
|
436
|
+
mockLoadAndMergeConfigs.mockReset().mockReturnValue({
|
|
437
|
+
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
438
|
+
issues: [],
|
|
439
|
+
});
|
|
440
|
+
mockSyncPermissionSystemStatus.mockReset();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("updates runtime.runtimeContext when ctx is provided", () => {
|
|
444
|
+
const runtime = makeRuntime();
|
|
445
|
+
const ctx = makeCtx();
|
|
446
|
+
refreshExtensionConfig(runtime, ctx);
|
|
447
|
+
expect(runtime.runtimeContext).toBe(ctx);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("does not override runtimeContext when ctx is omitted", () => {
|
|
451
|
+
const runtime = makeRuntime();
|
|
452
|
+
const existing = makeCtx();
|
|
453
|
+
runtime.runtimeContext = existing;
|
|
454
|
+
refreshExtensionConfig(runtime);
|
|
455
|
+
expect(runtime.runtimeContext).toBe(existing);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("updates runtime.config with normalized merged result", () => {
|
|
459
|
+
const runtime = makeRuntime();
|
|
460
|
+
mockLoadAndMergeConfigs.mockReturnValue({
|
|
461
|
+
merged: { debugLog: true, permissionReviewLog: false, yoloMode: false },
|
|
462
|
+
issues: [],
|
|
463
|
+
});
|
|
464
|
+
refreshExtensionConfig(runtime);
|
|
465
|
+
expect(runtime.config.debugLog).toBe(true);
|
|
466
|
+
expect(runtime.config.permissionReviewLog).toBe(false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("calls loadAndMergeConfigs with runtime.agentDir and cwd", () => {
|
|
470
|
+
const runtime = makeRuntime();
|
|
471
|
+
const ctx = makeCtx({ cwd: "/my/project" });
|
|
472
|
+
refreshExtensionConfig(runtime, ctx);
|
|
473
|
+
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
|
|
474
|
+
"/test/agent",
|
|
475
|
+
"/my/project",
|
|
476
|
+
expect.any(String), // EXTENSION_ROOT
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("writes config.loaded debug log", () => {
|
|
481
|
+
const runtime = makeRuntime();
|
|
482
|
+
refreshExtensionConfig(runtime);
|
|
483
|
+
expect(mockLoggerDebug).toHaveBeenCalledWith(
|
|
484
|
+
"config.loaded",
|
|
485
|
+
expect.objectContaining({ debugLog: false }),
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("sets lastConfigWarning when issues are present", () => {
|
|
490
|
+
const runtime = makeRuntime();
|
|
491
|
+
mockLoadAndMergeConfigs.mockReturnValue({
|
|
492
|
+
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
493
|
+
issues: ["legacy config detected"],
|
|
494
|
+
});
|
|
495
|
+
refreshExtensionConfig(runtime);
|
|
496
|
+
expect(runtime.lastConfigWarning).toBe("legacy config detected");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("clears lastConfigWarning when no issues", () => {
|
|
500
|
+
const runtime = makeRuntime();
|
|
501
|
+
runtime.lastConfigWarning = "old warning";
|
|
502
|
+
mockLoadAndMergeConfigs.mockReturnValue({
|
|
503
|
+
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
504
|
+
issues: [],
|
|
505
|
+
});
|
|
506
|
+
refreshExtensionConfig(runtime);
|
|
507
|
+
expect(runtime.lastConfigWarning).toBeNull();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("notifies UI when a new warning appears and hasUI is true", () => {
|
|
511
|
+
const runtime = makeRuntime();
|
|
512
|
+
const mockNotify = vi.fn();
|
|
513
|
+
const ctx = makeCtx({ hasUI: true, ui: { notify: mockNotify } as never });
|
|
514
|
+
mockLoadAndMergeConfigs.mockReturnValue({
|
|
515
|
+
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
516
|
+
issues: ["new warning"],
|
|
517
|
+
});
|
|
518
|
+
refreshExtensionConfig(runtime, ctx);
|
|
519
|
+
expect(mockNotify).toHaveBeenCalledWith("new warning", "warning");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("does not re-notify the same warning on subsequent calls", () => {
|
|
523
|
+
const runtime = makeRuntime();
|
|
524
|
+
const mockNotify = vi.fn();
|
|
525
|
+
const ctx = makeCtx({ hasUI: true, ui: { notify: mockNotify } as never });
|
|
526
|
+
mockLoadAndMergeConfigs.mockReturnValue({
|
|
527
|
+
merged: { ...DEFAULT_EXTENSION_CONFIG },
|
|
528
|
+
issues: ["persistent warning"],
|
|
529
|
+
});
|
|
530
|
+
refreshExtensionConfig(runtime, ctx);
|
|
531
|
+
refreshExtensionConfig(runtime, ctx);
|
|
532
|
+
expect(mockNotify).toHaveBeenCalledTimes(1);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("calls syncPermissionSystemStatus when hasUI is true", () => {
|
|
536
|
+
const runtime = makeRuntime();
|
|
537
|
+
const ctx = makeCtx({ hasUI: true });
|
|
538
|
+
refreshExtensionConfig(runtime, ctx);
|
|
539
|
+
expect(mockSyncPermissionSystemStatus).toHaveBeenCalledWith(
|
|
540
|
+
ctx,
|
|
541
|
+
expect.any(Object),
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("does not call syncPermissionSystemStatus when hasUI is false", () => {
|
|
546
|
+
const runtime = makeRuntime();
|
|
547
|
+
const ctx = makeCtx({ hasUI: false });
|
|
548
|
+
refreshExtensionConfig(runtime, ctx);
|
|
549
|
+
expect(mockSyncPermissionSystemStatus).not.toHaveBeenCalled();
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// ── resolveAgentName ──────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
describe("resolveAgentName", () => {
|
|
556
|
+
function makeRuntime() {
|
|
557
|
+
mockCreateLogger.mockReturnValue({
|
|
558
|
+
debug: mockLoggerDebug,
|
|
559
|
+
review: mockLoggerReview,
|
|
560
|
+
});
|
|
561
|
+
return createExtensionRuntime({ agentDir: "/test/agent" });
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function makeCtx(): ExtensionContext {
|
|
565
|
+
return {
|
|
566
|
+
cwd: "/test/project",
|
|
567
|
+
hasUI: false,
|
|
568
|
+
ui: {},
|
|
569
|
+
sessionManager: { getEntries: vi.fn(), addEntry: vi.fn() },
|
|
570
|
+
} as unknown as ExtensionContext;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
beforeEach(() => {
|
|
574
|
+
mockLoggerDebug.mockReset().mockReturnValue(undefined);
|
|
575
|
+
mockGetActiveAgentName.mockReset().mockReturnValue(null);
|
|
576
|
+
mockGetActiveAgentNameFromSystemPrompt.mockReset().mockReturnValue(null);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("returns and stores name from getActiveAgentName when available", () => {
|
|
580
|
+
const runtime = makeRuntime();
|
|
581
|
+
mockGetActiveAgentName.mockReturnValue("session-agent");
|
|
582
|
+
const result = resolveAgentName(runtime, makeCtx());
|
|
583
|
+
expect(result).toBe("session-agent");
|
|
584
|
+
expect(runtime.lastKnownActiveAgentName).toBe("session-agent");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("falls back to getActiveAgentNameFromSystemPrompt when session name is null", () => {
|
|
588
|
+
const runtime = makeRuntime();
|
|
589
|
+
mockGetActiveAgentName.mockReturnValue(null);
|
|
590
|
+
mockGetActiveAgentNameFromSystemPrompt.mockReturnValue("prompt-agent");
|
|
591
|
+
const result = resolveAgentName(runtime, makeCtx(), "system prompt text");
|
|
592
|
+
expect(result).toBe("prompt-agent");
|
|
593
|
+
expect(runtime.lastKnownActiveAgentName).toBe("prompt-agent");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("falls back to lastKnownActiveAgentName when both sources return null", () => {
|
|
597
|
+
const runtime = makeRuntime();
|
|
598
|
+
runtime.lastKnownActiveAgentName = "remembered-agent";
|
|
599
|
+
mockGetActiveAgentName.mockReturnValue(null);
|
|
600
|
+
mockGetActiveAgentNameFromSystemPrompt.mockReturnValue(null);
|
|
601
|
+
const result = resolveAgentName(runtime, makeCtx());
|
|
602
|
+
expect(result).toBe("remembered-agent");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("returns null when all sources are null and no prior name", () => {
|
|
606
|
+
const runtime = makeRuntime();
|
|
607
|
+
const result = resolveAgentName(runtime, makeCtx());
|
|
608
|
+
expect(result).toBeNull();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("does not update lastKnownActiveAgentName when falling back to stored value", () => {
|
|
612
|
+
const runtime = makeRuntime();
|
|
613
|
+
runtime.lastKnownActiveAgentName = "remembered-agent";
|
|
614
|
+
resolveAgentName(runtime, makeCtx());
|
|
615
|
+
// Value unchanged — not overwritten with null
|
|
616
|
+
expect(runtime.lastKnownActiveAgentName).toBe("remembered-agent");
|
|
617
|
+
});
|
|
618
|
+
});
|