@gotgenes/pi-permission-system 5.9.0 → 5.10.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.
@@ -0,0 +1,252 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ import {
4
+ getActiveAgentName,
5
+ getActiveAgentNameFromSystemPrompt,
6
+ } from "./active-agent";
7
+ import type { PermissionSystemExtensionConfig } from "./extension-config";
8
+ import type { ExtensionPaths } from "./extension-paths";
9
+ import type { ForwardingController } from "./forwarding-manager";
10
+ import type { PermissionManager } from "./permission-manager";
11
+ import type { Rule } from "./rule";
12
+ import { createPermissionManagerForCwd } from "./runtime";
13
+ import type { SessionLogger } from "./session-logger";
14
+ import { SessionRules } from "./session-rules";
15
+ import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
16
+ import type { PermissionCheckResult, PermissionState } from "./types";
17
+
18
+ /**
19
+ * Runtime operations that `PermissionSession` delegates to but does not own.
20
+ *
21
+ * Injected at construction time from the composition root (`index.ts`),
22
+ * where the `ExtensionRuntime` is available.
23
+ */
24
+ export interface PermissionSessionRuntimeDeps {
25
+ /** Reload merged config from disk; optionally update the stored runtime context. */
26
+ refreshExtensionConfig(ctx?: ExtensionContext): void;
27
+ /** Write the resolved config path set to the review and debug logs. */
28
+ logResolvedConfigPaths(): void;
29
+ /** Read current extension config (called at query time). */
30
+ getConfig(): PermissionSystemExtensionConfig;
31
+ }
32
+
33
+ /**
34
+ * Encapsulates all mutable session state and exposes operations instead of
35
+ * fields.
36
+ *
37
+ * Replaces the `SessionState` interface + scattered handler field mutations
38
+ * with a single class that owns the `PermissionManager`, `SessionRules`,
39
+ * cache keys, skill entries, and runtime context.
40
+ *
41
+ * Constructor deps:
42
+ * - `ExtensionPaths` — immutable path constants
43
+ * - `SessionLogger` — debug + review + warn
44
+ * - `ForwardingController` — polling lifecycle
45
+ * - `PermissionSessionRuntimeDeps` — config refresh + log delegates
46
+ */
47
+ export class PermissionSession {
48
+ private context: ExtensionContext | null = null;
49
+ private permissionManager: PermissionManager;
50
+ private readonly sessionRules = new SessionRules();
51
+ private skillEntries: SkillPromptEntry[] = [];
52
+ private knownAgentName: string | null = null;
53
+ private toolsCacheKey: string | null = null;
54
+ private promptCacheKey: string | null = null;
55
+
56
+ constructor(
57
+ private readonly paths: ExtensionPaths,
58
+ readonly logger: SessionLogger,
59
+ private readonly forwarding: ForwardingController,
60
+ private readonly runtimeDeps: PermissionSessionRuntimeDeps,
61
+ ) {
62
+ this.permissionManager = createPermissionManagerForCwd(
63
+ paths.agentDir,
64
+ undefined,
65
+ );
66
+ }
67
+
68
+ // ── Context lifecycle ──────────────────────────────────────────────────
69
+
70
+ /** Store the current extension context and start forwarding. */
71
+ activate(ctx: ExtensionContext): void {
72
+ this.context = ctx;
73
+ this.forwarding.start(ctx);
74
+ }
75
+
76
+ /** Clear the context and stop forwarding. */
77
+ deactivate(): void {
78
+ this.context = null;
79
+ this.forwarding.stop();
80
+ }
81
+
82
+ /** Return the current runtime context, or null if not activated. */
83
+ getRuntimeContext(): ExtensionContext | null {
84
+ return this.context;
85
+ }
86
+
87
+ // ── Permission checking (delegates to PermissionManager) ───────────────
88
+
89
+ checkPermission(
90
+ surface: string,
91
+ input: unknown,
92
+ agentName?: string,
93
+ sessionRules?: Rule[],
94
+ ): PermissionCheckResult {
95
+ return this.permissionManager.checkPermission(
96
+ surface,
97
+ input,
98
+ agentName,
99
+ sessionRules,
100
+ );
101
+ }
102
+
103
+ getToolPermission(toolName: string, agentName?: string): PermissionState {
104
+ return this.permissionManager.getToolPermission(toolName, agentName);
105
+ }
106
+
107
+ getConfigIssues(agentName?: string): string[] {
108
+ return this.permissionManager.getConfigIssues(agentName);
109
+ }
110
+
111
+ getPolicyCacheStamp(agentName?: string): string {
112
+ return this.permissionManager.getPolicyCacheStamp(agentName);
113
+ }
114
+
115
+ // ── Session rules (delegates to SessionRules) ──────────────────────────
116
+
117
+ getSessionRuleset(): Rule[] {
118
+ return this.sessionRules.getRuleset();
119
+ }
120
+
121
+ approveSessionRule(surface: string, pattern: string): void {
122
+ this.sessionRules.approve(surface, pattern);
123
+ }
124
+
125
+ // ── Session lifecycle ────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Reset all mutable state for a new session.
129
+ *
130
+ * Creates a fresh PermissionManager scoped to `ctx.cwd`, clears caches,
131
+ * skill entries, and activates the new context.
132
+ */
133
+ resetForNewSession(ctx: ExtensionContext): void {
134
+ this.permissionManager = createPermissionManagerForCwd(
135
+ this.paths.agentDir,
136
+ ctx.cwd,
137
+ );
138
+ this.skillEntries = [];
139
+ this.toolsCacheKey = null;
140
+ this.promptCacheKey = null;
141
+ this.activate(ctx);
142
+ }
143
+
144
+ /**
145
+ * Shut down the session: clear rules, caches, skill entries, and
146
+ * deactivate context + forwarding.
147
+ */
148
+ shutdown(): void {
149
+ this.sessionRules.clear();
150
+ this.skillEntries = [];
151
+ this.toolsCacheKey = null;
152
+ this.promptCacheKey = null;
153
+ this.deactivate();
154
+ }
155
+
156
+ /**
157
+ * Reload permission manager and clear caches for the current context.
158
+ * Used on config reload (e.g. `resources_discover` with reason "reload").
159
+ */
160
+ reload(): void {
161
+ this.permissionManager = createPermissionManagerForCwd(
162
+ this.paths.agentDir,
163
+ this.context?.cwd,
164
+ );
165
+ this.skillEntries = [];
166
+ this.toolsCacheKey = null;
167
+ this.promptCacheKey = null;
168
+ }
169
+
170
+ // ── Agent-start caching ────────────────────────────────────────────────
171
+
172
+ shouldUpdateActiveTools(cacheKey: string): boolean {
173
+ return this.toolsCacheKey !== cacheKey;
174
+ }
175
+
176
+ commitActiveToolsCacheKey(cacheKey: string): void {
177
+ this.toolsCacheKey = cacheKey;
178
+ }
179
+
180
+ shouldUpdatePromptState(cacheKey: string): boolean {
181
+ return this.promptCacheKey !== cacheKey;
182
+ }
183
+
184
+ commitPromptStateCacheKey(cacheKey: string): void {
185
+ this.promptCacheKey = cacheKey;
186
+ }
187
+
188
+ // ── Skill entries ──────────────────────────────────────────────────────
189
+
190
+ getActiveSkillEntries(): SkillPromptEntry[] {
191
+ return this.skillEntries;
192
+ }
193
+
194
+ setActiveSkillEntries(entries: SkillPromptEntry[]): void {
195
+ this.skillEntries = entries;
196
+ }
197
+
198
+ // ── Agent name ─────────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Resolve the active agent name from the session context, system prompt,
202
+ * or last known name. Updates lastKnownActiveAgentName as a side effect.
203
+ */
204
+ resolveAgentName(
205
+ ctx: ExtensionContext,
206
+ systemPrompt?: string,
207
+ ): string | null {
208
+ const fromSession = getActiveAgentName(ctx);
209
+ if (fromSession) {
210
+ this.knownAgentName = fromSession;
211
+ return fromSession;
212
+ }
213
+ const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
214
+ if (fromSystemPrompt) {
215
+ this.knownAgentName = fromSystemPrompt;
216
+ return fromSystemPrompt;
217
+ }
218
+ return this.knownAgentName;
219
+ }
220
+
221
+ get lastKnownActiveAgentName(): string | null {
222
+ return this.knownAgentName;
223
+ }
224
+
225
+ // ── Config ─────────────────────────────────────────────────────────────
226
+
227
+ /** Reload merged config from disk; optionally update the stored runtime context. */
228
+ refreshConfig(ctx?: ExtensionContext): void {
229
+ this.runtimeDeps.refreshExtensionConfig(ctx);
230
+ }
231
+
232
+ /** Write the resolved config path set to the review and debug logs. */
233
+ logResolvedConfigPaths(): void {
234
+ this.runtimeDeps.logResolvedConfigPaths();
235
+ }
236
+
237
+ /** Read current extension config. */
238
+ get config(): PermissionSystemExtensionConfig {
239
+ return this.runtimeDeps.getConfig();
240
+ }
241
+
242
+ // ── Infrastructure paths ───────────────────────────────────────────────
243
+
244
+ getInfrastructureDirs(): readonly string[] {
245
+ return this.paths.piInfrastructureDirs;
246
+ }
247
+
248
+ /** Config-derived infrastructure read paths (current at call time). */
249
+ getInfrastructureReadPaths(): string[] {
250
+ return this.config.piInfrastructureReadPaths ?? [];
251
+ }
252
+ }
package/src/runtime.ts CHANGED
@@ -12,10 +12,6 @@ import {
12
12
  getAgentDir,
13
13
  } from "@mariozechner/pi-coding-agent";
