@d3ara1n/pi-subagent 0.6.1 → 0.7.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/src/utils.test.ts CHANGED
@@ -13,266 +13,282 @@
13
13
  import { test, describe } from "node:test";
14
14
  import assert from "node:assert/strict";
15
15
  import {
16
- sanitizeFilename,
17
- isProviderError,
18
- AsyncSemaphore,
19
- previewArgs,
20
- truncateOutput,
21
- formatTokens,
22
- effectiveTimeout,
23
- elapsedSeconds,
16
+ sanitizeFilename,
17
+ isProviderError,
18
+ AsyncSemaphore,
19
+ previewArgs,
20
+ truncateOutput,
21
+ formatTokens,
22
+ effectiveTimeout,
23
+ elapsedSeconds,
24
24
  } from "./utils.ts";
25
25
  import type { SubagentResult, SubagentRole } from "./types.ts";
26
26
 
27
27
  // ── sanitizeFilename: guards the path-injection fix ──
28
28
  describe("sanitizeFilename", () => {
29
- test("never yields a path separator (no directory traversal)", () => {
30
- // Core security contract: result contains no / or \, so it can't escape the dir via path.join.
31
- for (const input of ["../../etc", "../passwd", "/etc/passwd", "a/b/c", "a\\b", "..", "///"]) {
32
- const out = sanitizeFilename(input);
33
- assert.ok(!out.includes("/"), `${input} -> "${out}" still contains /`);
34
- assert.ok(!out.includes("\\"), `${input} -> "${out}" still contains \\`);
35
- }
36
- });
37
- test("empty string falls back to unknown", () => {
38
- assert.equal(sanitizeFilename(""), "unknown");
39
- });
40
- test("pure-dots collapses to unknown (leading dots stripped, rest empty)", () => {
41
- assert.equal(sanitizeFilename(".."), "unknown");
42
- assert.equal(sanitizeFilename("..."), "unknown");
43
- });
44
- test("special chars become underscores", () => {
45
- assert.equal(sanitizeFilename("!!!"), "___");
46
- assert.equal(sanitizeFilename(" "), "___");
47
- assert.equal(sanitizeFilename("///"), "___");
48
- assert.equal(sanitizeFilename("a/b/c"), "a_b_c");
49
- });
50
- test("keeps normal uuid/alnum/dots/dashes as-is", () => {
51
- const id = "019eff4f-b603-7623-9eaa-17d32eb623d9";
52
- assert.equal(sanitizeFilename(id), id);
53
- assert.equal(sanitizeFilename("call_abc123.json"), "call_abc123.json");
54
- });
29
+ test("never yields a path separator (no directory traversal)", () => {
30
+ // Core security contract: result contains no / or \, so it can't escape the dir via path.join.
31
+ for (const input of ["../../etc", "../passwd", "/etc/passwd", "a/b/c", "a\\b", "..", "///"]) {
32
+ const out = sanitizeFilename(input);
33
+ assert.ok(!out.includes("/"), `${input} -> "${out}" still contains /`);
34
+ assert.ok(!out.includes("\\"), `${input} -> "${out}" still contains \\`);
35
+ }
36
+ });
37
+ test("empty string falls back to unknown", () => {
38
+ assert.equal(sanitizeFilename(""), "unknown");
39
+ });
40
+ test("pure-dots collapses to unknown (leading dots stripped, rest empty)", () => {
41
+ assert.equal(sanitizeFilename(".."), "unknown");
42
+ assert.equal(sanitizeFilename("..."), "unknown");
43
+ });
44
+ test("special chars become underscores", () => {
45
+ assert.equal(sanitizeFilename("!!!"), "___");
46
+ assert.equal(sanitizeFilename(" "), "___");
47
+ assert.equal(sanitizeFilename("///"), "___");
48
+ assert.equal(sanitizeFilename("a/b/c"), "a_b_c");
49
+ });
50
+ test("keeps normal uuid/alnum/dots/dashes as-is", () => {
51
+ const id = "019eff4f-b603-7623-9eaa-17d32eb623d9";
52
+ assert.equal(sanitizeFilename(id), id);
53
+ assert.equal(sanitizeFilename("call_abc123.json"), "call_abc123.json");
54
+ });
55
55
  });
