@aaroncql/pim-agent 0.0.1 → 0.1.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/README.md +19 -8
- package/bin/pim.ts +55 -3
- package/package.json +20 -5
- package/src/extensions/_init/index.ts +3 -2
- package/src/extensions/bash/capture.test.ts +0 -126
- package/src/extensions/bash/format.test.ts +0 -240
- package/src/extensions/bash/run.test.ts +0 -262
- package/src/extensions/command-picker/ranker.test.ts +0 -46
- package/src/extensions/edit/edit.test.ts +0 -285
- package/src/extensions/file-picker/catalog.test.ts +0 -263
- package/src/extensions/file-picker/index.test.ts +0 -168
- package/src/extensions/file-picker/ranker.test.ts +0 -94
- package/src/extensions/footer/git.test.ts +0 -76
- package/src/extensions/footer/index.test.ts +0 -161
- package/src/extensions/footer/segments.test.ts +0 -164
- package/src/extensions/glob/glob.test.ts +0 -171
- package/src/extensions/glob/index.test.ts +0 -68
- package/src/extensions/glob/render.test.ts +0 -126
- package/src/extensions/grep/grep.test.ts +0 -387
- package/src/extensions/grep/index.test.ts +0 -68
- package/src/extensions/grep/render.test.ts +0 -269
- package/src/extensions/read/read.test.ts +0 -177
- package/src/extensions/read/render.test.ts +0 -61
- package/src/extensions/subagent/index.test.ts +0 -44
- package/src/extensions/subagent/render.test.ts +0 -292
- package/src/extensions/subagent/subagent.test.ts +0 -315
- package/src/extensions/system-prompt/prompt.test.ts +0 -64
- package/src/extensions/todo/index.test.ts +0 -244
- package/src/extensions/todo/render.test.ts +0 -180
- package/src/extensions/todo/todo.test.ts +0 -222
- package/src/extensions/tps/index.test.ts +0 -254
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
- package/src/extensions/web-fetch/fetch.test.ts +0 -244
- package/src/extensions/web-fetch/render.test.ts +0 -56
- package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
- package/src/extensions/web-search/render.test.ts +0 -21
- package/src/extensions/web-search/search.test.ts +0 -53
- package/src/extensions/working-indicator/index.test.ts +0 -21
- package/src/extensions/write/render.test.ts +0 -64
- package/src/extensions/write/write.test.ts +0 -108
- package/src/shared/DiffLines.test.ts +0 -193
- package/src/shared/DiffRenderer.test.ts +0 -206
- package/src/shared/EditMatcher.test.ts +0 -123
- package/src/shared/FileScanner.test.ts +0 -158
- package/src/shared/FuzzyMatcher.test.ts +0 -114
- package/src/shared/GitignoreFilter.test.ts +0 -64
- package/src/shared/Lines.test.ts +0 -25
- package/src/shared/McpClient.test.ts +0 -235
- package/src/shared/OutputBudget.test.ts +0 -99
- package/src/shared/Paths.test.ts +0 -51
- package/src/shared/PimSettings.test.ts +0 -90
- package/src/shared/Renderer.test.ts +0 -190
- package/src/shared/SpillCache.test.ts +0 -94
- package/src/shared/Tools.test.ts +0 -392
- package/src/telegram/Config.test.ts +0 -275
- package/src/telegram/Markdown.test.ts +0 -143
- package/src/telegram/Renderer.test.ts +0 -216
- package/src/telegram/SessionRegistry.test.ts +0 -89
- package/src/telegram/TaskScheduler.test.ts +0 -278
- package/src/telegram/TaskTool.test.ts +0 -179
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { OutputBudget } from "../../shared/OutputBudget";
|
|
3
|
-
import type { GrepMatch } from "./grep";
|
|
4
|
-
import { formatTitle, renderMatches } from "./render";
|
|
5
|
-
|
|
6
|
-
const fixture: readonly GrepMatch[] = [
|
|
7
|
-
{
|
|
8
|
-
filePath: "/repo/older.ts",
|
|
9
|
-
mtime: 1_000,
|
|
10
|
-
fileLines: ["alpha"],
|
|
11
|
-
ranges: [{ startLineNumber: 1, endLineNumber: 1 }],
|
|
12
|
-
lines: [{ lineNumber: 1, text: "alpha" }],
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
filePath: "/repo/newer.ts",
|
|
16
|
-
mtime: 2_000,
|
|
17
|
-
fileLines: ["intro", "alpha", "middle", "tail", "alphabet"],
|
|
18
|
-
ranges: [
|
|
19
|
-
{ startLineNumber: 2, endLineNumber: 2 },
|
|
20
|
-
{ startLineNumber: 5, endLineNumber: 5 },
|
|
21
|
-
],
|
|
22
|
-
lines: [
|
|
23
|
-
{ lineNumber: 2, text: "alpha" },
|
|
24
|
-
{ lineNumber: 5, text: "alphabet" },
|
|
25
|
-
],
|
|
26
|
-
},
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
const relativeOptions = {
|
|
30
|
-
cwd: "/repo",
|
|
31
|
-
pathFormat: "relative",
|
|
32
|
-
context: 0,
|
|
33
|
-
} as const;
|
|
34
|
-
|
|
35
|
-
const absoluteOptions = {
|
|
36
|
-
cwd: "/repo",
|
|
37
|
-
pathFormat: "absolute",
|
|
38
|
-
context: 0,
|
|
39
|
-
} as const;
|
|
40
|
-
|
|
41
|
-
describe("renderMatches", () => {
|
|
42
|
-
test("files_with_matches sorts by recency desc and renders relative paths", () => {
|
|
43
|
-
const outcome = renderMatches(
|
|
44
|
-
fixture,
|
|
45
|
-
"files_with_matches",
|
|
46
|
-
1000,
|
|
47
|
-
relativeOptions
|
|
48
|
-
);
|
|
49
|
-
expect(outcome.body).toBe("newer.ts\nolder.ts");
|
|
50
|
-
expect(outcome.fileCount).toBe(2);
|
|
51
|
-
expect(outcome.totalMatches).toBe(3);
|
|
52
|
-
expect(outcome.totalItems).toBe(2);
|
|
53
|
-
expect(outcome.truncated).toBe(false);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("can render absolute paths", () => {
|
|
57
|
-
const outcome = renderMatches(
|
|
58
|
-
fixture,
|
|
59
|
-
"files_with_matches",
|
|
60
|
-
1000,
|
|
61
|
-
absoluteOptions
|
|
62
|
-
);
|
|
63
|
-
expect(outcome.body).toBe("/repo/newer.ts\n/repo/older.ts");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("content emits path:line:text lines without markers when context is omitted", () => {
|
|
67
|
-
const outcome = renderMatches(fixture, "content", 1000, relativeOptions);
|
|
68
|
-
expect(outcome.body).toBe(
|
|
69
|
-
["newer.ts:2:alpha", "newer.ts:5:alphabet", "older.ts:1:alpha"].join("\n")
|
|
70
|
-
);
|
|
71
|
-
expect(outcome.totalItems).toBe(3);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("content can include context lines and distinguish matches", () => {
|
|
75
|
-
const outcome = renderMatches(fixture, "content", 1000, {
|
|
76
|
-
...relativeOptions,
|
|
77
|
-
context: 1,
|
|
78
|
-
});
|
|
79
|
-
expect(outcome.body).toBe(
|
|
80
|
-
[
|
|
81
|
-
" newer.ts:1:intro",
|
|
82
|
-
"> newer.ts:2:alpha",
|
|
83
|
-
" newer.ts:3:middle",
|
|
84
|
-
" newer.ts:4:tail",
|
|
85
|
-
"> newer.ts:5:alphabet",
|
|
86
|
-
"> older.ts:1:alpha",
|
|
87
|
-
].join("\n")
|
|
88
|
-
);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("content inserts separators between non-overlapping context blocks", () => {
|
|
92
|
-
const outcome = renderMatches(
|
|
93
|
-
[
|
|
94
|
-
{
|
|
95
|
-
filePath: "/repo/gapped.ts",
|
|
96
|
-
mtime: 3_000,
|
|
97
|
-
fileLines: ["1", "2", "hit", "4", "5", "6", "hit", "8", "9"],
|
|
98
|
-
ranges: [
|
|
99
|
-
{ startLineNumber: 3, endLineNumber: 3 },
|
|
100
|
-
{ startLineNumber: 7, endLineNumber: 7 },
|
|
101
|
-
],
|
|
102
|
-
lines: [
|
|
103
|
-
{ lineNumber: 3, text: "hit" },
|
|
104
|
-
{ lineNumber: 7, text: "hit" },
|
|
105
|
-
],
|
|
106
|
-
},
|
|
107
|
-
],
|
|
108
|
-
"content",
|
|
109
|
-
1000,
|
|
110
|
-
{ ...relativeOptions, context: 1 }
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
expect(outcome.body).toBe(
|
|
114
|
-
[
|
|
115
|
-
" gapped.ts:2:2",
|
|
116
|
-
"> gapped.ts:3:hit",
|
|
117
|
-
" gapped.ts:4:4",
|
|
118
|
-
"--",
|
|
119
|
-
" gapped.ts:6:6",
|
|
120
|
-
"> gapped.ts:7:hit",
|
|
121
|
-
" gapped.ts:8:8",
|
|
122
|
-
].join("\n")
|
|
123
|
-
);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("count sorts by match count desc, then mtime desc, then path asc", () => {
|
|
127
|
-
const outcome = renderMatches(fixture, "count", 1000, relativeOptions);
|
|
128
|
-
expect(outcome.body).toBe("newer.ts:2\nolder.ts:1");
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("flags truncation when results exceed headLimit", () => {
|
|
132
|
-
const outcome = renderMatches(fixture, "content", 2, relativeOptions);
|
|
133
|
-
expect(outcome.body).toBe("newer.ts:2:alpha\nnewer.ts:5:alphabet");
|
|
134
|
-
expect(outcome.truncated).toBe(true);
|
|
135
|
-
expect(outcome.visibleItems).toBe(2);
|
|
136
|
-
expect(outcome.totalItems).toBe(3);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("returns a no-match outcome when there are no results", () => {
|
|
140
|
-
const outcome = renderMatches([], "content", 1000, relativeOptions);
|
|
141
|
-
expect(outcome.body).toBe("No matches.");
|
|
142
|
-
expect(outcome.truncated).toBe(false);
|
|
143
|
-
expect(outcome.fileCount).toBe(0);
|
|
144
|
-
expect(outcome.totalMatches).toBe(0);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("truncates matched lines from minified files so a single hit cannot blow up the body", () => {
|
|
148
|
-
const minified = "a".repeat(50_000);
|
|
149
|
-
const outcome = renderMatches(
|
|
150
|
-
[
|
|
151
|
-
{
|
|
152
|
-
filePath: "/repo/dist/bundle.js",
|
|
153
|
-
mtime: 1_000,
|
|
154
|
-
fileLines: [minified],
|
|
155
|
-
ranges: [{ startLineNumber: 1, endLineNumber: 1 }],
|
|
156
|
-
lines: [{ lineNumber: 1, text: minified }],
|
|
157
|
-
},
|
|
158
|
-
],
|
|
159
|
-
"content",
|
|
160
|
-
1000,
|
|
161
|
-
relativeOptions
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
expect(outcome.body).toContain(
|
|
165
|
-
`(line truncated to ${OutputBudget.maxLineLength} chars)`
|
|
166
|
-
);
|
|
167
|
-
expect(Buffer.byteLength(outcome.body, "utf8")).toBeLessThanOrEqual(
|
|
168
|
-
OutputBudget.maxBytes
|
|
169
|
-
);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("byte cap drops trailing rendered lines when many matches each push toward the cap", () => {
|
|
173
|
-
const longText = "x".repeat(OutputBudget.maxLineLength);
|
|
174
|
-
const fileLines = Array.from({ length: 40 }, () => longText);
|
|
175
|
-
const ranges = fileLines.map((_, index) => ({
|
|
176
|
-
startLineNumber: index + 1,
|
|
177
|
-
endLineNumber: index + 1,
|
|
178
|
-
}));
|
|
179
|
-
const lines = fileLines.map((text, index) => ({
|
|
180
|
-
lineNumber: index + 1,
|
|
181
|
-
text,
|
|
182
|
-
}));
|
|
183
|
-
|
|
184
|
-
const outcome = renderMatches(
|
|
185
|
-
[
|
|
186
|
-
{
|
|
187
|
-
filePath: "/repo/big.txt",
|
|
188
|
-
mtime: 1_000,
|
|
189
|
-
fileLines,
|
|
190
|
-
ranges,
|
|
191
|
-
lines,
|
|
192
|
-
},
|
|
193
|
-
],
|
|
194
|
-
"content",
|
|
195
|
-
1000,
|
|
196
|
-
relativeOptions
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
expect(outcome.truncated).toBe(true);
|
|
200
|
-
expect(outcome.visibleItems).toBeLessThan(outcome.totalItems);
|
|
201
|
-
expect(Buffer.byteLength(outcome.body, "utf8")).toBeLessThanOrEqual(
|
|
202
|
-
OutputBudget.maxBytes
|
|
203
|
-
);
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
describe("formatTitle", () => {
|
|
208
|
-
test("uses relative path under cwd and includes glob", () => {
|
|
209
|
-
const title = formatTitle({
|
|
210
|
-
pattern: "alpha",
|
|
211
|
-
path: "/repo/src",
|
|
212
|
-
glob: "**/*.ts",
|
|
213
|
-
cwd: "/repo",
|
|
214
|
-
});
|
|
215
|
-
expect(title).toBe("/alpha/ in src **/*.ts");
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("formats regex patterns with slashes", () => {
|
|
219
|
-
const title = formatTitle({
|
|
220
|
-
pattern: "^alpha$",
|
|
221
|
-
path: "/repo/src",
|
|
222
|
-
glob: undefined,
|
|
223
|
-
cwd: "/repo",
|
|
224
|
-
});
|
|
225
|
-
expect(title).toBe("/^alpha$/ in src");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("falls back to '.' when path is omitted", () => {
|
|
229
|
-
const title = formatTitle({
|
|
230
|
-
pattern: "alpha",
|
|
231
|
-
path: undefined,
|
|
232
|
-
glob: undefined,
|
|
233
|
-
cwd: "/repo",
|
|
234
|
-
});
|
|
235
|
-
expect(title).toBe("/alpha/ in .");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test("resolves relative paths in titles", () => {
|
|
239
|
-
const title = formatTitle({
|
|
240
|
-
pattern: "alpha",
|
|
241
|
-
path: "src",
|
|
242
|
-
glob: undefined,
|
|
243
|
-
cwd: "/repo",
|
|
244
|
-
});
|
|
245
|
-
expect(title).toBe("/alpha/ in src");
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test("appends pluralized file count when provided", () => {
|
|
249
|
-
const title = formatTitle({
|
|
250
|
-
pattern: "alpha",
|
|
251
|
-
path: "/repo/src",
|
|
252
|
-
glob: "**/*.ts",
|
|
253
|
-
cwd: "/repo",
|
|
254
|
-
fileCount: 3,
|
|
255
|
-
});
|
|
256
|
-
expect(title).toBe("/alpha/ in src **/*.ts (3 files)");
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
test("uses singular noun for a single file", () => {
|
|
260
|
-
const title = formatTitle({
|
|
261
|
-
pattern: "alpha",
|
|
262
|
-
path: undefined,
|
|
263
|
-
glob: undefined,
|
|
264
|
-
cwd: "/repo",
|
|
265
|
-
fileCount: 1,
|
|
266
|
-
});
|
|
267
|
-
expect(title).toBe("/alpha/ in . (1 file)");
|
|
268
|
-
});
|
|
269
|
-
});
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { afterAll, describe, expect, test } from "bun:test";
|
|
5
|
-
import { OutputBudget } from "../../shared/OutputBudget";
|
|
6
|
-
import { buildReadRange, readFile } from "./read";
|
|
7
|
-
|
|
8
|
-
const MAX_LINE_LENGTH = OutputBudget.maxLineLength;
|
|
9
|
-
|
|
10
|
-
const tempRoots: string[] = [];
|
|
11
|
-
|
|
12
|
-
const tempRoot = async (): Promise<string> => {
|
|
13
|
-
const root = await mkdtemp(join(tmpdir(), "pim-read-tool-"));
|
|
14
|
-
tempRoots.push(root);
|
|
15
|
-
return root;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
afterAll(async () => {
|
|
19
|
-
await Promise.all(
|
|
20
|
-
tempRoots.map((root) => rm(root, { force: true, recursive: true }))
|
|
21
|
-
);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe("readFile", () => {
|
|
25
|
-
test("emits inclusive LINE:CONTENT ranges", async () => {
|
|
26
|
-
const root = await tempRoot();
|
|
27
|
-
const path = join(root, "notes.txt");
|
|
28
|
-
await writeFile(path, "alpha\nbeta\ngamma", "utf8");
|
|
29
|
-
|
|
30
|
-
const outcome = await readFile(path, buildReadRange(2, 2));
|
|
31
|
-
expect(outcome.body).toBe("2:beta");
|
|
32
|
-
expect(outcome.totalLines).toBe(3);
|
|
33
|
-
expect(outcome.visibleStart).toBe(2);
|
|
34
|
-
expect(outcome.visibleEnd).toBe(2);
|
|
35
|
-
expect(outcome.truncatedByByteCap).toBe(false);
|
|
36
|
-
expect(outcome.truncatedByEnd).toBe(true);
|
|
37
|
-
expect(outcome.nextStart).toBe(3);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("does not surface a phantom final line for files ending in a newline", async () => {
|
|
41
|
-
const root = await tempRoot();
|
|
42
|
-
const path = join(root, "notes.txt");
|
|
43
|
-
await writeFile(path, "alpha\nbeta\ngamma\n", "utf8");
|
|
44
|
-
|
|
45
|
-
const outcome = await readFile(path, buildReadRange(undefined, undefined));
|
|
46
|
-
expect(outcome.body).toBe(["1:alpha", "2:beta", "3:gamma"].join("\n"));
|
|
47
|
-
expect(outcome.totalLines).toBe(3);
|
|
48
|
-
expect(outcome.truncatedByEnd).toBe(false);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("strips UTF-8 BOM from output and reports it in details", async () => {
|
|
52
|
-
const root = await tempRoot();
|
|
53
|
-
const path = join(root, "bom.txt");
|
|
54
|
-
await writeFile(path, "\uFEFFalpha\nbeta", "utf8");
|
|
55
|
-
|
|
56
|
-
const outcome = await readFile(path, buildReadRange(undefined, undefined));
|
|
57
|
-
expect(outcome.body).toBe("1:alpha\n2:beta");
|
|
58
|
-
expect(outcome.hadBom).toBe(true);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("truncates very long individual lines", async () => {
|
|
62
|
-
const root = await tempRoot();
|
|
63
|
-
const path = join(root, "long-line.txt");
|
|
64
|
-
await writeFile(path, `${"x".repeat(MAX_LINE_LENGTH + 10)}\nshort`, "utf8");
|
|
65
|
-
|
|
66
|
-
const outcome = await readFile(path, buildReadRange(1, 1));
|
|
67
|
-
expect(outcome.body).toBe(
|
|
68
|
-
`1:${"x".repeat(MAX_LINE_LENGTH)}... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
|
69
|
-
);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("throws on empty files and out-of-range starts", async () => {
|
|
73
|
-
const root = await tempRoot();
|
|
74
|
-
const path = join(root, "empty.txt");
|
|
75
|
-
await writeFile(path, "", "utf8");
|
|
76
|
-
|
|
77
|
-
await expect(
|
|
78
|
-
readFile(path, buildReadRange(undefined, undefined))
|
|
79
|
-
).rejects.toThrow("File is empty. Use the write tool to create content.");
|
|
80
|
-
|
|
81
|
-
const populated = join(root, "small.txt");
|
|
82
|
-
await writeFile(populated, "alpha\nbeta", "utf8");
|
|
83
|
-
await expect(
|
|
84
|
-
readFile(populated, buildReadRange(99, undefined))
|
|
85
|
-
).rejects.toThrow(
|
|
86
|
-
"Start 99 is beyond end of file (2 lines total). Use start=1 to read from the beginning, or start=2 to read the last line."
|
|
87
|
-
);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("rejects directories and binary files", async () => {
|
|
91
|
-
const root = await tempRoot();
|
|
92
|
-
const nested = join(root, "nested");
|
|
93
|
-
const binary = join(root, "data.bin");
|
|
94
|
-
|
|
95
|
-
await mkdir(nested);
|
|
96
|
-
await Bun.write(binary, new Uint8Array([1, 0, 2]));
|
|
97
|
-
|
|
98
|
-
await expect(
|
|
99
|
-
readFile(nested, buildReadRange(undefined, undefined))
|
|
100
|
-
).rejects.toThrow(`Path is a directory: ${nested}`);
|
|
101
|
-
|
|
102
|
-
await expect(
|
|
103
|
-
readFile(binary, buildReadRange(undefined, undefined))
|
|
104
|
-
).rejects.toThrow("Read only supports UTF-8 text files");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("returns missing-file error with sibling suggestions", async () => {
|
|
108
|
-
const root = await tempRoot();
|
|
109
|
-
const missing = join(root, "note.txt");
|
|
110
|
-
await writeFile(join(root, "notes.txt"), "alpha", "utf8");
|
|
111
|
-
|
|
112
|
-
await expect(
|
|
113
|
-
readFile(missing, buildReadRange(undefined, undefined))
|
|
114
|
-
).rejects.toThrow(/Did you mean one of these\?[\s\S]*notes\.txt/);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test("surfaces permission-denied as a structured error", async () => {
|
|
118
|
-
if (process.getuid?.() === 0) {
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const root = await tempRoot();
|
|
123
|
-
const path = join(root, "locked.txt");
|
|
124
|
-
await writeFile(path, "secret", "utf8");
|
|
125
|
-
await chmod(path, 0o000);
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
await expect(
|
|
129
|
-
readFile(path, buildReadRange(undefined, undefined))
|
|
130
|
-
).rejects.toThrow(`Permission denied reading ${path}.`);
|
|
131
|
-
} finally {
|
|
132
|
-
await chmod(path, 0o644);
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("applies a 32 KiB head-only byte cap and reports pagination metadata", async () => {
|
|
137
|
-
const root = await tempRoot();
|
|
138
|
-
const path = join(root, "long.txt");
|
|
139
|
-
const lines = Array.from(
|
|
140
|
-
{ length: 80 },
|
|
141
|
-
(_, index) => `${index + 1}: ${"x".repeat(500)}`
|
|
142
|
-
);
|
|
143
|
-
await writeFile(path, lines.join("\n"), "utf8");
|
|
144
|
-
|
|
145
|
-
const outcome = await readFile(path, buildReadRange(undefined, undefined));
|
|
146
|
-
expect(Buffer.byteLength(outcome.body, "utf8")).toBeLessThanOrEqual(
|
|
147
|
-
32 * 1024
|
|
148
|
-
);
|
|
149
|
-
expect(outcome.truncatedByByteCap).toBe(true);
|
|
150
|
-
expect(outcome.truncatedByEnd).toBe(true);
|
|
151
|
-
expect(outcome.nextStart).toBe(outcome.visibleEnd + 1);
|
|
152
|
-
expect(outcome.totalLines).toBe(80);
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
describe("buildReadRange", () => {
|
|
157
|
-
test("rejects end before start", () => {
|
|
158
|
-
expect(() => buildReadRange(5, 4)).toThrow(
|
|
159
|
-
"Read end line 4 must be >= start line 5."
|
|
160
|
-
);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("rejects non-positive integers", () => {
|
|
164
|
-
expect(() => buildReadRange(0, undefined)).toThrow(
|
|
165
|
-
"Read start 0 must be a positive integer."
|
|
166
|
-
);
|
|
167
|
-
expect(() => buildReadRange(undefined, -1)).toThrow(
|
|
168
|
-
"Read end -1 must be a positive integer."
|
|
169
|
-
);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("defaults start to 1", () => {
|
|
173
|
-
expect(buildReadRange(undefined, undefined)).toEqual({
|
|
174
|
-
start: 1,
|
|
175
|
-
});
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { formatTitlePath } from "./render";
|
|
3
|
-
|
|
4
|
-
describe("formatTitlePath", () => {
|
|
5
|
-
const cwd = "/work/repo";
|
|
6
|
-
|
|
7
|
-
test("renders relative path without format suffix", () => {
|
|
8
|
-
expect(
|
|
9
|
-
formatTitlePath({
|
|
10
|
-
path: "/work/repo/src/foo.ts",
|
|
11
|
-
cwd,
|
|
12
|
-
start: undefined,
|
|
13
|
-
end: undefined,
|
|
14
|
-
})
|
|
15
|
-
).toBe("src/foo.ts");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test("renders explicit start-end range", () => {
|
|
19
|
-
expect(
|
|
20
|
-
formatTitlePath({
|
|
21
|
-
path: "/work/repo/src/foo.ts",
|
|
22
|
-
cwd,
|
|
23
|
-
start: 40,
|
|
24
|
-
end: 80,
|
|
25
|
-
})
|
|
26
|
-
).toBe("src/foo.ts:40-80");
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("renders start-only range", () => {
|
|
30
|
-
expect(
|
|
31
|
-
formatTitlePath({
|
|
32
|
-
path: "/work/repo/src/foo.ts",
|
|
33
|
-
cwd,
|
|
34
|
-
start: 40,
|
|
35
|
-
end: undefined,
|
|
36
|
-
})
|
|
37
|
-
).toBe("src/foo.ts:40");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("falls back to absolute path when outside cwd", () => {
|
|
41
|
-
expect(
|
|
42
|
-
formatTitlePath({
|
|
43
|
-
path: "/etc/hosts",
|
|
44
|
-
cwd,
|
|
45
|
-
start: undefined,
|
|
46
|
-
end: undefined,
|
|
47
|
-
})
|
|
48
|
-
).toBe("/etc/hosts");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("placeholder when path is missing", () => {
|
|
52
|
-
expect(
|
|
53
|
-
formatTitlePath({
|
|
54
|
-
path: undefined,
|
|
55
|
-
cwd,
|
|
56
|
-
start: undefined,
|
|
57
|
-
end: undefined,
|
|
58
|
-
})
|
|
59
|
-
).toBe("...");
|
|
60
|
-
});
|
|
61
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
4
|
-
import registerSubagent from "./index";
|
|
5
|
-
import { subagentSchema } from "./schema";
|
|
6
|
-
|
|
7
|
-
function validate(args: unknown): void {
|
|
8
|
-
validateToolArguments(
|
|
9
|
-
{ name: "subagent", parameters: subagentSchema } as never,
|
|
10
|
-
{
|
|
11
|
-
type: "toolCall",
|
|
12
|
-
id: "1",
|
|
13
|
-
name: "subagent",
|
|
14
|
-
arguments: args as Record<string, unknown>,
|
|
15
|
-
}
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
describe("subagent extension registration", () => {
|
|
20
|
-
test("schema rejects an empty prompt", () => {
|
|
21
|
-
expect(() => validate({ prompt: "" })).toThrow();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("registers one parallel tool without a prompt snippet", () => {
|
|
25
|
-
let tool:
|
|
26
|
-
| {
|
|
27
|
-
readonly name: string;
|
|
28
|
-
readonly executionMode?: string;
|
|
29
|
-
readonly promptSnippet?: string;
|
|
30
|
-
readonly parameters: unknown;
|
|
31
|
-
}
|
|
32
|
-
| undefined;
|
|
33
|
-
registerSubagent({
|
|
34
|
-
registerTool(def) {
|
|
35
|
-
tool = def;
|
|
36
|
-
},
|
|
37
|
-
} as ExtensionAPI);
|
|
38
|
-
|
|
39
|
-
expect(tool?.name).toBe("subagent");
|
|
40
|
-
expect(tool?.executionMode).toBe("parallel");
|
|
41
|
-
expect(tool?.promptSnippet).toBeUndefined();
|
|
42
|
-
expect(tool?.parameters).toBe(subagentSchema);
|
|
43
|
-
});
|
|
44
|
-
});
|