14 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 315 | Introduce a PermissionForwarder collaborator that owns forwarding state |
Introduce a PermissionForwarder collaborator
Problem Statement
The forwarding subsystem is half-converted to a class-based design.
The polling lifecycle already has an owner (ForwardingManager), but the forwarding behavior still lives as three free functions in src/forwarded-permissions/polling.ts (confirmPermission, waitForForwardedPermissionApproval, processForwardedPermissionRequests).
Each of those functions reaches into an 8-member PermissionForwardingDeps bag, and that bag is assembled in two places: once in index.ts and again, independently and with divergent values, in PermissionPrompter.buildForwardingDeps().
That is an anemic design — the forwarding state has no owner, so callers thread a bag and reach into it instead of telling an object what to do.
This issue is the first of a three-step lift-and-shift: introduce the class (this issue), fold the prompter's duplicate bag into it (#316), then inline the polling logic and delete the interface (#317).
Goals
- Add a
PermissionForwarderclass that owns the forwarding dependency set and exposes two behavior methods:requestApprovalandprocessInbox. - Wire
ForwardingManagerto tell aPermissionForwarder(forwarder.processInbox(ctx)per tick) instead of threading aPermissionForwardingDepsbag. - Construct exactly one forwarder instance in
index.tsand inject it intoForwardingManager. - Preserve behavior exactly — the methods delegate to the existing
polling.tsfree functions this issue (lift-and-shift, not behavior change).
Non-Goals
- Do not touch
PermissionPrompter.buildForwardingDeps()or its secondPermissionForwardingDepssynthesis — that is #316. - Do not inline the polling-function bodies into the forwarder or delete the
PermissionForwardingDepsinterface — that is #317. - Do not change the
confirmPermission/processForwardedPermissionRequestssignatures; they keep acceptingPermissionForwardingDepsso the prompter (untouched this issue) still calls them directly. - Do not change the
polling.tsmodule or itspermission-forwarding.test.tscoverage — the free functions are unchanged.
Background
Relevant modules:
src/forwarded-permissions/polling.ts— declaresPermissionForwardingDeps(8 members) and the three free functions.confirmPermission(ctx, message, deps, options?, forwarded?)returnsPromise<PermissionPromptDecision>; it branches UI-present vs. subagent-forwarding and delegates towaitForForwardedPermissionApproval.processForwardedPermissionRequests(ctx, deps)returnsPromise<void>and drains the parent's request inbox.src/forwarding-manager.ts—ForwardingManagerowns the poll timer, current context, and processing lock. Today its constructor takes(subagentSessionsDir, forwardingDeps: PermissionForwardingDeps, registry?)and the tick callsprocessForwardedPermissionRequests(this.context, this.forwardingDeps). It already exposes a narrowForwardingControllerinterface (start/stop) thatPermissionSessiondepends on — the package's established convention for collaborator seams.src/index.ts— the composition root assembles theforwardingDepsbag literal and threads it intonew ForwardingManager(...).
Constraints from AGENTS.md and the loaded skills:
- ES2024 target; pnpm only.
- Import siblings via
#src//#test/path aliases, not relative paths. - When a shared interface references a collaborator, use a narrow interface type, not the concrete class — so test mocks need no casts (code-design / design-review).
- Lift-and-shift sequencing: introduce-new-alongside-old, remove-old-last (architecture roadmap, Phase 3, Step 2).
Design Overview
The collaborator
PermissionForwarder is the missing owner for the forwarding dependency set.
For this lift-and-shift step it holds the existing PermissionForwardingDeps bag privately and delegates each method to the matching free function:
/** Narrow seam: what ForwardingManager needs from the forwarder. */
export interface InboxProcessor {
processInbox(ctx: ExtensionContext): Promise<void>;
}
export class PermissionForwarder implements InboxProcessor {
constructor(private readonly deps: PermissionForwardingDeps) {}
requestApproval(
ctx: ExtensionContext,
message: string,
options?: RequestPermissionOptions,
forwarded?: ForwardedPromptDisplay,
): Promise<PermissionPromptDecision> {
return confirmPermission(ctx, message, this.deps, options, forwarded);
}
processInbox(ctx: ExtensionContext): Promise<void> {
return processForwardedPermissionRequests(ctx, this.deps);
}
}
Both methods return the delegate promise directly (no async/await), so @typescript-eslint/require-await does not fire.
Decision: reuse PermissionForwardingDeps as the constructor parameter
The issue frames the forwarder as owning the individual fields (forwardingDir, subagentSessionsDir, registry, events, logger, shouldAutoApprove).
Those six plus the two the issue omits (writeReviewLog, requestPermissionDecisionFromUi) are exactly the eight members of PermissionForwardingDeps, and the delegated free functions still require the full bag this issue.
Defining a separate PermissionForwarderDeps interface now would duplicate PermissionForwardingDeps field-for-field with no benefit, then be deleted in #317.
So the constructor reuses PermissionForwardingDeps; shouldAutoApprove arrives as a constructor-supplied policy (it is set once at construction, never re-assigned).
The "owns individual fields" end state is realized in #317, when the bag interface is deleted and the polling bodies are inlined as methods reading this.
The narrow seam
ForwardingManager only ever calls processInbox, so it depends on the one-method InboxProcessor interface, not the concrete PermissionForwarder.
This mirrors the existing ForwardingController seam the package already uses for PermissionSession → ForwardingManager, keeps forwarding-manager.test.ts free of as unknown as casts (it can inject a plain { processInbox: vi.fn() } mock), and does not constrain #316/#317.
requestApproval is not on the seam — it exists for #316, when the prompter will consume it via a separate narrow ApprovalRequester interface.
ForwardingManager call site
constructor(
private readonly subagentSessionsDir: string,
private readonly forwarder: InboxProcessor,
private readonly registry?: SubagentSessionRegistry,
) {}
// inside the tick:
void this.forwarder.processInbox(this.context).finally(() => {
this.processing = false;
});
subagentSessionsDir and registry stay (still used for isSubagentExecutionContext); only the forwardingDeps field is replaced by forwarder.
index.ts wiring
The forwardingDeps bag literal stays in index.ts this issue (it feeds the forwarder constructor); #317 removes it.
The change is to construct the forwarder and pass it instead of the bag:
const forwardingDeps: PermissionForwardingDeps = { /* unchanged */ };
const forwarder = new PermissionForwarder(forwardingDeps);
// ...
new ForwardingManager(runtime.subagentSessionsDir, forwarder, subagentRegistry),
PermissionPrompter construction is untouched.
Edge cases
- Behavior is byte-for-byte unchanged: the same
ctx,deps,options, andforwardedvalues reach the same free functions. composition-root.test.tsruns the realindex.tsthroughmake-fake-pi.ts; it constructs the real forwarder and must stay green.runtime.test.tsmockspollingdefensively but never constructsForwardingManager; no change expected (verify it still compiles).
Module-Level Changes
src/forwarded-permissions/permission-forwarder.ts(new) —InboxProcessorinterface andPermissionForwarderclass; importsconfirmPermission,processForwardedPermissionRequests, andPermissionForwardingDepsfrom./polling, plus the SDKExtensionContextand theRequestPermissionOptions/PermissionPromptDecision/ForwardedPromptDisplaytypes the method signatures reference.src/forwarding-manager.ts— replace theforwardingDeps: PermissionForwardingDepsconstructor field withforwarder: InboxProcessor; change the tick to callthis.forwarder.processInbox(this.context); drop the now-unusedprocessForwardedPermissionRequests/PermissionForwardingDepsimports and add theInboxProcessorimport.src/index.ts— constructconst forwarder = new PermissionForwarder(forwardingDeps)and passforwardertonew ForwardingManager(...)in place offorwardingDeps; add thePermissionForwarderimport (thePermissionForwardingDepsimport stays — the bag literal is still built here until #317).test/permission-forwarder.test.ts(new) — unit tests for delegation (see Test Impact Analysis).test/forwarding-manager.test.ts— replacemakeForwardingDeps()+ thevi.mock("../src/forwarded-permissions/polling")setup with an injected{ processInbox: vi.fn() }forwarder mock; update tick assertions frommockProcessForwardedPermissionRequeststo the mock'sprocessInbox; drop theas unknown as PermissionForwardingDepscast.
No architecture-doc layout/metric tables reference these specific files by path beyond the Phase 3 roadmap entry (which already names them and predicts this outcome), so no architecture-doc edit is required for this step.
Test Impact Analysis
- New tests the extraction enables.
test/permission-forwarder.test.tscan unit-test the forwarder in isolation by mocking./polling: assertrequestApproval(ctx, msg, options, forwarded)callsconfirmPermission(ctx, msg, deps, options, forwarded)and returns its result; assertprocessInbox(ctx)callsprocessForwardedPermissionRequests(ctx, deps). Previously there was no class to test — the delegation logic did not exist as a unit. - Tests that become simpler.
forwarding-manager.test.tscurrently fabricates a fullPermissionForwardingDepsviamakeForwardingDeps()and casts it withas unknown as. With theInboxProcessorseam it injects a one-method mock and assertsprocessInboxis called with the latest context — the cast and the fake bag disappear. - Tests that stay as-is.
permission-forwarding.test.tsexercises thepolling.tsfree functions directly; those functions are unchanged, so its coverage stays exactly as-is.composition-root.test.tskeeps verifying end-to-end wiring through the realindex.ts.
TDD Order
- Add the
PermissionForwardercollaborator (red → green → commit). Surface: newtest/permission-forwarder.test.tswithvi.mock("#src/forwarded-permissions/polling", ...)(hoistedvi.fn()stubs reset inbeforeEach). Covers:requestApprovaldelegates toconfirmPermissionwith the stored deps and forwards the return value;processInboxdelegates toprocessForwardedPermissionRequests. Implementsrc/forwarded-permissions/permission-forwarder.tsto pass. This step is purely additive — no existing module changes, so the suite stays green. Commit:refactor: add PermissionForwarder collaborator delegating to polling (#315). - Wire
ForwardingManagerandindex.tsto the forwarder (red → green → commit). This is one atomic step: the constructor signature change forces theindex.tscall site and theforwarding-manager.test.tsmock to update in the same commit (the type checker rejects splitting them). Surface: updatetest/forwarding-manager.test.tsto inject a{ processInbox: vi.fn() }InboxProcessormock and assertprocessInboxis called per tick / with the latest context / skipped while processing; then changesrc/forwarding-manager.tsto holdInboxProcessorand callforwarder.processInbox; then updatesrc/index.tsto construct and inject the forwarder. Runpnpm run checkimmediately after (shared-interface change) and the fullpnpm -r run test(the wiring touches the composition-root suite). Commit:refactor: wire ForwardingManager and index to PermissionForwarder (#315).
Risks and Mitigations
- Risk: a behavior change sneaks in during the rewire.
Mitigation: lift-and-shift only — the forwarder passes the identical
depsstraight through;permission-forwarding.test.tsandcomposition-root.test.ts(unchanged) guard the round-trip behavior. - Risk:
forwarding-manager.test.tsrewrite changes what is actually asserted. Mitigation: keep the same test scenarios (idempotent start, context update, processing-lock skip, no-UI/subagent stop) and only swap the polling-module mock for the injectedInboxProcessormock. - Risk: leaving
PermissionForwarderbriefly unconsumed by production after Step 1. Mitigation: the test imports it immediately, and Step 2 lands the production consumer in the same PR;fallow dead-code(run at pre-completion) evaluates the final state, which has anindex.tsconsumer. - Risk: a stale
processForwardedPermissionRequests/PermissionForwardingDepsimport lingers inforwarding-manager.ts. Mitigation: remove them in Step 2;pnpm run lint(no-unused) catches any miss.