Files
my-pi/pi-permission-system/docs/plans/archive/0111-narrow-handler-dependencies.md
T

23 KiB
Raw Blame History

issue, issue_title
issue issue_title
111 refactor: narrow handler dependencies and runtime access

Narrow handler dependencies by eliminating deep mocking

Problem Statement

Every handler test file contains a near-identical makeRuntime() factory constructing an 18-field ExtensionRuntime mock. Gate tests must build deeply nested mock trees — deps.runtime.permissionManager.checkPermission, deps.runtime.sessionRules.getRuleset() — with as unknown as casts, even though each gate only calls 57 leaf methods.

The pain shows up concretely in tests:

  1. Copy-paste makeRuntime() — 6 files × identical 18-field factories. Adding a field to ExtensionRuntime breaks all 6.
  2. Deep mock nestingdeps.runtime.permissionManager.checkPermission is 3 levels deep. Every test that cares about a permission result wraps it in two objects.
  3. Irrelevant fields in scope — gate tests never read permissionForwardingTimer, lastConfigWarning, globalLogsDir, yet must provide them.
  4. deps.runtime.runtimeContext! — gates fish out the context only to pass it back to deps.promptPermission(ctx, ...). The ctx was already available to handleToolCall.
  5. Two-tier override dance — tests override both makeDeps({ runtime: makeRuntime({ ... }) }) AND makeDeps({ promptPermission: ... }) for the same scenario.

Goals

  • Each gate declares a flat, per-gate interface with only the leaf methods it calls — no object nesting, no ExtensionContext, no .runtime..
  • Gate tests become trivial: flat vi.fn() stubs, zero as unknown as casts.
  • The orchestrator (handleToolCall) builds closure-based adapters that capture ctx — gates never see ExtensionContext.
  • Lifecycle handlers (handleSessionStart, etc.) access mutable state through a slim SessionState interface, not the full 18-field runtime.
  • No behavioral change — same permission decisions, same event emissions.

Non-Goals

  • Removing ExtensionRuntime entirely — it remains as the internal composition root in src/index.ts.
  • Changing the /permission-system slash command or config format.
  • Refactoring applyPermissionGate — it already follows the right pattern (injected callbacks).
  • Extracting handleInput's permission logic into a gate (follow-up).
  • #114 (closed as duplicate of #111) — describes the per-gate interface segregation in detail. Folded into this plan.
  • #107 — extracted gate functions into src/handlers/gates/. Already implemented. This plan narrows their dependency signatures.

Background

What each gate actually calls (leaf methods)

Gate Leaf methods used
evaluateToolGate checkPermission, getSessionRuleset, approveSessionRule, writeReviewLog, emitDecision, canConfirm, promptPermission
evaluateExternalDirectoryGate checkPermission, getSessionRuleset, approveSessionRule, writeReviewLog, emitDecision, canConfirm, promptPermission, getPiInfrastructureDirs
evaluateBashExternalDirectoryGate checkPermission, getSessionRuleset, approveSessionRule, writeReviewLog, canConfirm, promptPermission
evaluateSkillReadGate getActiveSkillEntries, writeReviewLog, emitDecision, canConfirm, promptPermission, createRequestId

Note: canConfirm and promptPermission in the narrow interface do NOT take ctx — the adapter closure captures it.

Permission surfaces involved

None directly — pure internal refactor. All surfaces are exercised by the handlers being refactored; integration tests validate correctness.

Design Overview

Per-gate flat interfaces

/** Narrow deps for evaluateToolGate — every field is a leaf method. */
export interface ToolGateDeps {
  checkPermission(
    surface: string,
    input: unknown,
    agentName?: string,
    sessionRules?: Rule[],
  ): PermissionCheckResult;
  getSessionRuleset(): Rule[];
  approveSessionRule(surface: string, pattern: string): void;
  writeReviewLog(event: string, details: Record<string, unknown>): void;
  emitDecision(event: PermissionDecisionEvent): void;
  canConfirm(): boolean;
  promptPermission(
    details: PromptPermissionDetails,
  ): Promise<PermissionPromptDecision>;
}

/** Narrow deps for evaluateExternalDirectoryGate. */
export interface ExternalDirectoryGateDeps {
  checkPermission(
    surface: string,
    input: unknown,
    agentName?: string,
    sessionRules?: Rule[],
  ): PermissionCheckResult;
  getSessionRuleset(): Rule[];
  approveSessionRule(surface: string, pattern: string): void;
  writeReviewLog(event: string, details: Record<string, unknown>): void;
  emitDecision(event: PermissionDecisionEvent): void;
  canConfirm(): boolean;
  promptPermission(
    details: PromptPermissionDetails,
  ): Promise<PermissionPromptDecision>;
  /** Resolved infrastructure dirs (static + config-based). */
  getInfrastructureDirs(): string[];
}

