@d3ara1n/pi-subagent 0.2.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 +58 -12
- package/package.json +2 -1
- package/src/config.ts +9 -2
- package/src/index.ts +411 -237
- package/src/spawn.ts +125 -40
- package/src/types.ts +46 -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
|
},
|
|
@@ -140,6 +141,7 @@ export async function spawnSubagent(
|
|
|
140
141
|
output: "",
|
|
141
142
|
stderr: "",
|
|
142
143
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
144
|
+
activityLog: [],
|
|
143
145
|
};
|
|
144
146
|
|
|
145
147
|
let tmpDir: string | null = null;
|
|
@@ -174,6 +176,8 @@ export async function spawnSubagent(
|
|
|
174
176
|
// Spawn process
|
|
175
177
|
const invocation = getPiInvocation(args);
|
|
176
178
|
let wasAborted = false;
|
|
179
|
+
let budgetExceeded = false;
|
|
180
|
+
let wasTimeout = false;
|
|
177
181
|
let buffer = "";
|
|
178
182
|
|
|
179
183
|
const emitProgress = () => {
|
|
@@ -183,9 +187,26 @@ export async function spawnSubagent(
|
|
|
183
187
|
usage: { ...result.usage },
|
|
184
188
|
model: result.model,
|
|
185
189
|
stopReason: result.stopReason,
|
|
190
|
+
activityLog: result.activityLog.map((a) => ({ ...a })),
|
|
186
191
|
});
|
|
187
192
|
};
|
|
188
193
|
|
|
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
|
+
};
|
|
209
|
+
|
|
189
210
|
const processLine = (line: string) => {
|
|
190
211
|
if (!line.trim()) return;
|
|
191
212
|
let event: any;
|
|
@@ -208,7 +229,8 @@ export async function spawnSubagent(
|
|
|
208
229
|
result.usage.cacheRead += usage.cacheRead || 0;
|
|
209
230
|
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
210
231
|
result.usage.cost += usage.cost?.total || 0;
|
|
211
|
-
|
|
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);
|
|
212
234
|
}
|
|
213
235
|
if (!result.model && msg.model) result.model = msg.model;
|
|
214
236
|
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
@@ -220,15 +242,55 @@ export async function spawnSubagent(
|
|
|
220
242
|
result.output = part.text;
|
|
221
243
|
}
|
|
222
244
|
}
|
|
245
|
+
|
|
246
|
+
checkBudget();
|
|
223
247
|
}
|
|
224
248
|
|
|
225
249
|
emitProgress();
|
|
226
250
|
}
|
|
227
251
|
|
|
228
|
-
|
|
229
|
-
|
|
252
|
+
// Activity log: track thinking blocks and tool calls in arrival order.
|
|
253
|
+
// Both update in place so the TUI reflects real-time state.
|
|
254
|
+
if (event.type === "tool_execution_start" && event.toolCallId) {
|
|
255
|
+
toolCallIndex.set(event.toolCallId, result.activityLog.length);
|
|
256
|
+
result.activityLog.push({
|
|
257
|
+
kind: "toolCall",
|
|
258
|
+
id: event.toolCallId,
|
|
259
|
+
status: "running",
|
|
260
|
+
toolName: event.toolName,
|
|
261
|
+
args: event.args ?? {},
|
|
262
|
+
});
|
|
263
|
+
emitProgress();
|
|
264
|
+
} else if (event.type === "tool_execution_end" && event.toolCallId) {
|
|
265
|
+
const idx = toolCallIndex.get(event.toolCallId);
|
|
266
|
+
if (idx !== undefined) result.activityLog[idx].status = event.isError ? "failed" : "done";
|
|
230
267
|
emitProgress();
|
|
231
268
|
}
|
|
269
|
+
|
|
270
|
+
// Thinking-block lifecycle: pi wraps thinking_start/end inside
|
|
271
|
+
// message_update.assistantMessageEvent. These arrive BEFORE message_end,
|
|
272
|
+
// so we can't rely on messages[] to show real-time thinking state —
|
|
273
|
+
// register them in the activity log directly.
|
|
274
|
+
const aev = event.assistantMessageEvent;
|
|
275
|
+
if (event.type === "message_update" && aev) {
|
|
276
|
+
if (aev.type === "thinking_start") {
|
|
277
|
+
result.activityLog.push({
|
|
278
|
+
kind: "thinking",
|
|
279
|
+
id: `thinking-${thinkingCounter++}`,
|
|
280
|
+
status: "running",
|
|
281
|
+
});
|
|
282
|
+
emitProgress();
|
|
283
|
+
} else if (aev.type === "thinking_end") {
|
|
284
|
+
// Mark the most recent still-running thinking block as done.
|
|
285
|
+
for (let i = result.activityLog.length - 1; i >= 0; i--) {
|
|
286
|
+
if (result.activityLog[i].kind === "thinking" && result.activityLog[i].status === "running") {
|
|
287
|
+
result.activityLog[i].status = "done";
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
emitProgress();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
232
294
|
};
|
|
233
295
|
|
|
234
296
|
// Build env with optional subagent allowlist and tmpdir for researcher role
|
|
@@ -238,73 +300,96 @@ export async function spawnSubagent(
|
|
|
238
300
|
}
|
|
239
301
|
// Expose tmpdir as env var so subagent bash commands (e.g. git clone) can use it
|
|
240
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);
|
|
241
305
|
|
|
242
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
|
+
};
|
|
243
335
|
|
|
244
336
|
const exitCode = await new Promise<number>((resolve) => {
|
|
245
|
-
|
|
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, {
|
|
246
346
|
cwd: options.cwd,
|
|
247
347
|
env: childEnv,
|
|
248
348
|
shell: false,
|
|
249
349
|
stdio: ["ignore", "pipe", "pipe"],
|
|
250
350
|
});
|
|
351
|
+
proc = p;
|
|
251
352
|
|
|
252
|
-
|
|
353
|
+
p.stdout.on("data", (data: Buffer) => {
|
|
253
354
|
buffer += data.toString();
|
|
254
355
|
const lines = buffer.split("\n");
|
|
255
356
|
buffer = lines.pop() || "";
|
|
256
357
|
for (const line of lines) processLine(line);
|
|
257
358
|
});
|
|
258
359
|
|
|
259
|
-
|
|
360
|
+
p.stderr.on("data", (data: Buffer) => {
|
|
260
361
|
result.stderr += data.toString();
|
|
261
362
|
});
|
|
262
363
|
|
|
263
|
-
|
|
364
|
+
p.on("close", (code) => {
|
|
264
365
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
366
|
+
for (const t of escalationTimers) clearTimeout(t);
|
|
367
|
+
if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
|
|
265
368
|
if (buffer.trim()) processLine(buffer);
|
|
266
|
-
|
|
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)));
|
|
267
372
|
});
|
|
268
373
|
|
|
269
|
-
|
|
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);
|
|
270
380
|
resolve(1);
|
|
271
381
|
});
|
|
272
382
|
|
|
273
|
-
// Handle abort signal
|
|
274
|
-
if (options.signal) {
|
|
275
|
-
const killProc = () => {
|
|
276
|
-
wasAborted = true;
|
|
277
|
-
proc.kill("SIGTERM");
|
|
278
|
-
setTimeout(() => {
|
|
279
|
-
if (!proc.killed) proc.kill("SIGKILL");
|
|
280
|
-
}, 5000);
|
|
281
|
-
};
|
|
282
|
-
if (options.signal.aborted) killProc();
|
|
283
|
-
else options.signal.addEventListener("abort", killProc, { once: true });
|
|
284
|
-
}
|
|
285
|
-
|
|
286
383
|
// Handle timeout
|
|
287
384
|
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
288
|
-
timeoutHandle = setTimeout(() =>
|
|
289
|
-
if (!proc.killed) {
|
|
290
|
-
proc.kill("SIGTERM");
|
|
291
|
-
setTimeout(() => {
|
|
292
|
-
if (!proc.killed) proc.kill("SIGKILL");
|
|
293
|
-
}, 5000);
|
|
294
|
-
}
|
|
295
|
-
}, options.timeoutMs);
|
|
385
|
+
timeoutHandle = setTimeout(() => killProc("timeout"), options.timeoutMs);
|
|
296
386
|
}
|
|
297
387
|
});
|
|
298
388
|
|
|
299
389
|
result.exitCode = exitCode;
|
|
300
390
|
if (wasAborted) throw new Error("Subagent was aborted");
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
if (result.output.length > MAX_OUTPUT_CHARS) {
|
|
304
|
-
const head = result.output.slice(0, 30_000);
|
|
305
|
-
const tail = result.output.slice(-(MAX_OUTPUT_CHARS - 30_050));
|
|
306
|
-
result.output = `[Output truncated — ${result.output.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
|
|
307
|
-
}
|
|
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.
|
|
308
393
|
} finally {
|
|
309
394
|
// Cleanup temp directory and all contents
|
|
310
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,10 +60,30 @@ 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
|
}
|
|
47
72
|
|
|
73
|
+
/** Status of an individual tool call within a subagent run. */
|
|
74
|
+
export type ToolStatus = "running" | "done" | "failed";
|
|
75
|
+
|
|
76
|
+
/** A single entry in the real-time activity log (thinking block or tool call). */
|
|
77
|
+
export interface ActivityEntry {
|
|
78
|
+
kind: "thinking" | "toolCall";
|
|
79
|
+
/** Synthetic id (thinking-N) or the toolCallId from the event stream. */
|
|
80
|
+
id: string;
|
|
81
|
+
status: ToolStatus;
|
|
82
|
+
/** Tool name + args (toolCall only). */
|
|
83
|
+
toolName?: string;
|
|
84
|
+
args?: Record<string, any>;
|
|
85
|
+
}
|
|
86
|
+
|
|
48
87
|
/** Usage statistics from a subagent execution. */
|
|
49
88
|
export interface SubagentUsage {
|
|
50
89
|
input: number;
|
|
@@ -88,6 +127,10 @@ export interface SubagentResult {
|
|
|
88
127
|
task: string;
|
|
89
128
|
/** Process exit code (-1 = still running for streaming) */
|
|
90
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";
|
|
91
134
|
/** All messages from the event stream (assistant + tool results) */
|
|
92
135
|
messages: SubagentMessage[];
|
|
93
136
|
/** Last assistant text output */
|
|
@@ -104,6 +147,8 @@ export interface SubagentResult {
|
|
|
104
147
|
stopReason?: string;
|
|
105
148
|
/** Error message if failed */
|
|
106
149
|
errorMessage?: string;
|
|
150
|
+
/** Real-time activity log: thinking blocks and tool calls in arrival order. */
|
|
151
|
+
activityLog: ActivityEntry[];
|
|
107
152
|
}
|
|
108
153
|
|
|
109
154
|
/** TUI details structure passed via tool result details. */
|
|
@@ -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
|
+
});
|