Files
my-pi/pi-permission-system/docs/plans/0282-extract-tool-preview-formatter.md
T

23 KiB
Raw Blame History

issue, issue_title
issue issue_title
282 Extract ToolPreviewFormatter from tool-input-preview.ts

Extract ToolPreviewFormatter from tool-input-preview.ts

Problem Statement

src/tool-input-preview.ts is a flat module of 15 exports (3 constants + 12 functions) that mix prompt formatting, log formatting, and text utilities. The config-dependent formatting functions (formatToolInputForPrompt, sanitizeInlineText, etc.) read module-level constants (TOOL_INPUT_PREVIEW_MAX_LENGTH = 200, TOOL_TEXT_SUMMARY_MAX_LENGTH = 80) with no way to receive runtime configuration.

The call chain from PermissionGateHandler.handleToolCall -> describeToolGate -> formatAskPrompt -> formatToolInputForPrompt -> formatJsonInputForPrompt spans 5 pure-function layers. Adding configurable limits (#266) would require threading two numbers through every layer.

This is prerequisite work for #266 (configurable preview limits). Phase 1 of the improvement roadmap in packages/pi-permission-system/docs/architecture/architecture.md defines this extraction as Step 1 and Step 2.

Goals

  • Extract a ToolPreviewFormatter class from tool-input-preview.ts that accepts limits in its constructor.
  • Thread the formatter through the gate descriptor chain: describeToolGate and formatAskPrompt gain a formatter parameter.
  • PermissionGateHandler.handleToolCall constructs the formatter with default values and passes it to the tool gate.
  • Eliminate the module-level vi.mock("../src/tool-input-preview.js") in permission-prompts.test.ts — tests inject the formatter directly.
  • All existing tests pass without behavioral changes.

Non-Goals

  • No configuration fields added to PermissionSystemExtensionConfig — that is #266 Step 3.
  • No register() formatter extension seam — that is #283.
  • No change to tool.ts describeToolGate's return shape, GateDescriptor, GateResult, or GateRunnerDeps.
  • No change to the gate pipeline in permission-gate-handler.ts (already decomposed by #285).
  • No renaming of pure utility functions that stay in tool-input-preview.ts.

Background

Current module boundaries

src/tool-input-preview.ts exports 15 items:

Export Category Config-dependent?
TOOL_INPUT_PREVIEW_MAX_LENGTH (200) constant default value
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH (1000) constant default value
TOOL_TEXT_SUMMARY_MAX_LENGTH (80) constant default value
truncateInlineText(value, maxLength) pure utility no — maxLength is a parameter
sanitizeInlineText(value, maxLength?) formatter yes — default uses TOOL_TEXT_SUMMARY_MAX_LENGTH
countTextLines(value) pure utility no
formatCount(value, singular, plural) pure utility no
getPromptPath(input) pure utility no
formatEditInputForPrompt(input) pure utility no
formatWriteInputForPrompt(input) pure utility no
formatReadInputForPrompt(input) pure utility no
formatSearchInputForPrompt(toolName, input) formatter yes — calls sanitizeInlineText
serializeToolInputPreview(input) pure utility no
formatJsonInputForPrompt(input) formatter yes — uses TOOL_INPUT_PREVIEW_MAX_LENGTH
formatToolInputForPrompt(toolName, input) formatter yes — dispatches to formatJsonInputForPrompt
formatGenericToolInputForLog(input) log formatter yes — uses TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH
getToolInputPreviewForLog(result, input, pathBearingTools) log formatter yes — calls config-dependent functions
getPermissionLogContext(result, input, pathBearingTools) log formatter yes — calls config-dependent functions

Current call chain

PermissionGateHandler.handleToolCall()
  └─ gate producer thunk
       ├─ describeToolGate(tcc, check)
       │    ├─ getPermissionLogContext(check, tcc.input, PATH_BEARING_TOOLS)
       │    │    └─ getToolInputPreviewForLog(result, input, pathBearingTools)
       │    │         ├─ formatToolInputForPrompt(toolName, input)
       │    │         │    └─ formatJsonInputForPrompt(input)
       │    │         │         └─ truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)
       │    │         └─ formatGenericToolInputForLog(input)
       │    │              └─ truncateInlineText(inline, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)
       │    └─ formatAskPrompt(check, agentName, input)
       │         └─ formatToolInputForPrompt(toolName, input)
       │              └─ formatJsonInputForPrompt(input)
       │                   └─ truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)
       └─ toolDescriptor.preCheck = toolCheck

