@d3ara1n/pi-subagent 0.10.4 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils.test.ts CHANGED
@@ -4,10 +4,12 @@
4
4
  * Zero-dependency: runs on node's built-in test runner.
5
5
  * node --test packages/pi-subagent/src/utils.test.ts
6
6
  *
7
- * These guard the bug fixes introduced during the improvement rounds:
8
- * path-injection (sanitizeFilename), concurrency/abort/negative-active/unlimited
9
- * semantics (AsyncSemaphore), provider-error word list (isProviderError), unknown-tool
10
- * formatting (previewArgs), output truncation fallback (truncateOutput).
7
+ * Coverage highlights: path-injection safety (sanitizeFilename),
8
+ * concurrency/abort/unlimited semantics (AsyncSemaphore), provider-error
9
+ * heuristics (isProviderError), shared result-view composition
10
+ * (terminalResultLine), time/budget formatting (formatTimePart), budget-stop
11
+ * and fallback provenance notes, background-run helpers, and notification
12
+ * throttling (createThrottler).
11
13
  */
12
14
 
13
15
  import { test, describe } from "node:test";
@@ -19,47 +21,63 @@ import {
19
21
  previewArgs,
20
22
  truncateOutput,
21
23
  formatTokens,
24
+ formatUsageStats,
22
25
  effectiveTimeout,
23
26
  elapsedSeconds,
24
27
  hasFailedSubagentResult,
28
+ buildFallbackFrom,
29
+ formatFallback,
30
+ FALLBACK_STDERR_TAIL,
31
+ deriveRunState,
32
+ isWaitTimedOut,
33
+ describeCurrentActivity,
34
+ formatUsageFooter,
35
+ formatFallbackNote,
36
+ formatBudgetNote,
37
+ formatCheckText,
38
+ formatTimePart,
39
+ freezeFrame,
40
+ createThrottler,
41
+ terminalResultLine,
25
42
  } from "./utils.ts";
26
43
  import type { SubagentResult, SubagentRole } from "./types.ts";
27
44
 
