@gotgenes/pi-permission-system 10.0.0 → 10.2.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 +33 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent-prep-session.ts +28 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +11 -0
- package/src/forwarded-permissions/permission-forwarder.ts +549 -0
- package/src/forwarding-manager.ts +3 -7
- package/src/gate-handler-session.ts +13 -0
- package/src/gate-prompter.ts +14 -0
- package/src/handlers/before-agent-start.ts +2 -3
- package/src/handlers/gates/bash-command.ts +4 -18
- package/src/handlers/gates/bash-external-directory.ts +3 -15
- package/src/handlers/gates/bash-path.ts +3 -16
- package/src/handlers/gates/descriptor.ts +0 -28
- package/src/handlers/gates/path.ts +3 -15
- package/src/handlers/gates/runner.ts +142 -105
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +44 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
- package/src/handlers/lifecycle.ts +9 -9
- package/src/handlers/permission-gate-handler.ts +34 -238
- package/src/index.ts +53 -69
- package/src/mcp-targets.ts +56 -46
- package/src/permission-manager.ts +69 -3
- package/src/permission-prompter.ts +7 -58
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +83 -27
- package/src/permissions-service.ts +53 -0
- package/src/runtime.ts +1 -37
- package/src/service-lifecycle.ts +49 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-lifecycle-session.ts +24 -0
- package/src/tool-input-preview.ts +0 -62
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +6 -4
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +62 -0
- package/test/forwarding-manager.test.ts +26 -44
- package/test/handlers/before-agent-start.test.ts +45 -21
- package/test/handlers/external-directory-integration.test.ts +83 -114
- package/test/handlers/external-directory-session-dedup.test.ts +102 -55
- package/test/handlers/gates/bash-command.test.ts +49 -90
- package/test/handlers/gates/bash-external-directory.test.ts +54 -95
- package/test/handlers/gates/bash-path.test.ts +54 -157
- package/test/handlers/gates/path.test.ts +38 -105
- package/test/handlers/gates/runner.test.ts +151 -186
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +128 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
- package/test/handlers/input.test.ts +1 -2
- package/test/handlers/lifecycle.test.ts +49 -33
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/handlers/tool-call.test.ts +44 -153
- package/test/helpers/gate-fixtures.ts +212 -17
- package/test/helpers/handler-fixtures.ts +226 -29
- package/test/mcp-targets.test.ts +55 -0
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-forwarding.test.ts +0 -282
- package/test/permission-manager-unified.test.ts +159 -1
- package/test/permission-prompter.test.ts +33 -44
- package/test/permission-session.test.ts +211 -105
- package/test/permissions-service.test.ts +151 -0
- package/test/runtime.test.ts +2 -86
- package/test/service-lifecycle.test.ts +162 -0
- package/test/tool-input-preview.test.ts +0 -111
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/src/forwarded-permissions/polling.ts +0 -411
package/test/runtime.test.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
2
|
|
|
4
3
|
// ── logger stub ────────────────────────────────────────────────────────────
|
|
@@ -49,10 +48,6 @@ vi.mock("../src/config-reporter", () => ({
|
|
|
49
48
|
buildResolvedConfigLogEntry: mockBuildResolvedConfigLogEntry,
|
|
50
49
|
}));
|
|
51
50
|
|
|
52
|
-
vi.mock("../src/forwarded-permissions/polling", () => ({
|
|
53
|
-
processForwardedPermissionRequests: vi.fn().mockResolvedValue(undefined),
|
|
54
|
-
}));
|
|
55
|
-
|
|
56
51
|
vi.mock("../src/subagent-context", () => ({
|
|
57
52
|
isSubagentExecutionContext: vi.fn().mockReturnValue(false),
|
|
58
53
|
}));
|
|
@@ -67,19 +62,9 @@ vi.mock("../src/session-rules", () => ({
|
|
|
67
62
|
}));
|
|
68
63
|
|
|
69
64
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
70
|
-
import {
|
|
71
|
-
getGlobalConfigPath,
|
|
72
|
-
getGlobalLogsDir,
|
|
73
|
-
getProjectConfigPath,
|
|
74
|
-
} from "#src/config-paths";
|
|
65
|
+
import { getGlobalLogsDir } from "#src/config-paths";
|
|
75
66
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
76
|
-
import {
|
|
77
|
-
import {
|
|
78
|
-
createExtensionRuntime,
|
|
79
|
-
createPermissionManagerForCwd,
|
|
80
|
-
derivePiProjectPaths,
|
|
81
|
-
refreshExtensionConfig,
|
|
82
|
-
} from "#src/runtime";
|
|
67
|
+
import { createExtensionRuntime, refreshExtensionConfig } from "#src/runtime";
|
|
83
68
|
|
|
84
69
|
// ── test suite ─────────────────────────────────────────────────────────────
|
|
85
70
|
|
|
@@ -355,75 +340,6 @@ describe("createExtensionRuntime", () => {
|
|
|
355
340
|
});
|
|
356
341
|
});
|
|
357
342
|
|
|
358
|
-
// ── derivePiProjectPaths ───────────────────────────────────────────────────
|
|
359
|
-
|
|
360
|
-
describe("derivePiProjectPaths", () => {
|
|
361
|
-
it("returns null for null cwd", () => {
|
|
362
|
-
expect(derivePiProjectPaths(null)).toBeNull();
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it("returns null for undefined cwd", () => {
|
|
366
|
-
expect(derivePiProjectPaths(undefined)).toBeNull();
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it("returns null for empty string cwd", () => {
|
|
370
|
-
expect(derivePiProjectPaths("")).toBeNull();
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
it("returns projectGlobalConfigPath via getProjectConfigPath", () => {
|
|
374
|
-
const result = derivePiProjectPaths("/my/project");
|
|
375
|
-
expect(result?.projectGlobalConfigPath).toBe(
|
|
376
|
-
getProjectConfigPath("/my/project"),
|
|
377
|
-
);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
it("returns projectAgentsDir as .pi/agent/agents under cwd", () => {
|
|
381
|
-
const result = derivePiProjectPaths("/my/project");
|
|
382
|
-
expect(result?.projectAgentsDir).toBe(
|
|
383
|
-
join("/my/project", ".pi", "agent", "agents"),
|
|
384
|
-
);
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
// ── createPermissionManagerForCwd ─────────────────────────────────────────
|
|
389
|
-
|
|
390
|
-
describe("createPermissionManagerForCwd", () => {
|
|
391
|
-
beforeEach(() => {
|
|
392
|
-
// PermissionManager is already mocked as vi.fn() at module scope.
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it("creates a PermissionManager with globalConfigPath from agentDir", () => {
|
|
396
|
-
const MockPM = PermissionManager as ReturnType<typeof vi.fn>;
|
|
397
|
-
MockPM.mockClear();
|
|
398
|
-
createPermissionManagerForCwd("/test/agent", null);
|
|
399
|
-
expect(MockPM).toHaveBeenCalledWith(
|
|
400
|
-
expect.objectContaining({
|
|
401
|
-
globalConfigPath: getGlobalConfigPath("/test/agent"),
|
|
402
|
-
}),
|
|
403
|
-
);
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
it("includes projectGlobalConfigPath when cwd is provided", () => {
|
|
407
|
-
const MockPM = PermissionManager as ReturnType<typeof vi.fn>;
|
|
408
|
-
MockPM.mockClear();
|
|
409
|
-
createPermissionManagerForCwd("/test/agent", "/my/project");
|
|
410
|
-
expect(MockPM).toHaveBeenCalledWith(
|
|
411
|
-
expect.objectContaining({
|
|
412
|
-
globalConfigPath: getGlobalConfigPath("/test/agent"),
|
|
413
|
-
projectGlobalConfigPath: getProjectConfigPath("/my/project"),
|
|
414
|
-
}),
|
|
415
|
-
);
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
it("excludes projectGlobalConfigPath when cwd is null", () => {
|
|
419
|
-
const MockPM = PermissionManager as ReturnType<typeof vi.fn>;
|
|
420
|
-
MockPM.mockClear();
|
|
421
|
-
createPermissionManagerForCwd("/test/agent", null);
|
|
422
|
-
const callArg = MockPM.mock.calls[0][0] as Record<string, unknown>;
|
|
423
|
-
expect(callArg.projectGlobalConfigPath).toBeUndefined();
|
|
424
|
-
});
|
|
425
|
-
});
|
|
426
|
-
|
|
427
343
|
// ── refreshExtensionConfig ────────────────────────────────────────────────
|
|
428
344
|
|
|
429
345
|
describe("refreshExtensionConfig", () => {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { PermissionsService } from "#src/service";
|
|
3
|
+
import {
|
|
4
|
+
PermissionServiceLifecycle,
|
|
5
|
+
type ServiceLifecycle,
|
|
6
|
+
} from "#src/service-lifecycle";
|
|
7
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
8
|
+
|
|
9
|
+
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
10
|
+
|
|
11
|
+
// ── module stubs ───────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const mockIsRegisteredSubagentChild = vi.hoisted(() =>
|
|
14
|
+
vi.fn<(ctx: unknown, registry: unknown) => boolean>().mockReturnValue(false),
|
|
15
|
+
);
|
|
16
|
+
const mockPublishPermissionsService = vi.hoisted(() => vi.fn<() => void>());
|
|
17
|
+
const mockUnpublishPermissionsService = vi.hoisted(() => vi.fn<() => void>());
|
|
18
|
+
const mockEmitReadyEvent = vi.hoisted(() => vi.fn<() => void>());
|
|
19
|
+
|
|
20
|
+
vi.mock("#src/subagent-context", () => ({
|
|
21
|
+
isRegisteredSubagentChild: mockIsRegisteredSubagentChild,
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("#src/service", () => ({
|
|
24
|
+
publishPermissionsService: mockPublishPermissionsService,
|
|
25
|
+
unpublishPermissionsService: mockUnpublishPermissionsService,
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("#src/permission-events", () => ({
|
|
28
|
+
emitReadyEvent: mockEmitReadyEvent,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function makeService(): PermissionsService {
|
|
34
|
+
return {
|
|
35
|
+
checkPermission: vi.fn(),
|
|
36
|
+
getToolPermission: vi.fn(),
|
|
37
|
+
registerToolInputFormatter: vi.fn(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeRegistry(): SubagentSessionRegistry {
|
|
42
|
+
return {
|
|
43
|
+
has: vi.fn().mockReturnValue(false),
|
|
44
|
+
} as unknown as SubagentSessionRegistry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeLifecycle(overrides?: { subscriptions?: (() => void)[] }) {
|
|
48
|
+
const service = makeService();
|
|
49
|
+
const registry = makeRegistry();
|
|
50
|
+
const events = { emit: vi.fn(), on: vi.fn() };
|
|
51
|
+
const subscriptions = overrides?.subscriptions ?? [];
|
|
52
|
+
const lifecycle = new PermissionServiceLifecycle(
|
|
53
|
+
service,
|
|
54
|
+
registry,
|
|
55
|
+
events,
|
|
56
|
+
subscriptions,
|
|
57
|
+
);
|
|
58
|
+
return { lifecycle, service, registry, events, subscriptions };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
mockIsRegisteredSubagentChild.mockReset();
|
|
63
|
+
mockIsRegisteredSubagentChild.mockReturnValue(false);
|
|
64
|
+
mockPublishPermissionsService.mockReset();
|
|
65
|
+
mockUnpublishPermissionsService.mockReset();
|
|
66
|
+
mockEmitReadyEvent.mockReset();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── ServiceLifecycle interface shape ──────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
it("PermissionServiceLifecycle satisfies ServiceLifecycle", () => {
|
|
72
|
+
const { lifecycle } = makeLifecycle();
|
|
73
|
+
const _: ServiceLifecycle = lifecycle;
|
|
74
|
+
expect(_).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── activate ──────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
describe("activate", () => {
|
|
80
|
+
it("publishes the service for a non-child session", () => {
|
|
81
|
+
const ctx = makeCtx();
|
|
82
|
+
const { lifecycle, service } = makeLifecycle();
|
|
83
|
+
mockIsRegisteredSubagentChild.mockReturnValue(false);
|
|
84
|
+
lifecycle.activate(ctx);
|
|
85
|
+
expect(mockPublishPermissionsService).toHaveBeenCalledWith(service);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("skips publishing for a registered child session", () => {
|
|
89
|
+
const ctx = makeCtx();
|
|
90
|
+
const { lifecycle } = makeLifecycle();
|
|
91
|
+
mockIsRegisteredSubagentChild.mockReturnValue(true);
|
|
92
|
+
lifecycle.activate(ctx);
|
|
93
|
+
expect(mockPublishPermissionsService).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("always emits the ready event, even for a child session", () => {
|
|
97
|
+
const ctx = makeCtx();
|
|
98
|
+
const { lifecycle, events } = makeLifecycle();
|
|
99
|
+
mockIsRegisteredSubagentChild.mockReturnValue(true);
|
|
100
|
+
lifecycle.activate(ctx);
|
|
101
|
+
expect(mockEmitReadyEvent).toHaveBeenCalledWith(events);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("emits ready after publishing the service", () => {
|
|
105
|
+
const ctx = makeCtx();
|
|
106
|
+
const order: string[] = [];
|
|
107
|
+
mockPublishPermissionsService.mockImplementation(() =>
|
|
108
|
+
order.push("publish"),
|
|
109
|
+
);
|
|
110
|
+
mockEmitReadyEvent.mockImplementation(() => order.push("ready"));
|
|
111
|
+
const { lifecycle } = makeLifecycle();
|
|
112
|
+
lifecycle.activate(ctx);
|
|
113
|
+
expect(order).toEqual(["publish", "ready"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("passes ctx and registry to isRegisteredSubagentChild", () => {
|
|
117
|
+
const ctx = makeCtx();
|
|
118
|
+
const { lifecycle, registry } = makeLifecycle();
|
|
119
|
+
lifecycle.activate(ctx);
|
|
120
|
+
expect(mockIsRegisteredSubagentChild).toHaveBeenCalledWith(ctx, registry);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── teardown ──────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe("teardown", () => {
|
|
127
|
+
it("calls each subscription unsubscribe function", () => {
|
|
128
|
+
const unsub1 = vi.fn();
|
|
129
|
+
const unsub2 = vi.fn();
|
|
130
|
+
const unsub3 = vi.fn();
|
|
131
|
+
const { lifecycle } = makeLifecycle({
|
|
132
|
+
subscriptions: [unsub1, unsub2, unsub3],
|
|
133
|
+
});
|
|
134
|
+
lifecycle.teardown();
|
|
135
|
+
expect(unsub1).toHaveBeenCalledOnce();
|
|
136
|
+
expect(unsub2).toHaveBeenCalledOnce();
|
|
137
|
+
expect(unsub3).toHaveBeenCalledOnce();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("unpublishes the service after running subscriptions", () => {
|
|
141
|
+
const order: string[] = [];
|
|
142
|
+
const unsub = vi.fn(() => order.push("unsub"));
|
|
143
|
+
mockUnpublishPermissionsService.mockImplementation(() =>
|
|
144
|
+
order.push("unpublish"),
|
|
145
|
+
);
|
|
146
|
+
const { lifecycle } = makeLifecycle({ subscriptions: [unsub] });
|
|
147
|
+
lifecycle.teardown();
|
|
148
|
+
expect(order).toEqual(["unsub", "unpublish"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("passes the service to unpublishPermissionsService", () => {
|
|
152
|
+
const { lifecycle, service } = makeLifecycle();
|
|
153
|
+
lifecycle.teardown();
|
|
154
|
+
expect(mockUnpublishPermissionsService).toHaveBeenCalledWith(service);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("works with no subscriptions", () => {
|
|
158
|
+
const { lifecycle } = makeLifecycle({ subscriptions: [] });
|
|
159
|
+
expect(() => lifecycle.teardown()).not.toThrow();
|
|
160
|
+
expect(mockUnpublishPermissionsService).toHaveBeenCalledOnce();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -9,10 +9,6 @@ import { safeJsonStringify } from "#src/logging";
|
|
|
9
9
|
import {
|
|
10
10
|
countTextLines,
|
|
11
11
|
formatCount,
|
|
12
|
-
formatEditInputForPrompt,
|
|
13
|
-
formatReadInputForPrompt,
|
|
14
|
-
formatWriteInputForPrompt,
|
|
15
|
-
getPromptPath,
|
|
16
12
|
serializeToolInputPreview,
|
|
17
13
|
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
18
14
|
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
@@ -102,113 +98,6 @@ describe("formatCount", () => {
|
|
|
102
98
|
});
|
|
103
99
|
});
|
|
104
100
|
|
|
105
|
-
describe("getPromptPath", () => {
|
|
106
|
-
test("returns path from 'path' key", () => {
|
|
107
|
-
expect(getPromptPath({ path: "/foo/bar" })).toBe("/foo/bar");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("falls back to 'file_path' key", () => {
|
|
111
|
-
expect(getPromptPath({ file_path: "/baz" })).toBe("/baz");
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("returns null when neither key is present", () => {
|
|
115
|
-
expect(getPromptPath({})).toBeNull();
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("returns null when path is empty string", () => {
|
|
119
|
-
expect(getPromptPath({ path: "" })).toBeNull();
|
|
120
|
-
});
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
describe("formatEditInputForPrompt", () => {
|
|
124
|
-
test("returns path-only description when no edits provided", () => {
|
|
125
|
-
const result = formatEditInputForPrompt({ path: "/foo.ts" });
|
|
126
|
-
expect(result).toBe("for '/foo.ts' with edit input");
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("formats single replacement with line counts", () => {
|
|
130
|
-
const result = formatEditInputForPrompt({
|
|
131
|
-
path: "/foo.ts",
|
|
132
|
-
edits: [{ oldText: "line1\nline2", newText: "replaced" }],
|
|
133
|
-
});
|
|
134
|
-
expect(result).toContain("for '/foo.ts'");
|
|
135
|
-
expect(result).toContain("1 replacement");
|
|
136
|
-
expect(result).toContain("2 lines");
|
|
137
|
-
expect(result).toContain("1 line");
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("formats multiple replacements mentioning additional edits", () => {
|
|
141
|
-
const result = formatEditInputForPrompt({
|
|
142
|
-
path: "/foo.ts",
|
|
143
|
-
edits: [
|
|
144
|
-
{ oldText: "a", newText: "b" },
|
|
145
|
-
{ oldText: "c", newText: "d" },
|
|
146
|
-
{ oldText: "e", newText: "f" },
|
|
147
|
-
],
|
|
148
|
-
});
|
|
149
|
-
expect(result).toContain("3 replacements");
|
|
150
|
-
expect(result).toContain("2 additional edits");
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test("falls back to oldText/newText when no edits array", () => {
|
|
154
|
-
const result = formatEditInputForPrompt({
|
|
155
|
-
path: "/bar.ts",
|
|
156
|
-
oldText: "old",
|
|
157
|
-
newText: "new",
|
|
158
|
-
});
|
|
159
|
-
expect(result).toContain("for '/bar.ts'");
|
|
160
|
-
expect(result).toContain("1 replacement");
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("works without a path", () => {
|
|
164
|
-
const result = formatEditInputForPrompt({
|
|
165
|
-
edits: [{ oldText: "x", newText: "y" }],
|
|
166
|
-
});
|
|
167
|
-
expect(result).not.toContain("for '");
|
|
168
|
-
expect(result).toContain("1 replacement");
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe("formatWriteInputForPrompt", () => {
|
|
173
|
-
test("includes path, line count, and character count", () => {
|
|
174
|
-
const result = formatWriteInputForPrompt({
|
|
175
|
-
path: "/out.ts",
|
|
176
|
-
content: "line1\nline2",
|
|
177
|
-
});
|
|
178
|
-
expect(result).toContain("for '/out.ts'");
|
|
179
|
-
expect(result).toContain("2 lines");
|
|
180
|
-
expect(result).toContain("11 characters");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("handles missing content as empty", () => {
|
|
184
|
-
const result = formatWriteInputForPrompt({ path: "/out.ts" });
|
|
185
|
-
expect(result).toContain("0 lines");
|
|
186
|
-
expect(result).toContain("0 characters");
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
describe("formatReadInputForPrompt", () => {
|
|
191
|
-
test("includes path", () => {
|
|
192
|
-
expect(formatReadInputForPrompt({ path: "/src/foo.ts" })).toBe(
|
|
193
|
-
"for path '/src/foo.ts'",
|
|
194
|
-
);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
test("includes offset and limit when present", () => {
|
|
198
|
-
const result = formatReadInputForPrompt({
|
|
199
|
-
path: "/x",
|
|
200
|
-
offset: 10,
|
|
201
|
-
limit: 50,
|
|
202
|
-
});
|
|
203
|
-
expect(result).toContain("offset 10");
|
|
204
|
-
expect(result).toContain("limit 50");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("returns empty string when no path and no options", () => {
|
|
208
|
-
expect(formatReadInputForPrompt({})).toBe("");
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
|
|
212
101
|
describe("serializeToolInputPreview", () => {
|
|
213
102
|
test("delegates serialization to safeJsonStringify", () => {
|
|
214
103
|
mockedStringify.mockReturnValue('{"key":"value"}');
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatEditInputForPrompt,
|
|
5
|
+
formatReadInputForPrompt,
|
|
6
|
+
formatWriteInputForPrompt,
|
|
7
|
+
getPromptPath,
|
|
8
|
+
} from "#src/tool-input-prompt-formatters";
|
|
9
|
+
|
|
10
|
+
describe("getPromptPath", () => {
|
|
11
|
+
test("returns path from 'path' key", () => {
|
|
12
|
+
expect(getPromptPath({ path: "/foo/bar" })).toBe("/foo/bar");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("falls back to 'file_path' key", () => {
|
|
16
|
+
expect(getPromptPath({ file_path: "/baz" })).toBe("/baz");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns null when neither key is present", () => {
|
|
20
|
+
expect(getPromptPath({})).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns null when path is empty string", () => {
|
|
24
|
+
expect(getPromptPath({ path: "" })).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("formatEditInputForPrompt", () => {
|
|
29
|
+
test("returns path-only description when no edits provided", () => {
|
|
30
|
+
const result = formatEditInputForPrompt({ path: "/foo.ts" });
|
|
31
|
+
expect(result).toBe("for '/foo.ts' with edit input");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("formats single replacement with line counts", () => {
|
|
35
|
+
const result = formatEditInputForPrompt({
|
|
36
|
+
path: "/foo.ts",
|
|
37
|
+
edits: [{ oldText: "line1\nline2", newText: "replaced" }],
|
|
38
|
+
});
|
|
39
|
+
expect(result).toContain("for '/foo.ts'");
|
|
40
|
+
expect(result).toContain("1 replacement");
|
|
41
|
+
expect(result).toContain("2 lines");
|
|
42
|
+
expect(result).toContain("1 line");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("formats multiple replacements mentioning additional edits", () => {
|
|
46
|
+
const result = formatEditInputForPrompt({
|
|
47
|
+
path: "/foo.ts",
|
|
48
|
+
edits: [
|
|
49
|
+
{ oldText: "a", newText: "b" },
|
|
50
|
+
{ oldText: "c", newText: "d" },
|
|
51
|
+
{ oldText: "e", newText: "f" },
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
expect(result).toContain("3 replacements");
|
|
55
|
+
expect(result).toContain("2 additional edits");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("falls back to oldText/newText when no edits array", () => {
|
|
59
|
+
const result = formatEditInputForPrompt({
|
|
60
|
+
path: "/bar.ts",
|
|
61
|
+
oldText: "old",
|
|
62
|
+
newText: "new",
|
|
63
|
+
});
|
|
64
|
+
expect(result).toContain("for '/bar.ts'");
|
|
65
|
+
expect(result).toContain("1 replacement");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("works without a path", () => {
|
|
69
|
+
const result = formatEditInputForPrompt({
|
|
70
|
+
edits: [{ oldText: "x", newText: "y" }],
|
|
71
|
+
});
|
|
72
|
+
expect(result).not.toContain("for '");
|
|
73
|
+
expect(result).toContain("1 replacement");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("formatWriteInputForPrompt", () => {
|
|
78
|
+
test("includes path, line count, and character count", () => {
|
|
79
|
+
const result = formatWriteInputForPrompt({
|
|
80
|
+
path: "/out.ts",
|
|
81
|
+
content: "line1\nline2",
|
|
82
|
+
});
|
|
83
|
+
expect(result).toContain("for '/out.ts'");
|
|
84
|
+
expect(result).toContain("2 lines");
|
|
85
|
+
expect(result).toContain("11 characters");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("handles missing content as empty", () => {
|
|
89
|
+
const result = formatWriteInputForPrompt({ path: "/out.ts" });
|
|
90
|
+
expect(result).toContain("0 lines");
|
|
91
|
+
expect(result).toContain("0 characters");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("formatReadInputForPrompt", () => {
|
|
96
|
+
test("includes path", () => {
|
|
97
|
+
expect(formatReadInputForPrompt({ path: "/src/foo.ts" })).toBe(
|
|
98
|
+
"for path '/src/foo.ts'",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("includes offset and limit when present", () => {
|
|
103
|
+
const result = formatReadInputForPrompt({
|
|
104
|
+
path: "/x",
|
|
105
|
+
offset: 10,
|
|
106
|
+
limit: 50,
|
|
107
|
+
});
|
|
108
|
+
expect(result).toContain("offset 10");
|
|
109
|
+
expect(result).toContain("limit 50");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("returns empty string when no path and no options", () => {
|
|
113
|
+
expect(formatReadInputForPrompt({})).toBe("");
|
|
114
|
+
});
|
|
115
|
+
});
|