/** Narrow deps for evaluateBashExternalDirectoryGate. */
export interface BashExternalDirectoryGateDeps {
  checkPermission(
    surface: string,
    input: unknown,
    agentName?: string,
    sessionRules?: Rule[],
  ): PermissionCheckResult;
  getSessionRuleset(): Rule[];
  approveSessionRule(surface: string, pattern: string): void;
  writeReviewLog(event: string, details: Record<string, unknown>): void;
  canConfirm(): boolean;
  promptPermission(
    details: PromptPermissionDetails,
  ): Promise<PermissionPromptDecision>;
}

/** Narrow deps for evaluateSkillReadGate. */
export interface SkillReadGateDeps {
  getActiveSkillEntries(): SkillPromptEntry[];
  writeReviewLog(event: string, details: Record<string, unknown>): void;
  emitDecision(event: PermissionDecisionEvent): void;
  canConfirm(): boolean;
  promptPermission(
    details: PromptPermissionDetails,
  ): Promise<PermissionPromptDecision>;
  createRequestId(prefix: string): string;
}

Key design choices:

  • canConfirm() takes no args — the adapter captures ctx via closure.
  • promptPermission(details) takes no ctx — same reason.
  • emitDecision(event) takes the full event — gates already build the event data; no reason to split it.
  • getActiveSkillEntries() is a getter — skill entries are mutable (set by handleBeforeAgentStart), so the gate reads current state via function call.
  • getInfrastructureDirs() merges static + config — hides piInfrastructureDirs + config.piInfrastructureReadPaths behind one call.

Adapter construction in handleToolCall

export async function handleToolCall(deps: HandlerDeps, event: unknown, ctx: ExtensionContext) {
  deps.session.runtimeContext = ctx;
  deps.startForwardedPermissionPolling(ctx);

  const agentName = deps.resolveAgentName(ctx);
  // ... tool name validation ...

  const tcc: ToolCallContext = { toolName, agentName, input, toolCallId, cwd: ctx.cwd };

  // Shared adapter base — captures ctx in closures
  const canConfirm = () => deps.canRequestPermissionConfirmation(ctx);
  const promptPermission = (details: PromptPermissionDetails) =>
    deps.promptPermission(ctx, details);

  // Gate-specific adapters (cheap — just function references + closures)
  const toolGateDeps: ToolGateDeps = {
    checkPermission: (s, i, a, r) => deps.session.permissionManager.checkPermission(s, i, a, r),
    getSessionRuleset: () => deps.session.sessionRules.getRuleset(),
    approveSessionRule: (s, p) => deps.session.sessionRules.approve(s, p),
    writeReviewLog: deps.writeReviewLog,
    emitDecision: (e) => emitDecisionEvent(deps.events, e),
    canConfirm,
    promptPermission,
  };

  // ... call gates with narrow deps ...
}

SessionState for lifecycle handlers

/** Mutable session state — the only part of ExtensionRuntime that handlers mutate. */
export interface SessionState {
  runtimeContext: ExtensionContext | null;
  permissionManager: PermissionManager;
  readonly sessionRules: SessionRules;
  activeSkillEntries: SkillPromptEntry[];
  lastKnownActiveAgentName: string | null;
  lastActiveToolsCacheKey: string | null;
  lastPromptStateCacheKey: string | null;
}

Slimmed HandlerDeps

export interface HandlerDeps {
  // ── Session state (replaces `runtime`) ───────────────────────────────
  readonly session: SessionState;

  // ── Immutable paths ──────────────────────────────────────────────────
  readonly piInfrastructureDirs: string[];
  /** Returns config-derived infrastructure read paths (current at call time). */
  getPiInfrastructureReadPaths(): string[];

  // ── Logging (promoted from runtime) ──────────────────────────────────
  writeDebugLog(event: string, details?: Record<string, unknown>): void;
  writeReviewLog(event: string, details?: Record<string, unknown>): void;

  // ── Event bus ────────────────────────────────────────────────────────
  readonly events: PermissionEventBus;

  // ── Factories & helpers ──────────────────────────────────────────────
  createPermissionManagerForCwd(cwd: string | undefined | null): PermissionManager;
  refreshExtensionConfig(ctx?: ExtensionContext): void;
  notifyWarning(message: string): void;
  logResolvedConfigPaths(): void;
  resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
  canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
  promptPermission(
    ctx: ExtensionContext,
    details: PromptPermissionDetails,
  ): Promise<PermissionPromptDecision>;
  createPermissionRequestId(prefix: string): string;
  startForwardedPermissionPolling(ctx: ExtensionContext): void;
  stopForwardedPermissionPolling(): void;
  stopPermissionRpcHandlers(): void;
  getAllTools(): unknown[];
  setActiveTools(names: string[]): void;
}