45
+ /** Shared SubagentResult fixture. */
46
+ const baseResult = (overrides: Partial<SubagentResult> = {}): SubagentResult => ({
47
+ role: "worker",
48
+ task: "test task",
49
+ exitCode: 0,
50
+ output: "ok",
51
+ stderr: "",
52
+ usage: {
53
+ input: 0,
54
+ output: 0,
55
+ cacheRead: 0,
56
+ cacheWrite: 0,
57
+ cost: 0,
58
+ contextTokens: 0,
59
+ turns: 0,
60
+ },
61
+ activityLog: [],
62
+ ...overrides,
63
+ });
64
+
28
65
  describe("subagent failure details", () => {
29
- const baseResult = (overrides: Partial<SubagentResult> = {}): SubagentResult => ({
30
- role: "worker",
31
- task: "test task",
32
- exitCode: 0,
33
- messages: [],
34
- output: "ok",
35
- stderr: "",
36
- usage: {
37
- input: 0,
38
- output: 0,
39
- cacheRead: 0,
40
- cacheWrite: 0,
41
- cost: 0,
42
- contextTokens: 0,
43
- turns: 0,
44
- },
45
- activityLog: [],
46
- ...overrides,
47
- });
48
66
 
49
67
  test("detects failed delegate results for tool_result error marking", () => {
50
68
  assert.equal(
51
- hasFailedSubagentResult({ mode: "single", results: [baseResult({ exitCode: 1 })] }),
69
+ hasFailedSubagentResult({ results: [baseResult({ exitCode: 1 })] }),
52
70
  true,
53
71
  );
54
72
  assert.equal(
55
- hasFailedSubagentResult({ mode: "single", results: [baseResult({ stopReason: "timeout" })] }),
73
+ hasFailedSubagentResult({ results: [baseResult({ stopReason: "timeout" })] }),
56
74
  true,
57
75
  );
58
76
  });
59
77
 
60
78
  test("does not mark successful or malformed details as failed", () => {
61
- assert.equal(hasFailedSubagentResult({ mode: "single", results: [baseResult()] }), false);
62
- assert.equal(hasFailedSubagentResult({ mode: "single", results: [] }), false);
79
+ assert.equal(hasFailedSubagentResult({ results: [baseResult()] }), false);
80
+ assert.equal(hasFailedSubagentResult({ results: [] }), false);
63
81
  assert.equal(hasFailedSubagentResult(undefined), false);
64
82
  assert.equal(hasFailedSubagentResult({}), false);
65
83
  });
@@ -97,26 +115,7 @@ describe("sanitizeFilename", () => {
97
115
 
98
116
  // ── isProviderError: guards the #9 expanded word list ──
99
117
  describe("isProviderError", () => {
100
- const mk = (stderr: string, errorMessage = ""): SubagentResult =>
101
- ({
102
- stderr,
103
- errorMessage,
104
- role: "",
105
- task: "",
106
- exitCode: 0,
107
- messages: [],
108
- output: "",
109
- usage: {
110
- input: 0,
111
- output: 0,
112
- cacheRead: 0,
113
- cacheWrite: 0,
114
- cost: 0,
115
- contextTokens: 0,
116
- turns: 0,
117
- },
118
- activityLog: [],
119
- }) as unknown as SubagentResult;
118
+ const mk = (stderr: string, errorMessage = ""): SubagentResult => baseResult({ stderr, errorMessage });
120
119
 
121
120
  test("matches provider error keywords", () => {
122
121
  const cases = [
@@ -293,10 +292,10 @@ describe("effectiveTimeout", () => {
293
292
  assert.equal(effectiveTimeout(role(["read", "grep"])), 0);
294
293
  });
295
294
  test("delegate-capable role without timeout is also unlimited", () => {
296
- assert.equal(effectiveTimeout(role(["read", "delegate"])), 0);
295
+ assert.equal(effectiveTimeout(role(["read", "subagent_delegate"])), 0);
297
296
  });
298
297
  test("explicit role timeout is honored", () => {
299
- assert.equal(effectiveTimeout(role(["read", "delegate"], 300)), 300);
298
+ assert.equal(effectiveTimeout(role(["read", "subagent_delegate"], 300)), 300);
300
299
  });
301
300
  test("negative and non-finite values normalize to unlimited", () => {
302
301
  assert.equal(effectiveTimeout(role(["read"], -1)), 0);
@@ -368,3 +367,276 @@ describe("elapsedSeconds", () => {
368
367
  assert.equal(elapsedSeconds({ exitCode: -1, startTime: start }), 0);
369
368
  });
370
369
  });
370
+
371
+ // ── formatTimePart: shared elapsed/budget(+grace) text ──
372
+ describe("formatTimePart", () => {
373
+ test("running frame with budget and grace", () => {
374
+ const r = { exitCode: -1, startTime: Date.now() - 42000, budgetMs: 900000, graceMs: 3000 };
375
+ assert.equal(formatTimePart(r), "42s/900s(+3s)");
376
+ });
377
+ test("running frame without budget", () => {
378
+ const r = { exitCode: -1, startTime: Date.now() - 3000 };
379
+ assert.equal(formatTimePart(r), "3s");
380
+ });
381
+ test("terminal frame uses the frozen elapsedMs", () => {
382
+ assert.equal(formatTimePart({ exitCode: 0, elapsedMs: 5000, budgetMs: 900000 }), "5s/900s");
383
+ });
384
+ test("queued frame has no time", () => {
385
+ assert.equal(formatTimePart({ exitCode: -1 }), null);
386
+ });
387
+ });
388
+
389
+ // ── terminalResultLine: one chain for every result view ──
390
+ describe("terminalResultLine", () => {
391
+ const id = (_color: string, text: string) => text;
392
+
393
+ test("failure shows the error message in error styling", () => {
394
+ assert.equal(
395
+ terminalResultLine(baseResult({ exitCode: 1, errorMessage: "boom" }), id),
396
+ "\u2717 boom",
397
+ );
398
+ });
399
+ test("timeout/budget map to the warning styling", () => {
400
+ assert.equal(
401
+ terminalResultLine(baseResult({ stopReason: "timeout", exitCode: 124, errorMessage: "Timed out after 900s" }), id),
402
+ "\u23F1 Timed out after 900s",
403
+ );
404
+ });
405
+ test("budget-exceeded is a warning line even though the run state is finished", () => {
406
+ assert.equal(
407
+ terminalResultLine(
408
+ baseResult({ stopReason: "budget_exceeded", errorMessage: "Budget exceeded (50 turns; partial output returned)" }),
409
+ id,
410
+ ),
411
+ "\u23F2 Budget exceeded (50 turns; partial output returned)",
412
+ );
413
+ });
414
+ test("success chain: AI summary wins, then output first line, then placeholder", () => {
415
+ assert.equal(terminalResultLine(baseResult({ summary: "did the thing" }), id), "\u2713 did the thing");
416
+ assert.equal(terminalResultLine(baseResult(), id), "\u2713 ok");
417
+ assert.equal(terminalResultLine(baseResult({ output: "" }), id), "\u2713 (no output)");
418
+ });
419
+ test("finishedText replaces the success chain (wait's status-only line)", () => {
420
+ assert.equal(terminalResultLine(baseResult(), id, "finished"), "\u2713 finished");
421
+ });
422
+ });
423
+
424
+ // ── createThrottler: burst coalescing for onUpdate ──
425
+ describe("createThrottler", () => {
426
+ test("coalesces a burst into one fire per window", async () => {
427
+ let fired = 0;
428
+ const t = createThrottler(() => fired++);
429
+ t.notify();
430
+ t.notify();
431
+ t.notify();
432
+ await new Promise((r) => setTimeout(r, 80));
433
+ assert.equal(fired, 1);
434
+ });
435
+ test("cancel drops the pending fire", async () => {
436
+ let fired = 0;
437
+ const t = createThrottler(() => fired++);
438
+ t.notify();
439
+ t.cancel();
440
+ await new Promise((r) => setTimeout(r, 80));
441
+ assert.equal(fired, 0);
442
+ });
443
+ });
444
+
445
+ describe("fallback observability", () => {
446
+ const failed = (overrides: Partial<SubagentResult> = {}): SubagentResult =>
447
+ baseResult({
448
+ role: "researcher",
449
+ exitCode: 1,
450
+ model: "opencode-go/deepseek-v4-flash",
451
+ stopReason: "timeout",
452
+ errorMessage: "Timed out after 900s",
453
+ ...overrides,
454
+ });
455
+
456
+ test("buildFallbackFrom snapshots the failed attempt", () => {
457
+ const f = buildFallbackFrom(failed());
458
+ assert.equal(f.model, "opencode-go/deepseek-v4-flash");
459
+ assert.equal(f.stopReason, "timeout");
460
+ assert.equal(f.errorMessage, "Timed out after 900s");
461
+ assert.equal(f.stderrTail, undefined);
462
+ });
463
+
464
+ test("buildFallbackFrom keeps a truncated stderr tail", () => {
465
+ const noise = "x".repeat(2000);
466
+ const f = buildFallbackFrom(failed({ stderr: `${noise}HTTP 429 at tail` }));
467
+ assert.ok(f.stderrTail!.endsWith("HTTP 429 at tail"));
468
+ assert.ok(f.stderrTail!.length <= FALLBACK_STDERR_TAIL);
469
+ });
470
+
471
+ test("buildFallbackFrom drops whitespace-only stderr", () => {
472
+ const f = buildFallbackFrom(failed({ stderr: " \n\t " }));
473
+ assert.equal(f.stderrTail, undefined);
474
+ });
475
+
476
+ test("formatFallback prefers errorMessage", () => {
477
+ assert.equal(
478
+ formatFallback({ model: "ds", stopReason: "timeout", errorMessage: "boom" }),
479
+ "first attempt ds failed (boom)",
480
+ );
481
+ });
482
+
483
+ test("formatFallback falls back to stopReason then a generic label", () => {
484
+ assert.equal(formatFallback({ model: "ds", stopReason: "timeout" }), "first attempt ds failed (timeout)");
485
+ assert.equal(formatFallback({}), "first attempt unknown model failed (provider error)");
486
+ });
487
+
488
+ test("formatFallback keeps a single truncated line", () => {
489
+ assert.equal(formatFallback({ model: "ds", errorMessage: "line1\nline2" }), "first attempt ds failed (line1)");
490
+ const long = "y".repeat(150);
491
+ const out = formatFallback({ model: "ds", errorMessage: long });
492
+ assert.equal(out, `first attempt ds failed (${"y".repeat(100)}...)`);
493
+ assert.ok(out.endsWith("...)"));
494
+ });
495
+
496
+ test("buildFallbackFrom fills model from the requested model when the child died early", () => {
497
+ const f = buildFallbackFrom(failed({ model: undefined }), "opencode-go/deepseek-v4-flash");
498
+ assert.equal(f.model, "opencode-go/deepseek-v4-flash");
499
+ });
500
+
501
+ test("buildFallbackFrom derives a reason from stderr when errorMessage is unset", () => {
502
+ const f = buildFallbackFrom(
503
+ failed({ errorMessage: undefined, stderr: "\x1b[2Knoise\nError: 429 Too Many Requests\n\x1b[?25h" }),
504
+ );
505
+ assert.equal(f.errorMessage, "Error: 429 Too Many Requests");
506
+ });
507
+
508
+ test("an explicit errorMessage wins over stderr", () => {
509
+ const f = buildFallbackFrom(failed({ stderr: "connection reset by peer 429" }));
510
+ assert.equal(f.errorMessage, "Timed out after 900s");
511
+ });
512
+
513
+ test("formatFallback shows the error message, never raw stderr content", () => {
514
+ const f = buildFallbackFrom(failed({ stderr: "CONNECTIVITY monster line mentioning 429" }));
515
+ const out = formatFallback(f);
516
+ assert.ok(out.includes("Timed out after 900s"));
517
+ assert.ok(!out.includes("CONNECTIVITY"));
518
+ });
519
+ });
520
+
521
+ describe("background run helpers", () => {
522
+ const queuedFrame = () => baseResult({ exitCode: -1, queued: true });
523
+ const runningFrame = () => baseResult({ exitCode: -1 });
524
+
525
+ test("deriveRunState maps frames to lifecycle states", () => {
526
+ assert.equal(deriveRunState(queuedFrame()), "queued");
527
+ assert.equal(deriveRunState(runningFrame()), "running");
528
+ assert.equal(deriveRunState(baseResult()), "finished");
529
+ assert.equal(deriveRunState(baseResult({ exitCode: 1 })), "failed");
530
+ assert.equal(deriveRunState(baseResult({ stopReason: "timeout", exitCode: 124 })), "failed");
531
+ // budget stops are intentional finishes
532
+ assert.equal(deriveRunState(baseResult({ stopReason: "budget_exceeded" })), "finished");
533
+ });
534
+
535
+ test("isWaitTimedOut only matches the explicit timeout flag", () => {
536
+ assert.equal(isWaitTimedOut({ entries: [], timedOut: true }), true);
537
+ assert.equal(isWaitTimedOut({ entries: [] }), false);
538
+ assert.equal(isWaitTimedOut(undefined), false);
539
+ });
540
+
541
+ test("describeCurrentActivity reports the latest activity item", () => {
542
+ assert.equal(describeCurrentActivity(runningFrame()), "waiting for first event");
543
+ const thinking = runningFrame();
544
+ thinking.activityLog = [{ kind: "thinking", id: "thinking-0", status: "running" }];
545
+ assert.equal(describeCurrentActivity(thinking), "thinking");
546
+ const withTool = runningFrame();
547
+ withTool.activityLog = [
548
+ { kind: "thinking", id: "thinking-0", status: "done" },
549
+ { kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: { command: "ls" } },
550
+ ];
551
+ assert.equal(describeCurrentActivity(withTool), "$ ls");
552
+ });
553
+
554
+ test("formatUsageFooter renders turns, tokens, cost, and model", () => {
555
+ assert.equal(formatUsageFooter(baseResult()), "");
556
+ const r = baseResult({
557
+ usage: { input: 1200, output: 300, cacheRead: 0, cacheWrite: 0, cost: 0.5, contextTokens: 0, turns: 2 },
558
+ model: "test/model-x",
559
+ });
560
+ assert.equal(formatUsageFooter(r), "\n\n--- 2 turns \u21911.2k \u2193300 $0.5000 test/model-x ---");
561
+ });
562
+
563
+ test("formatUsageStats adds cache and peak-context figures the footer omits", () => {
564
+ const usage = { input: 1200, output: 300, cacheRead: 900, cacheWrite: 100, cost: 0.5, contextTokens: 45000, turns: 2 };
565
+ assert.equal(
566
+ formatUsageStats(usage, "test/model-x"),
567
+ "2 turns \u21911.2k \u2193300 R900 W100 ctx45k $0.5000 test/model-x",
568
+ );
569
+ // Footer stays lean — no cache/context parts.
570
+ assert.equal(
571
+ formatUsageFooter({ usage, model: "test/model-x" }),
572
+ "\n\n--- 2 turns \u21911.2k \u2193300 $0.5000 test/model-x ---",
573
+ );
574
+ });
575
+
576
+ test("formatBudgetNote flags budget-stopped output as partial", () => {
577
+ assert.equal(formatBudgetNote(baseResult()), "");
578
+ assert.equal(
579
+ formatBudgetNote(baseResult({ stopReason: "budget_exceeded", errorMessage: "Budget exceeded (50 turns)" })),
580
+ "\n\n--- Budget exceeded (50 turns) ---",
581
+ );
582
+ });
583
+
584
+ test("formatFallbackNote is empty without a retry and descriptive with one", () => {
585
+ assert.equal(formatFallbackNote(baseResult()), "");
586
+ const r = baseResult({
587
+ fallbackFrom: { model: "primary/m1", errorMessage: "429 quota exceeded" },
588
+ model: "fallback/m2",
589
+ });
590
+ assert.equal(
591
+ formatFallbackNote(r),
592
+ "\n\n--- fallback: first attempt primary/m1 failed (429 quota exceeded); retried on fallback/m2 ---",
593
+ );
594
+ });
595
+
596
+ test("formatCheckText covers all four run states", () => {
597
+ assert.match(formatCheckText("sub-1", "explorer", queuedFrame()), /^sub-1 \(explorer\): queued —/);
598
+ assert.match(formatCheckText("sub-1", "explorer", runningFrame()), /^sub-1 \(explorer\): running — /);
599
+ assert.match(
600
+ formatCheckText("sub-1", "explorer", baseResult({ exitCode: 1, errorMessage: "boom" })),
601
+ /^sub-1 \(explorer\): failed — boom\n\nPartial output:\nok$/,
602
+ );
603
+ assert.match(formatCheckText("sub-1", "explorer", baseResult()), /^sub-1 \(explorer\): finished\n\nok$/);
604
+ });
605
+
606
+ test("formatCheckText flags budget-stopped runs as partial on the finished line", () => {
607
+ const r = baseResult({ stopReason: "budget_exceeded", errorMessage: "Budget exceeded (50 turns)" });
608
+ assert.match(
609
+ formatCheckText("sub-1", "explorer", r),
610
+ /^sub-1 \(explorer\): finished\n\nok\n\n--- Budget exceeded \(50 turns\) ---$/,
611
+ );
612
+ });
613
+
614
+ test("formatCheckText keeps the fallback note on failed runs too", () => {
615
+ const r = baseResult({
616
+ exitCode: 1,
617
+ errorMessage: "boom",
618
+ fallbackFrom: { model: "primary/m1", errorMessage: "429 quota exceeded" },
619
+ model: "fallback/m2",
620
+ });
621
+ assert.match(
622
+ formatCheckText("sub-1", "explorer", r),
623
+ /failed — boom\n\nPartial output:\nok\n\n--- fallback: first attempt primary\/m1 failed \(429 quota exceeded\); retried on fallback\/m2 ---$/,
624
+ );
625
+ });
626
+
627
+ test("freezeFrame stops the elapsed clock and folds the open pause into grace", () => {
628
+ const start = Date.now() - 5000;
629
+ const pausedAt = Date.now() - 2000;
630
+ const frozen = freezeFrame(baseResult({
631
+ exitCode: -1,
632
+ startTime: start,
633
+ budgetMs: 60000,
634
+ graceMs: 1000,
635
+ pauseStart: pausedAt,
636
+ }));
637
+ assert.equal(frozen.startTime, undefined);
638
+ assert.equal(frozen.pauseStart, undefined);
639
+ assert.ok(frozen.elapsedMs! >= 4990 && frozen.elapsedMs! <= 5010, `elapsedMs ~5000, got ${frozen.elapsedMs}`);
640
+ assert.ok(frozen.graceMs! >= 3000 && frozen.graceMs! <= 3010, `graceMs ~3000, got ${frozen.graceMs}`);
641
+ });
642
+ });