@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/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
- result.usage.contextTokens = usage.totalTokens || 0;
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
- if (event.type === "tool_result_end" && event.message) {
229
- result.messages.push(event.message as SubagentMessage);
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
- const proc = spawn(invocation.command, invocation.args, {
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
- proc.stdout.on("data", (data: Buffer) => {
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
- proc.stderr.on("data", (data: Buffer) => {
360
+ p.stderr.on("data", (data: Buffer) => {
260
361
  result.stderr += data.toString();
261
362
  });
262
363
 
263
- proc.on("close", (code) => {
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
- resolve(code ?? 0);
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
- proc.on("error", () => {
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
- // Truncate large outputs: keep head (findings) + tail (summary), drop middle
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: 300_000,
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
+ });