What test code looks like after

Gate test (evaluateToolGate):

function makeToolGateDeps(overrides: Partial<ToolGateDeps> = {}): ToolGateDeps {
  return {
    checkPermission: vi.fn().mockReturnValue({ state: "allow", source: "tool" }),
    getSessionRuleset: vi.fn().mockReturnValue([]),
    approveSessionRule: vi.fn(),
    writeReviewLog: vi.fn(),
    emitDecision: vi.fn(),
    canConfirm: vi.fn().mockReturnValue(true),
    promptPermission: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
    ...overrides,
  };
}

it("blocks when policy is deny", async () => {
  const deps = makeToolGateDeps({
    checkPermission: vi.fn().mockReturnValue({ state: "deny", source: "tool" }),
  });
  const result = await evaluateToolGate(tcc, deps);
  expect(result).toMatchObject({ action: "block" });
});

No makeRuntime(). No nesting. No as unknown as. One override for the field that matters.

Lifecycle test (handleSessionStart):

function makeSession(overrides: Partial<SessionState> = {}): SessionState {
  return {
    runtimeContext: null,
    permissionManager: { getConfigIssues: vi.fn().mockReturnValue([]) } as any,
    sessionRules: { approve: vi.fn(), getRuleset: vi.fn().mockReturnValue([]), clear: vi.fn() } as any,
    activeSkillEntries: [],
    lastKnownActiveAgentName: null,
    lastActiveToolsCacheKey: null,
    lastPromptStateCacheKey: null,
    ...overrides,
  };
}

7 fields instead of 18. No forwarding state, no path constants, no config warning.

Module-Level Changes

File Change
src/handlers/gates/types.ts Add ToolGateDeps, ExternalDirectoryGateDeps, BashExternalDirectoryGateDeps, SkillReadGateDeps
src/handlers/gates/tool.ts Accept ToolGateDeps; replace deps.runtime.* with flat method calls; drop HandlerDeps import
src/handlers/gates/external-directory.ts Accept ExternalDirectoryGateDeps; use deps.getInfrastructureDirs()
src/handlers/gates/bash-external-directory.ts Accept BashExternalDirectoryGateDeps
src/handlers/gates/skill-read.ts Accept SkillReadGateDeps; use deps.getActiveSkillEntries()
src/handlers/types.ts Replace runtime: ExtensionRuntime with session: SessionState; promote logging/paths
src/handlers/tool-call.ts Build per-gate adapter objects from deps + ctx; pass narrow deps to each gate
src/handlers/before-agent-start.ts Use deps.session.* instead of deps.runtime.*
src/handlers/lifecycle.ts Use deps.session.* + deps.writeDebugLog
src/handlers/input.ts Use deps.session.* + deps.writeReviewLog
src/runtime.ts Export SessionState interface; ExtensionRuntime extends it
src/index.ts Wire HandlerDeps.session from runtime; promote logging + paths
tests/handlers/gates/tool.test.ts New file — gate tests with makeToolGateDeps()
tests/handlers/gates/external-directory.test.ts New file
tests/handlers/gates/bash-external-directory.test.ts New file
tests/handlers/gates/skill-read.test.ts New file
tests/handlers/tool-call.test.ts Simplify — remove makeRuntime(), use makeSession()
tests/handlers/tool-call-events.test.ts Same simplification
tests/handlers/before-agent-start.test.ts Replace makeRuntime() with makeSession()
tests/handlers/lifecycle.test.ts Same
tests/handlers/input.test.ts Same
tests/handlers/input-events.test.ts Same
docs/architecture/target-architecture.md Update handler/gate architecture section

Test Impact Analysis

  1. New unit tests enabled: Each gate can now be tested in complete isolation in its own file with a 7-field flat mock. Previously impractical due to makeRuntime() cost.
  2. Existing handler tests become simpler: makeRuntime() (18 fields) → makeSession() (7 fields). Deep as unknown as ExtensionRuntime["permissionManager"] casts disappear. Gate-specific tests in existing handler files can be migrated to dedicated gate test files or simplified in place.
  3. Integration tests stay as-is: tests/permission-system.test.ts exercises the full extension through Pi SDK mocks — never constructs HandlerDeps directly — validates the wiring is correct.

TDD Order

