@gotgenes/pi-permission-system 3.6.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 +36 -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 +112 -0
- package/src/handlers/index.ts +16 -0
- package/src/handlers/input.ts +99 -0
- package/src/handlers/lifecycle.ts +81 -0
- package/src/handlers/tool-call.ts +410 -0
- package/src/handlers/types.ts +72 -0
- package/src/index.ts +73 -1040
- package/src/runtime.ts +484 -0
- package/tests/forwarded-permissions/io.test.ts +135 -0
- package/tests/handlers/before-agent-start.test.ts +290 -0
- package/tests/handlers/input.test.ts +301 -0
- package/tests/handlers/lifecycle.test.ts +352 -0
- package/tests/handlers/tool-call.test.ts +441 -0
- package/tests/runtime.test.ts +618 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
handleBeforeAgentStart,
|
|
6
|
+
shouldExposeTool,
|
|
7
|
+
} from "../../src/handlers/before-agent-start";
|
|
8
|
+
import type { HandlerDeps } from "../../src/handlers/types";
|
|
9
|
+
import type { PermissionManager } from "../../src/permission-manager";
|
|
10
|
+
import type { ExtensionRuntime } from "../../src/runtime";
|
|
11
|
+
import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
|
|
12
|
+
|
|
13
|
+
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
14
|
+
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
|
15
|
+
const original =
|
|
16
|
+
await importOriginal<typeof import("@mariozechner/pi-coding-agent")>();
|
|
17
|
+
return {
|
|
18
|
+
...original,
|
|
19
|
+
isToolCallEventType: vi.fn().mockReturnValue(false),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
26
|
+
return {
|
|
27
|
+
cwd: "/test/project",
|
|
28
|
+
hasUI: true,
|
|
29
|
+
ui: {
|
|
30
|
+
setStatus: vi.fn(),
|
|
31
|
+
notify: vi.fn(),
|
|
32
|
+
select: vi.fn(),
|
|
33
|
+
input: vi.fn(),
|
|
34
|
+
},
|
|
35
|
+
sessionManager: {
|
|
36
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
37
|
+
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
38
|
+
addEntry: vi.fn(),
|
|
39
|
+
},
|
|
40
|
+
...overrides,
|
|
41
|
+
} as unknown as ExtensionContext;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeEvent(systemPrompt = "You are an assistant.") {
|
|
45
|
+
return { systemPrompt };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Minimal PermissionManager stub for shouldExposeTool / policy-cache tests. */
|
|
49
|
+
function makePm(
|
|
50
|
+
toolPermission: "allow" | "deny" | "ask" = "allow",
|
|
51
|
+
): PermissionManager {
|
|
52
|
+
return {
|
|
53
|
+
getToolPermission: vi.fn().mockReturnValue(toolPermission),
|
|
54
|
+
getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
|
|
55
|
+
getConfigIssues: vi.fn().mockReturnValue([]),
|
|
56
|
+
checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
|
|
57
|
+
} as unknown as PermissionManager;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeRuntime(
|
|
61
|
+
overrides: Partial<ExtensionRuntime> = {},
|
|
62
|
+
): ExtensionRuntime {
|
|
63
|
+
return {
|
|
64
|
+
agentDir: "/test/agent",
|
|
65
|
+
sessionsDir: "/test/agent/sessions",
|
|
66
|
+
subagentSessionsDir: "/test/agent/subagent-sessions",
|
|
67
|
+
forwardingDir: "/test/agent/sessions/permission-forwarding",
|
|
68
|
+
globalLogsDir: "/test/agent/extensions/pi-permission-system/logs",
|
|
69
|
+
config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
|
|
70
|
+
runtimeContext: null,
|
|
71
|
+
permissionManager: makePm() as unknown as PermissionManager,
|
|
72
|
+
activeSkillEntries: [] as SkillPromptEntry[],
|
|
73
|
+
lastKnownActiveAgentName: null,
|
|
74
|
+
lastActiveToolsCacheKey: null,
|
|
75
|
+
lastPromptStateCacheKey: null,
|
|
76
|
+
lastConfigWarning: null,
|
|
77
|
+
sessionApprovalCache: {
|
|
78
|
+
approve: vi.fn(),
|
|
79
|
+
has: vi.fn(),
|
|
80
|
+
findMatchingPrefix: vi.fn(),
|
|
81
|
+
clear: vi.fn(),
|
|
82
|
+
} as unknown as ExtensionRuntime["sessionApprovalCache"],
|
|
83
|
+
permissionForwardingContext: null,
|
|
84
|
+
permissionForwardingTimer: null,
|
|
85
|
+
isProcessingForwardedRequests: false,
|
|
86
|
+
writeDebugLog: vi.fn(),
|
|
87
|
+
writeReviewLog: vi.fn(),
|
|
88
|
+
...overrides,
|
|
89
|
+
} as ExtensionRuntime;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
93
|
+
return {
|
|
94
|
+
runtime: makeRuntime(),
|
|
95
|
+
createPermissionManagerForCwd: vi.fn().mockReturnValue(makePm()),
|
|
96
|
+
refreshExtensionConfig: vi.fn(),
|
|
97
|
+
notifyWarning: vi.fn(),
|
|
98
|
+
logResolvedConfigPaths: vi.fn(),
|
|
99
|
+
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
100
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
|
|
101
|
+
promptPermission: vi
|
|
102
|
+
.fn()
|
|
103
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
104
|
+
createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
|
|
105
|
+
startForwardedPermissionPolling: vi.fn(),
|
|
106
|
+
stopForwardedPermissionPolling: vi.fn(),
|
|
107
|
+
getAllTools: vi.fn().mockReturnValue([]),
|
|
108
|
+
setActiveTools: vi.fn(),
|
|
109
|
+
...overrides,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── shouldExposeTool (pure helper) ─────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("shouldExposeTool", () => {
|
|
116
|
+
it("returns true when tool permission is allow", () => {
|
|
117
|
+
const pm = makePm("allow");
|
|
118
|
+
expect(shouldExposeTool("read", null, pm)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns true when tool permission is ask", () => {
|
|
122
|
+
const pm = makePm("ask");
|
|
123
|
+
expect(shouldExposeTool("bash", "agent-x", pm)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns false when tool permission is deny", () => {
|
|
127
|
+
const pm = makePm("deny");
|
|
128
|
+
expect(shouldExposeTool("write", null, pm)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("passes agentName through to getToolPermission", () => {
|
|
132
|
+
const pm = makePm("allow");
|
|
133
|
+
shouldExposeTool("read", "my-agent", pm);
|
|
134
|
+
expect(pm.getToolPermission).toHaveBeenCalledWith("read", "my-agent");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("converts null agentName to undefined for getToolPermission", () => {
|
|
138
|
+
const pm = makePm("allow");
|
|
139
|
+
shouldExposeTool("read", null, pm);
|
|
140
|
+
expect(pm.getToolPermission).toHaveBeenCalledWith("read", undefined);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── handleBeforeAgentStart ─────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe("handleBeforeAgentStart", () => {
|
|
147
|
+
it("refreshes extension config with ctx", async () => {
|
|
148
|
+
const ctx = makeCtx();
|
|
149
|
+
const deps = makeDeps();
|
|
150
|
+
await handleBeforeAgentStart(deps, makeEvent(), ctx);
|
|
151
|
+
expect(deps.refreshExtensionConfig).toHaveBeenCalledWith(ctx);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("starts forwarded permission polling", async () => {
|
|
155
|
+
const ctx = makeCtx();
|
|
156
|
+
const deps = makeDeps();
|
|
157
|
+
await handleBeforeAgentStart(deps, makeEvent(), ctx);
|
|
158
|
+
expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("resolves agent name using systemPrompt", async () => {
|
|
162
|
+
const ctx = makeCtx();
|
|
163
|
+
const deps = makeDeps();
|
|
164
|
+
await handleBeforeAgentStart(
|
|
165
|
+
deps,
|
|
166
|
+
makeEvent("<active_agent name='x'>"),
|
|
167
|
+
ctx,
|
|
168
|
+
);
|
|
169
|
+
expect(deps.resolveAgentName).toHaveBeenCalledWith(
|
|
170
|
+
ctx,
|
|
171
|
+
"<active_agent name='x'>",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("filters out denied tools from allowed list", async () => {
|
|
176
|
+
const pm = makePm("deny");
|
|
177
|
+
const deps = makeDeps({
|
|
178
|
+
runtime: makeRuntime({
|
|
179
|
+
permissionManager: pm as unknown as PermissionManager,
|
|
180
|
+
}),
|
|
181
|
+
getAllTools: vi
|
|
182
|
+
.fn()
|
|
183
|
+
.mockReturnValue([{ name: "write" }, { name: "read" }]),
|
|
184
|
+
});
|
|
185
|
+
// write is deny, read is deny (same pm stub — both denied)
|
|
186
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
187
|
+
expect(deps.setActiveTools).toHaveBeenCalledWith([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("includes allowed and ask tools in the active list", async () => {
|
|
191
|
+
const pm = makePm("allow");
|
|
192
|
+
const deps = makeDeps({
|
|
193
|
+
runtime: makeRuntime({
|
|
194
|
+
permissionManager: pm as unknown as PermissionManager,
|
|
195
|
+
}),
|
|
196
|
+
getAllTools: vi
|
|
197
|
+
.fn()
|
|
198
|
+
.mockReturnValue([{ name: "read" }, { name: "write" }]),
|
|
199
|
+
});
|
|
200
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
201
|
+
expect(deps.setActiveTools).toHaveBeenCalledWith(["read", "write"]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("updates the active-tools cache key after applying", async () => {
|
|
205
|
+
const deps = makeDeps({
|
|
206
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
207
|
+
});
|
|
208
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
209
|
+
expect(deps.runtime.lastActiveToolsCacheKey).not.toBeNull();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("skips setActiveTools when cache key is unchanged", async () => {
|
|
213
|
+
// Pre-populate the cache key to match what would be computed for ["read"]
|
|
214
|
+
const { createActiveToolsCacheKey } = await import(
|
|
215
|
+
"../../src/before-agent-start-cache"
|
|
216
|
+
);
|
|
217
|
+
const key = createActiveToolsCacheKey(["read"]);
|
|
218
|
+
const deps = makeDeps({
|
|
219
|
+
runtime: makeRuntime({ lastActiveToolsCacheKey: key }),
|
|
220
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
221
|
+
});
|
|
222
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
223
|
+
expect(deps.setActiveTools).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("updates the prompt-state cache key and returns modified systemPrompt", async () => {
|
|
227
|
+
// Provide a systemPrompt that sanitizeAvailableToolsSection will modify:
|
|
228
|
+
// it strips denied tools from the "Available tools:" section.
|
|
229
|
+
const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
|
|
230
|
+
const deps = makeDeps({
|
|
231
|
+
getAllTools: vi.fn().mockReturnValue([]),
|
|
232
|
+
});
|
|
233
|
+
const result = await handleBeforeAgentStart(
|
|
234
|
+
deps,
|
|
235
|
+
makeEvent(systemPrompt),
|
|
236
|
+
makeCtx(),
|
|
237
|
+
);
|
|
238
|
+
// The prompt was modified, so systemPrompt should be returned
|
|
239
|
+
expect(result).toHaveProperty("systemPrompt");
|
|
240
|
+
expect(deps.runtime.lastPromptStateCacheKey).not.toBeNull();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("returns empty object when systemPrompt is unchanged", async () => {
|
|
244
|
+
const prompt = "No tools section here.";
|
|
245
|
+
const deps = makeDeps({
|
|
246
|
+
getAllTools: vi.fn().mockReturnValue([]),
|
|
247
|
+
});
|
|
248
|
+
const result = await handleBeforeAgentStart(
|
|
249
|
+
deps,
|
|
250
|
+
makeEvent(prompt),
|
|
251
|
+
makeCtx(),
|
|
252
|
+
);
|
|
253
|
+
expect(result).toEqual({});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("stores resolved skill entries on deps", async () => {
|
|
257
|
+
const deps = makeDeps({
|
|
258
|
+
getAllTools: vi.fn().mockReturnValue([]),
|
|
259
|
+
});
|
|
260
|
+
await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
|
|
261
|
+
expect(deps.runtime.activeSkillEntries).toEqual(expect.any(Array));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("returns empty object and skips prompt work when prompt cache key is unchanged", async () => {
|
|
265
|
+
const { createBeforeAgentStartPromptStateKey } = await import(
|
|
266
|
+
"../../src/before-agent-start-cache"
|
|
267
|
+
);
|
|
268
|
+
const pm = makePm("allow");
|
|
269
|
+
const ctx = makeCtx({ cwd: "/proj" });
|
|
270
|
+
const allowedTools: string[] = ["read"];
|
|
271
|
+
const key = createBeforeAgentStartPromptStateKey({
|
|
272
|
+
agentName: null,
|
|
273
|
+
cwd: "/proj",
|
|
274
|
+
permissionStamp: "stamp-1",
|
|
275
|
+
systemPrompt: "hello",
|
|
276
|
+
allowedToolNames: allowedTools,
|
|
277
|
+
});
|
|
278
|
+
const deps = makeDeps({
|
|
279
|
+
runtime: makeRuntime({
|
|
280
|
+
permissionManager: pm as unknown as PermissionManager,
|
|
281
|
+
lastPromptStateCacheKey: key,
|
|
282
|
+
}),
|
|
283
|
+
getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
284
|
+
});
|
|
285
|
+
const result = await handleBeforeAgentStart(deps, makeEvent("hello"), ctx);
|
|
286
|
+
expect(result).toEqual({});
|
|
287
|
+
// activeSkillEntries was not assigned by the handler (early return)
|
|
288
|
+
expect(deps.runtime.activeSkillEntries).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
extractSkillNameFromInput,
|
|
6
|
+
handleInput,
|
|
7
|
+
} from "../../src/handlers/input";
|
|
8
|
+
import type { HandlerDeps } from "../../src/handlers/types";
|
|
9
|
+
import type { ExtensionRuntime } from "../../src/runtime";
|
|
10
|
+
import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
|
|
11
|
+
|
|
12
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
|
15
|
+
return {
|
|
16
|
+
cwd: "/test/project",
|
|
17
|
+
hasUI: true,
|
|
18
|
+
ui: {
|
|
19
|
+
setStatus: vi.fn(),
|
|
20
|
+
notify: vi.fn(),
|
|
21
|
+
select: vi.fn(),
|
|
22
|
+
input: vi.fn(),
|
|
23
|
+
},
|
|
24
|
+
sessionManager: {
|
|
25
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
26
|
+
getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
|
|
27
|
+
addEntry: vi.fn(),
|
|
28
|
+
},
|
|
29
|
+
...overrides,
|
|
30
|
+
} as unknown as ExtensionContext;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeInputEvent(text: string) {
|
|
34
|
+
return { text };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeRuntime(
|
|
38
|
+
overrides: Partial<ExtensionRuntime> = {},
|
|
39
|
+
): ExtensionRuntime {
|
|
40
|
+
return {
|
|
41
|
+
agentDir: "/test/agent",
|
|
42
|
+
sessionsDir: "/test/agent/sessions",
|
|
43
|
+
subagentSessionsDir: "/test/agent/subagent-sessions",
|
|
44
|
+
forwardingDir: "/test/agent/sessions/permission-forwarding",
|
|
45
|
+
globalLogsDir: "/test/agent/extensions/pi-permission-system/logs",
|
|
46
|
+
config: { debugLog: false, permissionReviewLog: true, yoloMode: false },
|
|
47
|
+
runtimeContext: null,
|
|
48
|
+
permissionManager: {
|
|
49
|
+
checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
|
|
50
|
+
} as unknown as ExtensionRuntime["permissionManager"],
|
|
51
|
+
activeSkillEntries: [] as SkillPromptEntry[],
|
|
52
|
+
lastKnownActiveAgentName: null,
|
|
53
|
+
lastActiveToolsCacheKey: null,
|
|
54
|
+
lastPromptStateCacheKey: null,
|
|
55
|
+
lastConfigWarning: null,
|
|
56
|
+
sessionApprovalCache: {
|
|
57
|
+
approve: vi.fn(),
|
|
58
|
+
has: vi.fn(),
|
|
59
|
+
findMatchingPrefix: vi.fn(),
|
|
60
|
+
clear: vi.fn(),
|
|
61
|
+
} as unknown as ExtensionRuntime["sessionApprovalCache"],
|
|
62
|
+
permissionForwardingContext: null,
|
|
63
|
+
permissionForwardingTimer: null,
|
|
64
|
+
isProcessingForwardedRequests: false,
|
|
65
|
+
writeDebugLog: vi.fn(),
|
|
66
|
+
writeReviewLog: vi.fn(),
|
|
67
|
+
...overrides,
|
|
68
|
+
} as ExtensionRuntime;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
72
|
+
return {
|
|
73
|
+
runtime: makeRuntime(),
|
|
74
|
+
createPermissionManagerForCwd: vi.fn(),
|
|
75
|
+
refreshExtensionConfig: vi.fn(),
|
|
76
|
+
notifyWarning: vi.fn(),
|
|
77
|
+
logResolvedConfigPaths: vi.fn(),
|
|
78
|
+
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
79
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
80
|
+
promptPermission: vi
|
|
81
|
+
.fn()
|
|
82
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
83
|
+
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
84
|
+
startForwardedPermissionPolling: vi.fn(),
|
|
85
|
+
stopForwardedPermissionPolling: vi.fn(),
|
|
86
|
+
getAllTools: vi.fn().mockReturnValue([]),
|
|
87
|
+
setActiveTools: vi.fn(),
|
|
88
|
+
...overrides,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── extractSkillNameFromInput ──────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe("extractSkillNameFromInput", () => {
|
|
95
|
+
it("returns null for plain text", () => {
|
|
96
|
+
expect(extractSkillNameFromInput("hello world")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns null for empty string", () => {
|
|
100
|
+
expect(extractSkillNameFromInput("")).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns null for bare /skill: with no name", () => {
|
|
104
|
+
expect(extractSkillNameFromInput("/skill:")).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("extracts skill name from /skill:<name>", () => {
|
|
108
|
+
expect(extractSkillNameFromInput("/skill:librarian")).toBe("librarian");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("extracts skill name stopping at whitespace", () => {
|
|
112
|
+
expect(extractSkillNameFromInput("/skill:librarian some extra")).toBe(
|
|
113
|
+
"librarian",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("trims leading whitespace before the prefix", () => {
|
|
118
|
+
expect(extractSkillNameFromInput(" /skill:my-skill")).toBe("my-skill");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns null when the skill name after trimming is empty", () => {
|
|
122
|
+
expect(extractSkillNameFromInput("/skill: ")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ── handleInput ───────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
describe("handleInput", () => {
|
|
129
|
+
it("sets runtime context", async () => {
|
|
130
|
+
const ctx = makeCtx();
|
|
131
|
+
const deps = makeDeps();
|
|
132
|
+
await handleInput(deps, makeInputEvent("hello"), ctx);
|
|
133
|
+
expect(deps.runtime.runtimeContext).toBe(ctx);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("starts forwarded permission polling", async () => {
|
|
137
|
+
const ctx = makeCtx();
|
|
138
|
+
const deps = makeDeps();
|
|
139
|
+
await handleInput(deps, makeInputEvent("hello"), ctx);
|
|
140
|
+
expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns continue for non-skill input", async () => {
|
|
144
|
+
const deps = makeDeps();
|
|
145
|
+
const result = await handleInput(
|
|
146
|
+
deps,
|
|
147
|
+
makeInputEvent("just a message"),
|
|
148
|
+
makeCtx(),
|
|
149
|
+
);
|
|
150
|
+
expect(result).toEqual({ action: "continue" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does not check permissions for non-skill input", async () => {
|
|
154
|
+
const deps = makeDeps();
|
|
155
|
+
await handleInput(deps, makeInputEvent("just a message"), makeCtx());
|
|
156
|
+
expect(
|
|
157
|
+
deps.runtime.permissionManager.checkPermission,
|
|
158
|
+
).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns continue when skill is allowed", async () => {
|
|
162
|
+
const deps = makeDeps();
|
|
163
|
+
// default makeRuntime() has checkPermission → { state: "allow" }
|
|
164
|
+
const result = await handleInput(
|
|
165
|
+
deps,
|
|
166
|
+
makeInputEvent("/skill:librarian"),
|
|
167
|
+
makeCtx(),
|
|
168
|
+
);
|
|
169
|
+
expect(result).toEqual({ action: "continue" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns handled when skill is denied", async () => {
|
|
173
|
+
const pm = {
|
|
174
|
+
checkPermission: vi.fn().mockReturnValue({ state: "deny" }),
|
|
175
|
+
};
|
|
176
|
+
const deps = makeDeps({
|
|
177
|
+
runtime: makeRuntime({
|
|
178
|
+
permissionManager:
|
|
179
|
+
pm as unknown as ExtensionRuntime["permissionManager"],
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
const result = await handleInput(
|
|
183
|
+
deps,
|
|
184
|
+
makeInputEvent("/skill:librarian"),
|
|
185
|
+
makeCtx(),
|
|
186
|
+
);
|
|
187
|
+
expect(result).toEqual({ action: "handled" });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("shows a warning notification when skill is denied and UI is available", async () => {
|
|
191
|
+
const ctx = makeCtx({ hasUI: true });
|
|
192
|
+
const pm = {
|
|
193
|
+
checkPermission: vi.fn().mockReturnValue({ state: "deny" }),
|
|
194
|
+
};
|
|
195
|
+
const deps = makeDeps({
|
|
196
|
+
runtime: makeRuntime({
|
|
197
|
+
permissionManager:
|
|
198
|
+
pm as unknown as ExtensionRuntime["permissionManager"],
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
await handleInput(deps, makeInputEvent("/skill:librarian"), ctx);
|
|
202
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
203
|
+
expect.stringContaining("librarian"),
|
|
204
|
+
"warning",
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("does not show a warning notification when skill is denied and UI is absent", async () => {
|
|
209
|
+
const ctx = makeCtx({ hasUI: false });
|
|
210
|
+
const pm = { checkPermission: vi.fn().mockReturnValue({ state: "deny" }) };
|
|
211
|
+
const deps = makeDeps({
|
|
212
|
+
runtime: makeRuntime({
|
|
213
|
+
permissionManager:
|
|
214
|
+
pm as unknown as ExtensionRuntime["permissionManager"],
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
await handleInput(deps, makeInputEvent("/skill:librarian"), ctx);
|
|
218
|
+
expect(ctx.ui.notify).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("returns handled when skill requires approval but no UI is available", async () => {
|
|
222
|
+
const pm = { checkPermission: vi.fn().mockReturnValue({ state: "ask" }) };
|
|
223
|
+
const deps = makeDeps({
|
|
224
|
+
runtime: makeRuntime({
|
|
225
|
+
permissionManager:
|
|
226
|
+
pm as unknown as ExtensionRuntime["permissionManager"],
|
|
227
|
+
}),
|
|
228
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
|
|
229
|
+
});
|
|
230
|
+
const result = await handleInput(
|
|
231
|
+
deps,
|
|
232
|
+
makeInputEvent("/skill:librarian"),
|
|
233
|
+
makeCtx(),
|
|
234
|
+
);
|
|
235
|
+
expect(result).toEqual({ action: "handled" });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("prompts and returns continue when skill ask is approved", async () => {
|
|
239
|
+
const pm = { checkPermission: vi.fn().mockReturnValue({ state: "ask" }) };
|
|
240
|
+
const deps = makeDeps({
|
|
241
|
+
runtime: makeRuntime({
|
|
242
|
+
permissionManager:
|
|
243
|
+
pm as unknown as ExtensionRuntime["permissionManager"],
|
|
244
|
+
}),
|
|
245
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
246
|
+
promptPermission: vi
|
|
247
|
+
.fn()
|
|
248
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
249
|
+
});
|
|
250
|
+
const result = await handleInput(
|
|
251
|
+
deps,
|
|
252
|
+
makeInputEvent("/skill:librarian"),
|
|
253
|
+
makeCtx(),
|
|
254
|
+
);
|
|
255
|
+
expect(result).toEqual({ action: "continue" });
|
|
256
|
+
expect(deps.promptPermission).toHaveBeenCalledOnce();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("returns handled when skill ask is denied by user", async () => {
|
|
260
|
+
const pm = { checkPermission: vi.fn().mockReturnValue({ state: "ask" }) };
|
|
261
|
+
const deps = makeDeps({
|
|
262
|
+
runtime: makeRuntime({
|
|
263
|
+
permissionManager:
|
|
264
|
+
pm as unknown as ExtensionRuntime["permissionManager"],
|
|
265
|
+
}),
|
|
266
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
267
|
+
promptPermission: vi
|
|
268
|
+
.fn()
|
|
269
|
+
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
270
|
+
});
|
|
271
|
+
const result = await handleInput(
|
|
272
|
+
deps,
|
|
273
|
+
makeInputEvent("/skill:librarian"),
|
|
274
|
+
makeCtx(),
|
|
275
|
+
);
|
|
276
|
+
expect(result).toEqual({ action: "handled" });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("passes agentName in the prompt permission request", async () => {
|
|
280
|
+
const pm = { checkPermission: vi.fn().mockReturnValue({ state: "ask" }) };
|
|
281
|
+
const deps = makeDeps({
|
|
282
|
+
runtime: makeRuntime({
|
|
283
|
+
permissionManager:
|
|
284
|
+
pm as unknown as ExtensionRuntime["permissionManager"],
|
|
285
|
+
}),
|
|
286
|
+
resolveAgentName: vi.fn().mockReturnValue("code-agent"),
|
|
287
|
+
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
288
|
+
promptPermission: vi
|
|
289
|
+
.fn()
|
|
290
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
291
|
+
});
|
|
292
|
+
await handleInput(deps, makeInputEvent("/skill:librarian"), makeCtx());
|
|
293
|
+
expect(deps.promptPermission).toHaveBeenCalledWith(
|
|
294
|
+
expect.anything(),
|
|
295
|
+
expect.objectContaining({
|
|
296
|
+
agentName: "code-agent",
|
|
297
|
+
skillName: "librarian",
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
});
|