56
56
 
57
57
  // ── isProviderError: guards the #9 expanded word list ──
58
58
  describe("isProviderError", () => {
59
- const mk = (stderr: string, errorMessage = ""): SubagentResult =>
60
- ({
61
- stderr,
62
- errorMessage,
63
- role: "",
64
- task: "",
65
- exitCode: 0,
66
- messages: [],
67
- output: "",
68
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
69
- activityLog: [],
70
- }) as unknown as SubagentResult;
59
+ const mk = (stderr: string, errorMessage = ""): SubagentResult =>
60
+ ({
61
+ stderr,
62
+ errorMessage,
63
+ role: "",
64
+ task: "",
65
+ exitCode: 0,
66
+ messages: [],
67
+ output: "",
68
+ usage: {
69
+ input: 0,
70
+ output: 0,
71
+ cacheRead: 0,
72
+ cacheWrite: 0,
73
+ cost: 0,
74
+ contextTokens: 0,
75
+ turns: 0,
76
+ },
77
+ activityLog: [],
78
+ }) as unknown as SubagentResult;
71
79
 
72
- test("matches provider error keywords", () => {
73
- const cases = [
74
- "429 Too Many Requests",
75
- "quota exceeded",
76
- "rate limit exceeded",
77
- "authentication error",
78
- "request timeout",
79
- "quota exhausted",
80
- "service unavailable",
81
- "503 Service Unavailable",
82
- "internal server error",
83
- "temporary failure",
84
- "request declined",
85
- "server overloaded",
86
- "ECONNRESET",
87
- "socket hang up",
88
- "EPIPE",
89
- "network error",
90
- "connection refused",
91
- ];
92
- for (const c of cases) {
93
- assert.equal(isProviderError(mk(c)), true, `should match: ${c}`);
94
- }
95
- });
96
- test("does not match business/programming errors", () => {
97
- assert.equal(isProviderError(mk("TypeError: Cannot read properties of undefined")), false);
98
- assert.equal(isProviderError(mk("Error: test failed, expected 5 got 3")), false);
99
- assert.equal(isProviderError(mk("AssertionError: values differ")), false);
100
- assert.equal(isProviderError(mk("")), false);
101
- });
102
- test("checks errorMessage too, not just stderr", () => {
103
- assert.equal(isProviderError(mk("", "rate limited")), true);
104
- });
80
+ test("matches provider error keywords", () => {
81
+ const cases = [
82
+ "429 Too Many Requests",
83
+ "quota exceeded",
84
+ "rate limit exceeded",
85
+ "authentication error",
86
+ "request timeout",
87
+ "quota exhausted",
88
+ "service unavailable",
89
+ "503 Service Unavailable",
90
+ "internal server error",
91
+ "temporary failure",
92
+ "request declined",
93
+ "server overloaded",
94
+ "ECONNRESET",
95
+ "socket hang up",
96
+ "EPIPE",
97
+ "network error",
98
+ "connection refused",
99
+ ];
100
+ for (const c of cases) {
101
+ assert.equal(isProviderError(mk(c)), true, `should match: ${c}`);
102
+ }
103
+ });
104
+ test("does not match business/programming errors", () => {
105
+ assert.equal(isProviderError(mk("TypeError: Cannot read properties of undefined")), false);
106
+ assert.equal(isProviderError(mk("Error: test failed, expected 5 got 3")), false);
107
+ assert.equal(isProviderError(mk("AssertionError: values differ")), false);
108
+ assert.equal(isProviderError(mk("")), false);
109
+ });
110
+ test("checks errorMessage too, not just stderr", () => {
111
+ assert.equal(isProviderError(mk("", "rate limited")), true);
112
+ });
105
113
  });
