Files
my-pi/pi-permission-system/docs/plans/0332-preview-length-config-loader-gap.md

14 KiB
Raw Permalink Blame History

issue, issue_title
issue issue_title
332 `toolInputPreviewMaxLength` (and `toolTextSummaryMaxLength`) in `config.json` are silently ignored — preview is always truncated at the hardcoded default

Fix toolInputPreviewMaxLength / toolTextSummaryMaxLength loader gap

Problem Statement

Setting toolInputPreviewMaxLength or toolTextSummaryMaxLength in config.json has no effect. The permission-prompt preview is always truncated at the hardcoded default of 200 (or 80 for text summaries), regardless of the configured value.

The downstream machinery is correct: normalizePermissionSystemConfig() parses both fields, resolveToolPreviewLimits() reads them, and ToolPreviewFormatter applies them. The break is in the loader pipeline. loadAndMergeConfigs() produces a UnifiedPermissionConfig (src/config-loader.ts) as its intermediate type, and that type does not declare the two fields. As a result:

  • normalizeUnifiedConfig() never reads the fields from raw JSON — they are silently dropped.
  • mergeUnifiedConfigs() iterates only over ["debugLog", "permissionReviewLog", "yoloMode"] when merging scalars, so even a present value would not survive the merge.
  • By the time ConfigStore.refresh() calls normalizePermissionSystemConfig(mergeResult.merged), the fields are already gone, so resolveToolPreviewLimits() falls back to the hardcoded constants.

A secondary symptom exists in ConfigStore.save() (src/config-store.ts). The merge { ...existing.config, debugLog, permissionReviewLog, yoloMode } loads existing.config through the same broken loader, so a user-set value is dropped from existing.config and therefore deleted from the global file on the next modal save. This symptom resolves automatically once the loader is fixed: the ...existing.config spread will carry the parsed fields through unchanged (see Design Overview).

Goals

  • Make toolInputPreviewMaxLength and toolTextSummaryMaxLength flow through the load/merge pipeline so configured values reach ToolPreviewFormatter.
  • Ensure ConfigStore.save() preserves an existing global value rather than deleting it.
  • Keep schema, example config, docs, loader, and TypeScript types aligned.

