@gotgenes/pi-permission-system 8.2.1 → 8.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [8.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.2.1...pi-permission-system-v8.3.0) (2026-06-01)
9
+
10
+
11
+ ### Features
12
+
13
+ * add built-in MCP input summarizer ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([2d47e36](https://github.com/gotgenes/pi-packages/commit/2d47e360b475c72c76026ea5ea4ebf6446b58c3e))
14
+ * add ToolInputFormatterRegistry ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([c2c2b3d](https://github.com/gotgenes/pi-packages/commit/c2c2b3d64664b03cf6715e630e0bb59c4d1b650c))
15
+ * consult custom formatter registry in ToolPreviewFormatter ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([9a0d756](https://github.com/gotgenes/pi-packages/commit/9a0d75600f7aa364c06bee7c0419c64d9a5325e9))
16
+ * expose registerToolInputFormatter on PermissionsService ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([2287484](https://github.com/gotgenes/pi-packages/commit/2287484e24392fffac37962e41ad985446e75d2d))
17
+
18
+
19
+ ### Documentation
20
+
21
+ * add authoring guide for tool input formatters ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([6d154a1](https://github.com/gotgenes/pi-packages/commit/6d154a14a7a1f26ded4f1d77d50b52d200b70a27))
22
+ * document tool input formatter seam ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([2fc9ff1](https://github.com/gotgenes/pi-packages/commit/2fc9ff1df97341b8825ef13c99a3ffd651dcd8e0))
23
+
8
24
  ## [8.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.2.0...pi-permission-system-v8.2.1) (2026-05-31)
9
25
 
10
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "8.2.1",
3
+ "version": "8.3.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Built-in tool input formatters registered through the public seam at startup.
3
+ *
4
+ * Each formatter here dogfoods `ToolInputFormatterRegistry.register` — it goes
5
+ * through exactly the same path a third-party extension would use.
6
+ */
7
+
8
+ import { toRecord } from "./common";
9
+ import type {
10
+ ToolInputFormatter,
11
+ ToolInputFormatterRegistry,
12
+ } from "./tool-input-formatter-registry";
13
+ import { truncateInlineText } from "./tool-input-preview";
14
+
15
+ /** Maximum total length of the generated argument summary (before "with " prefix). */
16
+ const MCP_ARGS_SUMMARY_MAX_LENGTH = 160;
17
+
18
+ /** Maximum length of a single string argument value (before quoting). */
19
+ const MCP_ARG_VALUE_MAX_LENGTH = 60;
20
+
21
+ /**
22
+ * Render a single MCP argument value as a compact, readable fragment.
23
+ *
24
+ * - Strings: quoted and truncated.
25
+ * - Numbers / booleans: plain string conversion.
26
+ * - Arrays: `[N items]`.
27
+ * - Objects: `{…}`.
28
+ * - Everything else: plain string conversion.
29
+ */
30
+ function renderArgValue(value: unknown): string {
31
+ if (typeof value === "string") {
32
+ return `"${truncateInlineText(value, MCP_ARG_VALUE_MAX_LENGTH)}"`;
33
+ }
34
+ if (typeof value === "number" || typeof value === "boolean") {
35
+ return String(value);
36
+ }
37
+ if (Array.isArray(value)) {
38
+ return `[${value.length} items]`;
39
+ }
40
+ if (typeof value === "object" && value !== null) {
41
+ return "{…}";
42
+ }
43
+ return String(value);
44
+ }
45
+
46
+ /**
47
+ * Format an MCP tool call's `arguments` payload as a human-readable summary.
48
+ *
49
+ * Returns `undefined` when `arguments` is absent or empty — the MCP ask-prompt
50
+ * is then left unchanged (no suffix appended).
51
+ *
52
+ * Intended to be registered as the `"mcp"` formatter via
53
+ * `registerBuiltinToolInputFormatters`.
54
+ */
55
+ export const formatMcpInputForPrompt: ToolInputFormatter = (
56
+ input: Record<string, unknown>,
57
+ ): string | undefined => {
58
+ const args = toRecord(input.arguments);
59
+ const entries = Object.entries(args);
60
+ if (entries.length === 0) return undefined;
61
+
62
+ const parts = entries.map(
63
+ ([key, value]) => `${key}: ${renderArgValue(value)}`,
64
+ );
65
+ const summary = truncateInlineText(
66
+ parts.join(", "),
67
+ MCP_ARGS_SUMMARY_MAX_LENGTH,
68
+ );
69
+ return `with ${summary}`;
70
+ };
71
+
72
+ /**
73
+ * Register all built-in tool input formatters into `registry`.
74
+ *
75
+ * Called once from the extension factory (`index.ts`) immediately after the
76
+ * registry is constructed, before any third-party registration can occur.
77
+ */
78
+ export function registerBuiltinToolInputFormatters(
79
+ registry: ToolInputFormatterRegistry,
80
+ ): void {
81
+ registry.register("mcp", formatMcpInputForPrompt);
82
+ }
@@ -16,6 +16,7 @@ import {
16
16
  formatUnknownToolReason,
17
17
  } from "#src/permission-prompts";
18
18
  import type { PermissionSession } from "#src/permission-session";
19
+ import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
19
20
  import {
20
21
  resolveToolPreviewLimits,
21
22
  ToolPreviewFormatter,
@@ -54,6 +55,7 @@ export class PermissionGateHandler {
54
55
  private readonly session: PermissionSession,
55
56
  private readonly events: PermissionEventBus,
56
57
  private readonly toolRegistry: ToolRegistry,
58
+ private readonly customFormatters?: ToolInputFormatterLookup,
57
59
  ) {}
58
60
 
59
61
  async handleToolCall(
@@ -145,6 +147,7 @@ export class PermissionGateHandler {
145
147
 
146
148
  const formatter = new ToolPreviewFormatter(
147
149
  resolveToolPreviewLimits(this.session.config),
150
+ this.customFormatters,
148
151
  );
149
152
 
150
153
  // ── Ordered gate pipeline ─────────────────────────────────────────────
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
2
3
  import { registerPermissionSystemCommand } from "./config-modal";
3
4
  import { getGlobalConfigPath } from "./config-paths";
4
5
  import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
@@ -29,6 +30,7 @@ import { createSessionLogger } from "./session-logger";
29
30
  import { isSubagentExecutionContext } from "./subagent-context";
30
31
  import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
31
32
  import { SubagentSessionRegistry } from "./subagent-registry";
33
+ import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
32
34
  import {
33
35
  canResolveAskPermissionRequest,
34
36
  shouldAutoApprovePermissionState,
@@ -37,6 +39,8 @@ import {
37
39
  export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
38
40
  const runtime = createExtensionRuntime();
39
41
  const subagentRegistry = new SubagentSessionRegistry();
42
+ const formatterRegistry = new ToolInputFormatterRegistry();
43
+ registerBuiltinToolInputFormatters(formatterRegistry);
40
44
 
41
45
  const prompter = new PermissionPrompter({
42
46
  getConfig: () => runtime.config,
@@ -121,6 +125,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
121
125
  getToolPermission(toolName, agentName) {
122
126
  return runtime.permissionManager.getToolPermission(toolName, agentName);
123
127
  },
128
+ registerToolInputFormatter(toolName, formatter) {
129
+ return formatterRegistry.register(toolName, formatter);
130
+ },
124
131
  };
125
132
  publishPermissionsService(permissionsService);
126
133
 
@@ -145,7 +152,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
145
152
  unpublishPermissionsService();
146
153
  });
147
154
  const agentPrep = new AgentPrepHandler(session, toolRegistry);
148
- const gates = new PermissionGateHandler(session, pi.events, toolRegistry);
155
+ const gates = new PermissionGateHandler(
156
+ session,
157
+ pi.events,
158
+ toolRegistry,
159
+ formatterRegistry,
160
+ );
149
161
 
150
162
  pi.on("session_start", (event, ctx) =>
151
163
  lifecycle.handleSessionStart(event, ctx),
@@ -46,7 +46,11 @@ export function formatAskPrompt(
46
46
  const patternInfo = result.matchedPattern
47
47
  ? ` (matched '${result.matchedPattern}')`
48
48
  : "";
49
- return `${subject} requested MCP target '${result.target}'${patternInfo}. Allow this call?`;
49
+ const mcpPreview = formatter
50
+ ? formatter.formatToolInputForPrompt("mcp", input)
51
+ : "";
52
+ const previewSuffix = mcpPreview ? ` ${mcpPreview}` : "";
53
+ return `${subject} requested MCP target '${result.target}'${patternInfo}${previewSuffix}. Allow this call?`;
50
54
  }
51
55
 
52
56
  const patternInfo = result.matchedPattern
package/src/service.ts CHANGED
@@ -11,9 +11,10 @@
11
11
  * reference — this ensures resilience across `/reload` and load-order edge cases.
12
12
  */
13
13
 
14
+ import type { ToolInputFormatter } from "./tool-input-formatter-registry";
14
15
  import type { PermissionCheckResult, PermissionState } from "./types";
15
16
 
16
- export type { PermissionCheckResult, PermissionState };
17
+ export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
17
18
 
18
19
  /** Process-global key for the service slot. */
19
20
  const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
@@ -43,6 +44,25 @@ export interface PermissionsService {
43
44
  agentName?: string,
44
45
  ): PermissionCheckResult;
45
46
 
47
+ /**
48
+ * Register a custom preview formatter for a specific tool name.
49
+ *
50
+ * The formatter is consulted first inside `ToolPreviewFormatter.formatToolInputForPrompt`;
51
+ * returning `undefined` falls through to the built-in switch (and ultimately
52
+ * the JSON default).
53
+ *
54
+ * Only one formatter may be registered per tool name — a second call for the
55
+ * same name throws. The returned disposer unregisters the formatter.
56
+ *
57
+ * @param toolName - Exact tool name to register for (e.g. `"mcp"`, `"my-server:run"`).
58
+ * @param formatter - Receives the raw `input` record; return a string to use
59
+ * as the prompt preview, or `undefined` to decline.
60
+ */
61
+ registerToolInputFormatter(
62
+ toolName: string,
63
+ formatter: ToolInputFormatter,
64
+ ): () => void;
65
+
46
66
  /**
47
67
  * Query the tool-level permission state for pre-filtering tools before
48
68
  * creating a child session.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Registry for custom tool-input preview formatters.
3
+ *
4
+ * Allows extensions to register a formatter for a specific tool name so
5
+ * permission prompts can show a human-readable summary instead of raw JSON.
6
+ * One formatter per tool name; duplicate registration throws.
7
+ */
8
+
9
+ /** A custom preview formatter for one tool's input. Returns `undefined` to decline. */
10
+ export type ToolInputFormatter = (
11
+ input: Record<string, unknown>,
12
+ ) => string | undefined;
13
+
14
+ /**
15
+ * Read-only lookup used by `ToolPreviewFormatter` (ISP — exposes only the
16
+ * read side, not the registration surface).
17
+ */
18
+ export interface ToolInputFormatterLookup {
19
+ get(toolName: string): ToolInputFormatter | undefined;
20
+ }
21
+
22
+ /**
23
+ * Persistent registry mapping tool names to custom preview formatters.
24
+ *
25
+ * Owned by the extension factory (`index.ts`) so it survives across the
26
+ * per-tool-call `ToolPreviewFormatter` construction cycle.
27
+ * Exposed to sibling extensions via `PermissionsService.registerToolInputFormatter`.
28
+ */
29
+ export class ToolInputFormatterRegistry implements ToolInputFormatterLookup {
30
+ private readonly formatters = new Map<string, ToolInputFormatter>();
31
+
32
+ /**
33
+ * Register a formatter for `toolName`.
34
+ *
35
+ * Throws if a formatter is already registered for that name — keeps
36
+ * resolution deterministic (a pi-permission-system package priority).
37
+ * Returns a disposer that removes the formatter; the disposer is
38
+ * identity-guarded so a stale call cannot evict a later registration.
39
+ */
40
+ register(toolName: string, formatter: ToolInputFormatter): () => void {
41
+ if (this.formatters.has(toolName)) {
42
+ throw new Error(
43
+ `A tool input formatter is already registered for '${toolName}'.`,
44
+ );
45
+ }
46
+ this.formatters.set(toolName, formatter);
47
+ return () => {
48
+ if (this.formatters.get(toolName) === formatter) {
49
+ this.formatters.delete(toolName);
50
+ }
51
+ };
52
+ }
53
+
54
+ get(toolName: string): ToolInputFormatter | undefined {
55
+ return this.formatters.get(toolName);
56
+ }
57
+ }
@@ -1,5 +1,6 @@
1
1
  import { getNonEmptyString, toRecord } from "./common";
2
2
  import type { PermissionSystemExtensionConfig } from "./extension-config";
3
+ import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
3
4
  import {
4
5
  formatEditInputForPrompt,
5
6
  formatReadInputForPrompt,
@@ -47,7 +48,10 @@ export function resolveToolPreviewLimits(
47
48
  * point for preview-length configuration (#266).
48
49
  */
49
50
  export class ToolPreviewFormatter {
50
- constructor(private readonly options: ToolPreviewFormatterOptions) {}
51
+ constructor(
52
+ private readonly options: ToolPreviewFormatterOptions,
53
+ private readonly customFormatters?: ToolInputFormatterLookup,
54
+ ) {}
51
55
 
52
56
  // ── Prompt formatting ───────────────────────────────────────────────────
53
57
 
@@ -107,6 +111,14 @@ export class ToolPreviewFormatter {
107
111
  formatToolInputForPrompt(toolName: string, input: unknown): string {
108
112
  const inputRecord = toRecord(input);
109
113
 
114
+ const custom = this.customFormatters?.get(toolName);
115
+ if (custom) {
116
+ const rendered = custom(inputRecord);
117
+ if (rendered !== undefined) {
118
+ return rendered;
119
+ }
120
+ }
121
+
110
122
  switch (toolName) {
111
123
  case "edit":
112
124
  return formatEditInputForPrompt(inputRecord);
@@ -118,6 +130,11 @@ export class ToolPreviewFormatter {
118
130
  case "grep":
119
131
  case "ls":
120
132
  return this.formatSearchInputForPrompt(toolName, inputRecord);
133
+ case "mcp":
134
+ // The MCP target is already surfaced in formatAskPrompt's MCP branch.
135
+ // When no custom formatter is registered (or it declines), produce no
136
+ // additional preview rather than leaking the raw event JSON.
137
+ return "";
121
138
  default:
122
139
  return this.formatJsonInputForPrompt(input);
123
140
  }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import {
4
+ formatMcpInputForPrompt,
5
+ registerBuiltinToolInputFormatters,
6
+ } from "#src/builtin-tool-input-formatters";
7
+ import { ToolInputFormatterRegistry } from "#src/tool-input-formatter-registry";
8
+
9
+ // ── formatMcpInputForPrompt ───────────────────────────────────────────────
10
+
11
+ describe("formatMcpInputForPrompt", () => {
12
+ test("returns undefined when arguments is absent", () => {
13
+ expect(formatMcpInputForPrompt({ tool: "exa:search" })).toBeUndefined();
14
+ });
15
+
16
+ test("returns undefined when arguments is an empty object", () => {
17
+ expect(
18
+ formatMcpInputForPrompt({ tool: "exa:search", arguments: {} }),
19
+ ).toBeUndefined();
20
+ });
21
+
22
+ test("returns a summary for a single string argument", () => {
23
+ const result = formatMcpInputForPrompt({
24
+ tool: "exa:search",
25
+ arguments: { query: "typescript generics" },
26
+ });
27
+ expect(result).toBeDefined();
28
+ expect(result).toContain("query");
29
+ expect(result).toContain("typescript generics");
30
+ expect(result).toMatch(/^with /);
31
+ });
32
+
33
+ test("returns a comma-separated summary for multiple arguments", () => {
34
+ const result = formatMcpInputForPrompt({
35
+ tool: "exa:search",
36
+ arguments: { query: "test", numResults: 5 },
37
+ });
38
+ expect(result).toContain("query");
39
+ expect(result).toContain("numResults");
40
+ expect(result).toContain("5");
41
+ });
42
+
43
+ test("renders number arguments without quotes", () => {
44
+ const result = formatMcpInputForPrompt({
45
+ arguments: { count: 42 },
46
+ });
47
+ expect(result).toContain("42");
48
+ expect(result).not.toContain('"42"');
49
+ });
50
+
51
+ test("renders boolean arguments without quotes", () => {
52
+ const result = formatMcpInputForPrompt({
53
+ arguments: { verbose: true },
54
+ });
55
+ expect(result).toContain("true");
56
+ });
57
+
58
+ test("renders array arguments as '[N items]'", () => {
59
+ const result = formatMcpInputForPrompt({
60
+ arguments: { ids: [1, 2, 3] },
61
+ });
62
+ expect(result).toContain("[3 items]");
63
+ });
64
+
65
+ test("renders nested object arguments as '{…}'", () => {
66
+ const result = formatMcpInputForPrompt({
67
+ arguments: { filter: { type: "file" } },
68
+ });
69
+ expect(result).toContain("{…}");
70
+ });
71
+
72
+ test("truncates the full summary when it exceeds the limit", () => {
73
+ // Need multiple long-valued args so the joined summary exceeds 160 chars
74
+ const result = formatMcpInputForPrompt({
75
+ arguments: {
76
+ first: "x".repeat(80),
77
+ second: "y".repeat(80),
78
+ third: "z".repeat(80),
79
+ },
80
+ });
81
+ expect(result).toBeDefined();
82
+ expect(result!.endsWith("…")).toBe(true);
83
+ });
84
+
85
+ test("truncates long string argument values", () => {
86
+ const result = formatMcpInputForPrompt({
87
+ arguments: { query: "x".repeat(100) },
88
+ });
89
+ expect(result).toBeDefined();
90
+ // Should not include the full 100-char string verbatim
91
+ expect(result).not.toContain("x".repeat(100));
92
+ });
93
+ });
94
+
95
+ // ── registerBuiltinToolInputFormatters ────────────────────────────────────
96
+
97
+ describe("registerBuiltinToolInputFormatters", () => {
98
+ test("registers the mcp formatter in the registry", () => {
99
+ const registry = new ToolInputFormatterRegistry();
100
+ registerBuiltinToolInputFormatters(registry);
101
+ expect(registry.get("mcp")).toBe(formatMcpInputForPrompt);
102
+ });
103
+
104
+ test("throws if called twice (duplicate registration guard)", () => {
105
+ const registry = new ToolInputFormatterRegistry();
106
+ registerBuiltinToolInputFormatters(registry);
107
+ expect(() => registerBuiltinToolInputFormatters(registry)).toThrow("mcp");
108
+ });
109
+ });
@@ -1,5 +1,4 @@
1
1
  import { describe, expect, test } from "vitest";
2
-
3
2
  import {
4
3
  formatAskPrompt,
5
4
  formatMissingToolNameReason,
@@ -8,6 +7,7 @@ import {
8
7
  formatUnknownToolReason,
9
8
  } from "#src/permission-prompts";
10
9
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
10
+ import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
11
11
  import {
12
12
  TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
13
13
  TOOL_INPUT_PREVIEW_MAX_LENGTH,
@@ -16,12 +16,21 @@ import {
16
16
  import { ToolPreviewFormatter } from "#src/tool-preview-formatter";
17
17
  import type { PermissionCheckResult } from "#src/types";
18
18
 
19
- function makeFormatter(): ToolPreviewFormatter {
20
- return new ToolPreviewFormatter({
21
- toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
22
- toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
23
- toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
24
- });
19
+ function makeFormatter(
20
+ lookup?: ToolInputFormatterLookup,
21
+ ): ToolPreviewFormatter {
22
+ return new ToolPreviewFormatter(
23
+ {
24
+ toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
25
+ toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
26
+ toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
27
+ },
28
+ lookup,
29
+ );
30
+ }
31
+
32
+ function makeMcpLookup(preview: string): ToolInputFormatterLookup {
33
+ return { get: (name) => (name === "mcp" ? () => preview : undefined) };
25
34
  }
26
35
 
27
36
  function toolResult(
@@ -163,6 +172,43 @@ describe("formatAskPrompt", () => {
163
172
  expect(result).toContain("matched 'server:*'");
164
173
  });
165
174
 
175
+ test("appends MCP argument summary when the formatter has an mcp formatter registered", () => {
176
+ const result = formatAskPrompt(
177
+ mcpResult("exa:search"),
178
+ undefined,
179
+ { tool: "exa:search", arguments: { query: "typescript" } },
180
+ makeFormatter(makeMcpLookup('with query: "typescript"')),
181
+ );
182
+ expect(result).toContain("exa:search");
183
+ expect(result).toContain('with query: "typescript"');
184
+ expect(result).toContain("Allow this call?");
185
+ });
186
+
187
+ test("MCP prompt is unchanged when the formatter returns undefined (no arguments)", () => {
188
+ const noArgsLookup: ToolInputFormatterLookup = {
189
+ get: (name) => (name === "mcp" ? () => undefined : undefined),
190
+ };
191
+ const result = formatAskPrompt(
192
+ mcpResult("exa:search"),
193
+ undefined,
194
+ { tool: "exa:search" },
195
+ makeFormatter(noArgsLookup),
196
+ );
197
+ expect(result).toContain("exa:search");
198
+ expect(result).not.toMatch(/with /);
199
+ expect(result).toContain("Allow this call?");
200
+ });
201
+
202
+ test("MCP prompt is unchanged when no formatter is provided", () => {
203
+ const result = formatAskPrompt(mcpResult("exa:search"), undefined, {
204
+ tool: "exa:search",
205
+ arguments: { query: "test" },
206
+ });
207
+ expect(result).toContain("exa:search");
208
+ expect(result).not.toMatch(/with /);
209
+ expect(result).toContain("Allow this call?");
210
+ });
211
+
166
212
  test("includes real input preview for non-bash non-mcp tools", () => {
167
213
  const result = formatAskPrompt(
168
214
  toolResult("read"),
@@ -6,6 +6,7 @@ import {
6
6
  publishPermissionsService,
7
7
  unpublishPermissionsService,
8
8
  } from "#src/service";
9
+ import { ToolInputFormatterRegistry } from "#src/tool-input-formatter-registry";
9
10
  import type { PermissionCheckResult } from "#src/types";
10
11
 
11
12
  // ── helpers ────────────────────────────────────────────────────────────────
@@ -16,6 +17,7 @@ function makeService(
16
17
  return {
17
18
  checkPermission: vi.fn(),
18
19
  getToolPermission: vi.fn(),
20
+ registerToolInputFormatter: vi.fn(),
19
21
  ...overrides,
20
22
  };
21
23
  }
@@ -136,6 +138,7 @@ describe("service adapter delegation", () => {
136
138
  getToolPermission(toolName, agentName) {
137
139
  return getToolPermissionFn(toolName, agentName);
138
140
  },
141
+ registerToolInputFormatter: vi.fn(),
139
142
  };
140
143
 
141
144
  publishPermissionsService(service);
@@ -157,6 +160,7 @@ describe("service adapter delegation", () => {
157
160
  getToolPermission(toolName, agentName) {
158
161
  return getToolPermissionFn(toolName, agentName);
159
162
  },
163
+ registerToolInputFormatter: vi.fn(),
160
164
  };
161
165
 
162
166
  publishPermissionsService(service);
@@ -182,3 +186,51 @@ describe("service adapter delegation", () => {
182
186
  expect(checkPermission).toHaveBeenCalledWith("read", {}, undefined, []);
183
187
  });
184
188
  });
189
+
190
+ // ── registerToolInputFormatter delegation ─────────────────────────────────
191
+
192
+ describe("registerToolInputFormatter delegation", () => {
193
+ afterEach(() => {
194
+ unpublishPermissionsService();
195
+ });
196
+
197
+ it("delegates to the registry and returns its disposer", () => {
198
+ const registry = new ToolInputFormatterRegistry();
199
+ const formatter = () => "preview";
200
+
201
+ const service = makeService({
202
+ registerToolInputFormatter(toolName, fmt) {
203
+ return registry.register(toolName, fmt);
204
+ },
205
+ });
206
+
207
+ publishPermissionsService(service);
208
+ const dispose = getPermissionsService()!.registerToolInputFormatter(
209
+ "my-tool",
210
+ formatter,
211
+ );
212
+
213
+ // Registry received the registration
214
+ expect(registry.get("my-tool")).toBe(formatter);
215
+
216
+ // Disposer returned from service removes it from the registry
217
+ dispose();
218
+ expect(registry.get("my-tool")).toBeUndefined();
219
+ });
220
+
221
+ it("throws when a formatter is already registered for the tool name", () => {
222
+ const registry = new ToolInputFormatterRegistry();
223
+ registry.register("my-tool", () => undefined);
224
+
225
+ const service = makeService({
226
+ registerToolInputFormatter(toolName, fmt) {
227
+ return registry.register(toolName, fmt);
228
+ },
229
+ });
230
+
231
+ publishPermissionsService(service);
232
+ expect(() =>
233
+ getPermissionsService()!.registerToolInputFormatter("my-tool", () => ""),
234
+ ).toThrow("my-tool");
235
+ });
236
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import {
4
+ type ToolInputFormatter,
5
+ ToolInputFormatterRegistry,
6
+ } from "#src/tool-input-formatter-registry";
7
+
8
+ const noopFormatter: ToolInputFormatter = () => "preview";
9
+
10
+ describe("ToolInputFormatterRegistry", () => {
11
+ describe("register", () => {
12
+ test("stores a formatter so get() returns it", () => {
13
+ const registry = new ToolInputFormatterRegistry();
14
+ registry.register("my-tool", noopFormatter);
15
+ expect(registry.get("my-tool")).toBe(noopFormatter);
16
+ });
17
+
18
+ test("returns a disposer that removes the formatter", () => {
19
+ const registry = new ToolInputFormatterRegistry();
20
+ const dispose = registry.register("my-tool", noopFormatter);
21
+ dispose();
22
+ expect(registry.get("my-tool")).toBeUndefined();
23
+ });
24
+
25
+ test("throws when a formatter is already registered for the same tool name", () => {
26
+ const registry = new ToolInputFormatterRegistry();
27
+ registry.register("my-tool", noopFormatter);
28
+ expect(() => registry.register("my-tool", () => undefined)).toThrow(
29
+ "my-tool",
30
+ );
31
+ });
32
+
33
+ test("allows registering different tool names independently", () => {
34
+ const registry = new ToolInputFormatterRegistry();
35
+ const formatterA: ToolInputFormatter = () => "a";
36
+ const formatterB: ToolInputFormatter = () => "b";
37
+ registry.register("tool-a", formatterA);
38
+ registry.register("tool-b", formatterB);
39
+ expect(registry.get("tool-a")).toBe(formatterA);
40
+ expect(registry.get("tool-b")).toBe(formatterB);
41
+ });
42
+ });
43
+
44
+ describe("disposer identity guard", () => {
45
+ test("stale disposer does not evict a later registration", () => {
46
+ const registry = new ToolInputFormatterRegistry();
47
+ const first: ToolInputFormatter = () => "first";
48
+ const second: ToolInputFormatter = () => "second";
49
+
50
+ const disposeFirst = registry.register("my-tool", first);
51
+ disposeFirst(); // removes first
52
+
53
+ registry.register("my-tool", second); // second registration is now valid
54
+ disposeFirst(); // calling stale disposer again — must not remove second
55
+
56
+ expect(registry.get("my-tool")).toBe(second);
57
+ });
58
+ });
59
+
60
+ describe("get", () => {
61
+ test("returns undefined for an unregistered tool name", () => {
62
+ const registry = new ToolInputFormatterRegistry();
63
+ expect(registry.get("unknown")).toBeUndefined();
64
+ });
65
+
66
+ test("the registered formatter is callable and returns its result", () => {
67
+ const registry = new ToolInputFormatterRegistry();
68
+ const fmt: ToolInputFormatter = (input) =>
69
+ typeof input.cmd === "string" ? `runs ${input.cmd}` : undefined;
70
+ registry.register("run", fmt);
71
+ expect(registry.get("run")?.({ cmd: "ls" })).toBe("runs ls");
72
+ expect(registry.get("run")?.({ other: true })).toBeUndefined();
73
+ });
74
+ });
75
+ });
@@ -1,5 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
2
 
3
+ import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
4
+
3
5
  // Mock logging collaborator before importing the module under test.
4
6
  vi.mock("../src/logging.js", () => ({
5
7
  safeJsonStringify: vi.fn((value: unknown) => JSON.stringify(value)),
@@ -201,6 +203,77 @@ describe("ToolPreviewFormatter.formatToolInputForPrompt", () => {
201
203
  });
202
204
  });
203
205
 
206
+ // ── formatToolInputForPrompt (custom formatter seam) ───────────────────────
207
+
208
+ describe("ToolPreviewFormatter.formatToolInputForPrompt — custom formatter seam", () => {
209
+ function makeLookup(
210
+ toolName: string,
211
+ result: string | undefined,
212
+ ): ToolInputFormatterLookup {
213
+ return {
214
+ get: (name) => (name === toolName ? () => result : undefined),
215
+ };
216
+ }
217
+
218
+ test("uses a custom formatter's string result verbatim, bypassing the switch", () => {
219
+ const lookup = makeLookup("my-tool", "custom preview");
220
+ const f = new ToolPreviewFormatter(
221
+ {
222
+ toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
223
+ toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
224
+ toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
225
+ },
226
+ lookup,
227
+ );
228
+ expect(f.formatToolInputForPrompt("my-tool", {})).toBe("custom preview");
229
+ });
230
+
231
+ test("falls through to the built-in switch when custom formatter returns undefined", () => {
232
+ mockedStringify.mockReturnValue('{"x":1}');
233
+ const lookup = makeLookup("unknown-tool", undefined);
234
+ const f = new ToolPreviewFormatter(
235
+ {
236
+ toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
237
+ toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
238
+ toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
239
+ },
240
+ lookup,
241
+ );
242
+ // Falls through to JSON default for unknown tools
243
+ expect(f.formatToolInputForPrompt("unknown-tool", { x: 1 })).toContain(
244
+ '{"x":1}',
245
+ );
246
+ });
247
+
248
+ test("custom formatter for a built-in tool overrides the built-in preview", () => {
249
+ const lookup = makeLookup("read", "custom read summary");
250
+ const f = new ToolPreviewFormatter(
251
+ {
252
+ toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
253
+ toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
254
+ toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
255
+ },
256
+ lookup,
257
+ );
258
+ // Would normally use formatReadInputForPrompt; custom overrides it
259
+ expect(f.formatToolInputForPrompt("read", { path: "/foo.ts" })).toBe(
260
+ "custom read summary",
261
+ );
262
+ });
263
+
264
+ test("absent lookup preserves current behaviour for all tool types", () => {
265
+ const f = new ToolPreviewFormatter({
266
+ toolInputPreviewMaxLength: TOOL_INPUT_PREVIEW_MAX_LENGTH,
267
+ toolTextSummaryMaxLength: TOOL_TEXT_SUMMARY_MAX_LENGTH,
268
+ toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
269
+ });
270
+ // Built-in path still works
271
+ expect(f.formatToolInputForPrompt("read", { path: "/foo.ts" })).toContain(
272
+ "/foo.ts",
273
+ );
274
+ });
275
+ });
276
+
204
277
  // ── formatGenericToolInputForLog ──────────────────────────────────────────
205
278
 
206
279
  describe("ToolPreviewFormatter.formatGenericToolInputForLog", () => {