mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
331 lines
24 KiB
Markdown
331 lines
24 KiB
Markdown
---
|
|
issue: 329
|
|
issue_title: "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 `SkillInputGatePipeline` collaborator that owns the skill-input gate assembly (pre-check, deny notify, descriptor, request-id mint, run) and exposes an `evaluate(...)` seam.
|
|
- Construct the pipeline in the composition root (`index.ts`) and inject it into `PermissionGateHandler`.
|
|
- Reduce `handleInput` to `activate → resolveAgentName → extract skill name → pipeline.evaluate → map outcome`, symmetric with `handleToolCall`.
|
|
- Shrink `GateHandlerSession` to the two-method context role (`activate`, `resolveAgentName`); `checkPermission` and `createPermissionRequestId` leave the handler's session surface.
|
|
- Fold the request-id minting (formerly `PermissionSession.createPermissionRequestId`) into the pipeline and remove it from `PermissionSession` — 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` / `SessionLifecycleHandler` against role interfaces — that is [#331].
|
|
- Reframing `index.ts` as collaborator injection — that is [#320]; this plan injects the *new* collaborator properly but leaves the surrounding factory shape alone.
|
|
- Trimming the `input*.test.ts` handler 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`; `handleInput` hand-rolls the skill-input gate, while `handleToolCall` already delegates to the injected `ToolCallGatePipeline`.
|
|
Constructor today is `(session: GateHandlerSession, toolRegistry, pipeline: ToolCallGatePipeline, runner: GateRunner)`.
|
|
- `src/gate-handler-session.ts` — `GateHandlerSession`, the four-method role the handler's `session` is 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`; owns `checkPermission` and `createPermissionRequestId`, and `implements GateHandlerSession`.
|
|
- `src/index.ts` — composition root; constructs the session, `ToolCallGatePipeline`, `GateRunner`, and `PermissionGateHandler`.
|
|
|
|
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` (avoids `as unknown as` casts).
|
|
- `@typescript-eslint/require-await` is enabled for `src/`: `evaluate` has no `await` of its own (it returns `runner.run(...)`), so it must be a non-`async` function returning `Promise<GateOutcome>`, not an `async` function with no `await`.
|
|
- `createPermissionRequestId` reads `Date.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`.
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
/** 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.
|
|
|
|
```typescript
|
|
// 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`:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
const skillInputGatePipeline = new SkillInputGatePipeline(session);
|
|
const gates = new PermissionGateHandler(
|
|
session,
|
|
toolRegistry,
|
|
toolCallGatePipeline,
|
|
skillInputGatePipeline,
|
|
gateRunner,
|
|
);
|
|
```
|
|
|
|
### Edge cases (all behavior-preserving)
|
|
|
|
- Non-skill input: `extractSkillNameFromInput` returns `null`; `handleInput` returns `{ action: "continue" }` before the pipeline is touched — `checkPermission` is 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 because `hasUI` is false — `ctx.ui.notify` is not called.
|
|
- Ask + no confirmation: handled inside `runner.run` exactly 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.warn` called with a message containing the skill name, and `describeSkillInputGate` run with the `preCheck`;
|
|
- allow / ask → `notifier.warn` not called; outcome maps from `runner.run`;
|
|
- block outcome → `{ action: "block" }`; allow outcome → `{ action: "allow" }`;
|
|
- `createSkillInputRequestId` format (`startsWith("skill-input-")`) and uniqueness across calls (relocated from `permission-session.test.ts`).
|
|
- Uses `makeSkillInputInputs` (cast-free single-method mock) + `makeGateRunner` (real runner with role mocks) + `makeNotifier`.
|
|
|
|
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 through `handleInput`; they remain valid integration coverage and still pass through the real handler → pipeline → notifier path after the `makeSession` mock 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 the `createPermissionRequestId` block moves; `checkPermission`, `resolve`, and the rest are untouched.
|
|
|
|
## TDD Order
|
|
|
|
1. **Introduce the pipeline.**
|
|
Add `SkillInputGateInputs` + `GateNotifier` + `SkillInputGatePipeline` + `createSkillInputRequestId` + `formatSkillDenyNotice` in the new module, plus `makeSkillInputInputs` and `makeNotifier` in `gate-fixtures.ts`.
|
|
Write `skill-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.
|
|
Run `pnpm run check`.
|
|
Commit: `feat: introduce SkillInputGatePipeline collaborator (#329)`.
|
|
|
|
2. **Inject the pipeline, rewrite `handleInput`, and remove the request-id minter.**
|
|
This is one commit because the constructor-arity change and the `GateHandlerSession` / `PermissionSession` shrink break every call site and the `createPermissionRequestId` consumers at the type level simultaneously:
|
|
- add the injected `skillInputPipeline` param to `PermissionGateHandler`; rewrite `handleInput` to build the notifier and delegate; drop the `describeSkillInputGate` import;
|
|
- shrink `GateHandlerSession` to `activate` + `resolveAgentName`; remove `PermissionSession.createPermissionRequestId`;
|
|
- construct + inject `SkillInputGatePipeline` in `index.ts`;
|
|
- update `makeHandler` / `makeSession` / `MockGateHandlerSession` and the two `external-directory-*.test.ts` local `makeHandler` + session mocks (construct the pipeline, drop `createPermissionRequestId`);
|
|
- remove the `createPermissionRequestId` describe block from `permission-session.test.ts`.
|
|
Verify `composition-root.test.ts`.
|
|
Run the full suite (`pnpm --filter @gotgenes/pi-permission-system exec vitest run`), `pnpm run check`, and `pnpm fallow dead-code` (confirm `createPermissionRequestId` and the old inline assembly are gone).
|
|
Commit: `refactor: delegate skill-input gate construction to injected SkillInputGatePipeline (#329, #330)`.
|
|
|
|
3. **Update docs.**
|
|
Add `skill-input-gate-pipeline.ts` to the `architecture.md` module tree; refresh the `gate-handler-session.ts`, `permission-gate-handler.ts`, and `permission-session.ts` descriptions; mark roadmap Step 12 done and note Step 13 ([#330]) folded in; document `makeSkillInputInputs` + `makeNotifier` in the package SKILL.
|
|
Commit: `docs: document SkillInputGatePipeline in architecture and package skill (#329)`.
|
|
|
|
## Risks and Mitigations
|
|
|
|
- **Session mocks missing the new shape → runtime surprise.**
|
|
`MockGateHandlerSession` casts 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 `hasUI` gate moves into the notifier closure.**
|
|
Mitigation: the deny/no-deny and UI-present/absent cases are pinned by the existing `input.test.ts` notify tests (unchanged) plus the new pipeline `notifier.warn` unit 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 `createPermissionRequestId` outright and `pnpm fallow dead-code` confirms no residual caller; [#330] is closed only after this ships.
|
|
- **`index.ts` wiring regression.**
|
|
Mitigation: `composition-root.test.ts` (the `make-fake-pi.ts` harness) covers handler registration and shared-instance contracts.
|
|
|
|
## Open Questions
|
|
|
|
- Whether the handler's five injected collaborators should be grouped — deferred to [#320] (composition-root reframe); track and watch.
|
|
- Whether the `input*.test.ts` integration tests should be trimmed once the pipeline unit tests exist — deferred to [#321].
|
|
|
|
[#320]: https://github.com/gotgenes/pi-packages/issues/320
|
|
[#321]: https://github.com/gotgenes/pi-packages/issues/321
|
|
[#323]: https://github.com/gotgenes/pi-packages/issues/323
|
|
[#326]: https://github.com/gotgenes/pi-packages/issues/326
|
|
[#327]: https://github.com/gotgenes/pi-packages/issues/327
|
|
[#330]: https://github.com/gotgenes/pi-packages/issues/330
|
|
[#331]: https://github.com/gotgenes/pi-packages/issues/331
|