19 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 339 | Extract a context-owning PromptingGateway; collapse the prompt twins |
Extract a context-owning PromptingGateway; collapse the prompt twins
Problem Statement
PermissionSession fuses two unrelated jobs: it owns the mutable session state (context, caches, rules, skill entries) and it plays the prompting role for the gate runner.
The prompting job arrives as two context-bound method twins.
canPrompt(ctx) takes the context per call; canConfirm() reads the stored context.
prompt(ctx, details) takes the context per call; promptPermission(details) reads the stored context.
The second member of each pair exists only to bind the stored context, so the class carries four prompting methods where two would do.
The cost lands in the tests.
makeSession re-implements the production canConfirm / promptPermission delegations as closures and threads undefined as unknown as ExtensionContext through them because the mock has no real context.
GateRunner(session, session, session, reporter) passes one object as three roles, so the prompting role cannot be substituted independently.
This is Phase 4 Step 6 (Track C: split the session), addressing Finding 2 in the architecture roadmap.
It depends on Step 1 (#334, closed — the injected PermissionManager made the session constructable).
It runs parallel with Step 7 (#340); both feed Step 8 (#341).
Goals
- Add
src/prompting-gateway.ts: aPromptingGatewaycollaborator that owns the stored context and exposes a single context-bound prompting pair,canConfirm()/prompt(details). - Move the "can we prompt?"
policy (UI / subagent / yolo) into the gateway so the
index.tscanRequestPermissionConfirmationclosure disappears (index closures 11 → 10). GateRunnerreceives the gateway for the prompting role;PermissionSessionno longer playsGatePrompter.- Collapse the
canPrompt/canConfirmandprompt/promptPermissiontwins into the single context-bound pair on the gateway. - Rename the
GatePrompterprompting methodpromptPermission→promptto match the collapsed pair. - Behavior-preserving: identical runtime decisions; the change relocates collaboration, not logic.
Non-Goals
- Extracting the
PermissionResolverrole (#340, Step 7) — that is the parallel track. - Retiring the fig-leaf role interfaces (
GateHandlerSession,AgentPrepSession,SessionLifecycleSession) or splittingmakeSessionper-collaborator (#341, Step 8). - Consolidating the session's own context store with the gateway's (see Risks); the session still owns
this.contextforgetRuntimeContext/reload/logResolvedConfigPaths. - Touching
permission-event-rpc.ts,lifecycle.ts,before-agent-start.ts, or the logger notify sink — all keep readingsession.getRuntimeContext()unchanged.
Background
Relevant modules:
src/permission-session.ts— the god object. Today its constructor takes aPermissionSessionRuntimeDepsbag ({ canRequestPermissionConfirmation, prompter }). Its prompting methods arecanPrompt(ctx),prompt(ctx, details),canConfirm(),promptPermission(details). Itsactivate(ctx)already drives a collaborator's lifecycle (this.forwarding.start(ctx));deactivate()mirrors it. It still needsthis.contextafter this change forgetRuntimeContext(),reload()(readsthis.context?.cwd), andlogResolvedConfigPaths().src/gate-prompter.ts— theGatePrompterrole interface (canConfirm()+promptPermission(details)). This is already the collapsed contract the runner sees; the twins live only on the session.src/handlers/gates/runner.ts—GateRunnercallsthis.prompter.canConfirm()andthis.prompter.promptPermission(...).src/permission-prompter.ts—PermissionPrompterApi.prompt(ctx, details)andPromptPermissionDetails; the gateway delegates the actual prompt here.src/yolo-mode.ts—canResolveAskPermissionRequest({ config, hasUI, isSubagent }).src/subagent-context.ts—isSubagentExecutionContext(ctx, subagentSessionsDir, registry).src/index.ts— the composition root; constructs the session, the gateway-to-be, andnew GateRunner(session, session, session, reporter).
Constraints from AGENTS.md and the package skill that apply:
- Keep Pi SDK imports at the edges; the gateway is an SDK consumer (it holds
ExtensionContext), which is allowed. - A non-
asyncmethod declaredPromise<T>mustreturn Promise.reject(...), neverthrow(testing skill; preserves the existing throw-when-unactivated contract underrejects.toThrow). - Lift-and-shift large test files: never rewrite an entire large test file in one step.
- When removing fields from a shared test type, every constructor of that type breaks in the same commit — fold those fixture updates together.
- When a roadmap step ships, mark it
✓ completeindocs/architecture/architecture.md(ship-time action, noted here for completeness).
Design Overview
The gateway
PromptingGateway owns the stored context and absorbs both the prompting action and the "can we prompt?"
policy.
Absorbing the policy is what lets the index.ts canRequestPermissionConfirmation closure disappear: the gateway computes the decision from its own deps rather than receiving a pre-bound closure.
export interface PromptingGatewayDeps {
/** Read current config for the yolo-mode branch of the can-prompt policy. */
config: ConfigReader;
/** Static path used to detect a forwarding subagent context. */
subagentSessionsDir: string;
/** Process-global registry used to detect a registered child session. */
registry?: SubagentSessionRegistry;
/** Resolves the permission decision: direct UI dialog or forwarded to parent. */
prompter: PermissionPrompterApi;
}
/** The lifecycle slice of the gateway that PermissionSession drives. */
export interface PromptingGatewayLifecycle {
activate(ctx: ExtensionContext): void;
deactivate(): void;
}
export class PromptingGateway implements GatePrompter, PromptingGatewayLifecycle {
private context: ExtensionContext | null = null;
constructor(private readonly deps: PromptingGatewayDeps) {}
activate(ctx: ExtensionContext): void {
this.context = ctx;
}
deactivate(): void {
this.context = null;
}
canConfirm(): boolean {
if (this.context === null) return false;
return canResolveAskPermissionRequest({
config: this.deps.config.current(),
hasUI: this.context.hasUI,
isSubagent: isSubagentExecutionContext(
this.context,
this.deps.subagentSessionsDir,
this.deps.registry,
),
});
}
prompt(
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision> {
if (this.context === null) {
return Promise.reject(
new Error("prompt called before the session was activated"),
);
}
return this.deps.prompter.prompt(this.context, details);
}
}
The four deps are all used (config + subagentSessionsDir + registry by canConfirm; prompter by prompt), so the bag passes the dependency-width check.
The method is named prompt here; until the rename cycle lands it carries the current GatePrompter name promptPermission (see TDD Order).
The session loses the prompting role
PermissionSession drops implements GatePrompter, deletes all four prompting methods, deletes PermissionSessionRuntimeDeps, and replaces the runtimeDeps constructor parameter with a PromptingGatewayLifecycle collaborator it forwards to:
activate(ctx: ExtensionContext): void {
this.context = ctx;
this.forwarding.start(ctx);
this.gateway.activate(ctx); // new: mirrors the forwarding lifecycle
}
deactivate(): void {
this.context = null;
this.forwarding.stop();
this.gateway.deactivate(); // new
}
The session keeps this.context for its remaining readers.
Forwarding through activate/deactivate is the only wiring the gateway needs, and it reuses the exact pattern already in place for ForwardingController — the session is the lifecycle coordinator that brings its collaborators online.
This is why the change stays inside the four target files: every existing session.activate(ctx) call site (the two handlers, before-agent-start, resetForNewSession) gets gateway activation for free.
Composition root
const gateway = new PromptingGateway({
config: configStore,
subagentSessionsDir: paths.subagentSessionsDir,
registry: subagentRegistry,
prompter,
});
const session = new PermissionSession(
paths, logger, forwardingManager, permissionManager, sessionRules, configStore,
gateway, // was the runtimeDeps bag
);
const gateRunner = new GateRunner(session, session, gateway, reporter);
index.ts drops the canRequestPermissionConfirmation closure and its now-unused imports of isSubagentExecutionContext and canResolveAskPermissionRequest (both relocate into the gateway).
PermissionPrompter is still built in index.ts and handed to the gateway.
Design-review check
- Dependency width:
PromptingGatewayDepshas 4 fields, every one read. Pass. - Law of Demeter:
this.deps.prompter.prompt(...)andthis.deps.config.current()are one-level calls on injected role interfaces, not stranger reach-throughs. Pass. - Output arguments: none;
canConfirmreturns a value,promptreturns a Promise. Pass. - Tell-Don't-Ask: the session tells the gateway to activate/deactivate (mirrors
forwarding.start/stop), rather than the gateway asking the session for context. Pass. - Procedure-splitting guard: the gateway owns state (
context) and returns values; it is a genuine collaborator, not a relocated statement block. Pass.
Module-Level Changes
Production:
src/prompting-gateway.ts(new) —PromptingGateway,PromptingGatewayDeps,PromptingGatewayLifecycle.src/gate-prompter.ts— rename the methodpromptPermission→promptonGatePrompter.src/handlers/gates/runner.ts—this.prompter.promptPermission(...)→this.prompter.prompt(...).src/permission-session.ts— removeimplements GatePrompter; deletecanPrompt,prompt,canConfirm,promptPermission; deletePermissionSessionRuntimeDepsand theruntimeDepsfield; add agateway: PromptingGatewayLifecycleconstructor parameter and forward it inactivate/deactivate; drop the now-unused imports (GatePrompter,PermissionPrompterApi,PromptPermissionDetails,PermissionPromptDecision).ExtensionContextstays (still used byactivate,getRuntimeContext,resolveAgentName).src/index.ts— constructPromptingGateway; pass it toPermissionSessionand asGateRunner's third argument; delete thecanRequestPermissionConfirmationclosure; drop theisSubagentExecutionContextandcanResolveAskPermissionRequestimports.
Tests:
test/prompting-gateway.test.ts(new) — unit tests for the gateway.test/permission-session.test.ts— deletemakeRuntimeDepsand the four promptingdescribeblocks (canConfirm,promptPermission,canPrompt,prompt); pass aPromptingGatewayLifecyclemock throughcreateSession; add assertions thatactivate/deactivateforward to the gateway.test/helpers/handler-fixtures.ts—MockGateHandlerSessiondrops& GatePrompterbut keepscanPrompt/prompt/canConfirm/promptPermissionas explicit test-only extras during the migration;makeHandlerbuilds a bridged prompter (delegating to those extras), accepts aprompter?override, returnsprompter, and passes it asGateRunner's third argument. Final cleanup later removes the extras, the casts, and the bridge.test/helpers/gate-fixtures.ts— rename themakeGateRunnerpromptPermissionoverride key →prompt.test/handlers/gates/runner.test.ts— renamepromptPermission→prompt(~12 call sites).test/handlers/input.test.ts,test/handlers/input-events.test.ts,test/handlers/tool-call-events.test.ts,test/handlers/external-directory-integration.test.ts— migrate prompting steering/assertions from the session to the prompter.test/handlers/external-directory-session-dedup.test.ts— its localmakeStatefulSession+makeHandlerForSessionmigrate the same way;session.promptassertions →prompter.prompt.
Docs:
docs/architecture/architecture.md— add aprompting-gateway.tsentry to the module layout (around line 495); update thegate-prompter.tsline tocanConfirm() + prompt(details); update thepermission-session.tsline to dropGatePrompterfrom the implements list and note prompting moved toPromptingGateway(#339); note the index-closure count 11 → 10. Mark roadmap Step 6✓ completeat ship time..pi/skills/package-pi-permission-system/SKILL.md— update thehandler-fixtures.tsdescription:makeSessionno longer carries prompting;makeHandlerexposes apromptermock for theGatePrompterrole.
Test Impact Analysis
- New tests the extraction enables —
test/prompting-gateway.test.tscan unit-test the prompting role directly, with no session fixture:canConfirm()returnsfalsebeforeactivate; afteractivate, returnstrue/falseacross the UI / subagent / yolo-mode permutations ofcanResolveAskPermissionRequest— previously only reachable through the session'sruntimeDepsclosure.prompt(details)delegates todeps.prompter.prompt(ctx, details)with the stored context, and rejects with the unactivated-session error beforeactivate.activate/deactivateset and clear the stored context (observable viacanConfirm).
- Tests that become redundant — the
canConfirm/promptPermission/canPrompt/promptdescribeblocks inpermission-session.test.tsmove to the gateway test;makeSession's prompting closures andundefined as unknown as ExtensionContextcasts are deleted in the final cleanup cycle. - Tests that must stay as-is — the rest of
permission-session.test.ts(state, lifecycle, config, resolve, skill entries);runner.test.ts(it exercises the prompting role through theGatePromptermock, nowprompt); the handler suites (they assert end-to-end gate behavior, now steered through the prompter).
TDD Order
The pivot is MockGateHandlerSession: removing fields from it breaks every constructor at once.
Lift-and-shift keeps the session's prompting fields alive as test-only extras with a bridge in makeHandler, migrates the handler suites file-by-file, then removes the extras last.
-
Add the gateway (additive). Surface:
test/prompting-gateway.test.ts. CoverscanConfirmpermutations,promptdelegation + rejection,activate/deactivate. Implementsrc/prompting-gateway.ts(method namedpromptPermissionto satisfy the currentGatePrompter); add the module-layout entry toarchitecture.md. Not wired yet. Commit:feat: add context-owning PromptingGateway. -
Wire the gateway; the session sheds the prompting role. Surface:
permission-session.ts,index.ts,permission-session.test.ts,handler-fixtures.ts,external-directory-session-dedup.test.ts(local fixtures only). Session dropsGatePrompter+ the four methods +runtimeDeps, gains the gateway param, forwards inactivate/deactivate;index.tsconstructs the gateway and passes it to the session andGateRunner;permission-session.test.tslosesmakeRuntimeDepsand the prompting blocks and gains forwarding assertions; the shared fixtures decoupleMockGateHandlerSessionfromGatePrompter, add the bridgedprompter(delegating to the retained extras) and theprompter?override, and pass it asGateRunner's third arg. Handler test cases stay green via the bridge. Update thepermission-session.tsline inarchitecture.md. Runpnpm run checkimmediately (shared-interface change with a single index call site). Commit:refactor: extract prompting into PromptingGateway; session sheds the prompting role. -
Rename the
GatePromptermethodpromptPermission→prompt. Surface:gate-prompter.ts,runner.ts,prompting-gateway.ts,gate-fixtures.ts,runner.test.ts, themakeHandlerbridge. Update thegate-prompter.tsline inarchitecture.md. Commit:refactor: rename GatePrompter.promptPermission to prompt. -
Migrate
input.test.tsto steer/assert the prompter. Commit:test: steer prompting via the gateway in input handler tests. -
Migrate
input-events.test.ts. Commit:test: steer prompting via the gateway in input-event tests. -
Migrate
tool-call-events.test.ts. Commit:test: steer prompting via the gateway in tool-call-event tests. -
Migrate
external-directory-integration.test.ts. Commit:test: steer prompting via the gateway in external-directory tests. -
Migrate
external-directory-session-dedup.test.ts(cases + its localmakeStatefulSession/makeHandlerForSession). Commit:test: steer prompting via the gateway in session-dedup tests. -
Final cleanup — drop the bridge. Surface:
handler-fixtures.ts(and the dedup local fixtures): remove thecanPrompt/prompt/canConfirm/promptPermissionextras and theundefined as unknown as ExtensionContextcasts;makeHandler's defaultprompterbecomes a cleanGatePromptermock; update theSKILL.mdfixture description. Commit:test: drop session prompting fixtures and undefined-context casts.
Smaller adjacent test files (e.g. input.test.ts and input-events.test.ts) may be grouped into a single cycle if each diff stays small; the large suites (external-directory-integration, external-directory-session-dedup) stay one-per-cycle.
Risks and Mitigations
- Dual context store — both the session (
this.context) and the gateway hold a context. Mitigation: the singleactivate/deactivatepath keeps them synchronized; the session's copy serves onlygetRuntimeContext/reload/logResolvedConfigPaths, which are out of scope here. Note it as a Step 8 (#341) consolidation candidate. - Big-bang fixture break — removing
GatePrompterfields fromMockGateHandlerSessioncould ripple through every handler suite at once. Mitigation: the lift-and-shift bridge keeps the extras alive until the suites are migrated; the type change in step 2 only touches the fixtures, not the cases. - Behavior drift in the relocated can-prompt policy — moving
canResolveAskPermissionRequestinto the gateway could subtly change the decision. Mitigation: the gateway'scanConfirmreproduces the exact closure (config.current(),ctx.hasUI,isSubagentExecutionContext(ctx, subagentSessionsDir, registry)); the new unit tests assert each permutation. - Transitional dead code — the gateway is exported but unwired after step 1.
Mitigation:
pnpm fallow dead-coderuns at pre-completion, after step 2 wires it.
Open Questions
- None blocking.
Consolidating the dual context store and splitting
makeSessionper-collaborator are explicitly deferred to Step 8 (#341).