@molecule/api-ai-tools 1.5.18 → 1.5.19

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/dist/utilities.js CHANGED
@@ -307,6 +307,14 @@ const LINE_REPORTER_FLAG = /--reporter([= ])line\b/;
307
307
  * `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
308
308
  * 8.3-minute walk of one suite could never trip the fail-fast note.
309
309
  *
310
+ * A run being ITERATED on (capped, narrowed, or by file) gets `list,line` instead
311
+ * of `list` alone. `list` holds every failure's error for the summary at the end,
312
+ * so mid-run its log says only how long each failure took; `line` writes the body
313
+ * as the failure happens, and a look at a running suite (`wait_for_task`'s
314
+ * repeating-cause note, `suite-fail-fast.ts`) can then name the repeating cause by
315
+ * its error message. X1 flash-m76 walked 15 tests for 4.0 minutes, every one a
316
+ * `TimeoutError: page.waitForURL` from one missing `navigate('/')`.
317
+ *
310
318
  * Only the command's last step is touched, and only its reporter.
311
319
  *
312
320
  * @param command - The command as the executor sent it.
@@ -332,30 +340,117 @@ export function streamingBackgroundCommand(command) {
332
340
  const finalPlaywrightRun = /\bplaywright\s+test\b/.test(lastStep) &&
333
341
  // `2>&1` only joins stderr to the log; any other pipe or redirect sends the output elsewhere.
334
342
  !/[|>]/.test(lastStep.replace(/\d?>&\d/g, ''));
343
+ // A diagnosis or a re-run (capped, last-failed, grepped, or named files) walks each failure
344
+ // once: the suite's own `retries` doubled every failure of X1 flash-m71's first whole run,
345
+ // which had dropped the `--retries=0` it used one command earlier. The uncapped proof run
346
+ // keeps the suite's retries.
347
+ const iterating = /--max-failures\b|--last-failed\b|(?:^|\s)(?:-g|--grep)\b|\S+\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+)?\b/.test(lastStep);
348
+ // `list` is the shape the platform reads a background suite back in (the fail-fast note and
349
+ // the Tests bar both parse its ` ✓/✘ <n> … (…)` lines), and it prints each failure's error
350
+ // only in the summary at the END. A run being iterated on is the one whose failures are read
351
+ // mid-run and stopped early, and for that the log has to carry the error as it happens —
352
+ // `line` writes exactly that, so an iterating run gets both reporters and a proof run, which
353
+ // nothing stops early, keeps `list` alone rather than doubling its log.
354
+ const wanted = iterating ? 'list,line' : 'list';
335
355
  if (finalPlaywrightRun && LINE_REPORTER_FLAG.test(lastStep)) {
336
356
  // Only the last step is rewritten, and `lastStep` is a suffix of `out`.
337
357
  out =
338
358
  out.slice(0, out.length - lastStep.length) +
339
- lastStep.replace(LINE_REPORTER_FLAG, '--reporter$1list');
340
- changes.push('Switched `--reporter=line` to `--reporter=list`: it also writes each test result as it ' +
341
- 'finishes, and it is the shape the platform reads back from a background run.');
359
+ lastStep.replace(LINE_REPORTER_FLAG, `--reporter$1${wanted}`);
360
+ changes.push(`Switched \`--reporter=line\` to \`--reporter=${wanted}\`: it also writes each test result as it ` +
361
+ 'finishes, and it is the shape the platform reads back from a background run.' +
362
+ (iterating
363
+ ? ' `line` stays alongside it so each failure writes its error to the log as it happens — that is what a mid-run look reads.'
364
+ : ''));
365
+ }
366
+ else if (finalPlaywrightRun && iterating && /--reporter([= ])list\b/.test(lastStep)) {
367
+ // Only the last step's reporter, the same rule as above.
368
+ out =
369
+ out.slice(0, out.length - lastStep.length) +
370
+ lastStep.replace(/--reporter([= ])list\b/, '--reporter$1list,line');
371
+ changes.push('Added `line` to your `--reporter=list`: `list` writes each test result as it finishes but ' +
372
+ 'holds every failure’s error for the summary at the end, and `line` writes it as the failure ' +
373
+ 'happens — which is what names a cause that repeats while this run is still going.');
342
374
  }