106
114
 
107
115
  // ── AsyncSemaphore: guards concurrency cap, negative-active, abort cleanup ──
108
116
  describe("AsyncSemaphore", () => {
109
- test("never goes negative on extra release", async () => {
110
- const s = new AsyncSemaphore(1);
111
- await s.acquire();
112
- s.release();
113
- s.release();
114
- s.release();
115
- assert.equal((s as any).active, 0);
116
- });
117
- test("respects concurrency cap (queues beyond max)", async () => {
118
- const s = new AsyncSemaphore(2);
119
- await s.acquire();
120
- await s.acquire();
121
- let entered = false;
122
- const p = s.acquire().then(() => {
123
- entered = true;
124
- });
125
- await Promise.resolve();
126
- await Promise.resolve();
127
- assert.equal(entered, false); // still queued
128
- s.release();
129
- await p;
130
- assert.equal(entered, true);
131
- });
132
- test("abort removes waiter from queue and rejects", async () => {
133
- const s = new AsyncSemaphore(1);
134
- await s.acquire();
135
- const c = new AbortController();
136
- const p = s.acquire(c.signal);
137
- c.abort();
138
- await assert.rejects(p);
139
- assert.equal((s as any).waiters.length, 0);
140
- });
141
- test("releases queued waiters in FIFO order", async () => {
142
- const s = new AsyncSemaphore(1);
143
- await s.acquire();
144
- const order: number[] = [];
145
- const p1 = s.acquire().then(() => order.push(1));
146
- const p2 = s.acquire().then(() => order.push(2));
147
- const p3 = s.acquire().then(() => order.push(3));
148
- s.release();
149
- await p1;
150
- s.release();
151
- await p2;
152
- s.release();
153
- await p3;
154
- assert.deepEqual(order, [1, 2, 3]);
155
- });
156
- test("acquires immediately when under cap", async () => {
157
- const s = new AsyncSemaphore(3);
158
- await s.acquire();
159
- await s.acquire();
160
- assert.equal((s as any).active, 2);
161
- });
117
+ test("never goes negative on extra release", async () => {
118
+ const s = new AsyncSemaphore(1);
119
+ await s.acquire();
120
+ s.release();
121
+ s.release();
122
+ s.release();
123
+ assert.equal((s as any).active, 0);
124
+ });
125
+ test("respects concurrency cap (queues beyond max)", async () => {
126
+ const s = new AsyncSemaphore(2);
127
+ await s.acquire();
128
+ await s.acquire();
129
+ let entered = false;
130
+ const p = s.acquire().then(() => {
131
+ entered = true;
132
+ });
133
+ await Promise.resolve();
134
+ await Promise.resolve();
135
+ assert.equal(entered, false); // still queued
136
+ s.release();
137
+ await p;
138
+ assert.equal(entered, true);
139
+ });
140
+ test("abort removes waiter from queue and rejects", async () => {
141
+ const s = new AsyncSemaphore(1);
142
+ await s.acquire();
143
+ const c = new AbortController();
144
+ const p = s.acquire(c.signal);
145
+ c.abort();
146
+ await assert.rejects(p);
147
+ assert.equal((s as any).waiters.length, 0);
148
+ });
149
+ test("releases queued waiters in FIFO order", async () => {
150
+ const s = new AsyncSemaphore(1);
151
+ await s.acquire();
152
+ const order: number[] = [];
153
+ const p1 = s.acquire().then(() => order.push(1));
154
+ const p2 = s.acquire().then(() => order.push(2));
155
+ const p3 = s.acquire().then(() => order.push(3));
156
+ s.release();
157
+ await p1;
158
+ s.release();
159
+ await p2;
160
+ s.release();
161
+ await p3;
162
+ assert.deepEqual(order, [1, 2, 3]);
163
+ });
164
+ test("acquires immediately when under cap", async () => {
165
+ const s = new AsyncSemaphore(3);
166
+ await s.acquire();
167
+ await s.acquire();
168
+ assert.equal((s as any).active, 2);
169
+ });
162
170
  });
