14 KiB
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()callsnormalizePermissionSystemConfig(mergeResult.merged), the fields are already gone, soresolveToolPreviewLimits()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
toolInputPreviewMaxLengthandtoolTextSummaryMaxLengthflow through the load/merge pipeline so configured values reachToolPreviewFormatter. - 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
toolInputFormattersextension point and smart MCP formatters from the parent enhancement (#266) — already shipped; not touched here. - Editing the two preview-length fields from the
/permission-systemconfig modal UI — out of scope. - Changing the merge semantics of the existing scalar knobs or the
permissionobject. - Schema,
config/config.example.json, anddocs/configuration.mdfield documentation — already present and correct (verified during planning); no edits needed.
Background
Relevant modules:
src/config-loader.ts— ownsUnifiedPermissionConfig,normalizeUnifiedConfig(),mergeUnifiedConfigs(),loadAndMergeConfigs(),loadUnifiedConfig(). This is the loader layer; it currently imports only./common,./config-paths,./permission-merge, and./types.src/extension-config.ts— ownsPermissionSystemExtensionConfig,normalizePermissionSystemConfig(), andnormalizeOptionalPositiveInt(). This is the higher-level config-shape layer.src/common.ts— shared, dependency-light helpers (toRecord,getNonEmptyString,isPermissionState, …). Bothconfig-loader.tsandextension-config.tsalready import from it; it imports nothing from either.src/config-store.ts—ConfigStore.refresh()(load → normalize → store) andConfigStore.save()(load existing → merge → write global).src/tool-preview-formatter.ts—resolveToolPreviewLimits()andToolPreviewFormatter(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) —
normalizeOptionalPositiveIntreturnsundefined, so the field is omitted and the default applies. Same semantics already enforced bynormalizePermissionSystemConfig. - 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— addnormalizeOptionalPositiveInt(moved verbatim fromextension-config.ts).src/extension-config.ts— remove the localnormalizeOptionalPositiveIntdefinition; import it from./common.normalizePermissionSystemConfigkeeps using it unchanged.src/config-loader.ts— add the two optional fields toUnifiedPermissionConfig; importnormalizeOptionalPositiveIntfrom./common; parse both fields innormalizeUnifiedConfig(); extend themergeUnifiedConfigs()scalar loop.src/config-store.ts— no change (the spread insave()does the work once the loader is fixed).test/common.test.ts— receive the migratednormalizeOptionalPositiveIntunit tests.test/extension-config.test.ts— drop thenormalizeOptionalPositiveIntdirect tests (now incommon.test.ts); keep thenormalizePermissionSystemConfigtests 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 thatsave()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 totest/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.
- New tests enabled: loader-level tests for the two fields (parse in
normalizeUnifiedConfig, survivemergeUnifiedConfigs) — previously the fields could not be exercised at the loader layer because the type omitted them. Asave()preservation regression test that was meaningless before (the value never reachedexisting.config). - Redundant tests: none become redundant.
The
normalizeOptionalPositiveIntdirect tests are relocated, not deleted — they continue to assert the same contract fromcommon.test.ts. ThenormalizePermissionSystemConfigfield tests inextension-config.test.tsstay; they verify the higher-level normalizer, a different layer from the loader. - Tests that must stay as-is: the
normalizePermissionSystemConfigandresolveToolPreviewLimitssuites genuinely exercise the downstream layers that were always correct; they remain unchanged.
TDD Order
- Relocate
normalizeOptionalPositiveInttocommon. Move the function tosrc/common.ts, import it intosrc/extension-config.ts, and move its direct unit tests fromtest/extension-config.test.tstotest/common.test.ts. This is a single atomic step: removing the export fromextension-config.tsand updating its sole test consumer must land together so the type checker stays green. Suggested commit:refactor: move normalizeOptionalPositiveInt to common module. - Parse the two fields in the loader (red → green).
Add the fields to
UnifiedPermissionConfig, importnormalizeOptionalPositiveIntfromcommon, parse both innormalizeUnifiedConfig(). Addtest/config-loader.test.tscases: 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. - Merge the two fields (red → green).
Extend the
mergeUnifiedConfigs()scalar loop. Addtest/config-loader.test.tscases: 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. - Regression-test save-path preservation (red → green).
Add a
test/config-store.test.tscase undersave()asserting that whenloadUnifiedConfigreturns a config containingtoolInputPreviewMaxLength, the written global config retains it. With steps 2–3 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
normalizeOptionalPositiveIntbreaks an unseen importer. Mitigation: grepsrc/andtest/for the symbol before finalizing (done during planning — onlyextension-config.tsandextension-config.test.tsreference 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.configspread 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.