--- issue: 127 issue_title: "refactor: extract SessionLogger interface to unify logging + notification" --- # Extract SessionLogger interface ## Problem statement Handlers receive three separate logging/notification functions via `HandlerDeps`: `writeDebugLog`, `writeReviewLog`, and `notifyWarning`. These are always used together and always wired identically in the composition root (`src/index.ts`). They add 3 fields to every `makeDeps()` test factory across 6 handler test files. ## Goals - Define a `SessionLogger` interface in a new `src/session-logger.ts` module. - Create a `createSessionLogger()` factory that wraps `createPermissionSystemLogger` + the notification callback. - Replace the 3 separate functions in `HandlerDeps` with a single `logger: SessionLogger` field. - Update all handler files to use `deps.logger.debug(...)`, `deps.logger.review(...)`, `deps.logger.warn(...)`. - Update all 6 handler test `makeDeps()` factories. - No behavioral change — pure extraction. ## Non-goals - Changing `GateRunnerDeps.writeReviewLog` — that interface is satisfied by the tool-call handler locally and will be updated when `PermissionSession` satisfies it directly (#129). - Changing `PermissionPrompterDeps.writeReviewLog` or `PermissionForwardingDeps.writeReviewLog` — those are separate dep interfaces with their own consumers. - Changing `PermissionGateParams.writeLog` — that is a generic log callback, not a handler dep. - Extracting `ForwardingManager` (#128) or `PermissionSession` (#129). ## Background This is step 2 of the handler decomposition series (see `docs/plans/0126-handler-decomposition.md`). Step 1 (`ExtensionPaths`, #126) is already implemented and merged. Steps #128 and #129 are still open; #127 is independent of #128 and a prerequisite for #129. ### Permission surface No permission surface is added, removed, or changed. This is a pure internal refactoring of the handler dependency shape. ### Affected files The three logging fields in `HandlerDeps` are consumed by: | Field | Handlers | | ---------------- | -------------------------------------------------------------------------- | | `writeDebugLog` | `lifecycle.ts` (2 sites) | | `writeReviewLog` | `tool-call.ts` (4 sites), `input.ts` (1 site), `gates/runner.ts` (2 sites) | | `notifyWarning` | `lifecycle.ts` (1 site) | The composition root (`src/index.ts`) wires all three from `runtime.writeDebugLog`/`writeReviewLog` and `runtime.runtimeContext?.ui.notify`. ## Design overview ### SessionLogger interface ```typescript /** Unified logging + notification surface for handler deps. */ export interface SessionLogger { debug(event: string, details?: Record): void; review(event: string, details?: Record): void; warn(message: string): void; } ``` ### createSessionLogger factory ```typescript export function createSessionLogger( runtime: ExtensionRuntime, ): SessionLogger { return { debug: (event, details) => runtime.writeDebugLog(event, details), review: (event, details) => runtime.writeReviewLog(event, details), warn: (message) => runtime.runtimeContext?.ui.notify(message, "warning"), }; } ``` The factory captures `runtime` by reference so `warn` always reads the current `runtimeContext` (same behavior as the existing `notifyWarning` closure in `index.ts`). ### HandlerDeps change ```typescript export interface HandlerDeps { // Remove: // writeDebugLog(event: string, details?: Record): void; // writeReviewLog(event: string, details?: Record): void; // notifyWarning(message: string): void; // Add: readonly logger: SessionLogger; // ... rest unchanged } ``` ### Handler migration (mechanical) | Before | After | | ------------------------------------- | ------------------------------------------------- | | `deps.writeDebugLog(event, details)` | `deps.logger.debug(event, details)` | | `deps.writeReviewLog(event, details)` | `deps.logger.review(event, details)` | | `deps.notifyWarning(message)` | `deps.logger.warn(message)` | | `const { writeReviewLog } = deps;` | `const { review: writeReviewLog } = deps.logger;` | The tool-call handler destructures `writeReviewLog` from `deps` and passes it into `GateRunnerDeps`. After this change the destructuring reads from `deps.logger` instead — `GateRunnerDeps` is unaware of the change. ### Test factory migration (mechanical) Before (3 fields): ```typescript writeDebugLog: vi.fn(), writeReviewLog: vi.fn(), // ... notifyWarning: vi.fn(), ``` After (1 field): ```typescript logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn(), }, ``` Test assertions change from `deps.writeDebugLog` to `deps.logger.debug`, etc. ## Module-level changes ### New files | File | Purpose | | ------------------------------ | ----------------------------------------------------------- | | `src/session-logger.ts` | `SessionLogger` interface + `createSessionLogger()` factory | | `tests/session-logger.test.ts` | Unit tests for `createSessionLogger()` | ### Changed files — source | File | Change | | ------------------------------ | ----------------------------------------------------------------------------------------------- | | `src/handlers/types.ts` | Replace 3 fields with `readonly logger: SessionLogger`; add import | | `src/handlers/lifecycle.ts` | `deps.writeDebugLog` → `deps.logger.debug`; `deps.notifyWarning` → `deps.logger.warn` | | `src/handlers/tool-call.ts` | `const { writeReviewLog } = deps` → `const { review: writeReviewLog } = deps.logger` | | `src/handlers/input.ts` | `deps.writeReviewLog` → `deps.logger.review` | | `src/handlers/gates/runner.ts` | `deps.writeReviewLog` → `deps.logger.review` (2 sites: session-approved log + `writeLog` param) | | `src/index.ts` | Replace 3 inline closures with `logger: createSessionLogger(runtime)`; add import | ### Changed files — tests | File | Change | | ------------------------------------------- | ---------------------------------------------------- | | `tests/handlers/lifecycle.test.ts` | `makeDeps` factory + assertions | | `tests/handlers/tool-call.test.ts` | `makeDeps` factory + assertions | | `tests/handlers/tool-call-events.test.ts` | `makeDeps` factory | | `tests/handlers/input.test.ts` | `makeDeps` factory + assertions | | `tests/handlers/input-events.test.ts` | `makeDeps` factory | | `tests/handlers/before-agent-start.test.ts` | `makeDeps` factory (no logging assertions to update) | ### Changed files — docs | File | Change | | ----------------------------------- | ---------------------------------------------------------------- | | `docs/architecture/architecture.md` | Update `types.ts` line in module tree to mention `SessionLogger` | ### Unchanged - `src/handlers/gates/descriptor.ts` — `GateRunnerDeps.writeReviewLog` stays as-is. - `tests/handlers/gates/runner.test.ts` — uses `GateRunnerDeps`, not `HandlerDeps`. - `src/permission-prompter.ts` — has its own `PermissionPrompterDeps.writeReviewLog`. - `src/forwarded-permissions/` — has its own `ForwardedPermissionLogger` and `PermissionForwardingDeps`. - `src/permission-event-rpc.ts` — has its own dep interface. - `tests/permission-system.test.ts` — integration test; never constructs `HandlerDeps`. ## Test impact analysis 1. **New unit tests enabled**: `createSessionLogger()` can be tested in isolation — verify `debug`/`review` delegate to `runtime.writeDebugLog`/`writeReviewLog`, and `warn` delegates to `runtime.runtimeContext?.ui.notify` (including the null-context case). These were previously untestable because the closures were inline in `index.ts`. 2. **Existing tests that become simpler**: All 6 handler `makeDeps()` factories shrink by 2 net fields (3 removed, 1 added). Assertions on logging behavior get a single parent object (`deps.logger`) instead of reaching into `deps` directly. 3. **Existing tests that must stay as-is**: All handler behavioral tests stay — they test permission logic, not logging wiring. `GateRunnerDeps` tests are completely unaffected. ## TDD order ### Step 1 — SessionLogger interface + createSessionLogger factory 1. **Red**: Write `tests/session-logger.test.ts` — test that `createSessionLogger()` delegates `debug` → `runtime.writeDebugLog`, `review` → `runtime.writeReviewLog`, and `warn` → `runtime.runtimeContext.ui.notify`. Test the null-context `warn` no-op path. 2. **Green**: Create `src/session-logger.ts` with the `SessionLogger` interface and `createSessionLogger()` factory. 3. **Commit**: `feat: add SessionLogger interface and createSessionLogger factory (#127)` ### Step 2 — Update HandlerDeps and handler source files 1. **Red**: `pnpm run build` fails after updating `HandlerDeps` (callers still use old field names). 2. **Green**: Update `src/handlers/types.ts` to replace the 3 fields with `readonly logger: SessionLogger`. Update all handler source files (`lifecycle.ts`, `tool-call.ts`, `input.ts`) and `gates/runner.ts` to use `deps.logger.*`. Update `src/index.ts` to wire `logger: createSessionLogger(runtime)` instead of 3 separate closures. 3. **Verify**: `pnpm run build` passes. Tests still fail (test factories reference old fields). 4. **Commit**: `refactor: replace HandlerDeps logging fields with SessionLogger (#127)` ### Step 3 — Update handler test factories and assertions 1. **Red**: `pnpm vitest run` shows failures in all 6 handler test files (old field names in `makeDeps` + assertions). 2. **Green**: Update `makeDeps()` in each test file to use `logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() }`. Update assertions that reference `deps.writeDebugLog` → `deps.logger.debug`, `deps.writeReviewLog` → `deps.logger.review`, `deps.notifyWarning` → `deps.logger.warn`. 3. **Verify**: `pnpm vitest run` passes. `pnpm run build` passes. 4. **Commit**: `test: update handler test factories for SessionLogger (#127)` ### Step 4 — Update architecture doc 1. **Green**: Update `docs/architecture/architecture.md` module tree entry for `types.ts`. 2. **Commit**: `docs: update architecture doc for SessionLogger (#127)` ## Risks and mitigations | Risk | Mitigation | | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Could silently weaken a permission? | No. Same `checkPermission` calls, same parameters, same gate evaluation order. Only logging/notification wiring changes. Integration test (`permission-system.test.ts`) is unaffected. | | Large blast radius across test files | All 6 handler test file changes are mechanical find-and-replace. Each `makeDeps()` factory is self-contained. Steps 2 and 3 are separated so source changes compile before test changes land. | | `GateRunnerDeps.writeReviewLog` type mismatch after rename | `GateRunnerDeps` is unchanged. The tool-call handler destructures `const { review: writeReviewLog } = deps.logger` and passes the function to `GateRunnerDeps` — no type-level change at the boundary. | | `createSessionLogger` captures `runtime` by reference — stale state? | Same pattern as the existing inline closures in `index.ts`. `warn` reads `runtime.runtimeContext` at call time (not capture time), matching current behavior. | ## Open questions None — the issue description is fully specified and the change is mechanical.