163
171
 
164
172
  // ── previewArgs: guards the #10 shape-based formatting ──
165
173
  describe("previewArgs", () => {
166
- test("command -> $ prefix", () => {
167
- assert.equal(previewArgs({ command: "ls -la" }), "$ ls -la");
168
- });
169
- test("command truncated at 60 chars", () => {
170
- const long = "x".repeat(70);
171
- const r = previewArgs({ command: long });
172
- assert.ok(r.startsWith("$ "));
173
- assert.ok(r.endsWith("..."));
174
- assert.ok(r.length < long.length);
175
- });
176
- test("file_path is shortened (home -> ~)", () => {
177
- const r = previewArgs({ file_path: "/home/user/foo.ts" });
178
- assert.ok(r.includes("foo.ts"));
179
- });
180
- test("url passthrough (truncated when long)", () => {
181
- assert.equal(previewArgs({ url: "https://example.com" }), "https://example.com");
182
- const longUrl = "https://" + "x".repeat(70);
183
- assert.ok(previewArgs({ url: longUrl }).endsWith("..."));
184
- });
185
- test("query/pattern/regex/search -> /.../ form", () => {
186
- assert.equal(previewArgs({ query: "foo" }), "/foo/");
187
- assert.equal(previewArgs({ pattern: "bar" }), "/bar/");
188
- assert.equal(previewArgs({ regex: "baz" }), "/baz/");
189
- assert.equal(previewArgs({ search: "qux" }), "/qux/");
190
- });
191
- test("empty object falls back to JSON {}", () => {
192
- assert.equal(previewArgs({}), "{}");
193
- });
174
+ test("command -> $ prefix", () => {
175
+ assert.equal(previewArgs({ command: "ls -la" }), "$ ls -la");
176
+ });
177
+ test("command truncated at 60 chars", () => {
178
+ const long = "x".repeat(70);
179
+ const r = previewArgs({ command: long });
180
+ assert.ok(r.startsWith("$ "));
181
+ assert.ok(r.endsWith("..."));
182
+ assert.ok(r.length < long.length);
183
+ });
184
+ test("file_path is shortened (home -> ~)", () => {
185
+ const r = previewArgs({ file_path: "/home/user/foo.ts" });
186
+ assert.ok(r.includes("foo.ts"));
187
+ });
188
+ test("url passthrough (truncated when long)", () => {
189
+ assert.equal(previewArgs({ url: "https://example.com" }), "https://example.com");
190
+ const longUrl = "https://" + "x".repeat(70);
191
+ assert.ok(previewArgs({ url: longUrl }).endsWith("..."));
192
+ });
193
+ test("query/pattern/regex/search -> /.../ form", () => {
194
+ assert.equal(previewArgs({ query: "foo" }), "/foo/");
195
+ assert.equal(previewArgs({ pattern: "bar" }), "/bar/");
196
+ assert.equal(previewArgs({ regex: "baz" }), "/baz/");
197
+ assert.equal(previewArgs({ search: "qux" }), "/qux/");
198
+ });
199
+ test("empty object falls back to JSON {}", () => {
200
+ assert.equal(previewArgs({}), "{}");
201
+ });
194
202
  });
195
203
 
196
204
  // ── effectiveTimeout: guards delegate-role auto-widening (seconds) ──
