23 KiB
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 5–7 leaf methods.
The pain shows up concretely in tests:
- Copy-paste
makeRuntime()— 6 files × identical 18-field factories. Adding a field toExtensionRuntimebreaks all 6. - Deep mock nesting —
deps.runtime.permissionManager.checkPermissionis 3 levels deep. Every test that cares about a permission result wraps it in two objects. - Irrelevant fields in scope — gate tests never read
permissionForwardingTimer,lastConfigWarning,globalLogsDir, yet must provide them. deps.runtime.runtimeContext!— gates fish out the context only to pass it back todeps.promptPermission(ctx, ...). Thectxwas already available tohandleToolCall.- Two-tier override dance — tests override both
makeDeps({ runtime: makeRuntime({ ... }) })ANDmakeDeps({ 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, zeroas unknown ascasts. - The orchestrator (
handleToolCall) builds closure-based adapters that capturectx— gates never seeExtensionContext. - Lifecycle handlers (
handleSessionStart, etc.) access mutable state through a slimSessionStateinterface, not the full 18-field runtime. - No behavioral change — same permission decisions, same event emissions.
Non-Goals
- Removing
ExtensionRuntimeentirely — it remains as the internal composition root insrc/index.ts. - Changing the
/permission-systemslash 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
/** 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 capturesctxvia closure.promptPermission(details)takes noctx— 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 byhandleBeforeAgentStart), so the gate reads current state via function call.getInfrastructureDirs()merges static + config — hidespiInfrastructureDirs+config.piInfrastructureReadPathsbehind 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
- 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. - Existing handler tests become simpler:
makeRuntime()(18 fields) →makeSession()(7 fields). Deepas 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. - Integration tests stay as-is:
tests/permission-system.test.tsexercises the full extension through Pi SDK mocks — never constructsHandlerDepsdirectly — validates the wiring is correct.
TDD Order
Phase 1: Per-gate interfaces + gate migration (the #114 work)
-
Define
ToolGateDepsinsrc/handlers/gates/types.ts. Writetests/handlers/gates/tool.test.tsusing the flat interface (red — gates don't accept it yet).test: add tool gate tests with narrow ToolGateDeps (#111)
-
Change
evaluateToolGatesignature to acceptToolGateDeps. Replace alldeps.runtime.*references with flat method calls. Gate tests go green. ExistinghandleToolCalltests still pass becausehandleToolCalladapts deps before calling the gate.refactor: evaluateToolGate accepts narrow ToolGateDeps (#111)
-
Same for
evaluateExternalDirectoryGate— defineExternalDirectoryGateDeps, write tests, migrate.test: add external-directory gate tests with narrow deps (#111)refactor: evaluateExternalDirectoryGate accepts ExternalDirectoryGateDeps (#111)
-
Same for
evaluateBashExternalDirectoryGate.test: add bash-external-directory gate tests with narrow deps (#111)refactor: evaluateBashExternalDirectoryGate accepts narrow deps (#111)
-
Same for
evaluateSkillReadGate.test: add skill-read gate tests with narrow deps (#111)refactor: evaluateSkillReadGate accepts SkillReadGateDeps (#111)
-
Update
handleToolCallto build per-gate adapter objects. During this step it still reads fromdeps.runtimeto construct the adapters.refactor: handleToolCall builds per-gate adapters (#111)
Phase 2: SessionState + slim HandlerDeps (the #111 decomposition)
-
Define
SessionStateinsrc/runtime.ts. MakeExtensionRuntimeextend it.refactor: define SessionState interface (#111)
-
Replace
runtime: ExtensionRuntimewithsession: SessionStateonHandlerDeps. PromotewriteDebugLog,writeReviewLog,piInfrastructureDirs,getPiInfrastructureReadPathsto top-level. Updatesrc/index.tswiring.refactor: HandlerDeps uses SessionState, promotes logging (#111)
-
Migrate
handleToolCalladapter construction to usedeps.session.*+deps.writeReviewLog.refactor: handleToolCall adapters use deps.session (#111)
-
Migrate
handleBeforeAgentStarttodeps.session.*.refactor: handleBeforeAgentStart uses deps.session (#111)
-
Migrate lifecycle handlers to
deps.session.*+deps.writeDebugLog.refactor: lifecycle handlers use deps.session (#111)
-
Migrate
handleInputtodeps.session.*+deps.writeReviewLog.refactor: handleInput uses deps.session (#111)
Phase 3: Test cleanup
-
Replace
makeRuntime()withmakeSession()across all handler test files. RemoveExtensionRuntimeimports.test: handler tests use makeSession instead of makeRuntime (#111)
-
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
- Update
docs/architecture/target-architecture.mdto 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 knowPermissionManagerexists. - Should
handleInput's permission logic be extracted into aSkillInputGateDeps-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.
BaseGateDepswithwriteReviewLog+canConfirm+promptPermission)? Possible, but risks re-introducing the "bag" problem for tests that use the base. Defer until repetition is clearly painful.