@gotgenes/pi-permission-system 8.2.1 → 8.3.1
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 +30 -0
- package/package.json +1 -1
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/handlers/permission-gate-handler.ts +3 -0
- package/src/index.ts +15 -3
- package/src/permission-prompts.ts +5 -1
- package/src/service.ts +21 -1
- package/src/subagent-registry.ts +37 -0
- package/src/tool-input-formatter-registry.ts +57 -0
- package/src/tool-preview-formatter.ts +18 -1
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/permission-prompts.test.ts +53 -7
- package/test/service.test.ts +52 -0
- package/test/subagent-registry.test.ts +45 -1
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-preview-formatter.test.ts +73 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ 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.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.0...pi-permission-system-v8.3.1) (2026-06-01)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* add process-global SubagentSessionRegistry accessor ([#296](https://github.com/gotgenes/pi-packages/issues/296)) ([d3fd3b0](https://github.com/gotgenes/pi-packages/commit/d3fd3b04223b2d276873094ad8c14f239654b8c8))
|
|
14
|
+
* share SubagentSessionRegistry across parent and child sessions ([#296](https://github.com/gotgenes/pi-packages/issues/296)) ([fed676a](https://github.com/gotgenes/pi-packages/commit/fed676aaa485abe8db158e522ba898705f3dff94))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* explain process-global subagent registry across session buses ([#296](https://github.com/gotgenes/pi-packages/issues/296)) ([1804dbb](https://github.com/gotgenes/pi-packages/commit/1804dbbb766d7b7fbc0e49da877f3238f5c3e8dc))
|
|
20
|
+
* use ADR-NNNN with links docs-wide ([c6b6431](https://github.com/gotgenes/pi-packages/commit/c6b6431c004f324931f23be46cf2e47e8fdac919))
|
|
21
|
+
|
|
22
|
+
## [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)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
* add built-in MCP input summarizer ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([2d47e36](https://github.com/gotgenes/pi-packages/commit/2d47e360b475c72c76026ea5ea4ebf6446b58c3e))
|
|
28
|
+
* add ToolInputFormatterRegistry ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([c2c2b3d](https://github.com/gotgenes/pi-packages/commit/c2c2b3d64664b03cf6715e630e0bb59c4d1b650c))
|
|
29
|
+
* consult custom formatter registry in ToolPreviewFormatter ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([9a0d756](https://github.com/gotgenes/pi-packages/commit/9a0d75600f7aa364c06bee7c0419c64d9a5325e9))
|
|
30
|
+
* expose registerToolInputFormatter on PermissionsService ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([2287484](https://github.com/gotgenes/pi-packages/commit/2287484e24392fffac37962e41ad985446e75d2d))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Documentation
|
|
34
|
+
|
|
35
|
+
* 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))
|
|
36
|
+
* document tool input formatter seam ([#283](https://github.com/gotgenes/pi-packages/issues/283)) ([2fc9ff1](https://github.com/gotgenes/pi-packages/commit/2fc9ff1df97341b8825ef13c99a3ffd651dcd8e0))
|
|
37
|
+
|
|
8
38
|
## [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
39
|
|
|
10
40
|
|
package/package.json
CHANGED
|
@@ -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";
|
|
@@ -28,7 +29,8 @@ import {
|
|
|
28
29
|
import { createSessionLogger } from "./session-logger";
|
|
29
30
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
30
31
|
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
31
|
-
import {
|
|
32
|
+
import { getSubagentSessionRegistry } from "./subagent-registry";
|
|
33
|
+
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
|
|
32
34
|
import {
|
|
33
35
|
canResolveAskPermissionRequest,
|
|
34
36
|
shouldAutoApprovePermissionState,
|
|
@@ -36,7 +38,9 @@ import {
|
|
|
36
38
|
|
|
37
39
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
38
40
|
const runtime = createExtensionRuntime();
|
|
39
|
-
const subagentRegistry =
|
|
41
|
+
const subagentRegistry = getSubagentSessionRegistry();
|
|
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(
|
|
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
|
-
|
|
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.
|
package/src/subagent-registry.ts
CHANGED
|
@@ -10,8 +10,45 @@
|
|
|
10
10
|
* The registry is keyed by session directory path, which is unique per
|
|
11
11
|
* session and available to both producer and consumer via
|
|
12
12
|
* `ctx.sessionManager.getSessionDir()`.
|
|
13
|
+
*
|
|
14
|
+
* The single registry instance is stored on `globalThis` (via `Symbol.for()`)
|
|
15
|
+
* so that the parent's permission-system instance (which registers children
|
|
16
|
+
* on the parent's event bus) and each child's separate jiti instance (which
|
|
17
|
+
* reads the registry to detect itself and resolve its forwarding target) share
|
|
18
|
+
* one store across per-session event buses. See `getSubagentSessionRegistry()`.
|
|
13
19
|
*/
|
|
14
20
|
|
|
21
|
+
/** Process-global key for the shared registry slot. */
|
|
22
|
+
const SUBAGENT_SESSION_REGISTRY_KEY = Symbol.for(
|
|
23
|
+
"@gotgenes/pi-permission-system:subagent-registry",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Return the process-global SubagentSessionRegistry, creating it on first call.
|
|
28
|
+
*
|
|
29
|
+
* Backed by `globalThis` + `Symbol.for()` so the parent's permission-system
|
|
30
|
+
* instance (which registers children on the parent event bus) and each child's
|
|
31
|
+
* separate jiti instance (which reads the registry to detect itself and resolve
|
|
32
|
+
* its forwarding target) share one store across per-session event buses.
|
|
33
|
+
*
|
|
34
|
+
* Intentionally has no shutdown/unpublish hook — a child's `session_shutdown`
|
|
35
|
+
* must not be able to wipe the parent's registrations. Entries are added and
|
|
36
|
+
* removed exclusively by the parent's `subagents:child:session-created` /
|
|
37
|
+
* `subagents:child:disposed` subscription.
|
|
38
|
+
*/
|
|
39
|
+
export function getSubagentSessionRegistry(): SubagentSessionRegistry {
|
|
40
|
+
const store = globalThis as Record<symbol, unknown>;
|
|
41
|
+
const existing = store[SUBAGENT_SESSION_REGISTRY_KEY] as
|
|
42
|
+
| SubagentSessionRegistry
|
|
43
|
+
| undefined;
|
|
44
|
+
if (existing) {
|
|
45
|
+
return existing;
|
|
46
|
+
}
|
|
47
|
+
const registry = new SubagentSessionRegistry();
|
|
48
|
+
store[SUBAGENT_SESSION_REGISTRY_KEY] = registry;
|
|
49
|
+
return registry;
|
|
50
|
+
}
|
|
51
|
+
|
|
15
52
|
/** Signal stored per registered in-process subagent session. */
|
|
16
53
|
export interface SubagentSessionInfo {
|
|
17
54
|
/** Parent session ID for permission forwarding. Omit when unknown. */
|
|
@@ -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(
|
|
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(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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"),
|
package/test/service.test.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { describe, expect, test } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
getSubagentSessionRegistry,
|
|
3
4
|
type SubagentSessionInfo,
|
|
4
5
|
SubagentSessionRegistry,
|
|
5
6
|
} from "#src/subagent-registry";
|
|
6
7
|
|
|
8
|
+
const REGISTRY_KEY = Symbol.for(
|
|
9
|
+
"@gotgenes/pi-permission-system:subagent-registry",
|
|
10
|
+
);
|
|
11
|
+
|
|
7
12
|
function makeInfo(
|
|
8
13
|
overrides: Partial<SubagentSessionInfo> = {},
|
|
9
14
|
): SubagentSessionInfo {
|
|
@@ -92,3 +97,42 @@ describe("SubagentSessionRegistry", () => {
|
|
|
92
97
|
expect(registry.has("/sessions/task-2")).toBe(true);
|
|
93
98
|
});
|
|
94
99
|
});
|
|
100
|
+
|
|
101
|
+
// ── process-global accessor ────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe("getSubagentSessionRegistry (process-global accessor)", () => {
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
|
|
106
|
+
delete (globalThis as Record<symbol, unknown>)[REGISTRY_KEY];
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns a SubagentSessionRegistry instance", () => {
|
|
110
|
+
const registry = getSubagentSessionRegistry();
|
|
111
|
+
expect(registry).toBeInstanceOf(SubagentSessionRegistry);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("returns the same instance on repeated calls", () => {
|
|
115
|
+
const first = getSubagentSessionRegistry();
|
|
116
|
+
const second = getSubagentSessionRegistry();
|
|
117
|
+
expect(first).toBe(second);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("state registered through one call is visible through another call", () => {
|
|
121
|
+
const writer = getSubagentSessionRegistry();
|
|
122
|
+
writer.register("/sessions/child-tasks", {
|
|
123
|
+
agentName: "Explore",
|
|
124
|
+
parentSessionId: "parent-abc",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const reader = getSubagentSessionRegistry();
|
|
128
|
+
expect(reader.has("/sessions/child-tasks")).toBe(true);
|
|
129
|
+
expect(reader.get("/sessions/child-tasks")?.parentSessionId).toBe(
|
|
130
|
+
"parent-abc",
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("starts empty on first call", () => {
|
|
135
|
+
const registry = getSubagentSessionRegistry();
|
|
136
|
+
expect(registry.has("/sessions/any-key")).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -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", () => {
|