197
205
  describe("effectiveTimeout", () => {
198
- const role = (tools: string[], timeout?: number): SubagentRole =>
199
- ({ role: "default", description: "", examples: [], decisionTrigger: "", tools, systemPrompt: "", timeout }) as unknown as SubagentRole;
206
+ const role = (tools: string[], timeout?: number): SubagentRole =>
207
+ ({
208
+ role: "default",
209
+ description: "",
210
+ examples: [],
211
+ decisionTrigger: "",
212
+ tools,
213
+ systemPrompt: "",
214
+ timeout,
215
+ }) as unknown as SubagentRole;
200
216
 
201
- test("non-delegate role uses base timeout", () => {
202
- assert.equal(effectiveTimeout(role(["read", "grep"]), 600), 600);
203
- });
204
- test("delegate role doubles base when no explicit timeout", () => {
205
- assert.equal(effectiveTimeout(role(["read", "delegate"]), 600), 1200);
206
- });
207
- test("explicit roleDef.timeout is always honored (no widening)", () => {
208
- assert.equal(effectiveTimeout(role(["read", "delegate"], 300), 600), 300);
209
- });
210
- test("explicit timeout on non-delegate also honored", () => {
211
- assert.equal(effectiveTimeout(role(["read"]), 600), 600);
212
- });
217
+ test("non-delegate role uses base timeout", () => {
218
+ assert.equal(effectiveTimeout(role(["read", "grep"]), 600), 600);
219
+ });
220
+ test("delegate role doubles base when no explicit timeout", () => {
221
+ assert.equal(effectiveTimeout(role(["read", "delegate"]), 600), 1200);
222
+ });
223
+ test("explicit roleDef.timeout is always honored (no widening)", () => {
224
+ assert.equal(effectiveTimeout(role(["read", "delegate"], 300), 600), 300);
225
+ });
226
+ test("explicit timeout on non-delegate also honored", () => {
227
+ assert.equal(effectiveTimeout(role(["read"]), 600), 600);
228
+ });
213
229
  });
214
230
 
215
231
  // ── truncateOutput: guards the #2 head+tail fallback ──
216
232
  describe("truncateOutput", () => {
217
- test("adds truncation header with original length", () => {
218
- const big = "x".repeat(60000);
219
- const r = truncateOutput(big);
220
- assert.ok(r.startsWith("[Output truncated"));
221
- assert.ok(r.includes("60000 chars total"));
222
- assert.ok(r.includes("[truncated]"));
223
- });
224
- test("keeps head and tail, drops the middle", () => {
225
- // 120000 chars: 40k H + 40k M + 40k T
226
- const content = "H".repeat(40000) + "M".repeat(40000) + "T".repeat(40000);
227
- const r = truncateOutput(content);
228
- assert.ok(r.includes("H"), "head preserved");
229
- assert.ok(r.includes("T"), "tail preserved");
230
- assert.ok(!r.includes("M"), "middle dropped");
231
- });
233
+ test("adds truncation header with original length", () => {
234
+ const big = "x".repeat(60000);
235
+ const r = truncateOutput(big);
236
+ assert.ok(r.startsWith("[Output truncated"));
237
+ assert.ok(r.includes("60000 chars total"));
238
+ assert.ok(r.includes("[truncated]"));
239
+ });
240
+ test("keeps head and tail, drops the middle", () => {
241
+ // 120000 chars: 40k H + 40k M + 40k T
242
+ const content = "H".repeat(40000) + "M".repeat(40000) + "T".repeat(40000);
243
+ const r = truncateOutput(content);
244
+ assert.ok(r.includes("H"), "head preserved");
245
+ assert.ok(r.includes("T"), "tail preserved");
246
+ assert.ok(!r.includes("M"), "middle dropped");
247
+ });
232
248
  });
233
249
 
234
250
  // ── formatTokens: boundary correctness ──
