Files
my-pi/pi-permission-system/docs/plans/archive/0130-handler-classes.md

347 lines
26 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: 130
issue_title: "refactor: replace HandlerDeps with handler classes using narrow constructor injection"
---
# Replace HandlerDeps with handler classes
## Problem statement
After `PermissionSession` exists (#129), `HandlerDeps` still acts as a monolithic bag.
Free-function handlers all receive the same 8-field interface even though each uses a different subset.
This makes dependency requirements invisible and test factories (`makeDeps()`) unnecessarily wide — every test file builds the same full bag regardless of which fields the handler under test touches.
## Goals
- Replace `HandlerDeps` + free-function handlers with three handler classes, each with narrow constructor injection:
1. `SessionLifecycleHandler` (2 deps: `session`, `cleanupRpc`)
2. `AgentPrepHandler` (2 deps: `session`, `toolRegistry`)
3. `PermissionGateHandler` (3 deps: `session`, `events`, `toolRegistry`)
- Extract a `ToolRegistry` interface wrapping `pi.getAllTools()` and `pi.setActiveTools()`.
- Absorb `canRequestPermissionConfirmation` and `promptPermission` into `PermissionSession` (deferred from #129, see its open questions).
- Delete `HandlerDeps` and `src/handlers/types.ts`.
- Move `PromptPermissionDetails` and `PermissionReviewSource` to a shared location before their host file is deleted.
- No behavioral change — same permission decisions, same event emissions, same config loading.
## Non-goals
- Refactoring `PermissionManager` or `PermissionPrompter` internals.
- Changing the `/permission-system` slash command or config format.
- Changing any default policy state.
- Migrating `registerPermissionRpcHandlers` or `registerPermissionSystemCommand` to use `PermissionSession` — they wire through `runtime` directly in `index.ts` and are unaffected.
- Extracting `handleInput`'s permission logic into a gate descriptor (future follow-up).
## Dependencies
- **#129 — PermissionSession** (closed, implemented).
- **#126 — ExtensionPaths** (closed, implemented).
- **#127 — SessionLogger** (closed, implemented).
- **#128 — ForwardingManager** (closed, implemented).
Part of the handler decomposition series — see [plan doc](0126-handler-decomposition.md).
## Background
### Permission surfaces involved
None directly — pure internal refactor.
All surfaces (tools, bash, mcp, skills, special, external_directory) are exercised by the handlers being refactored; integration tests validate correctness.
### Current state after #129
`HandlerDeps` has 8 fields:
| Field | Used by |
| --------------------------------------- | ---------------------------------------- |
| `session: PermissionSession` | all handlers |
| `events: PermissionEventBus` | tool-call, input (for emitDecisionEvent) |
| `canRequestPermissionConfirmation(ctx)` | tool-call, input |
| `promptPermission(ctx, details)` | tool-call, input |
| `createPermissionRequestId(prefix)` | input only |
| `stopPermissionRpcHandlers()` | lifecycle shutdown only |
| `getAllTools()` | before-agent-start, tool-call |
| `setActiveTools(names)` | before-agent-start only |
Each handler uses a different subset:
- **lifecycle**: session + stopPermissionRpcHandlers
- **before-agent-start**: session + getAllTools + setActiveTools
- **tool-call**: session + events + getAllTools + canRequestPermissionConfirmation + promptPermission
- **input**: session + events + canRequestPermissionConfirmation + promptPermission + createPermissionRequestId
### Types that must relocate
`src/handlers/types.ts` currently defines `PromptPermissionDetails` and `PermissionReviewSource`.
These are imported by `src/permission-prompter.ts` and `src/handlers/gates/descriptor.ts`.
They must move to a shared location before the file is deleted.
## Design overview
### ToolRegistry interface
```typescript
interface ToolRegistry {
getAll(): unknown[];
setActive(names: string[]): void;
}
```
Defined in `src/tool-registry.ts` (alongside existing `getToolNameFromValue` and `checkRequestedToolRegistration`).
Constructed in `index.ts` wrapping `pi.getAllTools()` and `pi.setActiveTools()`.
### Absorb prompting into PermissionSession
`PermissionSession` gains three new methods and two new runtime deps:
```typescript
// Added to PermissionSessionRuntimeDeps
canRequestPermissionConfirmation(ctx: ExtensionContext): boolean;
promptPermission(
ctx: ExtensionContext,
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision>;
// Added to PermissionSession
canPrompt(ctx: ExtensionContext): boolean;
prompt(
ctx: ExtensionContext,
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision>;
createPermissionRequestId(prefix: string): string;
```
`canPrompt` delegates to `runtimeDeps.canRequestPermissionConfirmation`.
`prompt` delegates to `runtimeDeps.promptPermission`.
`createPermissionRequestId` is a self-contained ID generator (moved from `index.ts` closure).
This completes the migration that #129 deferred: handler classes call `session.canPrompt(ctx)` and `session.prompt(ctx, details)` instead of reaching through `deps.*`.
### Handler classes
```typescript
// src/handlers/lifecycle.ts
class SessionLifecycleHandler {
constructor(
private readonly session: PermissionSession,
private readonly cleanupRpc: () => void,
) {}
handleSessionStart(event: SessionStartPayload, ctx: ExtensionContext): Promise<void>;
handleResourcesDiscover(event: ResourcesDiscoverPayload): Promise<void>;
handleSessionShutdown(): Promise<void>;
}
```
```typescript
// src/handlers/before-agent-start.ts
class AgentPrepHandler {
constructor(
private readonly session: PermissionSession,
private readonly toolRegistry: ToolRegistry,
) {}
handle(event: BeforeAgentStartPayload, ctx: ExtensionContext): Promise<BeforeAgentStartEventResult>;
}
```
```typescript
// src/handlers/tool-call.ts (handleToolCall method)
// src/handlers/input.ts (handleInput method)
class PermissionGateHandler {
constructor(
private readonly session: PermissionSession,
private readonly events: PermissionEventBus,
private readonly toolRegistry: ToolRegistry,
) {}
handleToolCall(event: unknown, ctx: ExtensionContext): Promise<{ block?: true; reason?: string }>;
handleInput(event: InputPayload, ctx: ExtensionContext): Promise<InputEventResult>;
}
```
### Wiring in index.ts
```typescript
const session = new PermissionSession(paths, logger, forwarding, runtimeDeps);
const toolRegistry: ToolRegistry = {
getAll: () => pi.getAllTools(),
setActive: (names) => pi.setActiveTools(names),
};
const lifecycle = new SessionLifecycleHandler(session, () => {
rpcHandles.unsubCheck();
rpcHandles.unsubPrompt();
});
const agentPrep = new AgentPrepHandler(session, toolRegistry);
const gates = new PermissionGateHandler(session, pi.events, toolRegistry);
pi.on("session_start", (e, ctx) => lifecycle.handleSessionStart(e, ctx));
pi.on("resources_discover", (e) => lifecycle.handleResourcesDiscover(e));
pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
pi.on("before_agent_start", (e, ctx) => agentPrep.handle(e, ctx));
pi.on("input", (e, ctx) => gates.handleInput(e, ctx));
pi.on("tool_call", (e, ctx) => gates.handleToolCall(e, ctx));
```
### PromptPermissionDetails relocation
Move `PromptPermissionDetails` and `PermissionReviewSource` from `src/handlers/types.ts` to `src/permission-prompter.ts` (collocated with the `PermissionPrompterApi` interface that already depends on it).
Update imports in `src/handlers/gates/descriptor.ts`, `src/handlers/tool-call.ts`, `src/handlers/input.ts`, `src/handlers/index.ts`.
### GateRunnerDeps construction after refactoring
Inside `PermissionGateHandler.handleToolCall`:
```typescript
const runnerDeps: GateRunnerDeps = {
checkPermission: (s, i, a, r) => this.session.checkPermission(s, i, a, r),
getSessionRuleset: () => this.session.getSessionRuleset(),
approveSessionRule: (s, p) => this.session.approveSessionRule(s, p),
writeReviewLog: this.session.logger.review,
emitDecision: (e) => emitDecisionEvent(this.events, e),
canConfirm: () => this.session.canPrompt(ctx),
promptPermission: (d) => this.session.prompt(ctx, d),
};
```
Same pattern but with `this.session` / `this.events` instead of `deps.session` / `deps.events`.
### Pure helpers that survive the class migration
`shouldExposeTool` (pure helper in `before-agent-start.ts`), `extractSkillNameFromInput` (pure helper in `input.ts`), `getEventInput` (pure helper in `tool-call.ts`) remain as exported free functions alongside their handler classes.
Tests for these pure helpers do not change.
## Module-level changes
| File | Change |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/permission-session.ts` | Add `canPrompt(ctx)`, `prompt(ctx, details)`, `createPermissionRequestId(prefix)`. Expand `PermissionSessionRuntimeDeps` with two new delegates. |
| `src/permission-prompter.ts` | Receives `PromptPermissionDetails` and `PermissionReviewSource` type definitions (moved from `src/handlers/types.ts`). |
| `src/tool-registry.ts` | Add `ToolRegistry` interface (alongside existing exports). |
| `src/handlers/types.ts` | **Deleted.** Types moved; `HandlerDeps` removed. |
| `src/handlers/lifecycle.ts` | Free functions → `SessionLifecycleHandler` class. Constructor takes `(session, cleanupRpc)`. Methods unchanged in logic. |
| `src/handlers/before-agent-start.ts` | Free function → `AgentPrepHandler` class. Constructor takes `(session, toolRegistry)`. `shouldExposeTool` stays as exported free function. |
| `src/handlers/tool-call.ts` | `handleToolCall` free function → `PermissionGateHandler.handleToolCall` method. Constructor takes `(session, events, toolRegistry)`. `getEventInput` stays as exported free function. Prompt closures use `this.session.canPrompt(ctx)` / `this.session.prompt(ctx, details)`. Tool registration check uses `this.toolRegistry.getAll()`. |
| `src/handlers/input.ts` | `handleInput` free function → `PermissionGateHandler.handleInput` method (same class as tool-call). `extractSkillNameFromInput` stays as exported free function. Uses `this.session.canPrompt(ctx)` / `this.session.prompt(ctx, details)` / `this.session.createPermissionRequestId(prefix)`. |
| `src/handlers/index.ts` | Update re-exports: export classes + pure helpers. Remove `HandlerDeps`, `PromptPermissionDetails`, `PermissionReviewSource` re-exports. |
| `src/handlers/gates/descriptor.ts` | Update `PromptPermissionDetails` import path to `src/permission-prompter.ts`. |
| `src/index.ts` | Replace `deps: HandlerDeps` construction with handler class instantiation. Wire events to class methods. Expand `PermissionSessionRuntimeDeps` construction with `canRequestPermissionConfirmation` and `promptPermission`. |
| `tests/permission-session.test.ts` | Add tests for `canPrompt`, `prompt`, `createPermissionRequestId`. |
| `tests/handlers/lifecycle.test.ts` | Replace `makeDeps()` with `new SessionLifecycleHandler(mockSession, mockCleanup)`. Remove unused fields from factory. |
| `tests/handlers/before-agent-start.test.ts` | Replace `makeDeps()` with `new AgentPrepHandler(mockSession, mockToolRegistry)`. Remove unused fields. |
| `tests/handlers/tool-call.test.ts` | Replace `makeDeps()` with `new PermissionGateHandler(mockSession, mockEvents, mockToolRegistry)`. Remove unused fields. Add `canPrompt`/`prompt` to session mock. |
| `tests/handlers/tool-call-events.test.ts` | Same pattern as `tool-call.test.ts`. |
| `tests/handlers/input.test.ts` | Replace `makeDeps()` with `new PermissionGateHandler(mockSession, mockEvents, mockToolRegistry)`. Add `canPrompt`/`prompt`/`createPermissionRequestId` to session mock. |
| `tests/handlers/input-events.test.ts` | Same pattern as `input.test.ts`. |
| `docs/architecture/architecture.md` | Update module listing: remove `types.ts` entry, update handler file descriptions to mention classes, add `ToolRegistry` to `tool-registry.ts` description. |
## Test impact analysis
1. **New unit tests enabled**: `PermissionSession.canPrompt`, `.prompt`, `.createPermissionRequestId` can be tested in isolation — these were previously untestable closures in `index.ts`.
2. **Existing tests simplified**: All 6 handler test files lose their `makeDeps()` factory.
Each test constructs the specific handler class with only the deps it needs.
The session mock gains `canPrompt`/`prompt`/`createPermissionRequestId` but this is mechanical — and the mock is still flat `vi.fn()` stubs.
3. **Existing tests that must stay**: Tests for handler orchestration logic (gate ordering, cache key checks, prompt flow) stay intact — they test the same class methods.
Tests for pure helpers (`shouldExposeTool`, `extractSkillNameFromInput`, `getEventInput`) are completely unchanged.
4. **Integration test unaffected**: `tests/permission-system.test.ts` calls `piPermissionSystemExtension(mockPi)` and never sees handler internals.
## TDD order
### Step 1: Relocate PromptPermissionDetails and PermissionReviewSource
Move types from `src/handlers/types.ts` to `src/permission-prompter.ts`.
Update all import paths.
`HandlerDeps` stays temporarily in `src/handlers/types.ts`.
`refactor: move PromptPermissionDetails to permission-prompter (#130)`
### Step 2: Add ToolRegistry interface
Add `ToolRegistry` interface to `src/tool-registry.ts`.
No consumers yet — this is a type-only addition.
Run `pnpm run build` to verify.
`feat: add ToolRegistry interface (#130)`
### Step 3: Add canPrompt, prompt, createPermissionRequestId to PermissionSession
1. Add tests to `tests/permission-session.test.ts` for the three new methods.
2. Expand `PermissionSessionRuntimeDeps` with `canRequestPermissionConfirmation` and `promptPermission`.
3. Implement the methods on `PermissionSession`.
4. Update `tests/permission-session.test.ts` helper (`makeRuntimeDeps`) to include new delegates.
5. Run `pnpm run build` to verify types.
`feat: PermissionSession absorbs prompting methods (#130)`
### Step 4: Convert lifecycle handlers to SessionLifecycleHandler
1. Convert `handleSessionStart`, `handleResourcesDiscover`, `handleSessionShutdown` from free functions to methods on `SessionLifecycleHandler` class.
2. Keep the free functions as deprecated re-exports temporarily (or just update callers).
3. Update `tests/handlers/lifecycle.test.ts`: replace `makeDeps()` + `handleSessionStart(deps, ...)` with `new SessionLifecycleHandler(mockSession, mockCleanup)` + `handler.handleSessionStart(...)`.
4. Update `src/index.ts` to construct `SessionLifecycleHandler` and wire lifecycle events.
5. Remove lifecycle handler fields from `HandlerDeps` that are no longer needed (just `stopPermissionRpcHandlers`).
`refactor: SessionLifecycleHandler class (#130)`
### Step 5: Convert before-agent-start handler to AgentPrepHandler
1. Convert `handleBeforeAgentStart` to `AgentPrepHandler.handle` method.
2. Update `tests/handlers/before-agent-start.test.ts`: replace `makeDeps()` with `new AgentPrepHandler(mockSession, mockToolRegistry)`.
3. Update `src/index.ts` to construct `AgentPrepHandler` and wire `before_agent_start`.
4. `shouldExposeTool` stays as an exported free function in the same file.
`refactor: AgentPrepHandler class (#130)`
### Step 6: Convert tool-call and input handlers to PermissionGateHandler
1. Create `PermissionGateHandler` class with `handleToolCall` and `handleInput` methods.
2. `handleToolCall` moves from `src/handlers/tool-call.ts` into the class. `getEventInput` stays as exported free function.
3. `handleInput` moves from `src/handlers/input.ts` into the class. `extractSkillNameFromInput` stays as exported free function.
4. Update `tests/handlers/tool-call.test.ts` and `tests/handlers/tool-call-events.test.ts`: replace `makeDeps()` with `new PermissionGateHandler(mockSession, mockEvents, mockToolRegistry)`.
Add `canPrompt`/`prompt` to session mock.
5. Update `tests/handlers/input.test.ts` and `tests/handlers/input-events.test.ts`: same pattern.
6. Update `src/index.ts` to construct `PermissionGateHandler` and wire `tool_call` + `input`.
`refactor: PermissionGateHandler class (#130)`
### Step 7: Delete HandlerDeps and src/handlers/types.ts
1. Remove the `HandlerDeps` interface from `src/handlers/types.ts`.
2. Delete `src/handlers/types.ts` entirely (types already relocated in step 1).
3. Update `src/handlers/index.ts` re-exports — export classes + pure helpers only.
4. Run `pnpm run build` + `pnpm vitest run` to verify nothing references `HandlerDeps`.
`refactor: delete HandlerDeps and handlers/types.ts (#130)`
### Step 8: Update architecture doc
Update `docs/architecture/architecture.md` module listing:
- Remove `types.ts` entry from `handlers/`.
- Update `lifecycle.ts`, `before-agent-start.ts`, `tool-call.ts`, `input.ts` descriptions to mention handler classes.
- Add `ToolRegistry` to `tool-registry.ts` description.
- Note that `PromptPermissionDetails` moved to `permission-prompter.ts`.
`docs: update architecture for handler classes (#130)`
## Risks and mitigations
| Risk | Mitigation |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Could this silently weaken a permission? | Pure refactor — same `checkPermission` calls, same parameters, same gate evaluation order. `PermissionGateHandler.handleToolCall` produces identical `GateRunnerDeps`. Integration tests validate end-to-end. |
| Handler class constructor changes could break test compilation | Each handler class is in its own file (or a new shared file for `PermissionGateHandler`). Migration is per-handler, not all-at-once. Each step leaves the repo green. |
| `PermissionSession` grows by 3 methods — risk of god object? | The 3 new methods (`canPrompt`, `prompt`, `createPermissionRequestId`) are thin delegates. The session already coordinates prompting conceptually (it owns the `PermissionPrompter`'s caller-side interface). Total method count stays reasonable. |
| `PromptPermissionDetails` relocation could break external consumers | No external consumers — it's an internal type. All import paths are updated mechanically. |
| `ToolRegistry` interface could be too narrow or too wide | It mirrors exactly `pi.getAllTools()` and `pi.setActiveTools()` — the only two Pi API methods handlers need. If more are needed later, the interface can grow. |
| `createPermissionRequestId` on `PermissionSession` may feel out of place | It's a trivial ID generator that only handlers call. Alternative: a free function or a private method on `PermissionGateHandler`. Placing it on `PermissionSession` keeps the handler class constructors exactly as specified in the issue (3 deps). If it feels wrong during implementation, it can be extracted to a free utility in the same step. |
| Steps 46 each partially consume `HandlerDeps` while it still exists | Each step updates `index.ts` wiring for the converted handler while leaving `HandlerDeps` in place for the remaining free functions. Step 7 is the final deletion. This incremental approach avoids a big-bang change. |
## Open questions
- Should `PermissionGateHandler` live in a single new file (e.g. `src/handlers/permission-gate-handler.ts`) or should `handleToolCall` stay in `tool-call.ts` and `handleInput` stay in `input.ts` with each exporting a class?
Leaning toward a single file since the issue proposes a single class, but implementation may reveal it's cleaner to keep two files with one class spanning them via re-export.
Decide during step 6.
- Should `createPermissionRequestId` live on `PermissionSession` or as a standalone free function?
The plan places it on `PermissionSession` to keep handler class constructors minimal, but it's a pure function with no state dependency.
Decide during step 3.