343
375
  else if (finalPlaywrightRun && !/--reporter\b/.test(lastStep)) {
344
- out = `${out.trimEnd()} --reporter=list`;
345
- changes.push('Added `--reporter=list` so each test result is written to the log as it finishes ' +
346
- '(the config reporter may print nothing until the end).');
376
+ out = `${out.trimEnd()} --reporter=${wanted}`;
377
+ changes.push(`Added \`--reporter=${wanted}\` so each test result is written to the log as it finishes ` +
378
+ '(the config reporter may print nothing until the end).' +
379
+ (iterating
380
+ ? ' `line` writes each failure’s error as it happens, which is what a mid-run look reads.'
381
+ : ''));
347
382
  }
348
- // A diagnosis or a re-run (capped, last-failed, grepped, or named files) walks each failure
349
- // once: the suite's own `retries` doubled every failure of X1 flash-m71's first whole run,
350
- // which had dropped the `--retries=0` it used one command earlier. The uncapped proof run
351
- // keeps the suite's retries.
352
- const iterating = /--max-failures\b|--last-failed\b|(?:^|\s)(?:-g|--grep)\b|\S+\.(?:spec|test)\.[cm]?[jt]sx?(?::\d+)?\b/.test(lastStep);
353
383
  if (finalPlaywrightRun && iterating && !/--retries\b/.test(lastStep)) {
354
384
  out = `${out.trimEnd()} --retries=0`;
355
385
  changes.push('Added `--retries=0`: a diagnosis or re-run walks each failure once; keep the suite’s retries for the proof run.');
356
386
  }
357
387
  return { command: out, changes };
358
388
  }
