24 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 329 | Extract a SkillInputGatePipeline for the handleInput skill-input gate |
Extract a SkillInputGatePipeline
Problem Statement
PermissionGateHandler.handleInput still hand-assembles the skill-input permission gate inline: a raw checkPermission pre-check, a deny notify, the describeSkillInputGate descriptor, a request-id mint, and the runner.run(...) call.
This is gate-construction work with no owner — the same shape ToolCallGatePipeline.evaluate already owns for the six tool-call gates (#327).
Because the assembly is inline, handleInput reaches the session for checkPermission and createPermissionRequestId, which is the only reason the handler's GateHandlerSession role still carries four members instead of the two-member context role (activate + resolveAgentName) both entry points actually share.
Extracting a SkillInputGatePipeline collaborator makes the input path symmetric with the tool_call path and lets GateHandlerSession shrink to its context role.
Goals
- Introduce a
SkillInputGatePipelinecollaborator that owns the skill-input gate assembly (pre-check, deny notify, descriptor, request-id mint, run) and exposes anevaluate(...)seam. - Construct the pipeline in the composition root (
index.ts) and inject it intoPermissionGateHandler. - Reduce
handleInputtoactivate → resolveAgentName → extract skill name → pipeline.evaluate → map outcome, symmetric withhandleToolCall. - Shrink
GateHandlerSessionto the two-method context role (activate,resolveAgentName);checkPermissionandcreatePermissionRequestIdleave the handler's session surface. - Fold the request-id minting (formerly
PermissionSession.createPermissionRequestId) into the pipeline and remove it fromPermissionSession— this absorbs the scope of #330, which can be closed when this lands. - Behavior-preserving: the untagged skill-input deny notify, the raw-
checkPermission(no-session-rules) semantics (#326), and the request-id format are all preserved.
Non-Goals
- Retyping
AgentPrepHandler/SessionLifecycleHandleragainst role interfaces — that is #331. - Reframing
index.tsas collaborator injection — that is #320; this plan injects the new collaborator properly but leaves the surrounding factory shape alone. - Trimming the
input*.test.tshandler integration tests that now overlap with the new pipeline unit tests — deferred to #321. - Changing any permission decision, emitted event, log entry, or user-facing message text.
Background
Relevant modules:
src/handlers/permission-gate-handler.ts—PermissionGateHandler;handleInputhand-rolls the skill-input gate, whilehandleToolCallalready delegates to the injectedToolCallGatePipeline. Constructor today is(session: GateHandlerSession, toolRegistry, pipeline: ToolCallGatePipeline, runner: GateRunner).src/gate-handler-session.ts—GateHandlerSession, the four-method role the handler'ssessionis typed against (activate,resolveAgentName,checkPermission,createPermissionRequestId); its own doc comment flags [#329] as the issue that shrinks it.src/handlers/gates/skill-input.ts—describeSkillInputGate(skillName, agentName, preCheck), the pure descriptor factory (#326); the pipeline imports it unchanged.src/handlers/gates/tool-call-gate-pipeline.ts—ToolCallGatePipeline+ToolCallGateInputs; the structural model this plan mirrors.src/handlers/gates/runner.ts—GateRunner.run(gate, agentName, toolCallId); reused per gate (#323).src/handlers/gates/types.ts—GateOutcome({ action: "allow" } | { action: "block"; reason }).src/permission-session.ts—PermissionSession; ownscheckPermissionandcreatePermissionRequestId, andimplements GateHandlerSession.src/index.ts— composition root; constructs the session,ToolCallGatePipeline,GateRunner, andPermissionGateHandler.
Constraints from AGENTS.md and the code-design skill:
- Default to dependency injection for non-trivial collaborators; inject the new pipeline rather than constructing it in the handler.
- Use a narrow interface type for an injected collaborator, not the concrete
PermissionSession(avoidsas unknown ascasts). @typescript-eslint/require-awaitis enabled forsrc/:evaluatehas noawaitof its own (it returnsrunner.run(...)), so it must be a non-asyncfunction returningPromise<GateOutcome>, not anasyncfunction with noawait.createPermissionRequestIdreadsDate.now()/Math.random()/process.pid; it is a handler-layer (SDK-consumer) helper, not a pure library util, so relocating it into the pipeline module is in keeping with the SDK-boundary rule.- The pipeline lives under
src/handlers/gates/(a handler/SDK-consumer layer), so importing the descriptor factory and SDK context types is fine.
Design Overview
New collaborator: SkillInputGatePipeline
A class in src/handlers/gates/skill-input-gate-pipeline.ts that owns the skill-input gate assembly and depends on a narrow SkillInputGateInputs interface — never the concrete PermissionSession.
export interface SkillInputGateInputs {
/** Raw permission check (no session rules) — preserves #326 skill-input semantics. */
checkPermission(
surface: string,
input: unknown,
agentName?: string,
): PermissionCheckResult;
}
/** Narrow UI seam: warn the user if an interactive UI is available, else no-op. */
export interface GateNotifier {
warn(message: string): void;
}
export class SkillInputGatePipeline {
constructor(private readonly inputs: SkillInputGateInputs) {}
evaluate(
skillName: string,
agentName: string | null,
notifier: GateNotifier,
runner: GateRunner,
): Promise<GateOutcome> {
const check = this.inputs.checkPermission(
"skill",
{ name: skillName },
agentName ?? undefined,
);
if (check.state === "deny") {
notifier.warn(formatSkillDenyNotice(skillName, agentName));
}
return runner.run(
describeSkillInputGate(skillName, agentName, check),
agentName,
createSkillInputRequestId(),
);
}
}
PermissionSession satisfies SkillInputGateInputs structurally at the construction call site (new SkillInputGatePipeline(session)); no implements clause is added, so the domain module never imports upward from the handler layer (same pattern ToolCallGatePipeline uses).
Why the pipeline owns the request-id mint (absorbing #330)
The user-settled decision for this plan is to fold request-id generation into the pipeline now rather than deferring to #330.
createPermissionRequestId touches zero session state — it is a misplaced utility on the session god-object whose sole caller is the skill-input assembly.
Folding it in means the pipeline mints its own id and PermissionSession.createPermissionRequestId is removed outright, so #330 is satisfied by this issue and can be closed when this ships.
The minter relocates as a small module-level helper so its format/uniqueness tests have a direct target:
/** Mint a unique id for a skill-input permission request. Format preserved from #330. */
export function createSkillInputRequestId(): string {
return `skill-input-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
}
The prefix parameter is dropped — the pipeline only ever minted "skill-input" ids.
Why the runner is passed per-call, not injected into the pipeline
GateRunner is shared: the handler holds it and both handleToolCall (via ToolCallGatePipeline) and handleInput (via SkillInputGatePipeline) pass it to evaluate.
This mirrors ToolCallGatePipeline.evaluate(tcc, runner) exactly and avoids dual ownership of the runner.
The notifier seam (Tell-Don't-Ask split)
The deny notify needs two facts: the permission decision (deny) and whether an interactive UI exists.
The pipeline owns the decision; the UI availability is per-event context.
Splitting them keeps the pipeline free of ExtensionContext: the pipeline tells the notifier to warn, and the notifier (built in the handler from ctx) decides whether a UI is present.
// handleInput — builds the notifier from ctx, then tells the pipeline to evaluate
const notifier: GateNotifier = {
warn: (message) => {
if (ctx.hasUI) {
ctx.ui.notify(message, "warning");
}
},
};
const outcome = await this.skillInputPipeline.evaluate(
skillName,
agentName,
notifier,
this.runner,
);
Net behavior is identical to today's if (check.state === "deny" && ctx.hasUI) ctx.ui.notify(...): the pipeline calls warn only on deny; the notifier delivers only when hasUI.
The notify message stays untagged (no [pi-permission-system] prefix) — distinct from the gate deny reasons the runner routes through formatDenyReason:
function formatSkillDenyNotice(skillName: string, agentName: string | null): string {
return agentName
? `Skill '${skillName}' is not permitted for agent '${agentName}'.`
: `Skill '${skillName}' is not permitted by the current skill policy.`;
}
handleInput after the change
async handleInput(
event: InputPayload,
ctx: ExtensionContext,
): Promise<InputEventResult> {
this.session.activate(ctx);
const skillName = extractSkillNameFromInput(event.text);
if (!skillName) {
return { action: "continue" };
}
const agentName = this.session.resolveAgentName(ctx);
const notifier: GateNotifier = {
warn: (message) => {
if (ctx.hasUI) {
ctx.ui.notify(message, "warning");
}
},
};
const outcome = await this.skillInputPipeline.evaluate(
skillName,
agentName,
notifier,
this.runner,
);
return outcome.action === "block"
? { action: "handled" }
: { action: "continue" };
}
The handler no longer calls session.checkPermission, session.createPermissionRequestId, or imports describeSkillInputGate.
GateHandlerSession shrinks to the context role
export interface GateHandlerSession {
activate(ctx: ExtensionContext): void;
resolveAgentName(ctx: ExtensionContext): string | null;
}
checkPermission and createPermissionRequestId are removed; the PermissionCheckResult import in gate-handler-session.ts goes with them.
PermissionSession implements GateHandlerSession still holds (it has both remaining methods); checkPermission stays on PermissionSession (used by resolve and structurally by SkillInputGateInputs), while createPermissionRequestId is removed entirely.
Handler constructor
constructor(
private readonly session: GateHandlerSession,
private readonly toolRegistry: ToolRegistry,
private readonly pipeline: ToolCallGatePipeline,
private readonly skillInputPipeline: SkillInputGatePipeline,
private readonly runner: GateRunner,
) {}
The fifth collaborator sits at the dependency-width threshold; all five are distinct injected collaborators (not a relay bag), and grouping them is #320's concern — track and watch, do not address here.
Composition-root wiring
const skillInputGatePipeline = new SkillInputGatePipeline(session);
const gates = new PermissionGateHandler(
session,
toolRegistry,
toolCallGatePipeline,
skillInputGatePipeline,
gateRunner,
);
Edge cases (all behavior-preserving)
- Non-skill input:
extractSkillNameFromInputreturnsnull;handleInputreturns{ action: "continue" }before the pipeline is touched —checkPermissionis never called (the existing "does not check permissions for non-skill input" test still holds). - Deny + no UI: pipeline calls
notifier.warn; the notifier no-ops becausehasUIis false —ctx.ui.notifyis not called. - Ask + no confirmation: handled inside
runner.runexactly as today (confirmation_unavailable). - Request-id format/uniqueness: preserved by
createSkillInputRequestId.
Module-Level Changes
| File | Change |
|---|---|
src/handlers/gates/skill-input-gate-pipeline.ts |
New. SkillInputGateInputs + GateNotifier interfaces, SkillInputGatePipeline class, and the createSkillInputRequestId + formatSkillDenyNotice helpers. |
src/handlers/permission-gate-handler.ts |
Add injected skillInputPipeline constructor param. Rewrite handleInput to build the notifier and delegate to pipeline.evaluate. Remove the inline checkPermission / notify / describeSkillInputGate / createPermissionRequestId block and the now-unused describeSkillInputGate import; add GateNotifier + SkillInputGatePipeline imports. |
src/gate-handler-session.ts |
Shrink GateHandlerSession to activate + resolveAgentName; remove checkPermission, createPermissionRequestId, the PermissionCheckResult import, and refresh the doc comment (drop the "transitional" note). |
src/permission-session.ts |
Remove the createPermissionRequestId method; drop any import left unused by its removal. checkPermission stays. |
src/index.ts |
Construct SkillInputGatePipeline and inject it into PermissionGateHandler (fifth arg). |
test/helpers/handler-fixtures.ts |
MockGateHandlerSession and makeSession drop createPermissionRequestId. makeHandler builds a real SkillInputGatePipeline(session) and injects it. |
test/helpers/gate-fixtures.ts |
Add makeSkillInputInputs (mock of SkillInputGateInputs) and makeNotifier (GateNotifier mock with a warn vi.fn()). |
test/handlers/gates/skill-input-gate-pipeline.test.ts |
New. Pipeline unit tests. |
test/handlers/external-directory-integration.test.ts |
Local makeHandler constructs + injects SkillInputGatePipeline; local session mock drops createPermissionRequestId. |
test/handlers/external-directory-session-dedup.test.ts |
Same construction + mock update as above. |
test/permission-session.test.ts |
Remove the createPermissionRequestId describe block (behavior relocated to the pipeline). |
test/composition-root.test.ts |
Verify handler registration / shared-instance wiring is unchanged; update only if it asserts the handler's constructor arity. |
docs/architecture/architecture.md |
Add skill-input-gate-pipeline.ts to the module tree; refresh the gate-handler-session.ts (now two-method) and permission-gate-handler.ts (fifth collaborator) descriptions; remove createPermissionRequestId from the permission-session.ts description; mark Step 12 done and note Step 13 (#330) folded into Step 12. |
.pi/skills/package-pi-permission-system/SKILL.md |
Document makeSkillInputInputs + makeNotifier in the gate-fixtures.ts inventory. |
Grep confirmation (every removed symbol): createPermissionRequestId appears only in gate-handler-session.ts, permission-session.ts, permission-gate-handler.ts, permission-session.test.ts, the two external-directory-*.test.ts local mocks, and handler-fixtures.ts — all listed above.
Test Impact Analysis
New unit tests the extraction enables (previously only reachable through the full handleInput path):
skill-input-gate-pipeline.test.ts— the pipeline in isolation:- deny →
notifier.warncalled with a message containing the skill name, anddescribeSkillInputGaterun with thepreCheck; - allow / ask →
notifier.warnnot called; outcome maps fromrunner.run; - block outcome →
{ action: "block" }; allow outcome →{ action: "allow" }; createSkillInputRequestIdformat (startsWith("skill-input-")) and uniqueness across calls (relocated frompermission-session.test.ts).- Uses
makeSkillInputInputs(cast-free single-method mock) +makeGateRunner(real runner with role mocks) +makeNotifier.
- deny →
Existing tests that become partially redundant but stay (behavior-preserving; trimming deferred to #321):
input.test.ts,input-events.test.ts— exercise the skill-input flow throughhandleInput; they remain valid integration coverage and still pass through the real handler → pipeline → notifier path after themakeSessionmock update. They now overlap with the pipeline unit tests; flag as #321 candidates, do not delete here.
Existing tests that must stay as-is (genuinely exercise their layer):
external-directory-integration.test.ts,external-directory-session-dedup.test.ts— drive the tool-call path; the constructor-arity + mock edits are mechanical, the assertions unchanged.permission-session.test.ts— only thecreatePermissionRequestIdblock moves;checkPermission,resolve, and the rest are untouched.
TDD Order
-
Introduce the pipeline. Add
SkillInputGateInputs+GateNotifier+SkillInputGatePipeline+createSkillInputRequestId+formatSkillDenyNoticein the new module, plusmakeSkillInputInputsandmakeNotifieringate-fixtures.ts. Writeskill-input-gate-pipeline.test.ts(deny-notify, allow/ask no-notify, block/allow mapping, request-id format + uniqueness). The pipeline is not yet wired into the handler. Runpnpm run check. Commit:feat: introduce SkillInputGatePipeline collaborator (#329). -
Inject the pipeline, rewrite
handleInput, and remove the request-id minter. This is one commit because the constructor-arity change and theGateHandlerSession/PermissionSessionshrink break every call site and thecreatePermissionRequestIdconsumers at the type level simultaneously:- add the injected
skillInputPipelineparam toPermissionGateHandler; rewritehandleInputto build the notifier and delegate; drop thedescribeSkillInputGateimport; - shrink
GateHandlerSessiontoactivate+resolveAgentName; removePermissionSession.createPermissionRequestId; - construct + inject
SkillInputGatePipelineinindex.ts; - update
makeHandler/makeSession/MockGateHandlerSessionand the twoexternal-directory-*.test.tslocalmakeHandler+ session mocks (construct the pipeline, dropcreatePermissionRequestId); - remove the
createPermissionRequestIddescribe block frompermission-session.test.ts. Verifycomposition-root.test.ts. Run the full suite (pnpm --filter @gotgenes/pi-permission-system exec vitest run),pnpm run check, andpnpm fallow dead-code(confirmcreatePermissionRequestIdand the old inline assembly are gone). Commit:refactor: delegate skill-input gate construction to injected SkillInputGatePipeline (#329, #330).
- add the injected
-
Update docs. Add
skill-input-gate-pipeline.tsto thearchitecture.mdmodule tree; refresh thegate-handler-session.ts,permission-gate-handler.ts, andpermission-session.tsdescriptions; mark roadmap Step 12 done and note Step 13 (#330) folded in; documentmakeSkillInputInputs+makeNotifierin the package SKILL. Commit:docs: document SkillInputGatePipeline in architecture and package skill (#329).
Risks and Mitigations
- Session mocks missing the new shape → runtime surprise.
MockGateHandlerSessioncasts away from the concrete class, so a dropped field can pass typecheck but fail at runtime. Mitigation: step 2 updates every session mock on the handler path and runs the full suite, not just the typecheck. - Notify behavior drift when the
hasUIgate moves into the notifier closure. Mitigation: the deny/no-deny and UI-present/absent cases are pinned by the existinginput.test.tsnotify tests (unchanged) plus the new pipelinenotifier.warnunit assertions. - Request-id format regression when the minter relocates.
Mitigation: the format + uniqueness tests move to the pipeline unit test against
createSkillInputRequestId; the expression is copied verbatim. - Closing #330 prematurely.
Mitigation: the plan removes
createPermissionRequestIdoutright andpnpm fallow dead-codeconfirms no residual caller; #330 is closed only after this ships. index.tswiring regression. Mitigation:composition-root.test.ts(themake-fake-pi.tsharness) covers handler registration and shared-instance contracts.