Phase 1: Per-gate interfaces + gate migration (the #114 work)

  1. Define ToolGateDeps in src/handlers/gates/types.ts. Write tests/handlers/gates/tool.test.ts using the flat interface (red — gates don't accept it yet).

    • test: add tool gate tests with narrow ToolGateDeps (#111)
  2. Change evaluateToolGate signature to accept ToolGateDeps. Replace all deps.runtime.* references with flat method calls. Gate tests go green. Existing handleToolCall tests still pass because handleToolCall adapts deps before calling the gate.

    • refactor: evaluateToolGate accepts narrow ToolGateDeps (#111)
  3. Same for evaluateExternalDirectoryGate — define ExternalDirectoryGateDeps, write tests, migrate.

    • test: add external-directory gate tests with narrow deps (#111)
    • refactor: evaluateExternalDirectoryGate accepts ExternalDirectoryGateDeps (#111)
  4. Same for evaluateBashExternalDirectoryGate.

    • test: add bash-external-directory gate tests with narrow deps (#111)
    • refactor: evaluateBashExternalDirectoryGate accepts narrow deps (#111)
  5. Same for evaluateSkillReadGate.

    • test: add skill-read gate tests with narrow deps (#111)
    • refactor: evaluateSkillReadGate accepts SkillReadGateDeps (#111)
  6. Update handleToolCall to build per-gate adapter objects. During this step it still reads from deps.runtime to construct the adapters.

    • refactor: handleToolCall builds per-gate adapters (#111)

Phase 2: SessionState + slim HandlerDeps (the #111 decomposition)

  1. Define SessionState in src/runtime.ts. Make ExtensionRuntime extend it.

    • refactor: define SessionState interface (#111)
  2. Replace runtime: ExtensionRuntime with session: SessionState on HandlerDeps. Promote writeDebugLog, writeReviewLog, piInfrastructureDirs, getPiInfrastructureReadPaths to top-level. Update src/index.ts wiring.

    • refactor: HandlerDeps uses SessionState, promotes logging (#111)
  3. Migrate handleToolCall adapter construction to use deps.session.* + deps.writeReviewLog.

    • refactor: handleToolCall adapters use deps.session (#111)
  4. Migrate handleBeforeAgentStart to deps.session.*.

    • refactor: handleBeforeAgentStart uses deps.session (#111)
  5. Migrate lifecycle handlers to deps.session.* + deps.writeDebugLog.

    • refactor: lifecycle handlers use deps.session (#111)
  6. Migrate handleInput to deps.session.* + deps.writeReviewLog.

    • refactor: handleInput uses deps.session (#111)

Phase 3: Test cleanup

  1. Replace makeRuntime() with makeSession() across all handler test files. Remove ExtensionRuntime imports.

    • test: handler tests use makeSession instead of makeRuntime (#111)
  2. Migrate gate-level assertions from handler test files to dedicated gate test files where they test more clearly in isolation.

    • test: consolidate gate tests in dedicated files (#111)

Phase 4: Docs

  1. Update docs/architecture/target-architecture.md to reflect per-gate interfaces and SessionState.
    • docs: update target architecture for gate interfaces (#111)

Risks and Mitigations

Risk Mitigation
Could this silently weaken a permission? No — pure refactor. Same checkPermission calls, same parameters, same gate evaluation order. Integration test validates end-to-end.
Adapter construction in handleToolCall adds overhead Adapter objects are cheap (function references + one closure for ctx). No allocation pressure vs. current path.
Gate interface drift — someone adds a dep to a gate without updating the interface TypeScript enforces it: if the gate calls deps.newMethod() and the interface lacks it, compilation fails.
Large blast radius across 6 test files Phase 1 (gates) lands independently and creates new test files without touching existing ones. Phase 2+3 migrates existing tests incrementally.
Shared method signatures across gate interfaces feel DRY-violating Intentional: each gate's interface documents exactly what it uses. A shared base type would re-introduce coupling and baggy mocks. Composition via extends can be applied later if a real shared subset emerges.

Open Questions

  • Should gate interfaces use Pick<PermissionManager, "checkPermission"> or a standalone function type? Standalone function type (as shown) — it's flatter and test-friendlier. The gate never needs to know PermissionManager exists.
  • Should handleInput's permission logic be extracted into a SkillInputGateDeps-style gate for consistency? Likely yes, but deferred to a follow-up to keep scope contained.
  • Can gate interfaces share a common base (e.g. BaseGateDeps with writeReviewLog + canConfirm + promptPermission)? Possible, but risks re-introducing the "bag" problem for tests that use the base. Defer until repetition is clearly painful.