14
14
 
15
- import {
16
- getActiveAgentName,
17
- getActiveAgentNameFromSystemPrompt,
18
- } from "./active-agent";
19
15
  import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
20
16
  import {
21
17
  DEBUG_LOG_FILENAME,
@@ -45,11 +41,12 @@ import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
45
41
  import { syncPermissionSystemStatus } from "./status";
46
42
 
47
43
  /**
48
- * Mutable session state — the subset of ExtensionRuntime that handlers
49
- * read and write. Lifecycle handlers reset fields here on session
50
- * start/shutdown; gate adapters read permissionManager and sessionRules.
44
+ * Mutable session state — the subset of ExtensionRuntime that holds
45
+ * per-session fields. `PermissionSession` now owns these for handler
46
+ * use; this interface remains so `ExtensionRuntime` can still serve
47
+ * as the internal composition root (config-modal, RPC handlers).
51
48
  */
52
- export interface SessionState {
49
+ interface SessionState {
53
50
  runtimeContext: ExtensionContext | null;
54
51
  permissionManager: PermissionManager;
55
52
  readonly sessionRules: SessionRules;
@@ -209,28 +206,6 @@ export function saveExtensionConfig(
209
206
  });
210
207
  }
211
208
 
212
- /**
213
- * Resolve the active agent name from the Pi session, system prompt, or last
214
- * known name. Updates `runtime.lastKnownActiveAgentName` as a side effect.
215
- */
216
- export function resolveAgentName(
217
- runtime: ExtensionRuntime,
218
- ctx: ExtensionContext,
219
- systemPrompt?: string,
220
- ): string | null {
221
- const fromSession = getActiveAgentName(ctx);
222
- if (fromSession) {
223
- runtime.lastKnownActiveAgentName = fromSession;
224
- return fromSession;
225
- }
226
- const fromSystemPrompt = getActiveAgentNameFromSystemPrompt(systemPrompt);
227
- if (fromSystemPrompt) {
228
- runtime.lastKnownActiveAgentName = fromSystemPrompt;
229
- return fromSystemPrompt;
230
- }
231
- return runtime.lastKnownActiveAgentName;
232
- }
233
-
234
209
  /**
235
210
  * Write the resolved config path set (global, project, legacy) to the review
236
211
  * and debug logs.
@@ -4,8 +4,19 @@ import {
4
4
  isPathWithinDirectory,
5
5
  normalizePathForComparison,
6
6
  } from "./path-utils";
7
- import type { PermissionManager } from "./permission-manager";
8
- import type { PermissionState } from "./types";
7
+ import type { PermissionCheckResult, PermissionState } from "./types";
8
+
9
+ /**
10
+ * Narrow interface for the permission checker used by skill prompt resolution.
11
+ * Both `PermissionManager` and `PermissionSession` satisfy this structurally.
12
+ */
13
+ export interface SkillPermissionChecker {
14
+ checkPermission(
15
+ surface: string,
16
+ input: unknown,
17
+ agentName?: string,
18
+ ): PermissionCheckResult;
19
+ }
9
20
 
10
21
  const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
11
22
  const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
@@ -148,7 +159,7 @@ export function parseAllSkillPromptSections(
148
159
 
149
160
  function resolvePermissionState(
150
161
  skillName: string,
151
- permissionManager: PermissionManager,
162
+ permissionManager: SkillPermissionChecker,
152
163
  agentName: string | null,
153
164
  cache: Map<string, PermissionState>,
154
165
  ): PermissionState {
@@ -205,7 +216,7 @@ function removePromptRange(prompt: string, start: number, end: number): string {
205
216
 
206
217
  export function resolveSkillPromptEntries(
207
218
  prompt: string,
208
- permissionManager: PermissionManager,
219
+ permissionManager: SkillPermissionChecker,
209
220
  agentName: string | null,
210
221
  cwd: string,
211
222
  ): { prompt: string; entries: SkillPromptEntry[] } {
@@ -6,9 +6,8 @@ import {
6
6
  shouldExposeTool,
7
7
  } from "../../src/handlers/before-agent-start";
8
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";
9
+ import type { PermissionSession } from "../../src/permission-session";
10
+ import type { PermissionState } from "../../src/types";
12
11
 
13
12
  // ── SDK stubs ──────────────────────────────────────────────────────────────
14
13
  vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
@@ -45,52 +44,36 @@ 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;
58
- }
59
-
60
- function makeSession(overrides: Partial<SessionState> = {}): SessionState {
61
- 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"],
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([]),
73
64
  ...overrides,
74
- };
65
+ } as unknown as PermissionSession;
75
66
  }
76
67
 
77
68
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
78
69
  return {
79
70
  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),
71
+ events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
87
72
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
88
73
  promptPermission: vi
89
74
  .fn()
90
75
  .mockResolvedValue({ approved: true, state: "approved" }),
91
76
  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
77
  stopPermissionRpcHandlers: vi.fn(),
95
78
  getAllTools: vi.fn().mockReturnValue([]),
96
79
  setActiveTools: vi.fn(),
@@ -102,48 +85,48 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
102
85
 
103
86
  describe("shouldExposeTool", () => {
104
87
  it("returns true when tool permission is allow", () => {
105
- const pm = makePm("allow");
106
- expect(shouldExposeTool("read", null, pm)).toBe(true);
88
+ const getter = vi.fn().mockReturnValue("allow");
89
+ expect(shouldExposeTool("read", null, getter)).toBe(true);
107
90
  });
108
91
 
109
92
  it("returns true when tool permission is ask", () => {
110
- const pm = makePm("ask");
111
- expect(shouldExposeTool("bash", "agent-x", pm)).toBe(true);
93
+ const getter = vi.fn().mockReturnValue("ask");
94
+ expect(shouldExposeTool("bash", "agent-x", getter)).toBe(true);
112
95
  });
113
96
 
114
97
  it("returns false when tool permission is deny", () => {
115
- const pm = makePm("deny");
116
- expect(shouldExposeTool("write", null, pm)).toBe(false);
98
+ const getter = vi.fn().mockReturnValue("deny");
99
+ expect(shouldExposeTool("write", null, getter)).toBe(false);
117
100
  });
118
101
 
119
102
  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");
103
+ const getter = vi.fn().mockReturnValue("allow");
104
+ shouldExposeTool("read", "my-agent", getter);
105
+ expect(getter).toHaveBeenCalledWith("read", "my-agent");
123
106
  });
124
107
 
125
108
  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);
109
+ const getter = vi.fn().mockReturnValue("allow");
110
+ shouldExposeTool("read", null, getter);
111
+ expect(getter).toHaveBeenCalledWith("read", undefined);
129
112
  });
