@gotgenes/pi-permission-system 5.7.0 → 5.9.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 CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.9.0](https://github.com/gotgenes/pi-permission-system/compare/v5.8.0...v5.9.0) (2026-05-08)
9
+
10
+
11
+ ### Features
12
+
13
+ * add ForwardingManager class ([#128](https://github.com/gotgenes/pi-permission-system/issues/128)) ([7790380](https://github.com/gotgenes/pi-permission-system/commit/7790380eb0291f55724425a0bd6bd0b45cf15d91))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan ForwardingManager extraction ([#128](https://github.com/gotgenes/pi-permission-system/issues/128)) ([2f10450](https://github.com/gotgenes/pi-permission-system/commit/2f10450974adaedd7a43e8a7d986f8f61a0508db))
19
+ * **retro:** add retro notes for issue [#127](https://github.com/gotgenes/pi-permission-system/issues/127) ([2dde534](https://github.com/gotgenes/pi-permission-system/commit/2dde53416c535331972367ca2a44ba302b25d2a0))
20
+
21
+ ## [5.8.0](https://github.com/gotgenes/pi-permission-system/compare/v5.7.0...v5.8.0) (2026-05-08)
22
+
23
+
24
+ ### Features
25
+
26
+ * add SessionLogger interface and createSessionLogger factory ([#127](https://github.com/gotgenes/pi-permission-system/issues/127)) ([8765ab8](https://github.com/gotgenes/pi-permission-system/commit/8765ab8cfe461324fc2a89c80486d3dde190d9d9))
27
+
28
+
29
+ ### Documentation
30
+
31
+ * plan SessionLogger extraction ([#127](https://github.com/gotgenes/pi-permission-system/issues/127)) ([b13ac62](https://github.com/gotgenes/pi-permission-system/commit/b13ac62513d4b233ee4fc3f554324a54518f75ba))
32
+ * **retro:** add retro notes for issue [#126](https://github.com/gotgenes/pi-permission-system/issues/126) ([3d8a38a](https://github.com/gotgenes/pi-permission-system/commit/3d8a38a09f9dfd2570178c856aec260ebdba89b1))
33
+ * update architecture doc for SessionLogger ([#127](https://github.com/gotgenes/pi-permission-system/issues/127)) ([8fa4123](https://github.com/gotgenes/pi-permission-system/commit/8fa41237dc1b43cbe4487ba7d0acf75dc768ad9c))
34
+
8
35
  ## [5.7.0](https://github.com/gotgenes/pi-permission-system/compare/v5.6.3...v5.7.0) (2026-05-08)
9
36
 
10
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "5.7.0",
3
+ "version": "5.9.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,76 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
4
+ import { processForwardedPermissionRequests } from "./forwarded-permissions/polling";
5
+ import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
6
+ import { isSubagentExecutionContext } from "./subagent-context";
7
+
8
+ /**
9
+ * Narrow interface for the forwarding lifecycle used by `HandlerDeps`.
10
+ * `ForwardingManager` satisfies it; tests can provide a plain object mock.
11
+ */
12
+ export interface ForwardingController {
13
+ start(ctx: ExtensionContext): void;
14
+ stop(): void;
15
+ }
16
+
17
+ /**
18
+ * Encapsulates the forwarded-permission polling lifecycle.
19
+ *
20
+ * Owns the timer, current context, and processing-lock state that previously
21
+ * lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
22
+ * session event that may activate forwarding; call `stop()` on session
23
+ * shutdown.
24
+ */
25
+ export class ForwardingManager {
26
+ private timer: NodeJS.Timeout | null = null;
27
+ private context: ExtensionContext | null = null;
28
+ private processing = false;
29
+
30
+ constructor(
31
+ private readonly subagentSessionsDir: string,
32
+ private readonly forwardingDeps: PermissionForwardingDeps,
33
+ ) {}
34
+
35
+ /**
36
+ * Start polling if `ctx` has UI and is not a subagent execution context.
37
+ * No-op (timer stays running) if already polling — updates the stored
38
+ * context so the next tick uses the latest session.
39
+ * Stops any existing poll when the context does not qualify for forwarding.
40
+ */
41
+ start(ctx: ExtensionContext): void {
42
+ if (
43
+ !ctx.hasUI ||
44
+ isSubagentExecutionContext(ctx, this.subagentSessionsDir)
45
+ ) {
46
+ this.stop();
47
+ return;
48
+ }
49
+ this.context = ctx;
50
+ if (this.timer) {
51
+ return;
52
+ }
53
+ this.timer = setInterval(() => {
54
+ if (!this.context || this.processing) {
55
+ return;
56
+ }
57
+ this.processing = true;
58
+ void processForwardedPermissionRequests(
59
+ this.context,
60
+ this.forwardingDeps,
61
+ ).finally(() => {
62
+ this.processing = false;
63
+ });
64
+ }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
65
+ }
66
+
67
+ /** Stop polling and clear all internal state. */
68
+ stop(): void {
69
+ if (this.timer) {
70
+ clearInterval(this.timer);
71
+ this.timer = null;
72
+ }
73
+ this.context = null;
74
+ this.processing = false;
75
+ }
76
+ }
@@ -43,7 +43,7 @@ export async function handleBeforeAgentStart(
43
43
  ): Promise<BeforeAgentStartEventResult> {
44
44
  deps.session.runtimeContext = ctx;
45
45
  deps.refreshExtensionConfig(ctx);
46
- deps.startForwardedPermissionPolling(ctx);
46
+ deps.forwarding.start(ctx);
47
47
 
48
48
  const agentName = deps.resolveAgentName(ctx, event.systemPrompt);
49
49
  const { permissionManager } = deps.session;
@@ -41,7 +41,7 @@ export async function handleInput(
41
41
  ctx: ExtensionContext,
42
42
  ): Promise<InputEventResult> {
43
43
  deps.session.runtimeContext = ctx;
44
- deps.startForwardedPermissionPolling(ctx);
44
+ deps.forwarding.start(ctx);
45
45
 
46
46
  const skillName = extractSkillNameFromInput(event.text);
47
47
  if (!skillName) {
@@ -82,7 +82,7 @@ export async function handleInput(
82
82
  skillInputAutoApproved = decision.autoApproved === true;
83
83
  return decision;
84
84
  },
85
- writeLog: deps.writeReviewLog,
85
+ writeLog: deps.logger.review,
86
86
  logContext: {
87
87
  source: "skill_input",
88
88
  skillName,
@@ -26,18 +26,18 @@ export async function handleSessionStart(
26
26
  deps.session.lastActiveToolsCacheKey = null;
27
27
  deps.session.lastPromptStateCacheKey = null;
28
28
  deps.session.lastKnownActiveAgentName = getActiveAgentName(ctx);
29
- deps.startForwardedPermissionPolling(ctx);
29
+ deps.forwarding.start(ctx);
30
30
  deps.logResolvedConfigPaths();
31
31
 
32
32
  const agentName = deps.session.lastKnownActiveAgentName;
33
33
  const policyIssues =
34
34
  deps.session.permissionManager.getConfigIssues(agentName);
35
35
  for (const issue of policyIssues) {
36
- deps.notifyWarning(issue);
36
+ deps.logger.warn(issue);
37
37
  }
38
38
 
39
39
  if (event.reason === "reload") {
40
- deps.writeDebugLog("lifecycle.reload", {
40
+ deps.logger.debug("lifecycle.reload", {
41
41
  triggeredBy: "session_start",
42
42
  reason: event.reason,
43
43
  cwd: ctx.cwd,
@@ -60,7 +60,7 @@ export async function handleResourcesDiscover(
60
60
  deps.session.activeSkillEntries = [];
61
61
  deps.session.lastActiveToolsCacheKey = null;
62
62
  deps.session.lastPromptStateCacheKey = null;
63
- deps.writeDebugLog("lifecycle.reload", {
63
+ deps.logger.debug("lifecycle.reload", {
64
64
  triggeredBy: "resources_discover",
65
65
  reason: event.reason,
66
66
  cwd: runtimeContext?.cwd ?? null,
@@ -77,6 +77,6 @@ export async function handleSessionShutdown(deps: HandlerDeps): Promise<void> {
77
77
  deps.session.lastActiveToolsCacheKey = null;
78
78
  deps.session.lastPromptStateCacheKey = null;
79
79
  deps.session.sessionRules.clear();
80
- deps.stopForwardedPermissionPolling();
80
+ deps.forwarding.stop();
81
81
  deps.stopPermissionRpcHandlers();
82
82
  }
@@ -44,7 +44,7 @@ export async function handleToolCall(
44
44
  ctx: ExtensionContext,
45
45
  ): Promise<{ block?: true; reason?: string }> {
46
46
  deps.session.runtimeContext = ctx;
47
- deps.startForwardedPermissionPolling(ctx);
47
+ deps.forwarding.start(ctx);
48
48
 
49
49
  const agentName = deps.resolveAgentName(ctx);
50
50
  const toolName = getToolNameFromValue(event);
@@ -91,7 +91,7 @@ export async function handleToolCall(
91
91
  deps.promptPermission(ctx, details);
92
92
  const emitDecision: GateRunnerDeps["emitDecision"] = (e) =>
93
93
  emitDecisionEvent(deps.events, e);
94
- const { writeReviewLog } = deps;
94
+ const { review: writeReviewLog } = deps.logger;
95
95
  const checkPermission: GateRunnerDeps["checkPermission"] = (
96
96
  surface,
97
97
  input,
@@ -1,9 +1,11 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
 
3
+ import type { ForwardingController } from "../forwarding-manager";
3
4
  import type { PermissionPromptDecision } from "../permission-dialog";
4
5
  import type { PermissionEventBus } from "../permission-events";
5
6
  import type { PermissionManager } from "../permission-manager";
6
7
  import type { SessionState } from "../runtime";
8
+ import type { SessionLogger } from "../session-logger";
7
9
 
8
10
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
9
11
 
@@ -37,9 +39,8 @@ export interface HandlerDeps {
37
39
  /** Mutable session state: permissionManager, sessionRules, cache keys. */
38
40
  readonly session: SessionState;
39
41
 
40
- // ── Logging (promoted from runtime) ───────────────────────────────────
41
- writeDebugLog(event: string, details?: Record<string, unknown>): void;
42
- writeReviewLog(event: string, details?: Record<string, unknown>): void;
42
+ // ── Logging ────────────────────────────────────────────────────────────
43
+ readonly logger: SessionLogger;
43
44
 
44
45
  // ── Immutable infrastructure paths ───────────────────────────────────
45
46
  readonly piInfrastructureDirs: readonly string[];
@@ -59,8 +60,6 @@ export interface HandlerDeps {
59
60
  // ── Config & lifecycle helpers ─────────────────────────────────────────
60
61
  /** Reload merged config from disk; optionally update the stored runtime context. */
61
62
  refreshExtensionConfig(ctx?: ExtensionContext): void;
62
- /** Show a warning notification to the user (no-op when no UI is available). */
63
- notifyWarning(message: string): void;
64
63
  /** Write the resolved config path set to the review and debug logs. */
65
64
  logResolvedConfigPaths(): void;
66
65
 
@@ -81,8 +80,7 @@ export interface HandlerDeps {
81
80
  createPermissionRequestId(prefix: string): string;
82
81
 
83
82
  // ── Forwarding ─────────────────────────────────────────────────────────
84
- startForwardedPermissionPolling(ctx: ExtensionContext): void;
85
- stopForwardedPermissionPolling(): void;
83
+ readonly forwarding: ForwardingController;
86
84
  /** Unsubscribe the permissions:rpc:check and permissions:rpc:prompt handlers. */
87
85
  stopPermissionRpcHandlers(): void;
88
86
 
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { registerPermissionSystemCommand } from "./config-modal";
3
3
  import { getGlobalConfigPath } from "./config-paths";
4
4
  import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
5
+ import { ForwardingManager } from "./forwarding-manager";
5
6
  import {
6
7
  type HandlerDeps,
7
8
  handleBeforeAgentStart,
@@ -22,9 +23,8 @@ import {
22
23
  refreshExtensionConfig,
23
24
  resolveAgentName,
24
25
  saveExtensionConfig,
25
- startForwardedPermissionPolling,
26
- stopForwardedPermissionPolling,
27
26
  } from "./runtime";
27
+ import { createSessionLogger } from "./session-logger";
28
28
  import { isSubagentExecutionContext } from "./subagent-context";
29
29
  import {
30
30
  canResolveAskPermissionRequest,
@@ -79,8 +79,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
79
79
 
80
80
  const deps: HandlerDeps = {
81
81
  session: runtime,
82
- writeDebugLog: (event, details) => runtime.writeDebugLog(event, details),
83
- writeReviewLog: (event, details) => runtime.writeReviewLog(event, details),
82
+ logger: createSessionLogger(runtime),
84
83
  piInfrastructureDirs: runtime.piInfrastructureDirs,
85
84
  getPiInfrastructureReadPaths: () =>
86
85
  runtime.config.piInfrastructureReadPaths ?? [],
@@ -88,8 +87,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
88
87
  createPermissionManagerForCwd: (cwd) =>
89
88
  createPermissionManagerForCwd(runtime.agentDir, cwd),
90
89
  refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
91
- notifyWarning: (message) =>
92
- runtime.runtimeContext?.ui.notify(message, "warning"),
93
90
  logResolvedConfigPaths: () => logResolvedConfigPaths(runtime),
94
91
  resolveAgentName: (ctx, systemPrompt) =>
95
92
  resolveAgentName(runtime, ctx, systemPrompt),
@@ -104,10 +101,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
104
101
  }),
105
102
  promptPermission: (ctx, details) => prompter.prompt(ctx, details),
106
103
  createPermissionRequestId,
107
- startForwardedPermissionPolling: (ctx) =>
108
- startForwardedPermissionPolling(runtime, forwardingDeps, ctx),
109
- stopForwardedPermissionPolling: () =>
110
- stopForwardedPermissionPolling(runtime),
104
+ forwarding: new ForwardingManager(
105
+ runtime.subagentSessionsDir,
106
+ forwardingDeps,
107
+ ),
111
108
  stopPermissionRpcHandlers: () => {
112
109
  rpcHandles.unsubCheck();
113
110
  rpcHandles.unsubPrompt();
package/src/runtime.ts CHANGED
@@ -38,17 +38,11 @@ import { computeExtensionPaths, type ExtensionPaths } from "./extension-paths";
38
38
 
39
39
  export type { ExtensionPaths } from "./extension-paths";
40
40
 
41
- import {
42
- type PermissionForwardingDeps,
43
- processForwardedPermissionRequests,
44
- } from "./forwarded-permissions/polling";
45
41
  import { createPermissionSystemLogger } from "./logging";
46
- import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
47
42
  import { PermissionManager } from "./permission-manager";
48
43
  import { SessionRules } from "./session-rules";
49
44
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
50
45
  import { syncPermissionSystemStatus } from "./status";
51
- import { isSubagentExecutionContext } from "./subagent-context";
52
46
 
53
47
  /**
54
48
  * Mutable session state — the subset of ExtensionRuntime that handlers
@@ -81,11 +75,6 @@ export interface ExtensionRuntime extends ExtensionPaths, SessionState {
81
75
  config: PermissionSystemExtensionConfig;
82
76
  lastConfigWarning: string | null;
83
77
 
84
- // ── Forwarding polling state ───────────────────────────────────────────
85
- permissionForwardingContext: ExtensionContext | null;
86
- permissionForwardingTimer: NodeJS.Timeout | null;
87
- isProcessingForwardedRequests: boolean;
88
-
89
78
  // ── Logging (backed by logger created at construction) ─────────────────
90
79
  writeDebugLog(event: string, details?: Record<string, unknown>): void;
91
80
  writeReviewLog(event: string, details?: Record<string, unknown>): void;
@@ -277,58 +266,6 @@ export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
277
266
  );
278
267
  }
279
268
 
280
- // ── Forwarding polling lifecycle ───────────────────────────────────────────
281
-
282
- /** Stop the forwarded-permission polling interval and clear related state. */
283
- export function stopForwardedPermissionPolling(
284
- runtime: ExtensionRuntime,
285
- ): void {
286
- if (runtime.permissionForwardingTimer) {
287
- clearInterval(runtime.permissionForwardingTimer);
288
- runtime.permissionForwardingTimer = null;
289
- }
290
- runtime.permissionForwardingContext = null;
291
- runtime.isProcessingForwardedRequests = false;
292
- }
293
-
294
- /**
295
- * Start the forwarded-permission polling interval.
296
- * No-ops (and stops any existing poll) when the context has no UI or is a
297
- * subagent execution context.
298
- */
299
- export function startForwardedPermissionPolling(
300
- runtime: ExtensionRuntime,
301
- forwardingDeps: PermissionForwardingDeps,
302
- ctx: ExtensionContext,
303
- ): void {
304
- if (
305
- !ctx.hasUI ||
306
- isSubagentExecutionContext(ctx, runtime.subagentSessionsDir)
307
- ) {
308
- stopForwardedPermissionPolling(runtime);
309
- return;
310
- }
311
- runtime.permissionForwardingContext = ctx;
312
- if (runtime.permissionForwardingTimer) {
313
- return;
314
- }
315
- runtime.permissionForwardingTimer = setInterval(() => {
316
- if (
317
- !runtime.permissionForwardingContext ||
318
- runtime.isProcessingForwardedRequests
319
- ) {
320
- return;
321
- }
322
- runtime.isProcessingForwardedRequests = true;
323
- void processForwardedPermissionRequests(
324
- runtime.permissionForwardingContext,
325
- forwardingDeps,
326
- ).finally(() => {
327
- runtime.isProcessingForwardedRequests = false;
328
- });
329
- }, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
330
- }
331
-
332
269
  // ── Factory ────────────────────────────────────────────────────────────────
333
270
 
334
271
  /**
@@ -356,9 +293,6 @@ export function createExtensionRuntime(options?: {
356
293
  lastPromptStateCacheKey: null,
357
294
  lastConfigWarning: null,
358
295
  sessionRules: new SessionRules(),
359
- permissionForwardingContext: null,
360
- permissionForwardingTimer: null,
361
- isProcessingForwardedRequests: false,
362
296
  // Logging methods are replaced below after the logger is constructed.
363
297
  writeDebugLog: () => {},
364
298
  writeReviewLog: () => {},
@@ -0,0 +1,29 @@
1
+ import type { ExtensionRuntime } from "./runtime";
2
+
3
+ /**
4
+ * Unified logging + notification surface for handler deps.
5
+ *
6
+ * Replaces three separate HandlerDeps fields (`writeDebugLog`,
7
+ * `writeReviewLog`, `notifyWarning`) with a single typed collaborator.
8
+ * This is an intermediate abstraction on the path to PermissionSession (#129).
9
+ */
10
+ export interface SessionLogger {
11
+ debug(event: string, details?: Record<string, unknown>): void;
12
+ review(event: string, details?: Record<string, unknown>): void;
13
+ warn(message: string): void;
14
+ }
15
+
16
+ /**
17
+ * Create a SessionLogger backed by an ExtensionRuntime.
18
+ *
19
+ * Captures `runtime` by reference so `warn` always reads the current
20
+ * `runtimeContext` at call time — matching the behavior of the inline
21
+ * closures it replaces in `src/index.ts`.
22
+ */
23
+ export function createSessionLogger(runtime: ExtensionRuntime): SessionLogger {
24
+ return {
25
+ debug: (event, details) => runtime.writeDebugLog(event, details),
26
+ review: (event, details) => runtime.writeReviewLog(event, details),
27
+ warn: (message) => runtime.runtimeContext?.ui.notify(message, "warning"),
28
+ };
29
+ }
@@ -0,0 +1,211 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { ForwardingManager } from "../src/forwarding-manager";
4
+
5
+ // ── Mocks ─────────────────────────────────────────────────────────────────
6
+
7
+ const mockProcessForwardedPermissionRequests = vi.hoisted(() => vi.fn());
8
+ const mockIsSubagentExecutionContext = vi.hoisted(() => vi.fn());
9
+
10
+ vi.mock("../src/forwarded-permissions/polling", () => ({
11
+ processForwardedPermissionRequests: mockProcessForwardedPermissionRequests,
12
+ }));
13
+
14
+ vi.mock("../src/subagent-context", () => ({
15
+ isSubagentExecutionContext: mockIsSubagentExecutionContext,
16
+ }));
17
+
18
+ // ── Helpers ───────────────────────────────────────────────────────────────
19
+
20
+ function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
21
+ return {
22
+ hasUI: overrides.hasUI ?? true,
23
+ sessionManager: {
24
+ getSessionId: vi.fn().mockReturnValue(overrides.sessionId ?? "sess-1"),
25
+ },
26
+ cwd: "/project",
27
+ } as unknown as import("@mariozechner/pi-coding-agent").ExtensionContext;
28
+ }
29
+
30
+ function makeForwardingDeps() {
31
+ return {
32
+ forwardingDir: "/agent/sessions/permission-forwarding",
33
+ subagentSessionsDir: "/agent/subagent-sessions",
34
+ logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
35
+ writeReviewLog: vi.fn(),
36
+ requestPermissionDecisionFromUi: vi.fn(),
37
+ shouldAutoApprove: vi.fn().mockReturnValue(false),
38
+ } as unknown as import("../src/forwarded-permissions/polling").PermissionForwardingDeps;
39
+ }
40
+
41
+ function makeManager() {
42
+ return new ForwardingManager(
43
+ "/agent/subagent-sessions",
44
+ makeForwardingDeps(),
45
+ );
46
+ }
47
+
48
+ // ── Tests ─────────────────────────────────────────────────────────────────
49
+
50
+ describe("ForwardingManager", () => {
51
+ beforeEach(() => {
52
+ vi.useFakeTimers();
53
+ mockIsSubagentExecutionContext.mockReset();
54
+ mockIsSubagentExecutionContext.mockReturnValue(false);
55
+ mockProcessForwardedPermissionRequests.mockReset();
56
+ mockProcessForwardedPermissionRequests.mockResolvedValue(undefined);
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.useRealTimers();
61
+ });
62
+
63
+ describe("stop()", () => {
64
+ it("is a no-op when not started", () => {
65
+ const manager = makeManager();
66
+ expect(() => manager.stop()).not.toThrow();
67
+ });
68
+
69
+ it("clears the timer and processing state after start()", async () => {
70
+ const manager = makeManager();
71
+ const ctx = makeCtx();
72
+ manager.start(ctx);
73
+ manager.stop();
74
+
75
+ // After stop, the timer fires no more callbacks.
76
+ mockProcessForwardedPermissionRequests.mockClear();
77
+ await vi.advanceTimersByTimeAsync(500);
78
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
79
+ });
80
+ });
81
+
82
+ describe("start()", () => {
83
+ it("does not start polling when hasUI is false", async () => {
84
+ const manager = makeManager();
85
+ const ctx = makeCtx({ hasUI: false });
86
+ manager.start(ctx);
87
+
88
+ await vi.advanceTimersByTimeAsync(500);
89
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("stops any existing poll and does not start a new one when hasUI is false", async () => {
93
+ const manager = makeManager();
94
+ const uiCtx = makeCtx({ hasUI: true });
95
+ const noUiCtx = makeCtx({ hasUI: false });
96
+
97
+ manager.start(uiCtx);
98
+ // Now stop the polling by calling start() with no-UI ctx.
99
+ manager.start(noUiCtx);
100
+
101
+ mockProcessForwardedPermissionRequests.mockClear();
102
+ await vi.advanceTimersByTimeAsync(500);
103
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("does not start polling when isSubagentExecutionContext returns true", async () => {
107
+ mockIsSubagentExecutionContext.mockReturnValue(true);
108
+ const manager = makeManager();
109
+ const ctx = makeCtx();
110
+ manager.start(ctx);
111
+
112
+ await vi.advanceTimersByTimeAsync(500);
113
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it("stops any existing poll when called with a subagent context", async () => {
117
+ mockIsSubagentExecutionContext.mockReturnValueOnce(false);
118
+ const manager = makeManager();
119
+ const ctx1 = makeCtx();
120
+ manager.start(ctx1);
121
+
122
+ // Second call with a subagent context.
123
+ mockIsSubagentExecutionContext.mockReturnValue(true);
124
+ const ctx2 = makeCtx();
125
+ manager.start(ctx2);
126
+
127
+ mockProcessForwardedPermissionRequests.mockClear();
128
+ await vi.advanceTimersByTimeAsync(500);
129
+ expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("starts polling and calls processForwardedPermissionRequests on tick", async () => {
133
+ const manager = makeManager();
134
+ const ctx = makeCtx();
135
+ manager.start(ctx);
136
+
137
+ await vi.advanceTimersByTimeAsync(250);
138
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
139
+ ctx,
140
+ expect.anything(),
141
+ );
142
+ });
143
+
144
+ it("is idempotent — calling start() twice does not create a second timer", async () => {
145
+ const manager = makeManager();
146
+ const ctx = makeCtx();
147
+ manager.start(ctx);
148
+ manager.start(ctx);
149
+
150
+ await vi.advanceTimersByTimeAsync(250);
151
+ // Only one tick should fire per interval, not two.
152
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
153
+ });
154
+
155
+ it("updates the context when called again while already running", async () => {
156
+ const manager = makeManager();
157
+ const ctx1 = makeCtx({ sessionId: "sess-1" });
158
+ const ctx2 = makeCtx({ sessionId: "sess-2" });
159
+ manager.start(ctx1);
160
+ manager.start(ctx2);
161
+
162
+ await vi.advanceTimersByTimeAsync(250);
163
+ // The process call should use the newer context.
164
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
165
+ ctx2,
166
+ expect.anything(),
167
+ );
168
+ });
169
+
170
+ it("skips a tick while processing is in progress", async () => {
171
+ // Make processForwardedPermissionRequests hang so processing=true persists.
172
+ let resolveProcess: () => void;
173
+ mockProcessForwardedPermissionRequests.mockReturnValue(
174
+ new Promise<void>((resolve) => {
175
+ resolveProcess = resolve;
176
+ }),
177
+ );
178
+
179
+ const manager = makeManager();
180
+ const ctx = makeCtx();
181
+ manager.start(ctx);
182
+
183
+ // First tick starts processing.
184
+ await vi.advanceTimersByTimeAsync(250);
185
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
186
+
187
+ // Second tick is skipped because processing flag is still true.
188
+ await vi.advanceTimersByTimeAsync(250);
189
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
190
+
191
+ // Resolve and a third tick should fire.
192
+ resolveProcess!();
193
+ await vi.advanceTimersByTimeAsync(250);
194
+ expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(2);
195
+ });
196
+
197
+ it("passes subagentSessionsDir from the constructor to isSubagentExecutionContext", () => {
198
+ const manager = new ForwardingManager(
199
+ "/custom/subagent-dir",
200
+ makeForwardingDeps(),
201
+ );
202
+ const ctx = makeCtx();
203
+ manager.start(ctx);
204
+
205
+ expect(mockIsSubagentExecutionContext).toHaveBeenCalledWith(
206
+ ctx,
207
+ "/custom/subagent-dir",
208
+ );
209
+ });
210
+ });
211
+ });
@@ -77,13 +77,11 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
77
77
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
78
78
  return {
79
79
  session: makeSession(),
80
- writeDebugLog: vi.fn(),
81
- writeReviewLog: vi.fn(),
80
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
82
81
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
83
82
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
84
83
  createPermissionManagerForCwd: vi.fn().mockReturnValue(makePm()),
85
84
  refreshExtensionConfig: vi.fn(),
86
- notifyWarning: vi.fn(),
87
85
  logResolvedConfigPaths: vi.fn(),
88
86
  resolveAgentName: vi.fn().mockReturnValue(null),
89
87
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
@@ -92,8 +90,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
92
90
  .mockResolvedValue({ approved: true, state: "approved" }),
93
91
  createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
94
92
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
95
- startForwardedPermissionPolling: vi.fn(),
96
- stopForwardedPermissionPolling: vi.fn(),
93
+ forwarding: { start: vi.fn(), stop: vi.fn() },
97
94
  stopPermissionRpcHandlers: vi.fn(),
98
95
  getAllTools: vi.fn().mockReturnValue([]),
99
96
  setActiveTools: vi.fn(),
@@ -146,7 +143,7 @@ describe("handleBeforeAgentStart", () => {
146
143
  const ctx = makeCtx();
147
144
  const deps = makeDeps();
148
145
  await handleBeforeAgentStart(deps, makeEvent(), ctx);
149
- expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
146
+ expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
150
147
  });
151
148
 
152
149
  it("resolves agent name using systemPrompt", async () => {
@@ -68,14 +68,12 @@ function makeDeps(
68
68
  ): HandlerDeps {
69
69
  return {
70
70
  session: makeSession(state),
71
- writeDebugLog: vi.fn(),
72
- writeReviewLog: vi.fn(),
71
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
73
72
  piInfrastructureDirs: ["/test/agent"],
74
73
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
75
74
  events: makeEvents(),
76
75
  createPermissionManagerForCwd: vi.fn(),
77
76
  refreshExtensionConfig: vi.fn(),
78
- notifyWarning: vi.fn(),
79
77
  logResolvedConfigPaths: vi.fn(),
80
78
  resolveAgentName: vi.fn().mockReturnValue(null),
81
79
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -83,8 +81,7 @@ function makeDeps(
83
81
  .fn()
84
82
  .mockResolvedValue({ approved: true, state: "approved" }),
85
83
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
86
- startForwardedPermissionPolling: vi.fn(),
87
- stopForwardedPermissionPolling: vi.fn(),
84
+ forwarding: { start: vi.fn(), stop: vi.fn() },
88
85
  stopPermissionRpcHandlers: vi.fn(),
89
86
  getAllTools: vi.fn().mockReturnValue([]),
90
87
  setActiveTools: vi.fn(),
@@ -56,13 +56,11 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
56
56
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
57
57
  return {
58
58
  session: makeSession(),
59
- writeDebugLog: vi.fn(),
60
- writeReviewLog: vi.fn(),
59
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
61
60
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
62
61
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
63
62
  createPermissionManagerForCwd: vi.fn(),
64
63
  refreshExtensionConfig: vi.fn(),
65
- notifyWarning: vi.fn(),
66
64
  logResolvedConfigPaths: vi.fn(),
67
65
  resolveAgentName: vi.fn().mockReturnValue(null),
68
66
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -71,8 +69,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
71
69
  .mockResolvedValue({ approved: true, state: "approved" }),
72
70
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
73
71
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
74
- startForwardedPermissionPolling: vi.fn(),
75
- stopForwardedPermissionPolling: vi.fn(),
72
+ forwarding: { start: vi.fn(), stop: vi.fn() },
76
73
  stopPermissionRpcHandlers: vi.fn(),
77
74
  getAllTools: vi.fn().mockReturnValue([]),
78
75
  setActiveTools: vi.fn(),
@@ -128,7 +125,7 @@ describe("handleInput", () => {
128
125
  const ctx = makeCtx();
129
126
  const deps = makeDeps();
130
127
  await handleInput(deps, makeInputEvent("hello"), ctx);
131
- expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
128
+ expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
132
129
  });
133
130
 
134
131
  it("returns continue for non-skill input", async () => {
@@ -83,15 +83,13 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
83
83
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
84
84
  return {
85
85
  session: makeSession(),
86
- writeDebugLog: vi.fn(),
87
- writeReviewLog: vi.fn(),
86
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
88
87
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
89
88
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
90
89
  createPermissionManagerForCwd: vi
91
90
  .fn()
92
91
  .mockReturnValue(makePermissionManager()),
93
92
  refreshExtensionConfig: vi.fn(),
94
- notifyWarning: vi.fn(),
95
93
  logResolvedConfigPaths: vi.fn(),
96
94
  resolveAgentName: vi.fn().mockReturnValue(null),
97
95
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
@@ -100,8 +98,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
100
98
  .mockResolvedValue({ approved: true, state: "approved" }),
101
99
  createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
102
100
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
103
- startForwardedPermissionPolling: vi.fn(),
104
- stopForwardedPermissionPolling: vi.fn(),
101
+ forwarding: { start: vi.fn(), stop: vi.fn() },
105
102
  stopPermissionRpcHandlers: vi.fn(),
106
103
  getAllTools: vi.fn().mockReturnValue([]),
107
104
  setActiveTools: vi.fn(),
@@ -173,7 +170,7 @@ describe("handleSessionStart", () => {
173
170
  const ctx = makeCtx();
174
171
  const deps = makeDeps();
175
172
  await handleSessionStart(deps, { reason: "startup" }, ctx);
176
- expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
173
+ expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
177
174
  });
178
175
 
179
176
  it("logs resolved config paths", async () => {
@@ -189,21 +186,21 @@ describe("handleSessionStart", () => {
189
186
  createPermissionManagerForCwd: vi.fn().mockReturnValue(pm),
190
187
  });
191
188
  await handleSessionStart(deps, { reason: "startup" }, makeCtx());
192
- expect(deps.notifyWarning).toHaveBeenCalledWith("issue A");
193
- expect(deps.notifyWarning).toHaveBeenCalledWith("issue B");
189
+ expect(deps.logger.warn).toHaveBeenCalledWith("issue A");
190
+ expect(deps.logger.warn).toHaveBeenCalledWith("issue B");
194
191
  });
195
192
 
196
193
  it("does not call notifyWarning when there are no policy issues", async () => {
197
194
  const deps = makeDeps();
198
195
  await handleSessionStart(deps, { reason: "startup" }, makeCtx());
199
- expect(deps.notifyWarning).not.toHaveBeenCalled();
196
+ expect(deps.logger.warn).not.toHaveBeenCalled();
200
197
  });
201
198
 
202
199
  it("writes lifecycle.reload debug log when reason is reload", async () => {
203
200
  const ctx = makeCtx({ cwd: "/proj" });
204
201
  const deps = makeDeps();
205
202
  await handleSessionStart(deps, { reason: "reload" }, ctx);
206
- expect(deps.writeDebugLog).toHaveBeenCalledWith("lifecycle.reload", {
203
+ expect(deps.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
207
204
  triggeredBy: "session_start",
208
205
  reason: "reload",
209
206
  cwd: "/proj",
@@ -213,7 +210,7 @@ describe("handleSessionStart", () => {
213
210
  it("does not write lifecycle.reload debug log for non-reload reasons", async () => {
214
211
  const deps = makeDeps();
215
212
  await handleSessionStart(deps, { reason: "startup" }, makeCtx());
216
- expect(deps.writeDebugLog).not.toHaveBeenCalled();
213
+ expect(deps.logger.debug).not.toHaveBeenCalled();
217
214
  });
218
215
  });
219
216
 
@@ -224,7 +221,7 @@ describe("handleResourcesDiscover", () => {
224
221
  const deps = makeDeps();
225
222
  await handleResourcesDiscover(deps, { reason: "startup" });
226
223
  expect(deps.createPermissionManagerForCwd).not.toHaveBeenCalled();
227
- expect(deps.writeDebugLog).not.toHaveBeenCalled();
224
+ expect(deps.logger.debug).not.toHaveBeenCalled();
228
225
  });
229
226
 
230
227
  it("creates and stores a new PM using runtimeContext.cwd on reload", async () => {
@@ -259,7 +256,7 @@ describe("handleResourcesDiscover", () => {
259
256
  const ctx = makeCtx({ cwd: "/proj" });
260
257
  const deps = makeDeps({ session: makeSession({ runtimeContext: ctx }) });
261
258
  await handleResourcesDiscover(deps, { reason: "reload" });
262
- expect(deps.writeDebugLog).toHaveBeenCalledWith("lifecycle.reload", {
259
+ expect(deps.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
263
260
  triggeredBy: "resources_discover",
264
261
  reason: "reload",
265
262
  cwd: "/proj",
@@ -269,7 +266,7 @@ describe("handleResourcesDiscover", () => {
269
266
  it("logs cwd as null when runtimeContext is null on reload", async () => {
270
267
  const deps = makeDeps();
271
268
  await handleResourcesDiscover(deps, { reason: "reload" });
272
- expect(deps.writeDebugLog).toHaveBeenCalledWith("lifecycle.reload", {
269
+ expect(deps.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
273
270
  triggeredBy: "resources_discover",
274
271
  reason: "reload",
275
272
  cwd: null,
@@ -321,7 +318,7 @@ describe("handleSessionShutdown", () => {
321
318
  it("stops forwarded permission polling", async () => {
322
319
  const deps = makeDeps();
323
320
  await handleSessionShutdown(deps);
324
- expect(deps.stopForwardedPermissionPolling).toHaveBeenCalledOnce();
321
+ expect(deps.forwarding.stop).toHaveBeenCalledOnce();
325
322
  });
326
323
 
327
324
  it("calls stopPermissionRpcHandlers on shutdown", async () => {
@@ -89,14 +89,12 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
89
89
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
90
90
  return {
91
91
  session: makeSession(),
92
- writeDebugLog: vi.fn(),
93
- writeReviewLog: vi.fn(),
92
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
94
93
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
95
94
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
96
95
  events: makeEvents(),
97
96
  createPermissionManagerForCwd: vi.fn(),
98
97
  refreshExtensionConfig: vi.fn(),
99
- notifyWarning: vi.fn(),
100
98
  logResolvedConfigPaths: vi.fn(),
101
99
  resolveAgentName: vi.fn().mockReturnValue(null),
102
100
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -104,8 +102,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
104
102
  .fn()
105
103
  .mockResolvedValue({ approved: true, state: "approved" }),
106
104
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
107
- startForwardedPermissionPolling: vi.fn(),
108
- stopForwardedPermissionPolling: vi.fn(),
105
+ forwarding: { start: vi.fn(), stop: vi.fn() },
109
106
  stopPermissionRpcHandlers: vi.fn(),
110
107
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
111
108
  setActiveTools: vi.fn(),
@@ -77,13 +77,11 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
77
77
  function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
78
78
  return {
79
79
  session: makeSession(),
80
- writeDebugLog: vi.fn(),
81
- writeReviewLog: vi.fn(),
80
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
82
81
  piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
83
82
  getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
84
83
  createPermissionManagerForCwd: vi.fn(),
85
84
  refreshExtensionConfig: vi.fn(),
86
- notifyWarning: vi.fn(),
87
85
  logResolvedConfigPaths: vi.fn(),
88
86
  resolveAgentName: vi.fn().mockReturnValue(null),
89
87
  canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
@@ -92,8 +90,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
92
90
  .mockResolvedValue({ approved: true, state: "approved" }),
93
91
  createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
94
92
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
95
- startForwardedPermissionPolling: vi.fn(),
96
- stopForwardedPermissionPolling: vi.fn(),
93
+ forwarding: { start: vi.fn(), stop: vi.fn() },
97
94
  stopPermissionRpcHandlers: vi.fn(),
98
95
  getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
99
96
  setActiveTools: vi.fn(),
@@ -141,7 +138,7 @@ describe("handleToolCall", () => {
141
138
  const ctx = makeCtx();
142
139
  const deps = makeDeps();
143
140
  await handleToolCall(deps, makeToolCallEvent("read"), ctx);
144
- expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
141
+ expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
145
142
  });
146
143
 
147
144
  it("blocks when tool name cannot be resolved", async () => {
@@ -212,21 +212,6 @@ describe("createExtensionRuntime", () => {
212
212
  expect(runtime.lastConfigWarning).toBeNull();
213
213
  });
214
214
 
215
- it("initializes permissionForwardingContext to null", () => {
216
- const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
217
- expect(runtime.permissionForwardingContext).toBeNull();
218
- });
219
-
220
- it("initializes permissionForwardingTimer to null", () => {
221
- const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
222
- expect(runtime.permissionForwardingTimer).toBeNull();
223
- });
224
-
225
- it("initializes isProcessingForwardedRequests to false", () => {
226
- const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
227
- expect(runtime.isProcessingForwardedRequests).toBe(false);
228
- });
229
-
230
215
  it("creates a sessionRules instance", () => {
231
216
  const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
232
217
  expect(runtime.sessionRules).toBeDefined();
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ExtensionRuntime } from "../src/runtime";
3
+ import { createSessionLogger } from "../src/session-logger";
4
+
5
+ // ── helpers ────────────────────────────────────────────────────────────────
6
+
7
+ function makeRuntime(
8
+ overrides: Partial<ExtensionRuntime> = {},
9
+ ): ExtensionRuntime {
10
+ return {
11
+ runtimeContext: null,
12
+ writeDebugLog: vi.fn(),
13
+ writeReviewLog: vi.fn(),
14
+ ...overrides,
15
+ } as unknown as ExtensionRuntime;
16
+ }
17
+
18
+ // ── createSessionLogger ────────────────────────────────────────────────────
19
+
20
+ describe("createSessionLogger", () => {
21
+ describe("debug", () => {
22
+ it("delegates to runtime.writeDebugLog with event and details", () => {
23
+ const runtime = makeRuntime();
24
+ const logger = createSessionLogger(runtime);
25
+
26
+ logger.debug("test.event", { key: "value" });
27
+
28
+ expect(runtime.writeDebugLog).toHaveBeenCalledWith("test.event", {
29
+ key: "value",
30
+ });
31
+ });
32
+
33
+ it("delegates to runtime.writeDebugLog with event and no details", () => {
34
+ const runtime = makeRuntime();
35
+ const logger = createSessionLogger(runtime);
36
+
37
+ logger.debug("test.event");
38
+
39
+ expect(runtime.writeDebugLog).toHaveBeenCalledWith(
40
+ "test.event",
41
+ undefined,
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("review", () => {
47
+ it("delegates to runtime.writeReviewLog with event and details", () => {
48
+ const runtime = makeRuntime();
49
+ const logger = createSessionLogger(runtime);
50
+
51
+ logger.review("permission.granted", { agentName: "coder" });
52
+
53
+ expect(runtime.writeReviewLog).toHaveBeenCalledWith(
54
+ "permission.granted",
55
+ { agentName: "coder" },
56
+ );
57
+ });
58
+
59
+ it("delegates to runtime.writeReviewLog with event and no details", () => {
60
+ const runtime = makeRuntime();
61
+ const logger = createSessionLogger(runtime);
62
+
63
+ logger.review("permission.granted");
64
+
65
+ expect(runtime.writeReviewLog).toHaveBeenCalledWith(
66
+ "permission.granted",
67
+ undefined,
68
+ );
69
+ });
70
+ });
71
+
72
+ describe("warn", () => {
73
+ it("calls ui.notify with the message and 'warning' severity when runtimeContext is present", () => {
74
+ const notify = vi.fn();
75
+ const runtime = makeRuntime({
76
+ runtimeContext: {
77
+ ui: { notify, setStatus: vi.fn(), select: vi.fn(), input: vi.fn() },
78
+ } as unknown as ExtensionRuntime["runtimeContext"],
79
+ });
80
+ const logger = createSessionLogger(runtime);
81
+
82
+ logger.warn("Something went wrong");
83
+
84
+ expect(notify).toHaveBeenCalledWith("Something went wrong", "warning");
85
+ });
86
+
87
+ it("does not throw when runtimeContext is null", () => {
88
+ const runtime = makeRuntime({ runtimeContext: null });
89
+ const logger = createSessionLogger(runtime);
90
+
91
+ expect(() => logger.warn("no-op warning")).not.toThrow();
92
+ });
93
+
94
+ it("reads runtimeContext at call time, not at creation time", () => {
95
+ const runtime = makeRuntime({ runtimeContext: null });
96
+ const logger = createSessionLogger(runtime);
97
+
98
+ // runtimeContext is null at creation — warn should be a no-op now
99
+ logger.warn("early warning");
100
+
101
+ // Later runtimeContext is set
102
+ const notify = vi.fn();
103
+ runtime.runtimeContext = {
104
+ ui: { notify, setStatus: vi.fn(), select: vi.fn(), input: vi.fn() },
105
+ } as unknown as ExtensionRuntime["runtimeContext"];
106
+
107
+ logger.warn("late warning");
108
+
109
+ expect(notify).toHaveBeenCalledOnce();
110
+ expect(notify).toHaveBeenCalledWith("late warning", "warning");
111
+ });
112
+ });
113
+ });