Prior work

#285 (Phase 2 Step 1) has already decomposed handleToolCall into a gate pipeline with a runGate helper and validateRequestedTool prelude. The tool gate is now a producer thunk in a gateProducers array, making the formatter injection point clean and isolated.

References

  • Architecture roadmap: packages/pi-permission-system/docs/architecture/architecture.md lines 543650.
  • Constants and config-dependent functions: src/tool-input-preview.ts.
  • Gate descriptor: src/handlers/gates/tool.ts.
  • Ask-prompt formatting: src/permission-prompts.ts.
  • Handler orchestrator: src/handlers/permission-gate-handler.ts.
  • Existing tests: test/tool-input-preview.test.ts, test/permission-prompts.test.ts, test/handlers/gates/tool.test.ts.

Design Overview

ToolPreviewFormatter class (new file: src/tool-preview-formatter.ts)

export interface ToolPreviewFormatterOptions {
  toolInputPreviewMaxLength: number;
  toolTextSummaryMaxLength: number;
  toolInputLogPreviewMaxLength: number;
}

export class ToolPreviewFormatter {
  constructor(private readonly options: ToolPreviewFormatterOptions) {}

  // Prompt formatting — config-dependent
  formatToolInputForPrompt(toolName: string, input: unknown): string;
  formatJsonInputForPrompt(input: unknown): string;
  formatSearchInputForPrompt(toolName: string, input: Record<string, unknown>): string;
  sanitizeInlineText(value: string, maxLength?: number): string;

  // Log formatting — config-dependent
  formatGenericToolInputForLog(input: unknown): string | undefined;
  getToolInputPreviewForLog(
    result: PermissionCheckResult,
    input: unknown,
    pathBearingTools: ReadonlySet<string>,
  ): string | undefined;
  getPermissionLogContext(
    result: PermissionCheckResult,
    input: unknown,
    pathBearingTools: ReadonlySet<string>,
  ): { command?: string; target?: string; toolInputPreview?: string; origin?: string };
}

The constructor is the single config injection point. All config-dependent methods use this.options instead of module-level constants. sanitizeInlineText preserves its optional maxLength override parameter — when provided, it bypasses the constructor default.

The class imports pure utilities from tool-input-preview.ts as-is (truncateInlineText, serializeToolInputPreview, getPromptPath).

tool-input-preview.ts after extraction

Keeps only: constants, pure utilities, and the tool-specific formatters that are purely structural (no config dependency):

  • Module-level constants (TOOL_INPUT_PREVIEW_MAX_LENGTH, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH, TOOL_TEXT_SUMMARY_MAX_LENGTH)
  • truncateInlineText, countTextLines, formatCount, getPromptPath
  • serializeToolInputPreview
  • formatEditInputForPrompt, formatWriteInputForPrompt, formatReadInputForPrompt

These functions have no config dependency and no reason to be instance methods.

Threading through the call chain

describeToolGate in src/handlers/gates/tool.ts:

export function describeToolGate(
  tcc: ToolCallContext,
  check: PermissionCheckResult,
  formatter: ToolPreviewFormatter,
): GateDescriptor {
  const permissionLogContext = formatter.getPermissionLogContext(
    check,
    tcc.input,
    PATH_BEARING_TOOLS,
  );
  // ...
  const askMessage = formatAskPrompt(
    check,
    tcc.agentName ?? undefined,
    tcc.input,
    formatter,
  );
  // ...
}

formatAskPrompt in src/permission-prompts.ts:

export function formatAskPrompt(
  result: PermissionCheckResult,
  agentName?: string,
  input?: unknown,
  formatter?: ToolPreviewFormatter,
): string {
  // ... bash and MCP branches unchanged ...
  const inputPreview = formatter
    ? formatter.formatToolInputForPrompt(result.toolName, input)
    : "";
  // ...
}

The formatter parameter is optional for backward compatibility — formatAskPrompt is not called from other entry points today, but keeping it optional avoids a forced change on consumers that don't have a formatter.

PermissionGateHandler.handleToolCall in src/handlers/permission-gate-handler.ts constructs the formatter once and passes it into the tool gate producer thunk:

const formatter = new ToolPreviewFormatter({
  toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
  toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
  toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
});

// Inside the gateProducers array, the last producer:
() => {
  const toolCheck = checkPermission(
    tcc.toolName,
    tcc.input,
    tcc.agentName ?? undefined,
    getSessionRuleset(),
  );
  const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
  toolDescriptor.preCheck = toolCheck;
  return toolDescriptor;
},

