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

408 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 111
issue_title: "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 nesting**`deps.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).
## Related Issues
- **#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
```typescript
/** 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
```typescript
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
```typescript
/** 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
```typescript
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):**
```typescript
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):**
```typescript
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.