@d3ara1n/pi-subagent 0.3.0 → 0.4.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 +56 -10
- package/package.json +1 -1
- package/src/config.ts +9 -2
- package/src/index.ts +390 -246
- package/src/spawn.ts +84 -40
- package/src/types.ts +30 -1
- package/src/utils.test.ts +252 -0
- package/src/utils.ts +263 -0
package/src/spawn.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Fires onProgress on each event for streaming updates.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { spawn } from "node:child_process";
|
|
9
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as os from "node:os";
|
|
12
12
|
import * as path from "node:path";
|
|
@@ -16,8 +16,6 @@ import type { SubagentMessage, SubagentResult } from "./types.ts";
|
|
|
16
16
|
/** Maximum task length before writing to a temp file (avoids CLI arg limits). */
|
|
17
17
|
const TASK_CHAR_LIMIT = 8000;
|
|
18
18
|
|
|
19
|
-
/** Maximum output characters returned to the main model. Larger outputs are truncated. */
|
|
20
|
-
const MAX_OUTPUT_CHARS = 50_000;
|
|
21
19
|
|
|
22
20
|
const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
23
21
|
|
|
@@ -128,6 +126,9 @@ export async function spawnSubagent(
|
|
|
128
126
|
systemPrompt?: string;
|
|
129
127
|
subagentRoles?: string[];
|
|
130
128
|
timeoutMs?: number;
|
|
129
|
+
depth?: number;
|
|
130
|
+
maxTurns?: number;
|
|
131
|
+
maxCost?: number;
|
|
131
132
|
signal?: AbortSignal;
|
|
132
133
|
onProgress?: (update: Partial<SubagentResult>) => void;
|
|
133
134
|
},
|
|
@@ -175,6 +176,8 @@ export async function spawnSubagent(
|
|
|
175
176
|
// Spawn process
|
|
176
177
|
const invocation = getPiInvocation(args);
|
|
177
178
|
let wasAborted = false;
|
|
179
|
+
let budgetExceeded = false;
|
|
180
|
+
let wasTimeout = false;
|
|
178
181
|
let buffer = "";
|
|
179
182
|
|
|
180
183
|
const emitProgress = () => {
|
|
@@ -189,6 +192,20 @@ export async function spawnSubagent(
|
|
|
189
192
|
};
|
|
190
193
|
|
|
191
194
|
let thinkingCounter = 0;
|
|
195
|
+
// O(1) lookup from toolCallId → activityLog index (was linear find → O(n²) on busy runs)
|
|
196
|
+
const toolCallIndex = new Map<string, number>();
|
|
197
|
+
|
|
198
|
+
// Kill the child when the configured turn/cost budget is exceeded.
|
|
199
|
+
// Called after each assistant message_end (usage already accumulated).
|
|
200
|
+
const checkBudget = () => {
|
|
201
|
+
const mt = options.maxTurns ?? 0;
|
|
202
|
+
const mc = options.maxCost ?? 0;
|
|
203
|
+
if (budgetExceeded || wasTimeout) return;
|
|
204
|
+
if ((mt > 0 && result.usage.turns >= mt) || (mc > 0 && result.usage.cost >= mc)) {
|
|
205
|
+
budgetExceeded = true;
|
|
206
|
+
killProc("budget");
|
|
207
|
+
}
|
|
208
|
+
};
|
|
192
209
|
|
|
193
210
|
const processLine = (line: string) => {
|
|
194
211
|
if (!line.trim()) return;
|
|
@@ -212,7 +229,8 @@ export async function spawnSubagent(
|
|
|
212
229
|
result.usage.cacheRead += usage.cacheRead || 0;
|
|
213
230
|
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
214
231
|
result.usage.cost += usage.cost?.total || 0;
|
|
215
|
-
|
|
232
|
+
// Peak context size, not last-turn size (accumulating is meaningless; max tells how close to the limit)
|
|
233
|
+
result.usage.contextTokens = Math.max(result.usage.contextTokens, usage.totalTokens || 0);
|
|
216
234
|
}
|
|
217
235
|
if (!result.model && msg.model) result.model = msg.model;
|
|
218
236
|
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
@@ -224,6 +242,8 @@ export async function spawnSubagent(
|
|
|
224
242
|
result.output = part.text;
|
|
225
243
|
}
|
|
226
244
|
}
|
|
245
|
+
|
|
246
|
+
checkBudget();
|
|
227
247
|
}
|
|
228
248
|
|
|
229
249
|
emitProgress();
|
|
@@ -232,6 +252,7 @@ export async function spawnSubagent(
|
|
|
232
252
|
// Activity log: track thinking blocks and tool calls in arrival order.
|
|
233
253
|
// Both update in place so the TUI reflects real-time state.
|
|
234
254
|
if (event.type === "tool_execution_start" && event.toolCallId) {
|
|
255
|
+
toolCallIndex.set(event.toolCallId, result.activityLog.length);
|
|
235
256
|
result.activityLog.push({
|
|
236
257
|
kind: "toolCall",
|
|
237
258
|
id: event.toolCallId,
|
|
@@ -241,8 +262,8 @@ export async function spawnSubagent(
|
|
|
241
262
|
});
|
|
242
263
|
emitProgress();
|
|
243
264
|
} else if (event.type === "tool_execution_end" && event.toolCallId) {
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
265
|
+
const idx = toolCallIndex.get(event.toolCallId);
|
|
266
|
+
if (idx !== undefined) result.activityLog[idx].status = event.isError ? "failed" : "done";
|
|
246
267
|
emitProgress();
|
|
247
268
|
}
|
|
248
269
|
|
|
@@ -279,73 +300,96 @@ export async function spawnSubagent(
|
|
|
279
300
|
}
|
|
280
301
|
// Expose tmpdir as env var so subagent bash commands (e.g. git clone) can use it
|
|
281
302
|
childEnv.PI_SUBAGENT_TMPDIR = tmpDir;
|
|
303
|
+
// Propagate nesting depth so child delegate calls can bound recursion
|
|
304
|
+
childEnv.PI_SUBAGENT_DEPTH = String(options.depth ?? 0);
|
|
282
305
|
|
|
283
306
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
307
|
+
let proc: ChildProcess | undefined;
|
|
308
|
+
|
|
309
|
+
// Shared kill helper used by abort, budget, and timeout paths.
|
|
310
|
+
// Centralizes reason → stopReason mapping and the SIGTERM → 5s → SIGKILL escalation.
|
|
311
|
+
const escalationTimers: ReturnType<typeof setTimeout>[] = [];
|
|
312
|
+
const killProc = (reason: "abort" | "budget" | "timeout") => {
|
|
313
|
+
if (reason === "abort") wasAborted = true;
|
|
314
|
+
else if (reason === "budget") {
|
|
315
|
+
result.stopReason = "budget_exceeded";
|
|
316
|
+
// Human-readable so the caller/TUI never falls back to raw stderr noise.
|
|
317
|
+
const mt = options.maxTurns ?? 0;
|
|
318
|
+
const mc = options.maxCost ?? 0;
|
|
319
|
+
const why = mt > 0 && result.usage.turns >= mt ? `${result.usage.turns} turns` : `$${result.usage.cost.toFixed(4)}`;
|
|
320
|
+
result.errorMessage = `Budget exceeded (${why}; partial output returned)`;
|
|
321
|
+
}
|
|
322
|
+
else if (reason === "timeout") {
|
|
323
|
+
result.stopReason = "timeout";
|
|
324
|
+
wasTimeout = true;
|
|
325
|
+
// Human-readable message so the caller/TUI never falls back to the
|
|
326
|
+
// raw stderr (which is full of TUI teardown escape sequences).
|
|
327
|
+
const secs = Math.round((options.timeoutMs ?? 0) / 1000);
|
|
328
|
+
result.errorMessage = `Timed out after ${secs}s (completed ${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"})`;
|
|
329
|
+
}
|
|
330
|
+
try { proc?.kill("SIGTERM"); } catch { /* ignore */ }
|
|
331
|
+
escalationTimers.push(setTimeout(() => {
|
|
332
|
+
try { if (proc && !proc.killed) proc.kill("SIGKILL"); } catch { /* ignore */ }
|
|
333
|
+
}, 5000));
|
|
334
|
+
};
|
|
284
335
|
|
|
285
336
|
const exitCode = await new Promise<number>((resolve) => {
|
|
286
|
-
|
|
337
|
+
// Register abort BEFORE spawning to close the (tiny) registration window
|
|
338
|
+
let onAbort: (() => void) | undefined;
|
|
339
|
+
if (options.signal) {
|
|
340
|
+
if (options.signal.aborted) { wasAborted = true; resolve(0); return; }
|
|
341
|
+
onAbort = () => killProc("abort");
|
|
342
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const p = spawn(invocation.command, invocation.args, {
|
|
287
346
|
cwd: options.cwd,
|
|
288
347
|
env: childEnv,
|
|
289
348
|
shell: false,
|
|
290
349
|
stdio: ["ignore", "pipe", "pipe"],
|
|
291
350
|
});
|
|
351
|
+
proc = p;
|
|
292
352
|
|
|
293
|
-
|
|
353
|
+
p.stdout.on("data", (data: Buffer) => {
|
|
294
354
|
buffer += data.toString();
|
|
295
355
|
const lines = buffer.split("\n");
|
|
296
356
|
buffer = lines.pop() || "";
|
|
297
357
|
for (const line of lines) processLine(line);
|
|
298
358
|
});
|
|
299
359
|
|
|
300
|
-
|
|
360
|
+
p.stderr.on("data", (data: Buffer) => {
|
|
301
361
|
result.stderr += data.toString();
|
|
302
362
|
});
|
|
303
363
|
|
|
304
|
-
|
|
364
|
+
p.on("close", (code) => {
|
|
305
365
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
366
|
+
for (const t of escalationTimers) clearTimeout(t);
|
|
367
|
+
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
|
|
306
368
|
if (buffer.trim()) processLine(buffer);
|
|
307
|
-
|
|
369
|
+
// Budget stops are intentional (success); timeouts are failures (exit 124, Unix convention);
|
|
370
|
+
// otherwise use the real exit code (signal kills yield null → 0).
|
|
371
|
+
resolve(budgetExceeded ? 0 : (wasTimeout ? 124 : (code ?? 0)));
|
|
308
372
|
});
|
|
309
373
|
|
|
310
|
-
|
|
374
|
+
p.on("error", (err) => {
|
|
375
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
376
|
+
for (const t of escalationTimers) clearTimeout(t);
|
|
377
|
+
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
|
|
378
|
+
// Surface the real cause (e.g. ENOENT when pi is not in PATH) instead of "unknown error".
|
|
379
|
+
result.errorMessage = err?.message || String(err);
|
|
311
380
|
resolve(1);
|
|
312
381
|
});
|
|
313
382
|
|
|
314
|
-
// Handle abort signal
|
|
315
|
-
if (options.signal) {
|
|
316
|
-
const killProc = () => {
|
|
317
|
-
wasAborted = true;
|
|
318
|
-
proc.kill("SIGTERM");
|
|
319
|
-
setTimeout(() => {
|
|
320
|
-
if (!proc.killed) proc.kill("SIGKILL");
|
|
321
|
-
}, 5000);
|
|
322
|
-
};
|
|
323
|
-
if (options.signal.aborted) killProc();
|
|
324
|
-
else options.signal.addEventListener("abort", killProc, { once: true });
|
|
325
|
-
}
|
|
326
|
-
|
|
327
383
|
// Handle timeout
|
|
328
384
|
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
329
|
-
timeoutHandle = setTimeout(() =>
|
|
330
|
-
if (!proc.killed) {
|
|
331
|
-
proc.kill("SIGTERM");
|
|
332
|
-
setTimeout(() => {
|
|
333
|
-
if (!proc.killed) proc.kill("SIGKILL");
|
|
334
|
-
}, 5000);
|
|
335
|
-
}
|
|
336
|
-
}, options.timeoutMs);
|
|
385
|
+
timeoutHandle = setTimeout(() => killProc("timeout"), options.timeoutMs);
|
|
337
386
|
}
|
|
338
387
|
});
|
|
339
388
|
|
|
340
389
|
result.exitCode = exitCode;
|
|
341
390
|
if (wasAborted) throw new Error("Subagent was aborted");
|
|
342
|
-
|
|
343
|
-
//
|
|
344
|
-
if (result.output.length > MAX_OUTPUT_CHARS) {
|
|
345
|
-
const head = result.output.slice(0, 30_000);
|
|
346
|
-
const tail = result.output.slice(-(MAX_OUTPUT_CHARS - 30_050));
|
|
347
|
-
result.output = `[Output truncated — ${result.output.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
|
|
348
|
-
}
|
|
391
|
+
// NOTE: large outputs are kept raw here — compression/truncation happens in
|
|
392
|
+
// the extension layer (index.ts) so the summary model can compress first.
|
|
349
393
|
} finally {
|
|
350
394
|
// Cleanup temp directory and all contents
|
|
351
395
|
if (tmpDir) try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
package/src/types.ts
CHANGED
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
/** Configuration for the subagent extension. */
|
|
6
6
|
export interface SubagentConfig {
|
|
7
7
|
timeoutMs: number;
|
|
8
|
+
/** Max number of subagents allowed to run concurrently. Extras queue with a TUI hint. */
|
|
9
|
+
maxConcurrency: number;
|
|
10
|
+
/** Max subagent nesting depth (the top-level session is depth 0). */
|
|
11
|
+
maxDepth: number;
|
|
12
|
+
/** Default turn budget (0 = unlimited). Per-role maxTurns overrides this. */
|
|
13
|
+
maxTurns: number;
|
|
14
|
+
/** Default cost budget in USD (0 = unlimited). Per-role maxCost overrides this. */
|
|
15
|
+
maxCost: number;
|
|
16
|
+
/** Persist each delegate run to .pi/subagent/history/{sessionId}/{id}.json for auditing. */
|
|
17
|
+
history: SubagentHistoryConfig;
|
|
8
18
|
summary: SubagentSummaryConfig;
|
|
9
19
|
/**
|
|
10
20
|
* Per-role overrides from settings.json. Keyed by role name.
|
|
@@ -14,13 +24,22 @@ export interface SubagentConfig {
|
|
|
14
24
|
agentOverrides: Record<string, Partial<SubagentRole> & { disabled?: boolean }>;
|
|
15
25
|
}
|
|
16
26
|
|
|
27
|
+
export interface SubagentHistoryConfig {
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
export interface SubagentSummaryConfig {
|
|
18
32
|
role: string;
|
|
19
33
|
enabled: boolean;
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
export const DEFAULT_CONFIG: SubagentConfig = {
|
|
23
|
-
timeoutMs:
|
|
37
|
+
timeoutMs: 600_000,
|
|
38
|
+
maxConcurrency: 4,
|
|
39
|
+
maxDepth: 3,
|
|
40
|
+
maxTurns: 0,
|
|
41
|
+
maxCost: 0,
|
|
42
|
+
history: { enabled: true },
|
|
24
43
|
summary: { role: "utility", enabled: true },
|
|
25
44
|
agentOverrides: {},
|
|
26
45
|
};
|
|
@@ -41,6 +60,12 @@ export interface SubagentRole {
|
|
|
41
60
|
tools: string[];
|
|
42
61
|
/** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
|
|
43
62
|
subagentRoles?: string[];
|
|
63
|
+
/** Per-role timeout override (ms). Falls back to config.timeoutMs when unset. */
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
/** Max assistant turns before the run is killed (0 = use config default; unset = unlimited). */
|
|
66
|
+
maxTurns?: number;
|
|
67
|
+
/** Max cumulative cost (USD) before the run is killed (0 = use config default; unset = unlimited). */
|
|
68
|
+
maxCost?: number;
|
|
44
69
|
/** Fallback pi-model-roles role name when this role's model is unavailable (provider error). Defaults to "default". */
|
|
45
70
|
fallbackRole?: string;
|
|
46
71
|
}
|
|
@@ -102,6 +127,10 @@ export interface SubagentResult {
|
|
|
102
127
|
task: string;
|
|
103
128
|
/** Process exit code (-1 = still running for streaming) */
|
|
104
129
|
exitCode: number;
|
|
130
|
+
/** True while waiting for a concurrency slot (TUI hint only). */
|
|
131
|
+
queued?: boolean;
|
|
132
|
+
/** How `output` was prepared for display: raw, compressed by summary model, or mechanically truncated. */
|
|
133
|
+
outputMethod?: "raw" | "compressed" | "truncated";
|
|
105
134
|
/** All messages from the event stream (assistant + tool results) */
|
|
106
135
|
messages: SubagentMessage[];
|
|
107
136
|
/** Last assistant text output */
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for pi-subagent pure helpers.
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency: runs on node's built-in test runner.
|
|
5
|
+
* node --test packages/pi-subagent/src/utils.test.ts
|
|
6
|
+
*
|
|
7
|
+
* These guard the bug fixes introduced during the improvement rounds:
|
|
8
|
+
* path-injection (sanitizeFilename), concurrency/abort/negative-active
|
|
9
|
+
* (AsyncSemaphore), provider-error word list (isProviderError), unknown-tool
|
|
10
|
+
* formatting (previewArgs), output truncation fallback (truncateOutput).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { test, describe } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import {
|
|
16
|
+
sanitizeFilename,
|
|
17
|
+
isProviderError,
|
|
18
|
+
AsyncSemaphore,
|
|
19
|
+
previewArgs,
|
|
20
|
+
truncateOutput,
|
|
21
|
+
formatTokens,
|
|
22
|
+
effectiveTimeoutMs,
|
|
23
|
+
} from "./utils.ts";
|
|
24
|
+
import type { SubagentResult, SubagentRole } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
// ── sanitizeFilename: guards the path-injection fix ──
|
|
27
|
+
describe("sanitizeFilename", () => {
|
|
28
|
+
test("never yields a path separator (no directory traversal)", () => {
|
|
29
|
+
// Core security contract: result contains no / or \, so it can't escape the dir via path.join.
|
|
30
|
+
for (const input of ["../../etc", "../passwd", "/etc/passwd", "a/b/c", "a\\b", "..", "///"]) {
|
|
31
|
+
const out = sanitizeFilename(input);
|
|
32
|
+
assert.ok(!out.includes("/"), `${input} -> "${out}" still contains /`);
|
|
33
|
+
assert.ok(!out.includes("\\"), `${input} -> "${out}" still contains \\`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
test("empty string falls back to unknown", () => {
|
|
37
|
+
assert.equal(sanitizeFilename(""), "unknown");
|
|
38
|
+
});
|
|
39
|
+
test("pure-dots collapses to unknown (leading dots stripped, rest empty)", () => {
|
|
40
|
+
assert.equal(sanitizeFilename(".."), "unknown");
|
|
41
|
+
assert.equal(sanitizeFilename("..."), "unknown");
|
|
42
|
+
});
|
|
43
|
+
test("special chars become underscores", () => {
|
|
44
|
+
assert.equal(sanitizeFilename("!!!"), "___");
|
|
45
|
+
assert.equal(sanitizeFilename(" "), "___");
|
|
46
|
+
assert.equal(sanitizeFilename("///"), "___");
|
|
47
|
+
assert.equal(sanitizeFilename("a/b/c"), "a_b_c");
|
|
48
|
+
});
|
|
49
|
+
test("keeps normal uuid/alnum/dots/dashes as-is", () => {
|
|
50
|
+
const id = "019eff4f-b603-7623-9eaa-17d32eb623d9";
|
|
51
|
+
assert.equal(sanitizeFilename(id), id);
|
|
52
|
+
assert.equal(sanitizeFilename("call_abc123.json"), "call_abc123.json");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── isProviderError: guards the #9 expanded word list ──
|
|
57
|
+
describe("isProviderError", () => {
|
|
58
|
+
const mk = (stderr: string, errorMessage = ""): SubagentResult =>
|
|
59
|
+
({
|
|
60
|
+
stderr,
|
|
61
|
+
errorMessage,
|
|
62
|
+
role: "",
|
|
63
|
+
task: "",
|
|
64
|
+
exitCode: 0,
|
|
65
|
+
messages: [],
|
|
66
|
+
output: "",
|
|
67
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
68
|
+
activityLog: [],
|
|
69
|
+
}) as unknown as SubagentResult;
|
|
70
|
+
|
|
71
|
+
test("matches provider error keywords", () => {
|
|
72
|
+
const cases = [
|
|
73
|
+
"429 Too Many Requests",
|
|
74
|
+
"quota exceeded",
|
|
75
|
+
"rate limit exceeded",
|
|
76
|
+
"authentication error",
|
|
77
|
+
"request timeout",
|
|
78
|
+
"quota exhausted",
|
|
79
|
+
"service unavailable",
|
|
80
|
+
"503 Service Unavailable",
|
|
81
|
+
"internal server error",
|
|
82
|
+
"temporary failure",
|
|
83
|
+
"request declined",
|
|
84
|
+
"server overloaded",
|
|
85
|
+
"ECONNRESET",
|
|
86
|
+
"socket hang up",
|
|
87
|
+
"EPIPE",
|
|
88
|
+
"network error",
|
|
89
|
+
"connection refused",
|
|
90
|
+
];
|
|
91
|
+
for (const c of cases) {
|
|
92
|
+
assert.equal(isProviderError(mk(c)), true, `should match: ${c}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
test("does not match business/programming errors", () => {
|
|
96
|
+
assert.equal(isProviderError(mk("TypeError: Cannot read properties of undefined")), false);
|
|
97
|
+
assert.equal(isProviderError(mk("Error: test failed, expected 5 got 3")), false);
|
|
98
|
+
assert.equal(isProviderError(mk("AssertionError: values differ")), false);
|
|
99
|
+
assert.equal(isProviderError(mk("")), false);
|
|
100
|
+
});
|
|
101
|
+
test("checks errorMessage too, not just stderr", () => {
|
|
102
|
+
assert.equal(isProviderError(mk("", "rate limited")), true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── AsyncSemaphore: guards concurrency cap, negative-active, abort cleanup ──
|
|
107
|
+
describe("AsyncSemaphore", () => {
|
|
108
|
+
test("never goes negative on extra release", async () => {
|
|
109
|
+
const s = new AsyncSemaphore(1);
|
|
110
|
+
await s.acquire();
|
|
111
|
+
s.release();
|
|
112
|
+
s.release();
|
|
113
|
+
s.release();
|
|
114
|
+
assert.equal((s as any).active, 0);
|
|
115
|
+
});
|
|
116
|
+
test("respects concurrency cap (queues beyond max)", async () => {
|
|
117
|
+
const s = new AsyncSemaphore(2);
|
|
118
|
+
await s.acquire();
|
|
119
|
+
await s.acquire();
|
|
120
|
+
let entered = false;
|
|
121
|
+
const p = s.acquire().then(() => {
|
|
122
|
+
entered = true;
|
|
123
|
+
});
|
|
124
|
+
await Promise.resolve();
|
|
125
|
+
await Promise.resolve();
|
|
126
|
+
assert.equal(entered, false); // still queued
|
|
127
|
+
s.release();
|
|
128
|
+
await p;
|
|
129
|
+
assert.equal(entered, true);
|
|
130
|
+
});
|
|
131
|
+
test("abort removes waiter from queue and rejects", async () => {
|
|
132
|
+
const s = new AsyncSemaphore(1);
|
|
133
|
+
await s.acquire();
|
|
134
|
+
const c = new AbortController();
|
|
135
|
+
const p = s.acquire(c.signal);
|
|
136
|
+
c.abort();
|
|
137
|
+
await assert.rejects(p);
|
|
138
|
+
assert.equal((s as any).waiters.length, 0);
|
|
139
|
+
});
|
|
140
|
+
test("releases queued waiters in FIFO order", async () => {
|
|
141
|
+
const s = new AsyncSemaphore(1);
|
|
142
|
+
await s.acquire();
|
|
143
|
+
const order: number[] = [];
|
|
144
|
+
const p1 = s.acquire().then(() => order.push(1));
|
|
145
|
+
const p2 = s.acquire().then(() => order.push(2));
|
|
146
|
+
const p3 = s.acquire().then(() => order.push(3));
|
|
147
|
+
s.release();
|
|
148
|
+
await p1;
|
|
149
|
+
s.release();
|
|
150
|
+
await p2;
|
|
151
|
+
s.release();
|
|
152
|
+
await p3;
|
|
153
|
+
assert.deepEqual(order, [1, 2, 3]);
|
|
154
|
+
});
|
|
155
|
+
test("acquires immediately when under cap", async () => {
|
|
156
|
+
const s = new AsyncSemaphore(3);
|
|
157
|
+
await s.acquire();
|
|
158
|
+
await s.acquire();
|
|
159
|
+
assert.equal((s as any).active, 2);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── previewArgs: guards the #10 shape-based formatting ──
|
|
164
|
+
describe("previewArgs", () => {
|
|
165
|
+
test("command -> $ prefix", () => {
|
|
166
|
+
assert.equal(previewArgs({ command: "ls -la" }), "$ ls -la");
|
|
167
|
+
});
|
|
168
|
+
test("command truncated at 60 chars", () => {
|
|
169
|
+
const long = "x".repeat(70);
|
|
170
|
+
const r = previewArgs({ command: long });
|
|
171
|
+
assert.ok(r.startsWith("$ "));
|
|
172
|
+
assert.ok(r.endsWith("..."));
|
|
173
|
+
assert.ok(r.length < long.length);
|
|
174
|
+
});
|
|
175
|
+
test("file_path is shortened (home -> ~)", () => {
|
|
176
|
+
const r = previewArgs({ file_path: "/home/user/foo.ts" });
|
|
177
|
+
assert.ok(r.includes("foo.ts"));
|
|
178
|
+
});
|
|
179
|
+
test("url passthrough (truncated when long)", () => {
|
|
180
|
+
assert.equal(previewArgs({ url: "https://example.com" }), "https://example.com");
|
|
181
|
+
const longUrl = "https://" + "x".repeat(70);
|
|
182
|
+
assert.ok(previewArgs({ url: longUrl }).endsWith("..."));
|
|
183
|
+
});
|
|
184
|
+
test("query/pattern/regex/search -> /.../ form", () => {
|
|
185
|
+
assert.equal(previewArgs({ query: "foo" }), "/foo/");
|
|
186
|
+
assert.equal(previewArgs({ pattern: "bar" }), "/bar/");
|
|
187
|
+
assert.equal(previewArgs({ regex: "baz" }), "/baz/");
|
|
188
|
+
assert.equal(previewArgs({ search: "qux" }), "/qux/");
|
|
189
|
+
});
|
|
190
|
+
test("empty object falls back to JSON {}", () => {
|
|
191
|
+
assert.equal(previewArgs({}), "{}");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── effectiveTimeoutMs: guards delegate-role auto-widening ──
|
|
196
|
+
describe("effectiveTimeoutMs", () => {
|
|
197
|
+
const role = (tools: string[], timeoutMs?: number): SubagentRole =>
|
|
198
|
+
({ role: "default", description: "", examples: [], decisionTrigger: "", tools, systemPrompt: "", timeoutMs }) as unknown as SubagentRole;
|
|
199
|
+
|
|
200
|
+
test("non-delegate role uses base timeout", () => {
|
|
201
|
+
assert.equal(effectiveTimeoutMs(role(["read", "grep"]), 600000), 600000);
|
|
202
|
+
});
|
|
203
|
+
test("delegate role doubles base when no explicit timeout", () => {
|
|
204
|
+
assert.equal(effectiveTimeoutMs(role(["read", "delegate"]), 600000), 1200000);
|
|
205
|
+
});
|
|
206
|
+
test("explicit roleDef.timeoutMs is always honored (no widening)", () => {
|
|
207
|
+
assert.equal(effectiveTimeoutMs(role(["read", "delegate"], 300000), 600000), 300000);
|
|
208
|
+
});
|
|
209
|
+
test("explicit timeout on non-delegate also honored", () => {
|
|
210
|
+
assert.equal(effectiveTimeoutMs(role(["read"]), 600000), 600000);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── truncateOutput: guards the #2 head+tail fallback ──
|
|
215
|
+
describe("truncateOutput", () => {
|
|
216
|
+
test("adds truncation header with original length", () => {
|
|
217
|
+
const big = "x".repeat(60000);
|
|
218
|
+
const r = truncateOutput(big);
|
|
219
|
+
assert.ok(r.startsWith("[Output truncated"));
|
|
220
|
+
assert.ok(r.includes("60000 chars total"));
|
|
221
|
+
assert.ok(r.includes("[truncated]"));
|
|
222
|
+
});
|
|
223
|
+
test("keeps head and tail, drops the middle", () => {
|
|
224
|
+
// 120000 chars: 40k H + 40k M + 40k T
|
|
225
|
+
const content = "H".repeat(40000) + "M".repeat(40000) + "T".repeat(40000);
|
|
226
|
+
const r = truncateOutput(content);
|
|
227
|
+
assert.ok(r.includes("H"), "head preserved");
|
|
228
|
+
assert.ok(r.includes("T"), "tail preserved");
|
|
229
|
+
assert.ok(!r.includes("M"), "middle dropped");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── formatTokens: boundary correctness ──
|
|
234
|
+
describe("formatTokens", () => {
|
|
235
|
+
test("under 1000 stays raw", () => {
|
|
236
|
+
assert.equal(formatTokens(0), "0");
|
|
237
|
+
assert.equal(formatTokens(999), "999");
|
|
238
|
+
});
|
|
239
|
+
test("1000-9999 with one decimal place", () => {
|
|
240
|
+
assert.equal(formatTokens(1000), "1.0k");
|
|
241
|
+
assert.equal(formatTokens(9500), "9.5k");
|
|
242
|
+
// 9999/1000 = 9.999, toFixed(1) rounds up to 10.0
|
|
243
|
+
assert.equal(formatTokens(9999), "10.0k");
|
|
244
|
+
});
|
|
245
|
+
test("10000-999999 rounded to integer k", () => {
|
|
246
|
+
assert.equal(formatTokens(10000), "10k");
|
|
247
|
+
assert.equal(formatTokens(999999), "1000k");
|
|
248
|
+
});
|
|
249
|
+
test(">= 1000000 in M", () => {
|
|
250
|
+
assert.equal(formatTokens(1000000), "1.0M");
|
|
251
|
+
});
|
|
252
|
+
});
|