When #266 adds configurable limits, the constructor call changes — the config values replace the defaults.

Design verification (Tell-Don't-Ask / Law of Demeter)

The formatter is a pure collaborator — it is called from describeToolGate to produce formatted strings, and from formatAskPrompt to produce input previews. Neither consumer reaches through the formatter to access its options. The consumer's call site reads naturally:

// tool.ts
const permissionLogContext = formatter.getPermissionLogContext(
  check,
  tcc.input,
  PATH_BEARING_TOOLS,
);

The formatter encapsulates its own state (the options) and exposes only the methods that consumers need. No output arguments, no reverse searches from the original code.

Edge cases

  • formatAskPrompt called without a formatter (if a future code path forgets to pass one): the optional parameter defaults to undefined, input preview is empty string, prompt still works.
  • sanitizeInlineText with explicit maxLength override: still works because the override parameter bypasses this.options.toolTextSummaryMaxLength.
  • getToolInputPreviewForLog with bash/mcp: returns undefined as before — the guard logic is unchanged, just moved to the class method.

Module-Level Changes

File Change
src/tool-preview-formatter.ts NewToolPreviewFormatter class + ToolPreviewFormatterOptions interface
src/tool-input-preview.ts Remove 7 config-dependent exports (formatToolInputForPrompt, formatJsonInputForPrompt, formatSearchInputForPrompt, sanitizeInlineText, formatGenericToolInputForLog, getToolInputPreviewForLog, getPermissionLogContext); keep 8 pure utilities + 3 constants
src/permission-prompts.ts Replace import { formatToolInputForPrompt } with import type { ToolPreviewFormatter }; add formatter?: ToolPreviewFormatter parameter to formatAskPrompt
src/handlers/gates/tool.ts Replace import { getPermissionLogContext } with import type { ToolPreviewFormatter }; add formatter: ToolPreviewFormatter parameter to describeToolGate
src/handlers/permission-gate-handler.ts Import ToolPreviewFormatter and constants; construct formatter in handleToolCall with default values; pass to tool gate producer
test/tool-preview-formatter.test.ts New — unit tests for ToolPreviewFormatter methods
test/tool-input-preview.test.ts Remove imports of the 7 moved functions; remove their test blocks; keep constant and pure-utility tests
test/permission-prompts.test.ts Remove vi.mock("../src/tool-input-preview.js"); import ToolPreviewFormatter; construct real instance or spy; pass as parameter
test/handlers/gates/tool.test.ts Import ToolPreviewFormatter; construct a formatter with defaults; pass to describeToolGate in each call
docs/architecture/architecture.md Update module listing at line ~527: add tool-preview-formatter.ts, update tool-input-preview.ts description

No symbol documented in .pi/skills/package-pi-permission-system/SKILL.md is removed — no skill update needed.

No barrel (src/index.ts) export changes — the formatter is an internal collaborator, not a public API.

Test Impact Analysis

  1. New unit tests enabled (test/tool-preview-formatter.test.ts):

    • Constructor stores options correctly.
    • formatToolInputForPrompt dispatches tool names correctly (edit/write/read/find/grep/ls/fallback).
    • formatJsonInputForPrompt truncates at the configured toolInputPreviewMaxLength.
    • sanitizeInlineText respects constructor default and explicit override.
    • formatSearchInputForPrompt calls sanitizeInlineText with config-dependent truncation.
    • formatGenericToolInputForLog truncates at toolInputLogPreviewMaxLength.
    • getToolInputPreviewForLog returns undefined for bash/mcp, path preview for path-bearing tools, JSON preview for others — all with config-dependent truncation.
    • getPermissionLogContext returns the correct shape with config-dependent preview. These tests were previously impossible without mocking because the constants were module-level.
  2. Tests that become redundant: The formatToolInputForPrompt tests in tool-input-preview.test.ts (the describe("formatToolInputForPrompt") block) become redundant with the new class-level tests that cover the same dispatch logic. They should be removed as part of the extraction to avoid duplication.

  3. Tests that stay as-is:

    • The constant-value tests in tool-input-preview.test.ts (the describe("constants") block) stay — the constants remain as default values.
    • The describeToolGate integration tests in tool.test.ts stay — they exercise the gate descriptor layer that the formatter is threaded through, and pass the formatter as a construction dependency.
    • The formatAskPrompt behavioral tests in permission-prompts.test.ts stay — they exercise the prompt message logic that is unchanged.
    • The end-to-end handler tests in tool-call.test.ts and tool-call-events.test.ts stay — they exercise the full pipeline with the formatter injected.

TDD Order

  1. test: Add ToolPreviewFormatter tests covering all 7 config-dependent methods. New file test/tool-preview-formatter.test.ts with constructor options, dispatch logic, config-dependent truncation at each of the three limits, and log formatting. Commit: test: add ToolPreviewFormatter tests

  2. refactor: Create src/tool-preview-formatter.ts with the class; remove the 7 config-dependent exports from tool-input-preview.ts. Update test/tool-input-preview.test.ts — remove formatToolInputForPrompt describe block and the 6 other moved-function imports/test blocks; keep constant and pure-utility tests. The class is not yet used by any consumer — this step is purely the extraction. Commit: refactor: extract ToolPreviewFormatter class from tool-input-preview

  3. test: Update test/handlers/gates/tool.test.ts — import ToolPreviewFormatter, construct with defaults, pass as third argument to describeToolGate. The test file is the first consumer; this step proves the type signature works before modifying production code. Commit: test: update tool gate tests with formatter parameter

  4. refactor: Thread the formatter through the gate descriptor chain.

    • src/handlers/gates/tool.ts: add formatter parameter to describeToolGate, replace getPermissionLogContext(...) import with formatter.getPermissionLogContext(...), pass formatter to formatAskPrompt.
    • src/permission-prompts.ts: add formatter?: ToolPreviewFormatter parameter to formatAskPrompt, use formatter.formatToolInputForPrompt(...) when present.
    • src/handlers/permission-gate-handler.ts: import ToolPreviewFormatter and the three constants; construct the formatter inside handleToolCall with default values; pass it to the tool gate producer thunk. All existing handler and gate tests remain green. Commit: refactor: thread ToolPreviewFormatter through gate descriptor chain
  5. test: Update test/permission-prompts.test.ts — remove vi.mock("../src/tool-input-preview.js") and the import { formatToolInputForPrompt } / vi.mocked(formatToolInputForPrompt) lines. Import ToolPreviewFormatter, construct a real instance with defaults, and pass it as the 4th argument to formatAskPrompt. The mock-based assertions (mockedFormatToolInput.mockReturnValue / toHaveBeenCalledWith) become direct assertions on the formatter instance or on the resulting string. Commit: test: replace vi.mock with direct formatter injection in permission-prompts tests

  6. docs: Update docs/architecture/architecture.md — add tool-preview-formatter.ts to the module listing under src/; update tool-input-preview.ts description from "Loggable context from tool inputs" to "Pure tool-input text utilities (truncation, counting, path extraction) + default constants". Mark Phase 1 Steps 1 and 2 as completed in the roadmap steps section. Commit: docs: mark Phase 1 steps 1-2 complete in permission-system architecture

Steps 3 and 4 follow the lift-and-shift pattern: introduce the class (step 2), then consume it in tests (step 3), then wire it in production (step 4). This keeps each commit individually reviewable and the type checker satisfied at each boundary.

Risks and Mitigations

  • Risk: Forgetting to thread the formatter to all call sites of the moved functions. Mitigation: Move the 7 exports from tool-input-preview.ts, so TypeScript immediately flags any remaining import at compile time. Only two modules import moved functions: tool.ts and permission-prompts.ts, both updated in step 4.
  • Risk: Backward compatibility — a third-party extension imports these functions. Mitigation: These are internal symbols with no barrel export from src/index.ts. No external consumer exists.
  • Risk: formatAskPrompt called without a formatter produces different behavior (empty input preview instead of the previously guaranteed preview). Mitigation: formatAskPrompt is only called from describeToolGate (updated) and from tests (updated). The optional parameter ensures TypeScript does not force a change on hypothetical future callers, and the empty-string fallback is the safe default (no preview is better than a wrong preview).
  • Risk: Module-level vi.mock removal in permission-prompts.test.ts accidentally changes test semantics. Mitigation: The mock currently returns "mocked preview" unconditionally. The injected real formatter will return real previews — tests that check the string content need adjustment to match actual formatting output rather than the constant mock value. This is surfaced explicitly in step 5's scope.

Open Questions

  • Whether TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH should also become configurable in #266. It is included in ToolPreviewFormatterOptions for consistency since the log-formatting methods that use it live on the class. If #266 decides not to expose it in PermissionSystemExtensionConfig, the field defaults to 1000 and remains internal.
  • Whether the constructor should also be used as a factory for a future extension seam (register() for custom tool formatters) — deferred to #283.