Non-Goals

  • The toolInputFormatters extension point and smart MCP formatters from the parent enhancement (#266) — already shipped; not touched here.
  • Editing the two preview-length fields from the /permission-system config modal UI — out of scope.
  • Changing the merge semantics of the existing scalar knobs or the permission object.
  • Schema, config/config.example.json, and docs/configuration.md field documentation — already present and correct (verified during planning); no edits needed.

Background

Relevant modules:

  • src/config-loader.ts — owns UnifiedPermissionConfig, normalizeUnifiedConfig(), mergeUnifiedConfigs(), loadAndMergeConfigs(), loadUnifiedConfig(). This is the loader layer; it currently imports only ./common, ./config-paths, ./permission-merge, and ./types.
  • src/extension-config.ts — owns PermissionSystemExtensionConfig, normalizePermissionSystemConfig(), and normalizeOptionalPositiveInt(). This is the higher-level config-shape layer.
  • src/common.ts — shared, dependency-light helpers (toRecord, getNonEmptyString, isPermissionState, …). Both config-loader.ts and extension-config.ts already import from it; it imports nothing from either.
  • src/config-store.tsConfigStore.refresh() (load → normalize → store) and ConfigStore.save() (load existing → merge → write global).
  • src/tool-preview-formatter.tsresolveToolPreviewLimits() and ToolPreviewFormatter (the correct, already-wired consumer).

Constraint from the package skill: "Keep schema, example config, docs/configuration.md, README.md, and TypeScript types/loaders aligned." Verified during planning that the schema (schemas/permissions.schema.json), example (config/config.example.json), and docs/configuration.md all already document both fields — only the loader is out of sync.

Constraint from the package skill: "Treat any declared config field not read at runtime as a maintenance trap." This plan closes exactly such a trap: the fields are declared on PermissionSystemExtensionConfig and documented, but never read from disk.

Design Overview

Shared normalizeOptionalPositiveInt

The loader needs the same positive-integer normalization that extension-config.ts already uses. normalizeOptionalPositiveInt is currently exported from extension-config.ts. Importing it into config-loader.ts would not create a literal import cycle today (verified: neither module imports the other), but it would make the low-level loader depend on the higher-level config-shape module — the wrong direction.

Move normalizeOptionalPositiveInt to src/common.ts (the dependency-light shared module both layers already import) and re-export nothing speculative. extension-config.ts imports it from common; config-loader.ts imports it from common.

Loader changes (the actual fix)

Add the two fields to UnifiedPermissionConfig, parse them in normalizeUnifiedConfig(), and include them in the mergeUnifiedConfigs() scalar loop:

export interface UnifiedPermissionConfig {
  debugLog?: boolean;
  permissionReviewLog?: boolean;
  yoloMode?: boolean;
  toolInputPreviewMaxLength?: number;
  toolTextSummaryMaxLength?: number;
  permission?: FlatPermissionConfig;
}
// in normalizeUnifiedConfig()
const toolInputPreviewMaxLength = normalizeOptionalPositiveInt(
  record.toolInputPreviewMaxLength,
);
if (toolInputPreviewMaxLength !== undefined)
  config.toolInputPreviewMaxLength = toolInputPreviewMaxLength;

const toolTextSummaryMaxLength = normalizeOptionalPositiveInt(
  record.toolTextSummaryMaxLength,
);
if (toolTextSummaryMaxLength !== undefined)
  config.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
// in mergeUnifiedConfigs()
for (const key of [
  "debugLog",
  "permissionReviewLog",
  "yoloMode",
  "toolInputPreviewMaxLength",
  "toolTextSummaryMaxLength",
] as const) {
  const value = override[key] ?? base[key];
  if (value !== undefined) {
    merged[key] = value;
  }
}

Merge semantics match the existing scalars: override (project / per-agent) replaces base (global) when present; last writer wins. This matches how mergeFlatPermissions treats per-surface overrides and is what users expect from the documented precedence (global → project → per-agent).

Save path (no code change — relies on the spread)

ConfigStore.save() already merges via:

const existing = loadUnifiedConfig(globalPath); // now parses both fields
const merged = {
  ...existing.config, // carries toolInputPreviewMaxLength / toolTextSummaryMaxLength through unchanged
  debugLog: normalized.debugLog,
  permissionReviewLog: normalized.permissionReviewLog,
  yoloMode: normalized.yoloMode,
};

Once loadUnifiedConfig() parses the two fields, existing.config carries them, and the spread preserves whatever is in the global file verbatim. No further change to save() is needed.

This is deliberately preferred over explicitly writing normalized.toolInputPreviewMaxLength into the merge (the issue's proposed fix). The in-memory normalized config is the merged value (global + project + per-agent); writing it into the global file would bake a project-level or per-agent override into global. The three booleans are editable in the modal, so persisting their merged value to global is the intended save behavior; the two preview-length fields are not modal-editable, so the correct behavior is to leave the on-disk global value untouched — exactly what the spread does. (Decision confirmed with the user during planning.)

Edge cases

  • Invalid values (zero, negative, non-integer, non-number) — normalizeOptionalPositiveInt returns undefined, so the field is omitted and the default applies. Same semantics already enforced by normalizePermissionSystemConfig.
  • Field present in project but not global — survives the merge as the override; refresh() picks it up.
  • Field present in global, modal save toggles a boolean — spread preserves the global value; nothing deleted.

Module-Level Changes

  • src/common.ts — add normalizeOptionalPositiveInt (moved verbatim from extension-config.ts).
  • src/extension-config.ts — remove the local normalizeOptionalPositiveInt definition; import it from ./common. normalizePermissionSystemConfig keeps using it unchanged.
  • src/config-loader.ts — add the two optional fields to UnifiedPermissionConfig; import normalizeOptionalPositiveInt from ./common; parse both fields in normalizeUnifiedConfig(); extend the mergeUnifiedConfigs() scalar loop.
  • src/config-store.ts — no change (the spread in save() does the work once the loader is fixed).
  • test/common.test.ts — receive the migrated normalizeOptionalPositiveInt unit tests.
  • test/extension-config.test.ts — drop the normalizeOptionalPositiveInt direct tests (now in common.test.ts); keep the normalizePermissionSystemConfig tests that exercise the two fields end-to-end through the higher-level normalizer.
  • test/config-loader.test.ts — add coverage for parsing and merging the two fields.
  • test/config-store.test.ts — add a regression test that save() preserves an existing global preview-length value.

No exports are removed except the relocation of normalizeOptionalPositiveInt from extension-config.ts to common.ts.

Consumers of the relocated symbol

normalizeOptionalPositiveInt is currently imported from extension-config by:

  • test/extension-config.test.ts (direct unit tests) — move these to test/common.test.ts.

No src/ module other than extension-config.ts itself imports it today, and the package skill (.pi/skills/package-pi-permission-system/SKILL.md) does not reference it. After the move, both extension-config.ts and config-loader.ts import it from common.

Test Impact Analysis

This is a bug fix plus a small symbol relocation, not an extraction that unlocks new isolated units.

  1. New tests enabled: loader-level tests for the two fields (parse in normalizeUnifiedConfig, survive mergeUnifiedConfigs) — previously the fields could not be exercised at the loader layer because the type omitted them. A save() preservation regression test that was meaningless before (the value never reached existing.config).
  2. Redundant tests: none become redundant. The normalizeOptionalPositiveInt direct tests are relocated, not deleted — they continue to assert the same contract from common.test.ts. The normalizePermissionSystemConfig field tests in extension-config.test.ts stay; they verify the higher-level normalizer, a different layer from the loader.
  3. Tests that must stay as-is: the normalizePermissionSystemConfig and resolveToolPreviewLimits suites genuinely exercise the downstream layers that were always correct; they remain unchanged.

TDD Order

  1. Relocate normalizeOptionalPositiveInt to common. Move the function to src/common.ts, import it into src/extension-config.ts, and move its direct unit tests from test/extension-config.test.ts to test/common.test.ts. This is a single atomic step: removing the export from extension-config.ts and updating its sole test consumer must land together so the type checker stays green. Suggested commit: refactor: move normalizeOptionalPositiveInt to common module.
  2. Parse the two fields in the loader (red → green). Add the fields to UnifiedPermissionConfig, import normalizeOptionalPositiveInt from common, parse both in normalizeUnifiedConfig(). Add test/config-loader.test.ts cases: valid positive integers are parsed; invalid values (0, negative, float, string) are omitted; absent fields stay absent. Suggested commit: fix: parse tool preview length fields in unified config loader.
  3. Merge the two fields (red → green). Extend the mergeUnifiedConfigs() scalar loop. Add test/config-loader.test.ts cases: override value wins over base; base value survives when override omits it; both absent yields absent. Suggested commit: fix: merge tool preview length fields across config layers.
  4. Regression-test save-path preservation (red → green). Add a test/config-store.test.ts case under save() asserting that when loadUnifiedConfig returns a config containing toolInputPreviewMaxLength, the written global config retains it. With steps 23 in place the spread already preserves it, so this test confirms the secondary symptom is closed. Suggested commit: test: confirm save preserves configured tool preview length.

If step 4 passes immediately on the parse/merge fix (expected), keep it as a guard rather than forcing a separate production change.

Risks and Mitigations

  • Risk: the relocation of normalizeOptionalPositiveInt breaks an unseen importer. Mitigation: grep src/ and test/ for the symbol before finalizing (done during planning — only extension-config.ts and extension-config.test.ts reference it); the type checker catches any miss in step 1.
  • Risk: merge precedence surprises (e.g. project value unexpectedly overriding a global value). Mitigation: precedence mirrors the existing booleans and the documented global → project → per-agent order; step 3 tests both directions.
  • Risk: baking a merged override into the global file on save. Mitigation: rely on the ...existing.config spread rather than writing the in-memory merged value; step 4 guards the global-preservation behavior.

Open Questions

None. The schema, example, and docs already document both fields; the fix is confined to the loader plus a symbol relocation.