@gotgenes/pi-permission-system 5.9.0 → 5.11.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.
@@ -2,13 +2,12 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import {
5
- handleBeforeAgentStart,
5
+ AgentPrepHandler,
6
6
  shouldExposeTool,
7
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 { SessionState } from "../../src/runtime";
11
- import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
8
+ import type { PermissionSession } from "../../src/permission-session";
9
+ import type { ToolRegistry } from "../../src/tool-registry";
10
+ import type { PermissionState } from "../../src/types";
12
11
 
13
12
  // ── SDK stubs ──────────────────────────────────────────────────────────────
14
13
  vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
@@ -45,234 +44,184 @@ function makeEvent(systemPrompt = "You are an assistant.") {
45
44
  return { systemPrompt };
46
45
  }
47
46
 
48
- /** Minimal PermissionManager stub for shouldExposeTool / policy-cache tests. */
49
- function makePm(
50
- toolPermission: "allow" | "deny" | "ask" = "allow",
51
- ): PermissionManager {
47
+ function makeSession(
48
+ overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
49
+ ): PermissionSession {
52
50
  return {
53
- getToolPermission: vi.fn().mockReturnValue(toolPermission),
54
- getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
55
- getConfigIssues: vi.fn().mockReturnValue([]),
51
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
52
+ activate: vi.fn(),
53
+ refreshConfig: vi.fn(),
54
+ resolveAgentName: vi.fn().mockReturnValue(null),
55
+ getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
56
56
  checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
57
- } as unknown as PermissionManager;
57
+ shouldUpdateActiveTools: vi.fn().mockReturnValue(true),
58
+ commitActiveToolsCacheKey: vi.fn(),
59
+ getPolicyCacheStamp: vi.fn().mockReturnValue("stamp-1"),
60
+ shouldUpdatePromptState: vi.fn().mockReturnValue(true),
61
+ commitPromptStateCacheKey: vi.fn(),
62
+ setActiveSkillEntries: vi.fn(),
63
+ getActiveSkillEntries: vi.fn().mockReturnValue([]),
64
+ ...overrides,
65
+ } as unknown as PermissionSession;
58
66
  }
59
67
 
60
- function makeSession(overrides: Partial<SessionState> = {}): SessionState {
68
+ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
61
69
  return {
62
- runtimeContext: null,
63
- permissionManager: makePm() as unknown as PermissionManager,
64
- activeSkillEntries: [] as SkillPromptEntry[],
65
- lastKnownActiveAgentName: null,
66
- lastActiveToolsCacheKey: null,
67
- lastPromptStateCacheKey: null,
68
- sessionRules: {
69
- approve: vi.fn(),
70
- getRuleset: vi.fn().mockReturnValue([]),
71
- clear: vi.fn(),
72
- } as unknown as SessionState["sessionRules"],
70
+ getAll: vi.fn().mockReturnValue([]),
71
+ setActive: vi.fn(),
73
72
  ...overrides,
74
73
  };
75
74
  }
76
75
 
77
- function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
78
- return {
79
- session: makeSession(),
80
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
81
- piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
82
- getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
83
- createPermissionManagerForCwd: vi.fn().mockReturnValue(makePm()),
84
- refreshExtensionConfig: vi.fn(),
85
- logResolvedConfigPaths: vi.fn(),
86
- resolveAgentName: vi.fn().mockReturnValue(null),
87
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
88
- promptPermission: vi
89
- .fn()
90
- .mockResolvedValue({ approved: true, state: "approved" }),
91
- createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
92
- events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
93
- forwarding: { start: vi.fn(), stop: vi.fn() },
94
- stopPermissionRpcHandlers: vi.fn(),
95
- getAllTools: vi.fn().mockReturnValue([]),
96
- setActiveTools: vi.fn(),
97
- ...overrides,
98
- };
76
+ function makeHandler(overrides?: {
77
+ session?: Partial<Record<keyof PermissionSession, unknown>>;
78
+ toolRegistry?: Partial<ToolRegistry>;
79
+ }): {
80
+ handler: AgentPrepHandler;
81
+ session: PermissionSession;
82
+ toolRegistry: ToolRegistry;
83
+ } {
84
+ const session = makeSession(overrides?.session);
85
+ const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
86
+ const handler = new AgentPrepHandler(session, toolRegistry);
87
+ return { handler, session, toolRegistry };
99
88
  }
100
89
 
101
90
  // ── shouldExposeTool (pure helper) ─────────────────────────────────────────
102
91
 
103
92
  describe("shouldExposeTool", () => {
104
93
  it("returns true when tool permission is allow", () => {
105
- const pm = makePm("allow");
106
- expect(shouldExposeTool("read", null, pm)).toBe(true);
94
+ const getter = vi.fn().mockReturnValue("allow");
95
+ expect(shouldExposeTool("read", null, getter)).toBe(true);
107
96
  });
108
97
 
109
98
  it("returns true when tool permission is ask", () => {
110
- const pm = makePm("ask");
111
- expect(shouldExposeTool("bash", "agent-x", pm)).toBe(true);
99
+ const getter = vi.fn().mockReturnValue("ask");
100
+ expect(shouldExposeTool("bash", "agent-x", getter)).toBe(true);
112
101
  });
113
102
 
114
103
  it("returns false when tool permission is deny", () => {
115
- const pm = makePm("deny");
116
- expect(shouldExposeTool("write", null, pm)).toBe(false);
104
+ const getter = vi.fn().mockReturnValue("deny");
105
+ expect(shouldExposeTool("write", null, getter)).toBe(false);
117
106
  });
118
107
 
119
108
  it("passes agentName through to getToolPermission", () => {
120
- const pm = makePm("allow");
121
- shouldExposeTool("read", "my-agent", pm);
122
- expect(pm.getToolPermission).toHaveBeenCalledWith("read", "my-agent");
109
+ const getter = vi.fn().mockReturnValue("allow");
110
+ shouldExposeTool("read", "my-agent", getter);
111
+ expect(getter).toHaveBeenCalledWith("read", "my-agent");
123
112
  });
124
113
 
125
114
  it("converts null agentName to undefined for getToolPermission", () => {
126
- const pm = makePm("allow");
127
- shouldExposeTool("read", null, pm);
128
- expect(pm.getToolPermission).toHaveBeenCalledWith("read", undefined);
115
+ const getter = vi.fn().mockReturnValue("allow");
116
+ shouldExposeTool("read", null, getter);
117
+ expect(getter).toHaveBeenCalledWith("read", undefined);
129
118
  });
130
119
  });
131
120
 
132
- // ── handleBeforeAgentStart ─────────────────────────────────────────────────
121
+ // ── AgentPrepHandler.handle ────────────────────────────────────────────────
133
122
 
134
- describe("handleBeforeAgentStart", () => {
135
- it("refreshes extension config with ctx", async () => {
123
+ describe("AgentPrepHandler.handle", () => {
124
+ it("activates the session with ctx", async () => {
136
125
  const ctx = makeCtx();
137
- const deps = makeDeps();
138
- await handleBeforeAgentStart(deps, makeEvent(), ctx);
139
- expect(deps.refreshExtensionConfig).toHaveBeenCalledWith(ctx);
126
+ const { handler, session } = makeHandler();
127
+ await handler.handle(makeEvent(), ctx);
128
+ expect(session.activate).toHaveBeenCalledWith(ctx);
140
129
  });
141
130
 
142
- it("starts forwarded permission polling", async () => {
131
+ it("refreshes config with ctx", async () => {
143
132
  const ctx = makeCtx();
144
- const deps = makeDeps();
145
- await handleBeforeAgentStart(deps, makeEvent(), ctx);
146
- expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
133
+ const { handler, session } = makeHandler();
134
+ await handler.handle(makeEvent(), ctx);
135
+ expect(session.refreshConfig).toHaveBeenCalledWith(ctx);
147
136
  });
148
137
 
149
138
  it("resolves agent name using systemPrompt", async () => {
150
139
  const ctx = makeCtx();
151
- const deps = makeDeps();
152
- await handleBeforeAgentStart(
153
- deps,
154
- makeEvent("<active_agent name='x'>"),
155
- ctx,
156
- );
157
- expect(deps.resolveAgentName).toHaveBeenCalledWith(
140
+ const { handler, session } = makeHandler();
141
+ await handler.handle(makeEvent("<active_agent name='x'>"), ctx);
142
+ expect(session.resolveAgentName).toHaveBeenCalledWith(
158
143
  ctx,
159
144
  "<active_agent name='x'>",
160
145
  );
161
146
  });
162
147
 
163
148
  it("filters out denied tools from allowed list", async () => {
164
- const pm = makePm("deny");
165
- const deps = makeDeps({
166
- session: makeSession({
167
- permissionManager: pm as unknown as PermissionManager,
168
- }),
169
- getAllTools: vi
170
- .fn()
171
- .mockReturnValue([{ name: "write" }, { name: "read" }]),
149
+ const { handler, toolRegistry } = makeHandler({
150
+ session: { getToolPermission: vi.fn().mockReturnValue("deny") },
151
+ toolRegistry: {
152
+ getAll: vi.fn().mockReturnValue([{ name: "write" }, { name: "read" }]),
153
+ },
172
154
  });
173
- // write is deny, read is deny (same pm stub — both denied)
174
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
175
- expect(deps.setActiveTools).toHaveBeenCalledWith([]);
155
+ await handler.handle(makeEvent(), makeCtx());
156
+ expect(toolRegistry.setActive).toHaveBeenCalledWith([]);
176
157
  });
177
158
 
178
159
  it("includes allowed and ask tools in the active list", async () => {
179
- const pm = makePm("allow");
180
- const deps = makeDeps({
181
- session: makeSession({
182
- permissionManager: pm as unknown as PermissionManager,
183
- }),
184
- getAllTools: vi
185
- .fn()
186
- .mockReturnValue([{ name: "read" }, { name: "write" }]),
160
+ const { handler, toolRegistry } = makeHandler({
161
+ toolRegistry: {
162
+ getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "write" }]),
163
+ },
187
164
  });
188
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
189
- expect(deps.setActiveTools).toHaveBeenCalledWith(["read", "write"]);
165
+ await handler.handle(makeEvent(), makeCtx());
166
+ expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
190
167
  });
191
168
 
192
- it("updates the active-tools cache key after applying", async () => {
193
- const deps = makeDeps({
194
- getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
169
+ it("commits active-tools cache key after applying", async () => {
170
+ const { handler, session } = makeHandler({
171
+ toolRegistry: {
172
+ getAll: vi.fn().mockReturnValue([{ name: "read" }]),
173
+ },
195
174
  });
196
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
197
- expect(deps.session.lastActiveToolsCacheKey).not.toBeNull();
175
+ await handler.handle(makeEvent(), makeCtx());
176
+ expect(session.commitActiveToolsCacheKey).toHaveBeenCalled();
198
177
  });
199
178
 
200
- it("skips setActiveTools when cache key is unchanged", async () => {
201
- // Pre-populate the cache key to match what would be computed for ["read"]
202
- const { createActiveToolsCacheKey } = await import(
203
- "../../src/before-agent-start-cache"
204
- );
205
- const key = createActiveToolsCacheKey(["read"]);
206
- const deps = makeDeps({
207
- session: makeSession({ lastActiveToolsCacheKey: key }),
208
- getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
179
+ it("skips setActive when cache key is unchanged", async () => {
180
+ const { handler, session, toolRegistry } = makeHandler({
181
+ session: { shouldUpdateActiveTools: vi.fn().mockReturnValue(false) },
182
+ toolRegistry: {
183
+ getAll: vi.fn().mockReturnValue([{ name: "read" }]),
184
+ },
209
185
  });
210
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
211
- expect(deps.setActiveTools).not.toHaveBeenCalled();
186
+ await handler.handle(makeEvent(), makeCtx());
187
+ expect(toolRegistry.setActive).not.toHaveBeenCalled();
188
+ expect(session.commitActiveToolsCacheKey).not.toHaveBeenCalled();
212
189
  });
213
190
 
214
- it("updates the prompt-state cache key and returns modified systemPrompt", async () => {
215
- // Provide a systemPrompt that sanitizeAvailableToolsSection will modify:
216
- // it strips denied tools from the "Available tools:" section.
217
- const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
218
- const deps = makeDeps({
219
- getAllTools: vi.fn().mockReturnValue([]),
191
+ it("returns empty object when prompt cache is unchanged", async () => {
192
+ const { handler, session } = makeHandler({
193
+ session: { shouldUpdatePromptState: vi.fn().mockReturnValue(false) },
220
194
  });
221
- const result = await handleBeforeAgentStart(
222
- deps,
223
- makeEvent(systemPrompt),
224
- makeCtx(),
225
- );
226
- // The prompt was modified, so systemPrompt should be returned
227
- expect(result).toHaveProperty("systemPrompt");
228
- expect(deps.session.lastPromptStateCacheKey).not.toBeNull();
195
+ const result = await handler.handle(makeEvent(), makeCtx());
196
+ expect(result).toEqual({});
197
+ expect(session.commitPromptStateCacheKey).not.toHaveBeenCalled();
229
198
  });
230
199
 
231
- it("returns empty object when systemPrompt is unchanged", async () => {
232
- const prompt = "No tools section here.";
233
- const deps = makeDeps({
234
- getAllTools: vi.fn().mockReturnValue([]),
235
- });
236
- const result = await handleBeforeAgentStart(
237
- deps,
238
- makeEvent(prompt),
239
- makeCtx(),
200
+ it("commits prompt-state cache key and processes prompt when cache is new", async () => {
201
+ const { handler, session } = makeHandler();
202
+ await handler.handle(makeEvent(), makeCtx());
203
+ expect(session.commitPromptStateCacheKey).toHaveBeenCalled();
204
+ });
205
+
206
+ it("stores resolved skill entries on the session", async () => {
207
+ const { handler, session } = makeHandler();
208
+ await handler.handle(makeEvent(), makeCtx());
209
+ expect(session.setActiveSkillEntries).toHaveBeenCalledWith(
210
+ expect.any(Array),
240
211
  );
241
- expect(result).toEqual({});
242
212
  });
243
213
 
244
- it("stores resolved skill entries on deps", async () => {
245
- const deps = makeDeps({
246
- getAllTools: vi.fn().mockReturnValue([]),
247
- });
248
- await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
249
- expect(deps.session.activeSkillEntries).toEqual(expect.any(Array));
214
+ it("returns modified systemPrompt when prompt changes", async () => {
215
+ const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
216
+ const { handler } = makeHandler();
217
+ const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
218
+ expect(result).toHaveProperty("systemPrompt");
250
219
  });
251
220
 
252
- it("returns empty object and skips prompt work when prompt cache key is unchanged", async () => {
253
- const { createBeforeAgentStartPromptStateKey } = await import(
254
- "../../src/before-agent-start-cache"
255
- );
256
- const pm = makePm("allow");
257
- const ctx = makeCtx({ cwd: "/proj" });
258
- const allowedTools: string[] = ["read"];
259
- const key = createBeforeAgentStartPromptStateKey({
260
- agentName: null,
261
- cwd: "/proj",
262
- permissionStamp: "stamp-1",
263
- systemPrompt: "hello",
264
- allowedToolNames: allowedTools,
265
- });
266
- const deps = makeDeps({
267
- session: makeSession({
268
- permissionManager: pm as unknown as PermissionManager,
269
- lastPromptStateCacheKey: key,
270
- }),
271
- getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
272
- });
273
- const result = await handleBeforeAgentStart(deps, makeEvent("hello"), ctx);
221
+ it("returns empty object when systemPrompt is unchanged", async () => {
222
+ const prompt = "No tools section here.";
223
+ const { handler } = makeHandler();
224
+ const result = await handler.handle(makeEvent(prompt), makeCtx());
274
225
  expect(result).toEqual({});
275
- // activeSkillEntries was not assigned by the handler (early return)
276
- expect(deps.session.activeSkillEntries).toEqual([]);
277
226
  });
278
227
  });
@@ -4,11 +4,12 @@
4
4
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
 
7
- import { handleInput } from "../../src/handlers/input";
8
- import type { HandlerDeps } from "../../src/handlers/types";
7
+ import { PermissionGateHandler } from "../../src/handlers/permission-gate-handler";
9
8
  import type { PermissionDecisionEvent } from "../../src/permission-events";
10
9
  import { PERMISSIONS_DECISION_CHANNEL } from "../../src/permission-events";
11
- import type { SessionState } from "../../src/runtime";
10
+ import type { PermissionSession } from "../../src/permission-session";
11
+ import type { ToolRegistry } from "../../src/tool-registry";
12
+ import type { PermissionState } from "../../src/types";
12
13
 
13
14
  // ── helpers ────────────────────────────────────────────────────────────────
14
15
 
@@ -38,60 +39,60 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
38
39
  } as unknown as ExtensionContext;
39
40
  }
40
41
 
41
- function makeSession(state: "allow" | "deny" | "ask" = "allow"): SessionState {
42
- return {
43
- runtimeContext: null,
44
- permissionManager: {
45
- checkPermission: vi.fn().mockReturnValue({
46
- state,
47
- toolName: "skill",
48
- source: "skill",
49
- origin: "global",
50
- matchedPattern: "*",
51
- }),
52
- } as unknown as SessionState["permissionManager"],
53
- activeSkillEntries: [],
54
- lastKnownActiveAgentName: null,
55
- lastActiveToolsCacheKey: null,
56
- lastPromptStateCacheKey: null,
57
- sessionRules: {
58
- approve: vi.fn(),
59
- getRuleset: vi.fn().mockReturnValue([]),
60
- clear: vi.fn(),
61
- } as unknown as SessionState["sessionRules"],
62
- };
63
- }
64
-
65
- function makeDeps(
42
+ function makeSession(
66
43
  state: "allow" | "deny" | "ask" = "allow",
67
- overrides: Partial<HandlerDeps> = {},
68
- ): HandlerDeps {
44
+ overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
45
+ ): PermissionSession {
69
46
  return {
70
- session: makeSession(state),
71
47
  logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
72
- piInfrastructureDirs: ["/test/agent"],
73
- getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
74
- events: makeEvents(),
75
- createPermissionManagerForCwd: vi.fn(),
76
- refreshExtensionConfig: vi.fn(),
77
- logResolvedConfigPaths: vi.fn(),
48
+ activate: vi.fn(),
78
49
  resolveAgentName: vi.fn().mockReturnValue(null),
79
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
80
- promptPermission: vi
81
- .fn()
82
- .mockResolvedValue({ approved: true, state: "approved" }),
50
+ checkPermission: vi.fn().mockReturnValue({
51
+ state,
52
+ toolName: "skill",
53
+ source: "skill",
54
+ origin: "global",
55
+ matchedPattern: "*",
56
+ }),
57
+ getToolPermission: vi.fn().mockReturnValue("allow" as PermissionState),
58
+ getSessionRuleset: vi.fn().mockReturnValue([]),
59
+ approveSessionRule: vi.fn(),
60
+ canPrompt: vi.fn().mockReturnValue(true),
61
+ prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
83
62
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
84
- forwarding: { start: vi.fn(), stop: vi.fn() },
85
- stopPermissionRpcHandlers: vi.fn(),
86
- getAllTools: vi.fn().mockReturnValue([]),
87
- setActiveTools: vi.fn(),
88
63
  ...overrides,
64
+ } as unknown as PermissionSession;
65
+ }
66
+
67
+ function makeToolRegistry(): ToolRegistry {
68
+ return {
69
+ getAll: vi.fn().mockReturnValue([]),
70
+ setActive: vi.fn(),
89
71
  };
90
72
  }
91
73
 
92
- function getDecisionEvents(deps: HandlerDeps): PermissionDecisionEvent[] {
93
- const emitMock = (deps.events as ReturnType<typeof makeEvents>).emit;
94
- return emitMock.mock.calls
74
+ function makeHandler(
75
+ state: "allow" | "deny" | "ask" = "allow",
76
+ sessionOverrides: Partial<Record<keyof PermissionSession, unknown>> = {},
77
+ ): {
78
+ handler: PermissionGateHandler;
79
+ events: ReturnType<typeof makeEvents>;
80
+ } {
81
+ const session = makeSession(state, sessionOverrides);
82
+ const events = makeEvents();
83
+ const handler = new PermissionGateHandler(
84
+ session,
85
+ events,
86
+ makeToolRegistry(),
87
+ );
88
+ return { handler, events };
89
+ }
90
+
91
+ /** Extract all permissions:decision payloads from the events.emit mock. */
92
+ function getDecisionEvents(
93
+ events: ReturnType<typeof makeEvents>,
94
+ ): PermissionDecisionEvent[] {
95
+ return events.emit.mock.calls
95
96
  .filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
96
97
  .map(([, payload]) => payload as PermissionDecisionEvent);
97
98
  }
@@ -100,18 +101,18 @@ function getDecisionEvents(deps: HandlerDeps): PermissionDecisionEvent[] {
100
101
 
101
102
  describe("handleInput decision events — skill gate", () => {
102
103
  it("does not emit when input is not a skill invocation", async () => {
103
- const deps = makeDeps();
104
- await handleInput(deps, { text: "hello world" }, makeCtx());
105
- expect(getDecisionEvents(deps)).toHaveLength(0);
104
+ const { handler, events } = makeHandler();
105
+ await handler.handleInput({ text: "hello world" }, makeCtx());
106
+ expect(getDecisionEvents(events)).toHaveLength(0);
106
107
  });
107
108
 
108
109
  it("emits allow with policy_allow for an allowed skill", async () => {
109
- const deps = makeDeps("allow");
110
- await handleInput(deps, { text: "/skill:librarian" }, makeCtx());
110
+ const { handler, events } = makeHandler("allow");
111
+ await handler.handleInput({ text: "/skill:librarian" }, makeCtx());
111
112
 
112
- const events = getDecisionEvents(deps);
113
- expect(events).toHaveLength(1);
114
- expect(events[0]).toMatchObject({
113
+ const decisions = getDecisionEvents(events);
114
+ expect(decisions).toHaveLength(1);
115
+ expect(decisions[0]).toMatchObject({
115
116
  surface: "skill",
116
117
  value: "librarian",
117
118
  result: "allow",
@@ -120,12 +121,12 @@ describe("handleInput decision events — skill gate", () => {
120
121
  });
121
122
 
122
123
  it("emits deny with policy_deny for a denied skill", async () => {
123
- const deps = makeDeps("deny");
124
- await handleInput(deps, { text: "/skill:restricted" }, makeCtx());
124
+ const { handler, events } = makeHandler("deny");
125
+ await handler.handleInput({ text: "/skill:restricted" }, makeCtx());
125
126
 
126
- const events = getDecisionEvents(deps);
127
- expect(events).toHaveLength(1);
128
- expect(events[0]).toMatchObject({
127
+ const decisions = getDecisionEvents(events);
128
+ expect(decisions).toHaveLength(1);
129
+ expect(decisions[0]).toMatchObject({
129
130
  surface: "skill",
130
131
  value: "restricted",
131
132
  result: "deny",
@@ -134,16 +135,14 @@ describe("handleInput decision events — skill gate", () => {
134
135
  });
135
136
 
136
137
  it("emits allow with user_approved when state=ask and user approves", async () => {
137
- const deps = makeDeps("ask", {
138
- promptPermission: vi
139
- .fn()
140
- .mockResolvedValue({ approved: true, state: "approved" }),
138
+ const { handler, events } = makeHandler("ask", {
139
+ prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
141
140
  });
142
- await handleInput(deps, { text: "/skill:explorer" }, makeCtx());
141
+ await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
143
142
 
144
- const events = getDecisionEvents(deps);
145
- expect(events).toHaveLength(1);
146
- expect(events[0]).toMatchObject({
143
+ const decisions = getDecisionEvents(events);
144
+ expect(decisions).toHaveLength(1);
145
+ expect(decisions[0]).toMatchObject({
147
146
  surface: "skill",
148
147
  value: "explorer",
149
148
  result: "allow",
@@ -152,16 +151,14 @@ describe("handleInput decision events — skill gate", () => {
152
151
  });
153
152
 
154
153
  it("emits deny with user_denied when state=ask and user denies", async () => {
155
- const deps = makeDeps("ask", {
156
- promptPermission: vi
157
- .fn()
158
- .mockResolvedValue({ approved: false, state: "denied" }),
154
+ const { handler, events } = makeHandler("ask", {
155
+ prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
159
156
  });
160
- await handleInput(deps, { text: "/skill:explorer" }, makeCtx());
157
+ await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
161
158
 
162
- const events = getDecisionEvents(deps);
163
- expect(events).toHaveLength(1);
164
- expect(events[0]).toMatchObject({
159
+ const decisions = getDecisionEvents(events);
160
+ expect(decisions).toHaveLength(1);
161
+ expect(decisions[0]).toMatchObject({
165
162
  surface: "skill",
166
163
  value: "explorer",
167
164
  result: "deny",
@@ -170,18 +167,17 @@ describe("handleInput decision events — skill gate", () => {
170
167
  });
171
168
 
172
169
  it("emits deny with confirmation_unavailable when state=ask but no UI", async () => {
173
- const deps = makeDeps("ask", {
174
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
170
+ const { handler, events } = makeHandler("ask", {
171
+ canPrompt: vi.fn().mockReturnValue(false),
175
172
  });
176
- await handleInput(
177
- deps,
173
+ await handler.handleInput(
178
174
  { text: "/skill:explorer" },
179
175
  makeCtx({ hasUI: false }),
180
176
  );
181
177
 
182
- const events = getDecisionEvents(deps);
183
- expect(events).toHaveLength(1);
184
- expect(events[0]).toMatchObject({
178
+ const decisions = getDecisionEvents(events);
179
+ expect(decisions).toHaveLength(1);
180
+ expect(decisions[0]).toMatchObject({
185
181
  surface: "skill",
186
182
  value: "explorer",
187
183
  result: "deny",
@@ -189,20 +185,19 @@ describe("handleInput decision events — skill gate", () => {
189
185
  });
190
186
  });
191
187
 
192
- it("emits allow with auto_approved when promptPermission returns autoApproved:true", async () => {
193
- const deps = makeDeps("ask", {
194
- // Simulate what PermissionPrompter returns in yolo mode
195
- promptPermission: vi.fn().mockResolvedValue({
188
+ it("emits allow with auto_approved when prompt returns autoApproved:true", async () => {
189
+ const { handler, events } = makeHandler("ask", {
190
+ prompt: vi.fn().mockResolvedValue({
196
191
  approved: true,
197
192
  state: "approved",
198
193
  autoApproved: true,
199
194
  }),
200
195
  });
201
- await handleInput(deps, { text: "/skill:explorer" }, makeCtx());
196
+ await handler.handleInput({ text: "/skill:explorer" }, makeCtx());
202
197
 
203
- const events = getDecisionEvents(deps);
204
- expect(events).toHaveLength(1);
205
- expect(events[0]).toMatchObject({
198
+ const decisions = getDecisionEvents(events);
199
+ expect(decisions).toHaveLength(1);
200
+ expect(decisions[0]).toMatchObject({
206
201
  surface: "skill",
207
202
  value: "explorer",
208
203
  result: "allow",