15 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 316 | Fold PermissionPrompter.buildForwardingDeps() into the injected forwarder |
Fold PermissionPrompter.buildForwardingDeps() into the injected forwarder
Problem Statement
PermissionPrompter re-synthesizes its own PermissionForwardingDeps bag (buildForwardingDeps()) solely to call confirmPermission().
This is the second independent construction of the same dependency set — index.ts already assembles one for the PermissionForwarder introduced in #315.
The prompter's copy diverges subtly (shouldAutoApprove: () => false, a no-op writeDebugLog) and drags along a cluster of eslint-disable @typescript-eslint/unbound-method lines.
It is a relay bag the prompter builds only to hand to a free function — anemic design with no owner.
This is step 2 of 3 in the forwarding lift-and-shift (#315 → #316 → #317).
315 has landed: PermissionForwarder exists in src/forwarded-permissions/permission-forwarder.ts with requestApproval() (currently unused by production) and processInbox(), plus a narrow InboxProcessor seam consumed by ForwardingManager
Goals
- Inject the single
PermissionForwarderintoPermissionPrompterthrough a narrowApprovalRequesterinterface exposing onlyrequestApproval. - Replace the
confirmPermission(ctx, …, this.buildForwardingDeps(), …)call withthis.deps.forwarder.requestApproval(ctx, …). - Delete
buildForwardingDeps(), the secondPermissionForwardingDepssynthesis, and itseslint-disable unbound-methodcluster. - Narrow
PermissionPrompterDepsby removing the four fields that existed only to feedbuildForwardingDeps()(subagentSessionsDir,forwardingDir,registry,requestPermissionDecisionFromUi). - Wire
index.tsto inject the existing single forwarder into the prompter (no second forwarder, no second bag). - Behavior-preserving: this is a
refactor:, not afeat:.
Non-Goals
- Inlining the
polling.tsfree-function bodies as methods onPermissionForwarderor deleting thePermissionForwardingDepsinterface — that is #317 (step 3 of 3). - Changing the forwarding wire protocol, request/response file shapes, or the UI dialog flow.
- Touching
ForwardingManageror itsInboxProcessorseam (settled in #315). - Altering yolo-mode handling — it stays at the prompter level, evaluated before
requestApprovalis reached.
Background
Relevant modules:
src/permission-prompter.ts—PermissionPrompterclass,PermissionPrompterDeps, and the privatebuildForwardingDeps()being deleted.src/forwarded-permissions/permission-forwarder.ts—PermissionForwarder(already implementsInboxProcessor);requestApproval(ctx, message, options?, forwarded?)already exists and delegates toconfirmPermission. This is where the newApprovalRequesterseam belongs, mirroring theInboxProcessorconvention established in #315.src/index.ts— composition root; constructs the prompter (line ~52), then theforwardingDepsbag andforwarder(lines ~64–76), thenPermissionSession/ForwardingManager.src/forwarded-permissions/polling.ts—confirmPermission()and thePermissionForwardingDepsinterface (untouched this issue).test/permission-prompter.test.ts— currentlyvi.mockspollingand asserts againstmockConfirmPermission; must migrate to an injected forwarder mock.
Constraints from AGENTS.md / package skill:
- The package is the sole authority for tool policy; this refactor must not alter any allow/deny/ask decision.
@typescript-eslint/require-awaitis enabled forsrc/;requestApprovalalready returns the delegated promise, so noasyncchurn.- Markdown is enforced by
rumdl(pnpm run lint:md), notmarkdownlint— theMDxxxIDs in conventions are for reference only (per #315 retro). - The seam type must be a narrow interface, never the concrete
PermissionForwarder— concrete class types leak private fields into the structural checker and force test casts (code-design + design-review guidance, confirmed by #315'sInboxProcessorwin).
Design Overview
The ApprovalRequester seam
Define a one-method interface alongside InboxProcessor in permission-forwarder.ts and add it to the class's implements clause:
/**
* Narrow seam describing what `PermissionPrompter` needs from the forwarder:
* resolve a permission decision for the current context (prompt directly when
* the session has UI, otherwise forward to the parent).
*/
export interface ApprovalRequester {
requestApproval(
ctx: ExtensionContext,
message: string,
options?: RequestPermissionOptions,
forwarded?: ForwardedPromptDisplay,
): Promise<PermissionPromptDecision>;
}
export class PermissionForwarder implements InboxProcessor, ApprovalRequester {
// unchanged
}
requestApproval already has exactly this signature, so the class body is unchanged — only the implements clause and the new interface declaration are added.
Prompter consumption (Tell-Don't-Ask call site)
PermissionPrompter depends on the seam, not the concrete forwarder:
export interface PermissionPrompterDeps {
getConfig(): PermissionSystemExtensionConfig; // yolo-mode check
writeReviewLog(event: string, details: Record<string, unknown>): void;
events: PermissionEventBus; // permissions:ui_prompt broadcast
forwarder: ApprovalRequester; // resolve the decision (UI or forwarded)
}
Inside prompt(), the confirmPermission(...) call becomes a tell:
const decision = await this.deps.forwarder.requestApproval(
ctx,
details.message,
details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
{ source: uiPrompt.source, surface: uiPrompt.surface, value: uiPrompt.value },
);
The prompter no longer reaches into a bag — it tells the forwarder.
PermissionPrompterDeps drops from 7 fields to 4, and every remaining field is read by prompt() directly (passes design-review check 1: every consumer uses every field).
Behavioral nuance: debug logging on the prompter's forwarding path
The deleted buildForwardingDeps() supplied a no-op writeDebugLog and shouldAutoApprove: () => false.
The shared forwarder (built in index.ts) supplies the real runtime.writeDebugLog and the real yolo policy.
shouldAutoApprove—confirmPermissionnever calls it (onlyprocessForwardedPermissionRequestsdoes, on its own deps), so sharing the real policy is inert on this path. Decision is unchanged.writeDebugLog—confirmPermission's helpers (writeJsonFileAtomic,safeDeleteFile, etc.) passdeps.loggerthrough, so the subagent forwarding path will now emit real debug-level log lines instead of swallowing them.
This is the intended convergence: the #315/#316 plan deferred "trace-level forwarding debug" as an open question, and consolidating onto one forwarder resolves it. The effect is strictly additive debug output on a path that previously logged nothing; no allow/deny/ask decision, review-log entry, or wire message changes. Flagged in Risks below.
Edge cases
- Yolo-mode short-circuit stays ahead of
requestApproval; the forwarder is never consulted whenyoloModeis on (existing test coverage preserved). sessionLabeland the display fields (source/surface/value) are relayed unchanged through the new call — the four positional arguments map 1:1 to the oldconfirmPermissioncall.
Module-Level Changes
src/forwarded-permissions/permission-forwarder.ts- Add
export interface ApprovalRequester { requestApproval(...) }next toInboxProcessor. - Add
ApprovalRequesterto thePermissionForwarderimplementsclause. - No method-body changes.
- Add
src/permission-prompter.ts- Add
forwarder: ApprovalRequestertoPermissionPrompterDeps; removesubagentSessionsDir,forwardingDir,registry,requestPermissionDecisionFromUi. - Replace the
confirmPermission(...)call inprompt()withthis.deps.forwarder.requestApproval(...). - Delete the private
buildForwardingDeps()method. - Remove now-unused imports:
confirmPermissionandPermissionForwardingDepsfrom./forwarded-permissions/polling,ForwardedPermissionLoggerfrom./forwarded-permissions/io,SubagentSessionRegistry, andRequestPermissionOptionsif no longer referenced (lint will confirm). - Add the
ApprovalRequestertype import from./forwarded-permissions/permission-forwarder. - Update the
PermissionPrompterDepsdoc comment ("synthesises the PermissionForwardingDeps it needs internally" is no longer true).
- Add
src/index.ts- Construct
forwardingDeps+forwarderbefore the prompter, then passforwarderintonew PermissionPrompter({ … }). - Remove the four dropped fields (
subagentSessionsDir,forwardingDir,registry,requestPermissionDecisionFromUi) from the prompter's deps literal — TypeScript excess-property checking rejects them once the interface narrows, so this must land in the same commit. forwardingDeps/forwarderremain (still consumed byForwardingManager); no second forwarder.
- Construct
test/permission-prompter.test.ts- Remove
vi.mock("../src/forwarded-permissions/polling")and the hoistedmockConfirmPermission. - Add a hoisted
mockRequestApprovaland injectforwarder: { requestApproval: mockRequestApproval }viamakeDeps. - Drop
subagentSessionsDir/forwardingDir/requestPermissionDecisionFromUifrom themakeDepsdefaults. - Re-point every
mockConfirmPermissionassertion tomockRequestApproval; the argument matchers shift by one position (the deps bag argument is gone, so the matchers become(ctx, message, options, forwarded)). - Reset/seed
mockRequestApprovalinbeforeEach.
- Remove
packages/pi-permission-system/docs/architecture/permission-prompter.md- Update the
PermissionPrompterDepsinterface block (4 fields, addforwarder: ApprovalRequester). - Replace the "Relationship to PermissionForwardingDeps" section: the prompter no longer constructs a bag; it depends on the injected
ApprovalRequester. - Refresh the "Wiring" note to show the forwarder injection.
- Update the
packages/pi-permission-system/docs/architecture/architecture.md- Mark Phase 3 Step 3 (#316)
✅with a past-tense outcome and forward reference to #317 (following the #315 status-convention precedent). - Update the Track-B roadmap row / Mermaid status node if it tracks per-step completion.
- Mark Phase 3 Step 3 (#316)
Test Impact Analysis
This is a seam swap, not a new extraction, so the test surface shifts rather than expands.
- New tests enabled — the prompter can now be tested against a trivially injected
{ requestApproval: vi.fn() }with no module mock. This removes thevi.mock("…/polling")indirection and makes the prompter's collaboration with the forwarder explicit and assertable (design-review check 6: mock depth drops, no casts). - Tests simplified — all assertions migrate from
mockConfirmPermission(module mock) tomockRequestApproval(injected mock); the deps-bag positional argument disappears, so matchers get simpler. No test is deleted — each still exercises a distinct prompter behavior (yolo short-circuit, waiting/approved/denied logging, UI-prompt emission, sessionLabel/display-field relay, forwarding path). - Tests that stay as-is —
test/permission-forwarder.test.tsalready coversrequestApproval's delegation toconfirmPermission(the layer being depended upon); it is untouched.test/composition-root.test.tsexercises real wiring and should stay green without edits (verify the forwarder-before-prompter reorder does not perturb it).
TDD Order
- Swap the prompter onto the injected
ApprovalRequesterseam (refactor:)- Test surface:
test/permission-prompter.test.ts. - Red: migrate the suite to inject
forwarder: { requestApproval: mockRequestApproval }, drop the polling module mock and the four removed deps, and re-point assertions tomockRequestApprovalwith the shifted argument positions. The suite fails to compile/run until production changes land. - Green: add
ApprovalRequestertopermission-forwarder.ts(+implements), narrowPermissionPrompterDeps, replace theconfirmPermissioncall withthis.deps.forwarder.requestApproval, deletebuildForwardingDeps()and itseslint-disablelines and now-unused imports, and updateindex.tsto construct the forwarder before the prompter and inject it (removing the four stale fields from the deps literal). - This is one atomic commit: narrowing the interface and removing
buildForwardingDepsbreakindex.ts(excess properties) and the test (missingforwarder) at the type level simultaneously, so production, wiring, and test migration cannot be separated. - Suggested message:
refactor: inject forwarder into PermissionPrompter, delete buildForwardingDeps (#316)
- Test surface:
- Update architecture docs (
docs:)- Refresh
docs/architecture/permission-prompter.md(deps interface, forwarder relationship, wiring) and mark Phase 3 Step 3✅indocs/architecture/architecture.md. - Suggested message:
docs: record forwarder injection into PermissionPrompter (#316)
- Refresh
Run after each step: pnpm --filter @gotgenes/pi-permission-system run check, run lint, run test, then pnpm fallow dead-code before handoff.
Risks and Mitigations
- Debug-log behavior change on the forwarding path — the prompter's forwarding path gains real
writeDebugLogoutput (was no-op). Mitigation: intended convergence (resolves the deferred debug open question); strictly additive debug-level output, no decision/log/wire change. Documented in Design Overview. - Argument-position drift in test assertions — removing the deps-bag positional argument shifts every
toHaveBeenCalledWithmatcher by one. Mitigation: migrate matchers mechanically and rely oncheck/testto catch any stale matcher; assert the exact 4-argument shape (ctx, message, options, forwarded). index.tsordering regression — the forwarder must exist before the prompter literal references it. Mitigation: reorder construction in the same commit;composition-root.test.tsverifies real wiring stays green.- Unused-import lint churn — removing
buildForwardingDepsorphans several imports. Mitigation:run lint(eslint auto-detects) catches and the implementer prunes them in the same commit.
Open Questions
- Whether to keep
RequestPermissionOptionsimported inpermission-prompter.tsdepends on whether the inline{ sessionLabel }literal still references the type after the swap — defer to the type checker during implementation; prune if unused. - #317 will dismantle
PermissionForwardingDepsand inline thepolling.tsbodies as forwarder methods; nothing in this plan should pre-empt that (keep the delegation intact).