130
113
  });
131
114
 
132
115
  // ── handleBeforeAgentStart ─────────────────────────────────────────────────
133
116
 
134
117
  describe("handleBeforeAgentStart", () => {
135
- it("refreshes extension config with ctx", async () => {
118
+ it("activates the session with ctx", async () => {
136
119
  const ctx = makeCtx();
137
120
  const deps = makeDeps();
138
121
  await handleBeforeAgentStart(deps, makeEvent(), ctx);
139
- expect(deps.refreshExtensionConfig).toHaveBeenCalledWith(ctx);
122
+ expect(deps.session.activate).toHaveBeenCalledWith(ctx);
140
123
  });
141
124
 
142
- it("starts forwarded permission polling", async () => {
125
+ it("refreshes config with ctx", async () => {
143
126
  const ctx = makeCtx();
144
127
  const deps = makeDeps();
145
128
  await handleBeforeAgentStart(deps, makeEvent(), ctx);
146
- expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
129
+ expect(deps.session.refreshConfig).toHaveBeenCalledWith(ctx);
147
130
  });
148
131
 
149
132
  it("resolves agent name using systemPrompt", async () => {
@@ -154,33 +137,28 @@ describe("handleBeforeAgentStart", () => {
154
137
  makeEvent("<active_agent name='x'>"),
155
138
  ctx,
156
139
  );
157
- expect(deps.resolveAgentName).toHaveBeenCalledWith(
140
+ expect(deps.session.resolveAgentName).toHaveBeenCalledWith(
158
141
  ctx,
159
142
  "<active_agent name='x'>",
160
143
  );
161
144
  });
162
145
 
163
146
  it("filters out denied tools from allowed list", async () => {
164
- const pm = makePm("deny");
147
+ const session = makeSession({
148
+ getToolPermission: vi.fn().mockReturnValue("deny"),
149
+ });
165
150
  const deps = makeDeps({
166
- session: makeSession({
167
- permissionManager: pm as unknown as PermissionManager,
168
- }),
151
+ session,
169
152
  getAllTools: vi
170
153
  .fn()
171
154
  .mockReturnValue([{ name: "write" }, { name: "read" }]),
172
155
  });
173
- // write is deny, read is deny (same pm stub — both denied)
174
156
  await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
175
157
  expect(deps.setActiveTools).toHaveBeenCalledWith([]);
176
158
  });
177
159
 
178
160
  it("includes allowed and ask tools in the active list", async () => {
179
- const pm = makePm("allow");
180
161
  const deps = makeDeps({
181
- session: makeSession({
182
- permissionManager: pm as unknown as PermissionManager,
183
- }),
184
162
  getAllTools: vi
185
163
  .fn()
186
164
  .mockReturnValue([{ name: "read" }, { name: "write" }]),
@@ -189,31 +167,59 @@ describe("handleBeforeAgentStart", () => {
189
167
  expect(deps.setActiveTools).toHaveBeenCalledWith(["read", "write"]);
190
168
  });
191
169
 
192
- it("updates the active-tools cache key after applying", async () => {
170
+ it("commits active-tools cache key after applying", async () => {
193
171
  const deps = makeDeps({
194
172
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
195
173
  });
196
174
  await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
197
- expect(deps.session.lastActiveToolsCacheKey).not.toBeNull();
175
+ expect(deps.session.commitActiveToolsCacheKey).toHaveBeenCalled();
198
176
  });
199
177
 
200
178
  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"]);
179
+ const session = makeSession({
180
+ shouldUpdateActiveTools: vi.fn().mockReturnValue(false),
181
+ });
206
182
  const deps = makeDeps({
207
- session: makeSession({ lastActiveToolsCacheKey: key }),
183
+ session,
208
184
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }]),
209
185
  });
210
186
  await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
211
187
  expect(deps.setActiveTools).not.toHaveBeenCalled();
188
+ expect(session.commitActiveToolsCacheKey).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it("returns empty object when prompt cache is unchanged", async () => {
192
+ const session = makeSession({
193
+ shouldUpdatePromptState: vi.fn().mockReturnValue(false),
194
+ });
195
+ const deps = makeDeps({
196
+ session,
197
+ getAllTools: vi.fn().mockReturnValue([]),
198
+ });
199
+ const result = await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
200
+ expect(result).toEqual({});
201
+ expect(session.commitPromptStateCacheKey).not.toHaveBeenCalled();
212
202
  });
213
203
 
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.
204
+ it("commits prompt-state cache key and processes prompt when cache is new", async () => {
205
+ const deps = makeDeps({
206
+ getAllTools: vi.fn().mockReturnValue([]),
207
+ });
208
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
209
+ expect(deps.session.commitPromptStateCacheKey).toHaveBeenCalled();
210
+ });
211
+
212
+ it("stores resolved skill entries on the session", async () => {
213
+ const deps = makeDeps({
214
+ getAllTools: vi.fn().mockReturnValue([]),
215
+ });
216
+ await handleBeforeAgentStart(deps, makeEvent(), makeCtx());
217
+ expect(deps.session.setActiveSkillEntries).toHaveBeenCalledWith(
218
+ expect.any(Array),
219
+ );
220
+ });
221
+
222
+ it("returns modified systemPrompt when prompt changes", async () => {
217
223
  const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
218
224
  const deps = makeDeps({
219
225
  getAllTools: vi.fn().mockReturnValue([]),
@@ -223,9 +229,7 @@ describe("handleBeforeAgentStart", () => {
223
229
  makeEvent(systemPrompt),
224
230
  makeCtx(),
225
231
  );
226
- // The prompt was modified, so systemPrompt should be returned
227
232
  expect(result).toHaveProperty("systemPrompt");
228
- expect(deps.session.lastPromptStateCacheKey).not.toBeNull();
229
233
  });
230
234
 
231
235
  it("returns empty object when systemPrompt is unchanged", async () => {
@@ -240,39 +244,4 @@ describe("handleBeforeAgentStart", () => {
240
244
  );
241
245
  expect(result).toEqual({});
242
246
  });
243
-
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));
250
- });
251
-
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);
274
- expect(result).toEqual({});
275
- // activeSkillEntries was not assigned by the handler (early return)
276
- expect(deps.session.activeSkillEntries).toEqual([]);
277
- });
278
247
  });