mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
293 lines
19 KiB
Markdown
293 lines
19 KiB
Markdown
---
|
|
issue: 339
|
|
issue_title: "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`: a `PromptingGateway` collaborator 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.ts` `canRequestPermissionConfirmation` closure disappears (index closures 11 → 10).
|
|
- `GateRunner` receives the gateway for the prompting role; `PermissionSession` no longer plays `GatePrompter`.
|
|
- Collapse the `canPrompt`/`canConfirm` and `prompt`/`promptPermission` twins into the single context-bound pair on the gateway.
|
|
- Rename the `GatePrompter` prompting method `promptPermission` → `prompt` to match the collapsed pair.
|
|
- Behavior-preserving: identical runtime decisions; the change relocates collaboration, not logic.
|
|
|
|
## Non-Goals
|
|
|
|
- Extracting the `PermissionResolver` role ([#340], Step 7) — that is the parallel track.
|
|
- Retiring the fig-leaf role interfaces (`GateHandlerSession`, `AgentPrepSession`, `SessionLifecycleSession`) or splitting `makeSession` per-collaborator ([#341], Step 8).
|
|
- Consolidating the session's own context store with the gateway's (see Risks); the session still owns `this.context` for `getRuntimeContext` / `reload` / `logResolvedConfigPaths`.
|
|
- Touching `permission-event-rpc.ts`, `lifecycle.ts`, `before-agent-start.ts`, or the logger notify sink — all keep reading `session.getRuntimeContext()` unchanged.
|
|
|
|
## Background
|
|
|
|
Relevant modules:
|
|
|
|
- `src/permission-session.ts` — the god object.
|
|
Today its constructor takes a `PermissionSessionRuntimeDeps` bag (`{ canRequestPermissionConfirmation, prompter }`).
|
|
Its prompting methods are `canPrompt(ctx)`, `prompt(ctx, details)`, `canConfirm()`, `promptPermission(details)`.
|
|
Its `activate(ctx)` already drives a collaborator's lifecycle (`this.forwarding.start(ctx)`); `deactivate()` mirrors it.
|
|
It still needs `this.context` after this change for `getRuntimeContext()`, `reload()` (reads `this.context?.cwd`), and `logResolvedConfigPaths()`.
|
|
- `src/gate-prompter.ts` — the `GatePrompter` role 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` — `GateRunner` calls `this.prompter.canConfirm()` and `this.prompter.promptPermission(...)`.
|
|
- `src/permission-prompter.ts` — `PermissionPrompterApi.prompt(ctx, details)` and `PromptPermissionDetails`; 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, and `new 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-`async` method declared `Promise<T>` must `return Promise.reject(...)`, never `throw` (testing skill; preserves the existing throw-when-unactivated contract under `rejects.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 `✓ complete` in `docs/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.
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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: `PromptingGatewayDeps` has 4 fields, every one read.
|
|
Pass.
|
|
- Law of Demeter: `this.deps.prompter.prompt(...)` and `this.deps.config.current()` are one-level calls on injected role interfaces, not stranger reach-throughs.
|
|
Pass.
|
|
- Output arguments: none; `canConfirm` returns a value, `prompt` returns 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 method `promptPermission` → `prompt` on `GatePrompter`.
|
|
- `src/handlers/gates/runner.ts` — `this.prompter.promptPermission(...)` → `this.prompter.prompt(...)`.
|
|
- `src/permission-session.ts` — remove `implements GatePrompter`; delete `canPrompt`, `prompt`, `canConfirm`, `promptPermission`; delete `PermissionSessionRuntimeDeps` and the `runtimeDeps` field; add a `gateway: PromptingGatewayLifecycle` constructor parameter and forward it in `activate`/`deactivate`; drop the now-unused imports (`GatePrompter`, `PermissionPrompterApi`, `PromptPermissionDetails`, `PermissionPromptDecision`). `ExtensionContext` stays (still used by `activate`, `getRuntimeContext`, `resolveAgentName`).
|
|
- `src/index.ts` — construct `PromptingGateway`; pass it to `PermissionSession` and as `GateRunner`'s third argument; delete the `canRequestPermissionConfirmation` closure; drop the `isSubagentExecutionContext` and `canResolveAskPermissionRequest` imports.
|
|
|
|
Tests:
|
|
|
|
- `test/prompting-gateway.test.ts` (new) — unit tests for the gateway.
|
|
- `test/permission-session.test.ts` — delete `makeRuntimeDeps` and the four prompting `describe` blocks (`canConfirm`, `promptPermission`, `canPrompt`, `prompt`); pass a `PromptingGatewayLifecycle` mock through `createSession`; add assertions that `activate`/`deactivate` forward to the gateway.
|
|
- `test/helpers/handler-fixtures.ts` — `MockGateHandlerSession` drops `& GatePrompter` but keeps `canPrompt`/`prompt`/`canConfirm`/`promptPermission` as explicit test-only extras during the migration; `makeHandler` builds a bridged prompter (delegating to those extras), accepts a `prompter?` override, returns `prompter`, and passes it as `GateRunner`'s third argument.
|
|
Final cleanup later removes the extras, the casts, and the bridge.
|
|
- `test/helpers/gate-fixtures.ts` — rename the `makeGateRunner` `promptPermission` override key → `prompt`.
|
|
- `test/handlers/gates/runner.test.ts` — rename `promptPermission` → `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 local `makeStatefulSession` + `makeHandlerForSession` migrate the same way; `session.prompt` assertions → `prompter.prompt`.
|
|
|
|
Docs:
|
|
|
|
- `docs/architecture/architecture.md` — add a `prompting-gateway.ts` entry to the module layout (around line 495); update the `gate-prompter.ts` line to `canConfirm() + prompt(details)`; update the `permission-session.ts` line to drop `GatePrompter` from the implements list and note prompting moved to `PromptingGateway` ([#339]); note the index-closure count 11 → 10.
|
|
Mark roadmap Step 6 `✓ complete` at ship time.
|
|
- `.pi/skills/package-pi-permission-system/SKILL.md` — update the `handler-fixtures.ts` description: `makeSession` no longer carries prompting; `makeHandler` exposes a `prompter` mock for the `GatePrompter` role.
|
|
|
|
## Test Impact Analysis
|
|
|
|
1. New tests the extraction enables — `test/prompting-gateway.test.ts` can unit-test the prompting role directly, with no session fixture:
|
|
- `canConfirm()` returns `false` before `activate`; after `activate`, returns `true`/`false` across the UI / subagent / yolo-mode permutations of `canResolveAskPermissionRequest` — previously only reachable through the session's `runtimeDeps` closure.
|
|
- `prompt(details)` delegates to `deps.prompter.prompt(ctx, details)` with the stored context, and rejects with the unactivated-session error before `activate`.
|
|
- `activate`/`deactivate` set and clear the stored context (observable via `canConfirm`).
|
|
2. Tests that become redundant — the `canConfirm` / `promptPermission` / `canPrompt` / `prompt` `describe` blocks in `permission-session.test.ts` move to the gateway test; `makeSession`'s prompting closures and `undefined as unknown as ExtensionContext` casts are deleted in the final cleanup cycle.
|
|
3. 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 the `GatePrompter` mock, now `prompt`); 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.
|
|
|
|
1. Add the gateway (additive).
|
|
Surface: `test/prompting-gateway.test.ts`.
|
|
Covers `canConfirm` permutations, `prompt` delegation + rejection, `activate`/`deactivate`.
|
|
Implement `src/prompting-gateway.ts` (method named `promptPermission` to satisfy the current `GatePrompter`); add the module-layout entry to `architecture.md`.
|
|
Not wired yet.
|
|
Commit: `feat: add context-owning PromptingGateway`.
|
|
|
|
2. 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 drops `GatePrompter` + the four methods + `runtimeDeps`, gains the gateway param, forwards in `activate`/`deactivate`; `index.ts` constructs the gateway and passes it to the session and `GateRunner`; `permission-session.test.ts` loses `makeRuntimeDeps` and the prompting blocks and gains forwarding assertions; the shared fixtures decouple `MockGateHandlerSession` from `GatePrompter`, add the bridged `prompter` (delegating to the retained extras) and the `prompter?` override, and pass it as `GateRunner`'s third arg.
|
|
Handler test *cases* stay green via the bridge.
|
|
Update the `permission-session.ts` line in `architecture.md`.
|
|
Run `pnpm run check` immediately (shared-interface change with a single index call site).
|
|
Commit: `refactor: extract prompting into PromptingGateway; session sheds the prompting role`.
|
|
|
|
3. Rename the `GatePrompter` method `promptPermission` → `prompt`.
|
|
Surface: `gate-prompter.ts`, `runner.ts`, `prompting-gateway.ts`, `gate-fixtures.ts`, `runner.test.ts`, the `makeHandler` bridge.
|
|
Update the `gate-prompter.ts` line in `architecture.md`.
|
|
Commit: `refactor: rename GatePrompter.promptPermission to prompt`.
|
|
|
|
4. Migrate `input.test.ts` to steer/assert the prompter.
|
|
Commit: `test: steer prompting via the gateway in input handler tests`.
|
|
|
|
5. Migrate `input-events.test.ts`.
|
|
Commit: `test: steer prompting via the gateway in input-event tests`.
|
|
|
|
6. Migrate `tool-call-events.test.ts`.
|
|
Commit: `test: steer prompting via the gateway in tool-call-event tests`.
|
|
|
|
7. Migrate `external-directory-integration.test.ts`.
|
|
Commit: `test: steer prompting via the gateway in external-directory tests`.
|
|
|
|
8. Migrate `external-directory-session-dedup.test.ts` (cases + its local `makeStatefulSession`/`makeHandlerForSession`).
|
|
Commit: `test: steer prompting via the gateway in session-dedup tests`.
|
|
|
|
9. Final cleanup — drop the bridge.
|
|
Surface: `handler-fixtures.ts` (and the dedup local fixtures): remove the `canPrompt`/`prompt`/`canConfirm`/`promptPermission` extras and the `undefined as unknown as ExtensionContext` casts; `makeHandler`'s default `prompter` becomes a clean `GatePrompter` mock; update the `SKILL.md` fixture 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 single `activate`/`deactivate` path keeps them synchronized; the session's copy serves only `getRuntimeContext`/`reload`/`logResolvedConfigPaths`, which are out of scope here.
|
|
Note it as a Step 8 ([#341]) consolidation candidate.
|
|
- Big-bang fixture break — removing `GatePrompter` fields from `MockGateHandlerSession` could 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 `canResolveAskPermissionRequest` into the gateway could subtly change the decision.
|
|
Mitigation: the gateway's `canConfirm` reproduces 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-code` runs at pre-completion, after step 2 wires it.
|
|
|
|
## Open Questions
|
|
|
|
- None blocking.
|
|
Consolidating the dual context store and splitting `makeSession` per-collaborator are explicitly deferred to Step 8 ([#341]).
|
|
|
|
[#334]: https://github.com/gotgenes/pi-packages/issues/334
|
|
[#339]: https://github.com/gotgenes/pi-packages/issues/339
|
|
[#340]: https://github.com/gotgenes/pi-packages/issues/340
|
|
[#341]: https://github.com/gotgenes/pi-packages/issues/341
|