@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/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
- 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);
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 entry = result.activityLog.find((a) => a.id === event.toolCallId);
245
- if (entry) entry.status = event.isError ? "failed" : "done";
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
- 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, {
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
- proc.stdout.on("data", (data: Buffer) => {
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
- proc.stderr.on("data", (data: Buffer) => {
360
+ p.stderr.on("data", (data: Buffer) => {
301
361
  result.stderr += data.toString();
302
362
  });
303
363
 
304
- proc.on("close", (code) => {
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
- 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)));
308
372
  });
309
373
 
310
- 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);
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
- // Truncate large outputs: keep head (findings) + tail (summary), drop middle
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: 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,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
+ });