22 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 331 | Narrow AgentPrepHandler and SessionLifecycleHandler against role interfaces |
Narrow AgentPrepHandler and SessionLifecycleHandler against role interfaces
Problem Statement
AgentPrepHandler and SessionLifecycleHandler both take session: PermissionSession — the concrete class, with private fields — even though each touches only a handful of its public members.
Because the parameter is a concrete class, the local makeSession fixtures in before-agent-start.test.ts and lifecycle.test.ts each cast as unknown as PermissionSession to satisfy the type, which disables TypeScript's structural check.
A consumer that calls a session method the mock lacks then fails at runtime, not at pnpm run check — the same smell #325 removed from PermissionGateHandler (and the exact regression that bit during #319).
This issue retypes both handlers against narrow role interfaces and drops the two remaining as unknown as PermissionSession casts in the handler test tree, completing the handler-narrowing arc.
Goals
- Type
AgentPrepHandler'ssessiondependency against a narrowAgentPrepSessionrole, not the concretePermissionSessionclass. - Type
SessionLifecycleHandler'ssessiondependency against a narrowSessionLifecycleSessionrole. - Reuse the existing two-method context role (
GateHandlerSession:activate+resolveAgentName) forAgentPrepHandlerrather than redefining it. - Drop the
as unknown as PermissionSessioncasts inbefore-agent-start.test.tsandlifecycle.test.ts, retyping their mocks to the role withvi.fn<T>()sopnpm run checkenforces mock completeness. - Behavior-preserving — no decision, event, log, or cache output changes.
Non-Goals
- Touching
PermissionGateHandleror its fixtures — done in #325. - Reframing
index.tsas collaborator injection — that is Step 15 (#320); this plan changes noindex.tswiring (the realPermissionSessionalready satisfies every role). - Splitting
PermissionSessionor relocating any of its methods — these roles are views onto the existing class, not a decomposition of it. - Adding new runtime behavior or new tests beyond the type-level enforcement the retyping enables.
- Extracting a shared
refreshConfigmicro-role — a single shared method does not clear the bar for its own interface (design-review check 7); declaring it on each role is cheaper than the wrong abstraction.
Background
Relevant modules and how they relate:
src/handlers/before-agent-start.ts(AgentPrepHandler) — handlesbefore_agent_start: tool filtering + prompt sanitization. Itshandlecallssession.activate,refreshConfig,resolveAgentName(ctx, systemPrompt),getToolPermission, the active-tools cache pair (shouldUpdateActiveTools/commitActiveToolsCacheKey),getPolicyCacheStamp, the prompt-state cache pair (shouldUpdatePromptState/commitPromptStateCacheKey), andsetActiveSkillEntries. It also passesthis.sessiontoresolveSkillPromptEntries, which consumes theSkillPermissionCheckerrole (checkPermission).src/handlers/lifecycle.ts(SessionLifecycleHandler) — handlessession_start,resources_discover,session_shutdown. It callssession.refreshConfig,resetForNewSession,logResolvedConfigPaths,resolveAgentName(ctx),getConfigIssues,reload,getRuntimeContext,shutdown, and readssession.logger(warn,debug). It does not callsession.activate.src/gate-handler-session.ts(GateHandlerSession) — the two-method context role (activate,resolveAgentName) established by #325 and shrunk to two methods by #329.resolveAgentNameis currently declared(ctx) => string | null;AgentPrepHandlercalls it with a secondsystemPromptargument, so reusing this role requires widening that one signature.src/skill-prompt-sanitizer.ts(SkillPermissionChecker) — the existing narrowcheckPermissionrole thatresolveSkillPromptEntriesaccepts.src/permission-session.ts(PermissionSession) — the concrete class. It alreadyimplements PermissionResolver, SessionApprovalRecorder, GatePrompter, GateHandlerSessionand already has every method both new roles need; this plan adds two more roles to thatimplementslist with no method-body changes.src/index.ts— constructs both handlers with the realsession. No change:PermissionSessionimplements the new roles, so it stays assignable to the narrowed constructor parameters.test/handlers/before-agent-start.test.ts,test/handlers/lifecycle.test.ts— each defines a localmakeSessionthat castsas unknown as PermissionSession. These are the only two casts left in the handler test tree (the sharedhandler-fixtures.tsmakeSessionwas de-casted in #325).
Constraints from AGENTS.md and the package skill:
- Role interfaces that
PermissionSessionimplements must live in top-levelsrc/(a domain module cannot import from thehandlers/layer without inverting the dependency) — mirrorgate-handler-session.ts, one role per file. pnpm fallow dead-codemust stay clean — each new interface must have a consumer in the same commit it is introduced (the handler constructor + theimplementsclause).- Adding to a barrel requires a real consumer; these roles are imported directly by their handler and by
permission-session.ts, so no barrel re-export is added. - The package skill (
.pi/skills/package-pi-permission-system/SKILL.md) names the sharedhandler-fixtures.tsmakeSessionbut not the two local ones, and not these handlers' constructor arity — no skill edit is required.
Design-review checklist (run before finalizing)
| Smell | Location | Evidence | Fix |
|---|---|---|---|
| Wide interface | AgentPrepHandler ctor |
session: PermissionSession (concrete class), uses ~11 members |
Narrow AgentPrepSession role (reuses GateHandlerSession + SkillPermissionChecker) |
| Wide interface | SessionLifecycleHandler ctor |
session: PermissionSession (concrete class), uses 9 members |
Narrow SessionLifecycleSession role |
| Test-mock depth | 2 local makeSession fixtures |
as unknown as PermissionSession |
Type against the role; vi.fn<T>() per method |
| ISP over-reach | lifecycle reuse of the context role | lifecycle never calls activate |
SessionLifecycleSession declares only resolveAgentName, not the full GateHandlerSession |
No Law-of-Demeter reach-throughs, output arguments, scattered resets, or parameter relays appear on either handler path — the handlers already talk only to session and tell it what to do.
The only structural change is the parameter type.
Design Overview
Introduce two narrow role interfaces — one per handler — each a cohesive view onto PermissionSession, reusing the existing context and skill-checker roles where the handler's usage matches them exactly.
Reuse the context role: widen GateHandlerSession.resolveAgentName
AgentPrepHandler uses both context methods (activate + resolveAgentName), so it reuses GateHandlerSession directly.
The only friction is that it calls resolveAgentName(ctx, event.systemPrompt) — a two-argument call the current role signature rejects.
Widen the role's one method to carry the optional second parameter the concrete method already accepts:
// src/gate-handler-session.ts
export interface GateHandlerSession {
activate(ctx: ExtensionContext): void;
resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
}
This is behavior-neutral for PermissionGateHandler (it still calls resolveAgentName(ctx) with one argument) and for PermissionSession (its method already has the optional systemPrompt parameter).
Widening — not redefining — keeps a single context role shared across the gate and agent-prep handlers, as the issue directs.
AgentPrepSession role
AgentPrepHandler's surface is the context role, the skill-permission checker (because it passes session to resolveSkillPromptEntries), and the agent-start prep operations (config refresh, tool exposure, the two cache pairs, the policy stamp, and skill-entry storage):
// src/agent-prep-session.ts
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { GateHandlerSession } from "./gate-handler-session";
import type { SkillPermissionChecker, SkillPromptEntry } from "./skill-prompt-sanitizer";
import type { PermissionState } from "./types";
/**
* The session surface AgentPrepHandler invokes during `before_agent_start`:
* bind context + identify the agent (GateHandlerSession), check skill
* permissions for prompt sanitization (SkillPermissionChecker), refresh
* config, decide tool exposure, manage the active-tools / prompt-state cache
* keys, and store the resolved skill entries.
*/
export interface AgentPrepSession extends GateHandlerSession, SkillPermissionChecker {
refreshConfig(ctx?: ExtensionContext): void;
getToolPermission(toolName: string, agentName?: string): PermissionState;
shouldUpdateActiveTools(cacheKey: string): boolean;
commitActiveToolsCacheKey(cacheKey: string): void;
getPolicyCacheStamp(agentName?: string): string;
shouldUpdatePromptState(cacheKey: string): boolean;
commitPromptStateCacheKey(cacheKey: string): void;
setActiveSkillEntries(entries: SkillPromptEntry[]): void;
}
ISP check: every member is read by AgentPrepHandler.handle (or, for checkPermission, by the resolveSkillPromptEntries call it makes), so the role carries no unused field.
SessionLifecycleSession role
SessionLifecycleHandler needs resolveAgentName but never calls activate, so it does not reuse GateHandlerSession (that would carry an unused method — an ISP violation).
It declares the config/lifecycle surface plus the logger it reads:
// src/session-lifecycle-session.ts
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { SessionLogger } from "./session-logger";
/**
* The session surface SessionLifecycleHandler invokes across session_start,
* resources_discover, and session_shutdown: refresh + report config, reset /
* reload / shut down session state, resolve the agent name, surface config
* issues, read the runtime context, and log.
*/
export interface SessionLifecycleSession {
refreshConfig(ctx?: ExtensionContext): void;
resetForNewSession(ctx: ExtensionContext): void;
logResolvedConfigPaths(): void;
resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
getConfigIssues(agentName?: string): string[];
reload(): void;
getRuntimeContext(): ExtensionContext | null;
shutdown(): void;
readonly logger: SessionLogger;
}
resolveAgentName overlaps with GateHandlerSession's signature, but that overlap is the nature of role interfaces — each role lists what its one consumer needs; a duplicated method signature is cheaper than forcing lifecycle to depend on activate.
Handler constructor changes (the call sites)
Only the parameter types change; both handle* bodies stay identical:
export class AgentPrepHandler {
constructor(
private readonly session: AgentPrepSession,
private readonly toolRegistry: ToolRegistry,
) {}
// handle(): activate → refreshConfig → resolveAgentName → tool filter →
// cache-keyed setActive → prompt sanitize (resolveSkillPromptEntries(…, this.session, …)) → setActiveSkillEntries
}
export class SessionLifecycleHandler {
constructor(
private readonly session: SessionLifecycleSession,
private readonly activateService: (ctx: ExtensionContext) => void,
private readonly cleanupRpc: () => void,
) {}
// handleSessionStart / handleResourcesDiscover / handleSessionShutdown bodies unchanged
}
PermissionSession adds AgentPrepSession and SessionLifecycleSession to its implements list.
It already has all of their methods (the four-argument checkPermission and two-argument resolveAgentName remain assignable to the narrower role signatures; readonly logger satisfies the role's logger), so no method body changes.
src/index.ts is untouched: new AgentPrepHandler(session, toolRegistry) and new SessionLifecycleHandler(session, …) still compile because session: PermissionSession is assignable to the narrowed parameters.
Test-fixture retyping (no cast)
Each local makeSession becomes a typed object literal returning the role, following the vi.fn<T>() pattern #325 established in handler-fixtures.ts:
// before-agent-start.test.ts
function makeSession(overrides: Partial<AgentPrepSession> = {}): AgentPrepSession {
return {
activate: vi.fn<AgentPrepSession["activate"]>(),
refreshConfig: vi.fn<AgentPrepSession["refreshConfig"]>(),
resolveAgentName: vi.fn<AgentPrepSession["resolveAgentName"]>().mockReturnValue(null),
checkPermission: vi.fn<AgentPrepSession["checkPermission"]>().mockReturnValue({ state: "allow" }),
getToolPermission: vi.fn<AgentPrepSession["getToolPermission"]>().mockReturnValue("allow"),
shouldUpdateActiveTools: vi.fn<AgentPrepSession["shouldUpdateActiveTools"]>().mockReturnValue(true),
commitActiveToolsCacheKey: vi.fn<AgentPrepSession["commitActiveToolsCacheKey"]>(),
getPolicyCacheStamp: vi.fn<AgentPrepSession["getPolicyCacheStamp"]>().mockReturnValue("stamp-1"),
shouldUpdatePromptState: vi.fn<AgentPrepSession["shouldUpdatePromptState"]>().mockReturnValue(true),
commitPromptStateCacheKey: vi.fn<AgentPrepSession["commitPromptStateCacheKey"]>(),
setActiveSkillEntries: vi.fn<AgentPrepSession["setActiveSkillEntries"]>(),
...overrides,
};
}
The current before-agent-start.test.ts mock carries a logger and a getActiveSkillEntries field that AgentPrepHandler never reads; the new typed literal drops both (the role does not declare them, and TypeScript's excess-property check would reject logger otherwise).
The lifecycle.test.ts mock is retyped the same way against SessionLifecycleSession, keeping its logger: { debug, review, warn } and dropping the cast.
The overrides parameter narrows from Partial<Record<keyof PermissionSession, unknown>> to Partial<Role>, so an override naming a non-member fails pnpm run check.
Edge case: lifecycle.test.ts overrides getConfigIssues, getRuntimeContext, refreshConfig, and resetForNewSession in various tests; all four are role members, so spreading ...overrides last still type-checks.
Module-Level Changes
src/gate-handler-session.ts— widenresolveAgentNameto(ctx, systemPrompt?). No new import; behavior-neutral for the gate handler.src/agent-prep-session.ts— new: theAgentPrepSessioninterface (extends GateHandlerSession, SkillPermissionChecker).src/session-lifecycle-session.ts— new: theSessionLifecycleSessioninterface.src/permission-session.ts— addAgentPrepSessionandSessionLifecycleSessionto theimplementslist; import both. No method-body changes.src/handlers/before-agent-start.ts— constructor parametersession: AgentPrepSession; swap theimport type { PermissionSession }forimport type { AgentPrepSession } from "#src/agent-prep-session".handlebody unchanged.src/handlers/lifecycle.ts— constructor parametersession: SessionLifecycleSession; swap thePermissionSessionimport forimport type { SessionLifecycleSession } from "#src/session-lifecycle-session". Handler bodies unchanged.test/handlers/before-agent-start.test.ts— retypemakeSessiontoAgentPrepSession(cast dropped,vi.fn<T>()per method,logger+getActiveSkillEntriesremoved); narrow theoverrideskey type toPartial<AgentPrepSession>; swap thePermissionSessionimport forAgentPrepSession.test/handlers/lifecycle.test.ts— retypemakeSessiontoSessionLifecycleSession(cast dropped,vi.fn<T>()per method,loggerretained); narrow theoverrideskey type; swap thePermissionSessionimport forSessionLifecycleSession.packages/pi-permission-system/docs/architecture/architecture.md— mark Phase 3 Step 14 ✅ and record the role names + theresolveAgentNamewidening; in the module-structure listing addagent-prep-session.tsandsession-lifecycle-session.ts, and update thebefore-agent-start.ts,lifecycle.ts, andpermission-session.tslines to name the new role dependencies.
Symbol-grep results (per AGENTS.md): the only new AgentPrepHandler(...) / new SessionLifecycleHandler(...) sites are index.ts and the two test files above; composition-root.test.ts drives both handlers through pi.fire, not their constructors, so it needs no change.
No exported symbol is removed or renamed; the handlers/index.ts barrel is unchanged.
Test Impact Analysis
- New tests enabled — the change is type-level; its payoff is compile-time enforcement (the two
implementsclauses plus the precise mock types), not a new runtime test. Naming the roles makes a future minimal unit test of either handler possible without the concrete class, but the existing suites already cover the behavior, so none is added here. - Tests that become redundant — none. No assertion is duplicated or obviated; only the fixture typing changes.
- Tests that must stay as-is — every behavior assertion in
before-agent-start.test.tsandlifecycle.test.tsgenuinely exercises the handler against a mocked session boundary; only the mock's type (and two vestigial fields) change, never the assertions.
TDD Order
This is a behavior-preserving refactor; the existing suite plus pnpm run check are the safety net, so the cycles are "change → green", not "new red test → green".
- Introduce the roles and retype both handlers — widen
GateHandlerSession.resolveAgentName; addsrc/agent-prep-session.tsandsrc/session-lifecycle-session.ts; add both toPermissionSession'simplementslist; change both handler constructor parameter types and swap their imports.index.tsis unchanged (the concrete session still satisfies the narrowed params); the two test mocks keep theiras unknown as PermissionSessioncasts for now (aPermissionSessionstill satisfies the narrow roles). Verifypnpm run checkand the full package suite are green. Commit:refactor: type AgentPrepHandler and SessionLifecycleHandler against session role interfaces (#331). - Drop the AgentPrepHandler mock cast — retype
before-agent-start.test.tsmakeSessiontoAgentPrepSessionwithvi.fn<T>(), drop the cast, remove the vestigiallogger+getActiveSkillEntries, and narrow theoverrideskey type. Run the file pluspnpm run check. Commit:refactor: drop as-unknown-as PermissionSession cast in AgentPrepHandler mock (#331). - Drop the SessionLifecycleHandler mock cast — retype
lifecycle.test.tsmakeSessiontoSessionLifecycleSessionthe same way, dropping the cast and narrowingoverrides. Run the file pluspnpm run check. Commit:refactor: drop as-unknown-as PermissionSession cast in SessionLifecycleHandler mock (#331). - Document — mark architecture Step 14 ✅ and update the module-structure listing.
Commit:
docs: record handler role-interface narrowing in architecture (#331).
Steps 2 and 3 are independent (different files) and could be folded into one commit; they are kept separate only for reviewability and may be merged if preferred.
Risks and Mitigations
- Risk: dropping a cast surfaces a missing or mistyped mock member.
Mitigation: that is the intended win —
pnpm run checknames the gap; the role definitions above list every required member so the typed literal is complete. - Risk: widening
GateHandlerSession.resolveAgentNameperturbs the gate handler or its mocks. Mitigation: an added optional parameter is backward-compatible; the gate handler still calls with one argument andMockGateHandlerSession'svi.fn<GateHandlerSession["resolveAgentName"]>()re-derives from the widened type. Keepcomposition-root.test.tsand the gate-handler suites green. - Risk: excess-property error when removing the cast because the literal still carries
logger/getActiveSkillEntries(AgentPrep) that the role omits. Mitigation: drop both fields in Step 2; the literal then matchesAgentPrepSessionexactly. - Risk:
readonly loggeronPermissionSessionfails to satisfy the role'slogger. Mitigation: the role declaresreadonly loggerand TypeScript treats areadonlysource property as assignable to a mutable target regardless; either way it type-checks.
Open Questions
- Should
AgentPrepHandlerget its ownresolveAgentName(ctx, systemPrompt?)declaration instead of wideningGateHandlerSession, to keep the gate context role free of an unusedsystemPromptparameter? Resolved in favor of widening: the issue directs reuse of the shared context role rather than redefining it, and the optional parameter is harmless to the gate path. - Should
refreshConfig(shared by both new roles) become its own micro-role? Deferred: a single shared method does not clear the design-review bar for a new interface; revisit only if a third consumer appears.