389
+ /**
390
+ * How a shell command starts each runner whose whole-suite run saturates the
391
+ * sandbox it shares. The three are the runners this platform runs whole suites
392
+ * of from a sandbox; `suiteRunKind` (molecule-dev `api/src/ai/suite-proof.ts`)
393
+ * is the platform's fuller list of URL-testing runners, and the shapes here are
394
+ * its shapes.
395
+ */
396
+ const SUITE_RUN_COMMANDS = [
397
+ // `playwright test`, not a bare `playwright` (`--version` is not a run).
398
+ /(?:^|[\s;&|(])(?:npx\s+(?:--no-install\s+)?)?(?:\S*\/)?playwright\s+test\b/,
399
+ // Vitest, however it is invoked (`vitest`, `npx vitest run`).
400
+ /(?:^|[\s;&|(])(?:npx\s+(?:--no-install\s+)?)?(?:\S*\/)?vitest\b/,
401
+ // `hurl --test …` or `hurl … x.hurl`.
402
+ /(?:^|[\s;&|(])(?:\S*\/)?hurl\s+[^|\n]*(?:--test|\.hurl)/,
403
+ ];
404
+ /**
405
+ * Whether a command runs a whole test suite — a Playwright, Vitest or Hurl run
406
+ * — rather than a build, an install or a one-off script.
407
+ *
408
+ * @param command - The shell command.
409
+ * @returns True when the command starts one of those runners.
410
+ */
411
+ export function isSuiteRunCommand(command) {
412
+ return SUITE_RUN_COMMANDS.some((re) => re.test(command));
413
+ }
414
+ /**
415
+ * Start a background suite run at lower CPU and IO priority, so the sandbox it
416
+ * shares with everything else stays responsive to what the executor does next.
417
+ *
418
+ * A whole-suite Playwright run saturates the one container the app's Vite
419
+ * server, its API process and every other command also live in: on X1 flash-m75
420
+ * the model backgrounded a 139-test run at +36:23 and the two-word shell
421
+ * command it sent next — tick its plan file — returned nothing for 2 min 40 s,
422
+ * measured on the chat payload and on the live-stream follower independently.
423
+ * The backgrounding note is right that the executor should go do other work;
424
+ * what it cannot promise is that the sandbox will let it.
425
+ *
426
+ * `nice -n 10` lowers the run's CPU share and `ionice -c3` its IO class. Both
427
+ * are PROBED in the sandbox by RUNNING them, not with `command -v`: a syscall a
428
+ * container's seccomp policy refuses leaves `-v` passing while the real run
429
+ * fails. Each is applied only if its probe passed, so a sandbox with neither,
430
+ * or with only one, runs the suite exactly as it did before. Playwright's own
431
+ * workers are children of this shell and inherit both (niceness and IO class
432
+ * are inherited across fork/exec).
433
+ *
434
+ * Only a command the caller has already detached reaches this: foreground
435
+ * commands are never touched.
436
+ *
437
+ * @param command - The command as the executor sent it; the suite detection reads this.
438
+ * @param launch - The shell line that would start it, with no prefix of its own.
439
+ * @returns The line to run, and one sentence for the tool note ('' when unchanged).
440
+ */
441
+ export function suiteRunPriorityLaunch(command, launch) {
442
+ if (!isSuiteRunCommand(command))
443
+ return { launch, note: '' };
444
+ return {
445
+ // `$p` is deliberately unquoted: it expands to the prefix words, and to
446
+ // nothing at all when neither probe passed.
447
+ launch: "p=''; nice -n 10 true >/dev/null 2>&1 && p='nice -n 10'; " +
448
+ 'ionice -c3 true >/dev/null 2>&1 && p="$p ionice -c3"; ' +
449
+ `$p ${launch}`,
450
+ note: 'Started at lower CPU and IO priority, so commands you run while it works stay ' +
451
+ 'responsive (the suite itself may take a little longer).',
452
+ };
453
+ }
359
454
  // ── Command blocking ──────────────────────────────────────────────────────────
360
455
  /**
361
456
  * Commands that dump environment variables — blocked to prevent secret leakage.
@@ -474,6 +569,64 @@ export function isValidGlob(pattern) {
474
569
  // `@` has no meaning to either.
475
570
  return /^[A-Za-z0-9@*?._/()[\]-]+$/.test(pattern);
476
571
  }
572
+ /**
573
+ * The `find` predicate for a `find_files` pattern.
574
+ *
575
+ * `find -name` compares a pattern against a file's BASENAME, so a pattern
576
+ * containing a slash (`helpers/*`, `e2e/*.spec.ts`, `src` + `**` + `/App.tsx`)
577
+ * can never match and the tool answers `[]` (xr-molecule-flash-m77, on two
578
+ * calls in one run). Such a pattern is matched with `-path` instead: an fnmatch
579
+ * glob over the whole printed path, whose `*` crosses `/` — the rule
580
+ * `.gitignore` uses, and the reason a bare `helpers/*` means "a `helpers/`
581
+ * directory anywhere below the root". The pattern is anchored with a leading
582
+ * `*` then `/` ("any parent") so the search root — an absolute
583
+ * `/workspace/<app>` in a sandbox — need not be spelled out.
584
+ *
585
+ * A doubled-star directory segment keeps its usual meaning of ZERO or more
586
+ * directories; `-path` alone would demand at least one, so the pattern is also
587
+ * emitted with each such segment removed.
588
+ *
589
+ * @param pattern - A pattern already accepted by {@link isValidGlob}.
590
+ * @returns A `find` predicate: `-name '…'` for a bare name, else one or more
591
+ * `-path '…'` alternatives, ORed inside parens when there is more than one.
592
+ */
593
+ export function findPatternArgs(pattern) {
594
+ if (!pattern.includes('/'))
595
+ return `-name ${shellQuote(pattern)}`;
596
+ const paths = pathPatternVariants(pattern).map((p) => `-path ${shellQuote(`*/${p}`)}`);
597
+ return paths.length === 1 ? paths[0] : `\\( ${paths.join(' -o ')} \\)`;
598
+ }
599
+ /**
600
+ * The `-path` globs for a pattern that contains a slash, one per way each
601
+ * doubled-star directory segment can stand for zero directories.
602
+ *
603
+ * Dropping the segment is not a convenience: `*` already crosses `/` in a
604
+ * `-path` glob, so the KEPT form (`src` + `**` + `/App.tsx`) matches one or more
605
+ * intervening directories at any depth, and only the DROPPED form
606
+ * (`src/App.tsx`) matches the file sitting directly in `src/`. Both are what
607
+ * the glob means to a caller.
608
+ *
609
+ * @param pattern - The glob, e.g. `src` + `**` + `/App.tsx`.
610
+ * @returns The path globs, each without the leading "any parent" anchor (added
611
+ * by {@link findPatternArgs}).
612
+ */
613
+ function pathPatternVariants(pattern) {
614
+ const rooted = pattern.replace(/^(?:\.?\/)+/, '');
615
+ const out = new Set();
616
+ const pending = [{ text: rooted, from: 0 }];
617
+ while (pending.length > 0) {
618
+ const { text, from } = pending.pop();
619
+ const at = text.indexOf('**/', from);
620
+ if (at === -1) {
621
+ out.add(text);
622
+ continue;
623
+ }
624
+ pending.push({ text, from: at + '**/'.length }); // keep it: one or more dirs
625
+ // Drop it — the zero-directories case, which `-path` cannot express on its own.
626
+ pending.push({ text: text.slice(0, at) + text.slice(at + '**/'.length), from: at });
627
+ }
628
+ return [...out];
629
+ }
477
630
  /**
478
631
  * Validate a file tool's `path` argument is a non-empty string. A weak model
479
632
  * sometimes omits it or passes a non-string, which would otherwise crash
@@ -592,6 +745,92 @@ export const MAX_READ_RETURN_CHARS = 80 * 1024;
592
745
  * file under it "fits in ONE call", and a smaller bound here broke that promise (X1 flash-m67).
593
746
  */
594
747
  export const SMALL_FILE_WHOLE_CHARS = MAX_READ_RETURN_CHARS;
748
+ /**
749
+ * How large an HTML document must be before a whole-file read is replaced by its
750
+ * outLINE — much smaller than {@link MAX_READ_RETURN_CHARS}, because the cost of
751
+ * an HTML read is almost never worth it.
752
+ *
753
+ * An entry document is boilerplate the reader rarely needs in full: meta tags,
754
+ * stylesheet and preload links, a `<style>` block, and a handful of `<script>`
755
+ * tags. The parts an executor acts on — the body's structure, where a script is
756
+ * loaded — are all visible in an outline of one line per tag. X0 flash-m77 paid
757
+ * 63,735 chars for one such read: ~60 KB of it was the IDE's own preview bridges,
758
+ * and the read pushed the transcript past its context cap, so the e2e specs the
759
+ * executor had just read were trimmed to summaries and it read the whole suite
760
+ * again — twice (findings, +01:23 and +02:56). The scaffold now keeps those
761
+ * bridges in separate files, but a template's own head is still ~5-12 KB, and a
762
+ * static-site or SSR entry can be far more.
763
+ *
764
+ * The outline is not a truncation: the note beside it names the exact
765
+ * offset/limit that returns the whole document in one call.
766
+ */
767
+ export const MAX_HTML_INLINE_CHARS = 16 * 1024;
768
+ /** Most lines an HTML outline prints before it stops and says so. */
769
+ export const MAX_HTML_OUTLINE_LINES = 200;
770
+ /** Longest a single outline line may be before it is cut. */
771
+ const MAX_HTML_OUTLINE_LINE_CHARS = 200;
772
+ /** Whether `text` is an HTML document (a doctype or an `<html` root tag). */
773
+ export function looksLikeHtml(text) {
774
+ return /^\s*(?:<!doctype\s+html|<html[\s>])/i.test(text);
775
+ }
776
+ /**
777
+ * An OUTLINE of an HTML document: its head's tags and the tags that open each
778
+ * body section, one per line, with line numbers.
779
+ *
780
+ * Line-based on purpose — a parser would be a dependency and an attack surface
781
+ * for a read that only has to be *navigable*. A line is shown when it starts with
782
+ * `<` after trimming, which is what keeps the inside of a `<style>` block, a
783
+ * `<script>` body or a prerendered text section out of the outline while keeping
784
+ * every tag the document is built from. Long lines (a minified tag, a data URI)
785
+ * are cut, and the whole outline is capped, so a pathological document cannot
786
+ * turn one read into another wall of text.
787
+ *
788
+ * @param text - The document's full text.
789
+ * @param label - What to call the document in the outline's header (its path).
790
+ * @returns The outline — or null when `text` is not an HTML document.
791
+ */
792
+ export function htmlOutline(text, label = 'document') {
793
+ if (!looksLikeHtml(text))
794
+ return null;
795
+ const lines = text.split('\n');
796
+ const out = [];
797
+ let skipped = 0;
798
+ let skippedChars = 0;
799
+ let truncated = false;
800
+ const flushSkipped = () => {
801
+ if (skipped === 0)
802
+ return;
803
+ out.push(` … ${skipped} more line${skipped === 1 ? '' : 's'} (${Math.round(skippedChars / 1024)} KB) …`);
804
+ skipped = 0;
805
+ skippedChars = 0;
806
+ };
807
+ for (let i = 0; i < lines.length; i++) {
808
+ const line = lines[i] ?? '';
809
+ const trimmed = line.trim();
810
+ if (trimmed !== '' && trimmed.startsWith('<')) {
811
+ if (out.length >= MAX_HTML_OUTLINE_LINES) {
812
+ truncated = true;
813
+ break;
814
+ }
815
+ flushSkipped();
816
+ const shown = trimmed.length > MAX_HTML_OUTLINE_LINE_CHARS
817
+ ? `${trimmed.slice(0, MAX_HTML_OUTLINE_LINE_CHARS)}… (${trimmed.length} chars on this line)`
818
+ : trimmed;
819
+ out.push(`${String(i + 1).padStart(6)} ${shown}`);
820
+ }
821
+ else {
822
+ skipped++;
823
+ skippedChars += line.length + 1;
824
+ }
825
+ }
826
+ flushSkipped();
827
+ // "0 KB" for anything under a kilobyte reads like a bug; say the real unit.
828
+ const size = text.length >= 1024 ? `${Math.round(text.length / 1024)} KB` : `${text.length} chars`;
829
+ const header = `OUTLINE of ${label} — ${size}, ${lines.length} lines. ` +
830
+ `Its tags and the tags that open each section, one line each; tag bodies (CSS, JS, text) are not shown.` +
831
+ (truncated ? ` (Outline capped at ${MAX_HTML_OUTLINE_LINES} lines.)` : '');
832
+ return `${header}\n\n${out.join('\n')}`;
833
+ }
595
834
  /** Max search results. */
596
835
  export const MAX_SEARCH_RESULTS = 50;
597
836
  /** Max find results. */
@@ -713,6 +952,16 @@ function truncateMiddleBody(s, maxLength) {
713
952
  `\n\n... [middle truncated — ${omitted} chars omitted; the head AND the tail are shown, a failing command's error is usually near the end] ...\n\n` +
714
953
  s.slice(s.length - tailLen));
715
954
  }
955
+ /**
956
+ * A Playwright failure block's header line:
957
+ * ` 3) [chromium] › e2e/a.spec.ts:5:1 › home › shows the title ────────`.
958
+ *
959
+ * Two things vary and both are optional here. The `[<project>] › ` half is absent from a run
960
+ * whose config declares no projects, and the `line` reporter draws a rule after the title on
961
+ * the same line. Both forms name a title path, so a header is only a header when the line
962
+ * carries a `›` — a plain numbered list in a build log must not read as test failures.
963
+ */
964
+ const FAILURE_BLOCK_HEADER = /^\s*\d+\)\s+(?:\[[^\]\n]*\]\s*›\s*)?(?=[^\n]*›)(.+?)\s*─*\s*$/;
716
965
  /**
717
966
  * The numbered failure blocks of a Playwright report (` 1) [chromium] › file:line › title`
718
967
  * up to the next block or the tally).
@@ -725,7 +974,7 @@ export function failureBlocks(s) {
725
974
  const blocks = [];
726
975
  let current = null;
727
976
  for (const line of lines) {
728
- if (/^\s*\d+\) \[[^\]\n]+\] › /.test(line)) {
977
+ if (FAILURE_BLOCK_HEADER.test(line)) {
729
978
  if (current)
730
979
  blocks.push(current.join('\n').trimEnd());
731
980
  current = [line];
@@ -770,6 +1019,229 @@ export function runningOutputSliceCommand(log, own) {
770
1019
  return (`{ ${read} | grep -aE ${shellQuote(RUNNING_FAILURE_LINE_RE)} | head -${RUNNING_FAILURE_LINES} | cut -c1-300; ` +
771
1020
  `echo; ${read} | tail -c ${RUNNING_TAIL_BYTES}; }`);
772
1021
  }
1022
+ /** Bytes of a running log a repeating-cause look reads back. */
1023
+ export const REPEATING_CAUSE_TAIL_BYTES = 40_000;
1024
+ /**
1025
+ * The shell command whose stdout is enough of a running log to tell whether one cause is
1026
+ * repeating: its tail, {@link REPEATING_CAUSE_TAIL_BYTES} of it.
1027
+ *
1028
+ * {@link runningOutputSliceCommand} is the wrong read for this: it keeps only the FIRST 40
1029
+ * failure lines plus 1 000 bytes, so on a run whose failures print a body each — which is what
1030
+ * a `line` reporter does, and the only form that names the cause as an error message rather than
1031
+ * a duration — three bodies do not fit, and the repeat is invisible. This read is not shown to
1032
+ * anyone; it is parsed.
1033
+ *
1034
+ * @param log - The task log path.
1035
+ * @param own - A file the command redirects its own output to, if any.
1036
+ * @returns The shell command, safe to hand to the execution backend as-is.
1037
+ */
1038
+ export function runningCauseCommand(log, own) {
1039
+ const read = `cat ${log}${own ? ` ${shellQuote(own)}` : ''} 2>/dev/null`;
1040
+ return `${read} | tail -c ${REPEATING_CAUSE_TAIL_BYTES}`;
1041
+ }
1042
+ /** Failures that must share one signature before a running suite's repeating cause is named. */
1043
+ export const REPEATING_FAILURE_MIN = 3;
1044
+ /** ANSI SGR sequences — the `line` reporter writes cursor moves even into a file. */
1045
+ // eslint-disable-next-line no-control-regex -- an escape sequence IS a control character
1046
+ const ANSI_SGR = /\u001B\[[0-9;]*[A-Za-z]/g;
1047
+ /**
1048
+ * A `list`-reporter result line: ` ✘ 3 [chromium] › e2e/a.spec.ts:5:1 › A › does x (15.6s)`.
1049
+ * The `[<project>]` half is optional (a config with no projects prints none), as is the title
1050
+ * path's `›` tail. Durations are Playwright's own: `ms`, `s`, `m`.
1051
+ */
1052
+ const LIST_RESULT_LINE = /^\s*([✓✘])\s+\d+\s+(?:\[[^\]\n]*\]\s*›\s*)?(.+?)\s+\((\d+(?:\.\d+)?)\s*(ms|s|m)\)\s*$/;
1053
+ /** A title path's location segment (`…/articles.spec.ts:32:3`). */
1054
+ const TITLE_LOCATION = /^(.*?:\d+)(?::\d+)?$/;
1055
+ /**
1056
+ * Split a Playwright title path (`…/a.spec.ts:5:1 › Home › shows the title`) into the location
1057
+ * and the test's own title, dropping the column (a re-run names `file:line`).
1058
+ *
1059
+ * @param titlePath - The title path the log writes.
1060
+ * @returns The location ('' when the path names none) and the title.
1061
+ */
1062
+ function splitTitlePath(titlePath) {
1063
+ const parts = titlePath.split(' › ');
1064
+ const location = TITLE_LOCATION.exec(parts[0]?.trim() ?? '')?.[1];
1065
+ if (location)
1066
+ return { where: location, title: parts.length > 1 ? (parts.at(-1) ?? '').trim() : '' };
1067
+ return { where: '', title: parts.join(' › ').trim() };
1068
+ }
1069
+ /**
1070
+ * The seconds a Playwright duration is worth, in Playwright's units.
1071
+ *
1072
+ * @param value - The digits the log printed.
1073
+ * @param unit - `ms`, `s` or `m`.
1074
+ * @returns Seconds.
1075
+ */
1076
+ function durationSeconds(value, unit) {
1077
+ const n = Number(value);
1078
+ if (unit === 'ms')
1079
+ return n / 1000;
1080
+ return unit === 'm' ? n * 60 : n;
1081
+ }
1082
+ /**
1083
+ * Every failed test a running log names, in the order it names them.
1084
+ *
1085
+ * @param log - The log so far (ANSI already stripped).
1086
+ * @returns The failed tests.
1087
+ */
1088
+ function failedTests(log) {
1089
+ const out = [];
1090
+ for (const line of log.split('\n')) {
1091
+ const m = LIST_RESULT_LINE.exec(line);
1092
+ if (!m || m[1] !== '✘')
1093
+ continue;
1094
+ const { where, title } = splitTitlePath(m[2]);
1095
+ out.push({ where, title, seconds: durationSeconds(m[3], m[4]) });
1096
+ }
1097
+ return out;
1098
+ }
1099
+ /**
1100
+ * The error a failure block reports: the first line of its body that names a thrown error
1101
+ * (`TimeoutError: page.waitForURL: Timeout 10000ms exceeded.`), else its first real line.
1102
+ *
1103
+ * The body's first line is usually the runner's own sentence (`Test timeout of 15000ms exceeded
1104
+ * while running "beforeEach" hook.`), which changes with the hook; the thrown error below it is
1105
+ * what repeats verbatim, so it is preferred.
1106
+ *
1107
+ * @param block - One block from {@link failureBlocks}.
1108
+ * @returns The error line, or null when the block has no body.
1109
+ */
1110
+ function failureMessage(block) {
1111
+ const body = block
1112
+ .split('\n')
1113
+ .slice(1)
1114
+ .map((l) => l.replace(ANSI_SGR, '').trim())
1115
+ // Progress lines the `list` reporter interleaves with the `line` reporter's blocks are not
1116
+ // the block's own words, and a code frame or an `at …` line is where the error happened, not
1117
+ // what it said.
1118
+ .filter((l) => l &&
1119
+ !LIST_RESULT_LINE.test(l) &&
1120
+ !/^(?:at\s|\d+ \||>\s*\d+ \||\|)/.test(l) &&
1121
+ !/^Error Context:/.test(l));
1122
+ const thrown = body.find((l) => /^[A-Za-z_$][\w.$]*(?:Error|Exception)\b/.test(l));
1123
+ return thrown ?? body[0] ?? null;
1124
+ }
1125
+ /**
1126
+ * The largest group of items sharing a key.
1127
+ *
1128
+ * @param groups - Keyed groups, in the order they were built.
1129
+ * @returns The largest group's key and members, or null when there are none.
1130
+ */
1131
+ function largestGroup(groups) {
1132
+ let best = null;
1133
+ for (const [key, members] of groups) {
1134
+ if (!best || members.length > best.members.length)
1135
+ best = { key, members };
1136
+ }
1137
+ return best;
1138
+ }
1139
+ /**
1140
+ * The cause repeating among a running suite's failures, or null.
1141
+ *
1142
+ * Playwright's `list` reporter prints one line per test as it finishes — its mark, its title
1143
+ * path and how long it ran — and defers each failure's error to the summary at the end, so
1144
+ * mid-run the only signature those lines carry is the duration. A `line` reporter prints the
1145
+ * failure bodies as they happen, and there the repeated error message itself is the signature,
1146
+ * which is stronger (it needs no assumption about how long a failure should take), so the
1147
+ * message is looked for first.
1148
+ *
1149
+ * Either way the point is the same: a suite whose failures repeat one cause spends its whole
1150
+ * runtime proving that one cause, and the executor watching it can be told so at the third
1151
+ * failure instead of at the last. X1 flash-m76 walked 15 tests in 4.0 minutes where 15.6 s
1152
+ * each was the test timeout and one missing `navigate('/')` was the cause of all of them.
1153
+ *
1154
+ * @param log - The log so far.
1155
+ * @returns The repeating cause, or null when no signature has {@link REPEATING_FAILURE_MIN}
1156
+ * failures behind it.
1157
+ */
1158
+ export function repeatingFailure(log) {
1159
+ const text = log.replace(ANSI_SGR, '');
1160
+ // The failure BODIES first: a message repeating verbatim names the cause itself.
1161
+ const byMessage = new Map();
1162
+ for (const block of failureBlocks(text)) {
1163
+ const message = failureMessage(block);
1164
+ if (message === null)
1165
+ continue;
1166
+ const m = FAILURE_BLOCK_HEADER.exec(block.split('\n')[0] ?? '');
1167
+ const { where, title } = splitTitlePath(m?.[1] ?? '');
1168
+ const group = byMessage.get(message);
1169
+ if (group)
1170
+ group.push({ where, title, seconds: null });
1171
+ else
1172
+ byMessage.set(message, [{ where, title, seconds: null }]);
1173
+ }
1174
+ const messageGroup = largestGroup(byMessage);
1175
+ if (messageGroup && messageGroup.members.length >= REPEATING_FAILURE_MIN)
1176
+ return {
1177
+ count: messageGroup.members.length,
1178
+ message: messageGroup.key,
1179
+ seconds: null,
1180
+ tests: messageGroup.members,
1181
+ };
1182
+ // Otherwise the durations: the largest set of failures that took the same time to within the
1183
+ // band. A test that runs to its timeout prints slightly different durations on each attempt
1184
+ // (15.6 s, 15.1 s, 15.0 s), so the band is 0.5 s or 5% of the duration, whichever is larger.
1185
+ const tests = failedTests(text);
1186
+ const withSeconds = tests.filter((t) => t.seconds !== null);
1187
+ let best = [];
1188
+ for (const t of withSeconds) {
1189
+ const band = Math.max(0.5, t.seconds * 0.05);
1190
+ const near = withSeconds.filter((x) => Math.abs(x.seconds - t.seconds) <= band);
1191
+ if (near.length > best.length)
1192
+ best = near;
1193
+ }
1194
+ if (best.length < REPEATING_FAILURE_MIN)
1195
+ return null;
1196
+ const mean = best.reduce((sum, t) => sum + t.seconds, 0) / best.length;
1197
+ return { count: best.length, message: null, seconds: Math.round(mean * 10) / 10, tests: best };
1198
+ }
1199
+ /** How many of a repeating cause's failures the note lists before it says "and N more". */
1200
+ export const REPEATING_FAILURE_LISTED = 5;
1201
+ /**
1202
+ * The shortest unambiguous tail of a logged path (`…/specs/e2e/articles.spec.ts:32` →
1203
+ * `e2e/articles.spec.ts:32`).
1204
+ *
1205
+ * @param where - The location the log wrote.
1206
+ * @returns The last two path segments.
1207
+ */
1208
+ function shortWhere(where) {
1209
+ const parts = where.split('/');
1210
+ return parts.slice(-2).join('/');
1211
+ }
1212
+ /**
1213
+ * The sentence that names a cause repeating among a running suite's failures, and the command
1214
+ * that stops the run so the cause can be fixed first.
1215
+ *
1216
+ * Kept here, beside {@link repeatingFailure}, so the two stay in step: every word below is about
1217
+ * what that function could actually see in the log.
1218
+ *
1219
+ * @param found - What {@link repeatingFailure} found.
1220
+ * @param o - The run's facts: its `taskId`, its `log` path, and `stop` — the shell command that
1221
+ * stops it (the pids the platform's own pid file leads to).
1222
+ * @returns The note.
1223
+ */
1224
+ export function repeatingFailureNote(found, o) {
1225
+ const listed = found.tests.slice(0, REPEATING_FAILURE_LISTED);
1226
+ const rows = listed
1227
+ .map((t) => ` ✘ ${[t.where && shortWhere(t.where), t.title].filter(Boolean).join(' › ')}` +
1228
+ (t.seconds === null ? '' : ` (${t.seconds}s)`))
1229
+ .join('\n');
1230
+ const more = found.count > listed.length ? `\n … and ${found.count - listed.length} more` : '';
1231
+ const signature = found.message !== null
1232
+ ? `every one with \`${found.message}\``
1233
+ : `each after ~${found.seconds}s — the same duration to within half a second`;
1234
+ const first = found.tests[0];
1235
+ const reproduce = first?.where && !found.message
1236
+ ? ` \`${first.where}\` alone reproduces the first one.`
1237
+ : first?.where
1238
+ ? ` Re-run \`${first.where}\` alone to see its error in full.`
1239
+ : '';
1240
+ return (`${found.count} of this run’s tests have already failed the same way — ${signature} — so one ` +
1241
+ `cause is repeating and the rest of the run only repeats it:\n${rows}${more}\n` +
1242
+ `Stop this run and fix that cause before the rest: \`exec_command: ${o.stop}\`. ` +
1243
+ `It is still going (task ${o.taskId}, log ${o.log}) — do not wait for the rest of it.${reproduce}`);
1244
+ }
773
1245
  /**
774
1246
  * Attempt a whitespace-tolerant replacement when an exact `old_string` match
775
1247
  * failed. Finds a contiguous run of lines in `content` whose per-line
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.5.18",
3
+ "version": "1.5.19",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "@molecule/api-ai": "^1.0.1"
32
32
  },
33
33
  "devDependencies": {
34
- "@molecule/api-ai": "1.3.1",
34
+ "@molecule/api-ai": "1.3.2",
35
35
  "@types/node": "26.1.2",
36
36
  "esbuild": "0.28.2",
37
37
  "typescript": "6.0.3",