Files
my-pi/pi-permission-system/docs/plans/0297-composition-root-test-coverage.md

17 KiB
Raw Permalink Blame History

issue, issue_title
issue issue_title
297 Add composition-root test coverage for pi-permission-system (makeFakePi harness + backfill)

Composition-root test coverage via a makeFakePi() harness

Problem Statement

The composition root of @gotgenes/pi-permission-system — the piPermissionSystemExtension(pi) default export in src/index.ts — has effectively no targeted test coverage, so a whole class of wiring faults slips past the suite. Issue #296 is a concrete instance: a one-line wiring fault (new SubagentSessionRegistry() instead of the shared process-global instance) disabled subagent forwarding, and every existing test missed it because the tests inject a registry into the subscriber and the detector, sharing one hand-made instance by construction, and never run the factory that decides which instance each side gets.

That pattern generalizes. Unit tests prove each piece works in isolation; they cannot see a handler that was never registered, two collaborators that must share an instance but got two (the #296 class), a teardown that leaked, an ordering contract, or multi-instance global-state interplay. None of these are expressible as unit tests, because the contract under test is the wiring itself.

The fix is a makeFakePi() test harness that lets a test run the real piPermissionSystemExtension(pi) and introspect/drive the result, plus a backfill of wiring tests that exercise the contracts above.

Goals

  • Build a reusable makeFakePi() harness that runs the real factory and exposes the registered handlers, a real event bus, a minimal tool registry, and captured command registrations.
  • Backfill composition-root tests for the six wiring contracts the issue enumerates: registry sharing, handler-registration completeness, shutdown teardown, service↔gate formatter-registry sharing, ready-after-publish ordering, and multi-instance global-state interplay.
  • Characterize target 6 (suspected latent multi-instance bug) with a real assertion; if it confirms a bug, file a separate fix issue rather than fixing it here.
  • After the backfill lands, consolidate the existing inline createToolCallHarness in test/permission-system.test.ts onto makeFakePi() to remove duplication.

Non-Goals

  • Not fixing the suspected target-6 bug (child shutdown unpublishing the parent's global service) in this plan — characterize only, then defer to a follow-up issue (see Open Questions).
  • No changes to production src/ modules. The harness runs the factory as-is; if a target test reveals a production bug, that fix is a separate issue.
  • No changes to @gotgenes/pi-subagents.
  • Not broadening env-hint or filesystem subagent detection (the still-open #22).
  • No change to the PermissionsService public surface, the RPC contract, or the config schema.

Background

Relevant modules in packages/pi-permission-system/:

  • src/index.ts — the composition root. It constructs runtime (via createExtensionRuntime()), the shared getSubagentSessionRegistry(), a single ToolInputFormatterRegistry, the PermissionsService, and wires six pi.on(...) handlers: session_start, resources_discover, session_shutdown, before_agent_start, input, tool_call. It publishes the service via publishPermissionsService(...), subscribes the subagent lifecycle via subscribeSubagentLifecycle(pi.events, registry), emits permissions:ready after publishing, and registers a teardown closure on the SessionLifecycleHandler that unsubscribes RPC + lifecycle and calls unpublishPermissionsService().
  • src/service.tspublishPermissionsService / getPermissionsService / unpublishPermissionsService, backed by globalThis + Symbol.for("@gotgenes/pi-permission-system:service").
  • src/subagent-registry.tsgetSubagentSessionRegistry(), backed by globalThis + Symbol.for("@gotgenes/pi-permission-system:subagent-registry"). The class is keyed by sessionId, not sessionDir.
  • src/subagent-lifecycle-events.tssubscribeSubagentLifecycle(events, registry) registers on SUBAGENT_CHILD_SESSION_CREATED ("subagents:child:session-created") and unregisters on SUBAGENT_CHILD_DISPOSED. The session-created payload it reads is { sessionId: string; parentSessionId?: string }.
  • src/subagent-context.tsisSubagentExecutionContext(ctx, subagentSessionsDir, registry) checks registry.has(ctx.sessionManager.getSessionId()) first.
  • src/runtime.tscreateExtensionRuntime() calls getAgentDir() (from the SDK) at invocation time, reading PI_CODING_AGENT_DIR. The factory invokes it with no agentDir option, so composition-root tests must control the env.
  • test/helpers/handler-fixtures.ts — existing makeCtx, makeEvents (a no-op emit/on stub), makeToolRegistry, etc.
  • test/permission-system.test.ts — already contains an inline createToolCallHarness (≈line 110) that sets PI_CODING_AGENT_DIR to a tmpdir, writes a config file, and runs the real factory with a hand-rolled fake pi. Its event bus is a no-op stub (not a real createEventBus()), its handlers is a Record<string, MockHandler> (last-write-wins, not inspectable for completeness), and it has no generic fire() driver. makeFakePi() is the generalization of this harness.

Constraints from AGENTS.md / the package-pi-permission-system skill:

  • Under jiti, module-scoped state is isolated per extension instance; the two Symbol.for() global slots are the shared channels. Tests that run the factory mutate both slots and must clean them in afterEach, or state leaks across tests (especially the multi-invocation targets 1, 3, and 6).
  • The session-created handler must stay synchronous — the harness fire() must support both sync and async handlers.
  • Prefer vi.stubEnv + vi.unstubAllEnvs over manual process.env save/restore for the agent-dir isolation.

Discrepancy to carry into the tests: the issue's pseudocode keys the registry and event payload by sessionDir, but the current code (post #221 / #296) keys by sessionId. The backfill tests use sessionId and a ctx.sessionManager.getSessionId() that returns the registered id.

Design Overview

makeFakePi() harness

A test-only factory in test/helpers/make-fake-pi.ts that returns a FakePi — a structural subset of ExtensionAPI (ISP: only the methods the factory touches) plus inspection/drive affordances.

import { createEventBus, type EventBus } from "@earendil-works/pi-coding-agent";

/** A handler recorded by `pi.on(...)`, kept generic over event/result shapes. */
type RecordedHandler = (event: unknown, ctx: unknown) => unknown;

export interface FakePi {
  /** Real event bus so cross-extension pub/sub and RPC behave as in production. */
  events: EventBus;
  /** Every `pi.on(event, handler)` registration, keyed by event name. */
  handlers: Map<string, RecordedHandler>;
  /** Every `pi.registerCommand(name, …)` registration, keyed by command name. */
  commands: Map<string, unknown>;
  /** Drive a registered handler; resolves to its (possibly async) result. */
  fire(event: string, input?: unknown, ctx?: unknown): Promise<unknown>;
  /** Minimal tool registry. */
  getAllTools(): { name: string }[];
  setActiveTools(names: string[]): void;
}

export interface MakeFakePiOptions {
  /** Inject a shared bus to model parent/child instances; defaults to a fresh bus. */
  events?: EventBus;
  /** Tool names returned by getAllTools(); defaults to a small set. */
  toolNames?: readonly string[];
}

export function makeFakePi(options: MakeFakePiOptions = {}): FakePi { /* … */ }

Notes on the harness:

  • events defaults to createEventBus(); tests pass { events: sharedBus } to model two factory instances sharing (or not sharing) a bus.
  • on(event, handler) records into handlers; fire(event, input, ctx) looks the handler up and returns Promise.resolve(handler(input, ctx)) so both sync (session_shutdownPromise<void>) and async (tool_call) handlers work uniformly.
  • registerCommand(name, opts) records into commands; registerProvider, exec, and any other unused ExtensionAPI methods the cast needs are no-op vi.fn() stubs.
  • The object is cast as unknown as ExtensionAPI at the call to piPermissionSystemExtension(pi); the FakePi interface itself stays narrow.

Shared composition-root test setup

Every composition-root test needs the same isolation, so factor it into a beforeEach/afterEach block (kept local to the new test file, not in makeFakePi — env/global lifecycle is not the pi object's concern):

let agentDir: string;
beforeEach(() => {
  agentDir = mkdtempSync(join(tmpdir(), "pi-perm-comp-root-"));
  vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
});
afterEach(() => {
  // Drop both process-global slots so factory runs do not leak across tests.
  delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
  delete (globalThis as Record<symbol, unknown>)[SUBAGENT_REGISTRY_KEY];
  vi.unstubAllEnvs();
  rmSync(agentDir, { recursive: true, force: true });
});

The service slot is cleared via unpublishPermissionsService() (preferred over a raw delete); the registry slot has no public unpublish accessor by design (a child's shutdown must never wipe the parent's registrations), so the test deletes the Symbol.for(...) slot directly — the same pattern test/subagent-registry.test.ts already uses.

Per-target sketches

  1. Registry sharing across instances (the #296 class). Run the factory twice with different buses; emit SUBAGENT_CHILD_SESSION_CREATED on the parent bus with { sessionId, parentSessionId }; assert the child's tool_call for an external-directory ls is not blocked (it detects itself as a subagent via the shared global registry and forwards instead).
  2. Handler-registration completeness. piPermissionSystemExtension(makeFakePi()); assert [...pi.handlers.keys()].sort() equals the six expected events sorted. Guards against a refactor silently dropping a handler.
  3. Shutdown teardown chain. Run the factory; assert getPermissionsService() is defined; await pi.fire("session_shutdown"); assert the service is unpublished and that a post-shutdown SUBAGENT_CHILD_SESSION_CREATED does not land a registration (lifecycle unsubscribed).
  4. Service↔gate share one ToolInputFormatterRegistry. Register a formatter via getPermissionsService()!.registerToolInputFormatter("mcp", fmt); fire an mcp tool_call that resolves to ask under a UI-capturing ctx; assert the captured prompt preview reflects fmt's output (proves the live gate consults the same registry the service wrote to).
  5. ready-after-publish ordering. Subscribe to permissions:ready on the bus before running the factory; in the listener push whether getPermissionsService() is present; assert the recorded sequence is ["present"].
  6. Multi-instance global-state interplay (characterization). Run the factory for a parent, then a child; await child.fire("session_shutdown"); assert the current behavior of getPermissionsService(). If the suspicion holds (the child's shutdown deletes the parent's slot → undefined), document it with test.fails asserting the desired behavior (toBeDefined()) and file a follow-up fix issue; otherwise assert the passing behavior directly.

Module-Level Changes

  • test/helpers/make-fake-pi.tsnew. Exports makeFakePi, FakePi, MakeFakePiOptions.
  • test/composition-root.test.tsnew. Houses the shared beforeEach/afterEach isolation and the six target tests (targets 16).
  • test/permission-system.test.tschanged (final step). Migrate the inline createToolCallHarness onto makeFakePi(), removing the duplicated hand-rolled fake pi while preserving the existing config-file write, tmpdir, and ctx/prompt-capture behavior those tests depend on.
  • No src/ changes.
  • No docs/architecture/ updates needed — no module is added, removed, or moved in src/.

Test Impact Analysis

  1. New coverage enabled: the six wiring contracts above, none of which any existing unit test can express (they require running the real factory and observing cross-instance global state, handler registration, teardown, and event ordering).
  2. Redundant existing tests: none are made redundant — the new tests cover the composition root, a layer no current test touches. The final consolidation step removes duplication of harness code, not test coverage: the assertions in permission-system.test.ts are preserved, only their fake-pi plumbing is swapped for makeFakePi().
  3. Tests that must stay as-is: all existing unit tests (they exercise injected collaborators in isolation, which remains the right granularity for those modules).

TDD Order

  1. Build makeFakePi() + target 2 (handler-registration completeness). Surface: test/composition-root.test.ts + test/helpers/make-fake-pi.ts. Covers: harness boot, real event bus, handlers map, the six-handler completeness assertion. Red: test imports a non-existent makeFakePi. Green: harness built, factory runs, keys match. Run pnpm --filter @gotgenes/pi-permission-system run check immediately (new test infra + SDK type cast). Commit: test: add makeFakePi harness and handler-registration completeness test (#297).
  2. Target 1 — registry sharing across instances. Covers: two factory instances on different buses share the global registry; child forwards instead of blocking. Commit: test: cover subagent registry sharing across factory instances (#297).
  3. Target 3 — shutdown teardown chain. Covers: service unpublished and lifecycle unsubscribed after session_shutdown. Commit: test: cover composition-root shutdown teardown chain (#297).
  4. Target 4 — service↔gate formatter-registry sharing. Covers: a formatter registered via the published service reaches the live gate's prompt preview. Commit: test: cover service and gate sharing one formatter registry (#297).
  5. Target 5 — ready-after-publish ordering. Covers: a permissions:ready listener can immediately resolve the service. Commit: test: cover ready emitted after service publication (#297).
  6. Target 6 — multi-instance global-state interplay (investigation). Covers: parent/child publish/unpublish interplay on the global service slot. Land a real assertion of current behavior; if buggy, test.fails the desired behavior and open a follow-up fix issue. Commit: test: characterize multi-instance global service interplay (#297).
  7. Consolidate createToolCallHarness onto makeFakePi(). Migrate test/permission-system.test.ts's inline fake pi to the shared harness; keep config write / tmpdir / prompt-capture behavior; run the full package suite before committing. Commit: refactor(test): migrate createToolCallHarness onto makeFakePi (#297).

Risks and Mitigations

  • Global-state leakage across tests — running the factory mutates two Symbol.for() slots. Mitigation: the shared afterEach clears both slots and unstubs env; verify by running targets 1, 3, and 6 in isolation and together.
  • Filesystem side effects — the real factory loads config and creates a logs directory under getAgentDir(). Mitigation: vi.stubEnv("PI_CODING_AGENT_DIR", <tmpdir>) per the existing inline harness; rmSync the tmpdir in afterEach.
  • Target-4 prompt-preview assertion is the most coupled — it needs an ask-resolving config and a UI-capturing ctx. Mitigation: reuse makeCtx / config patterns from existing handler and forwarding tests; if the preview path proves brittle, fall back to asserting both sides reference the same registry instance via a narrower observable (documented inline).
  • The sessionDir-vs-sessionId discrepancy could be miscopied from the issue pseudocode. Mitigation: the plan pins the key to sessionId; tests build ctx.sessionManager.getSessionId() to return the registered id.
  • Consolidation step touches a 2585-line test file. Mitigation: it is the final, isolated step; run the full suite before committing; the change is plumbing-only with assertions preserved.

Open Questions

  • Does target 6 confirm the latent bug? If getPermissionsService() returns undefined after a child's shutdown while the parent is still alive, file a dedicated fix issue (likely: scope the service slot per-instance, or make the child skip publish/unpublish when a parent service already occupies the slot). This plan only characterizes it.
  • Should makeFakePi() eventually replace makeEvents (the no-op bus) in handler-fixtures.ts for handler tests that would benefit from a real bus? Deferred — out of scope here; revisit if a handler test needs real pub/sub.