235
251
  describe("formatTokens", () => {
236
- test("under 1000 stays raw", () => {
237
- assert.equal(formatTokens(0), "0");
238
- assert.equal(formatTokens(999), "999");
239
- });
240
- test("1000-9999 with one decimal place", () => {
241
- assert.equal(formatTokens(1000), "1.0k");
242
- assert.equal(formatTokens(9500), "9.5k");
243
- // 9999/1000 = 9.999, toFixed(1) rounds up to 10.0
244
- assert.equal(formatTokens(9999), "10.0k");
245
- });
246
- test("10000-999999 rounded to integer k", () => {
247
- assert.equal(formatTokens(10000), "10k");
248
- assert.equal(formatTokens(999999), "1000k");
249
- });
250
- test(">= 1000000 in M", () => {
251
- assert.equal(formatTokens(1000000), "1.0M");
252
- });
252
+ test("under 1000 stays raw", () => {
253
+ assert.equal(formatTokens(0), "0");
254
+ assert.equal(formatTokens(999), "999");
255
+ });
256
+ test("1000-9999 with one decimal place", () => {
257
+ assert.equal(formatTokens(1000), "1.0k");
258
+ assert.equal(formatTokens(9500), "9.5k");
259
+ // 9999/1000 = 9.999, toFixed(1) rounds up to 10.0
260
+ assert.equal(formatTokens(9999), "10.0k");
261
+ });
262
+ test("10000-999999 rounded to integer k", () => {
263
+ assert.equal(formatTokens(10000), "10k");
264
+ assert.equal(formatTokens(999999), "1000k");
265
+ });
266
+ test(">= 1000000 in M", () => {
267
+ assert.equal(formatTokens(1000000), "1.0M");
268
+ });
253
269
  });
254
270
 
255
271
  // ── elapsedSeconds: live/terminal time derivation ──
256
272
  describe("elapsedSeconds", () => {
257
- test("terminal state: rounds elapsedMs to whole seconds", () => {
258
- assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 12345 }), 12);
259
- assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 400 }), 0);
260
- assert.equal(elapsedSeconds({ exitCode: 1, elapsedMs: 59999 }), 60);
261
- });
262
- test("terminal state without elapsedMs -> undefined", () => {
263
- assert.equal(elapsedSeconds({ exitCode: 0 }), undefined);
264
- });
265
- test("queued (running sentinel, no startTime) -> undefined", () => {
266
- assert.equal(elapsedSeconds({ exitCode: -1 }), undefined);
267
- });
268
- test("running: live seconds from startTime (within ~1s drift)", () => {
269
- const start = Date.now() - 3500;
270
- const s = elapsedSeconds({ exitCode: -1, startTime: start });
271
- assert.ok(s !== undefined, "should be defined while running");
272
- assert.ok(s >= 3 && s <= 4, `expected ~3s, got ${s}`);
273
- });
274
- test("running: clamps negative drift (future startTime) to 0", () => {
275
- const start = Date.now() + 10000; // 10s in the future
276
- assert.equal(elapsedSeconds({ exitCode: -1, startTime: start }), 0);
277
- });
273
+ test("terminal state: rounds elapsedMs to whole seconds", () => {
274
+ assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 12345 }), 12);
275
+ assert.equal(elapsedSeconds({ exitCode: 0, elapsedMs: 400 }), 0);
276
+ assert.equal(elapsedSeconds({ exitCode: 1, elapsedMs: 59999 }), 60);
277
+ });
278
+ test("terminal state without elapsedMs -> undefined", () => {
279
+ assert.equal(elapsedSeconds({ exitCode: 0 }), undefined);
280
+ });
281
+ test("queued (running sentinel, no startTime) -> undefined", () => {
282
+ assert.equal(elapsedSeconds({ exitCode: -1 }), undefined);
283
+ });
284
+ test("running: live seconds from startTime (within ~1s drift)", () => {
285
+ const start = Date.now() - 3500;
286
+ const s = elapsedSeconds({ exitCode: -1, startTime: start });
287
+ assert.ok(s !== undefined, "should be defined while running");
288
+ assert.ok(s >= 3 && s <= 4, `expected ~3s, got ${s}`);
289
+ });
290
+ test("running: clamps negative drift (future startTime) to 0", () => {
291
+ const start = Date.now() + 10000; // 10s in the future
292
+ assert.equal(elapsedSeconds({ exitCode: -1, startTime: start }), 0);
293
+ });
278
294
  });