@molecule/api-ai-tools 1.6.6 → 1.7.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/dist/tools.js CHANGED
@@ -52,8 +52,20 @@ const repeatingCauseLookMs = () => Number(process.env.MOL_AI_BG_CAUSE_LOOK_MS) |
52
52
  * Task ids a wait has already named a repeating cause for. The note is news once; a second wait
53
53
  * on the same run has nothing to add to it, and re-sending it would spend the executor's turns
54
54
  * telling it what it has already been told.
55
+ *
56
+ * Only a wait that ENDED EARLY on the finding lands here ({@link earlyReturnCause}); a lead does
57
+ * not, because the wait it was found in keeps looking — a run can go from a shared duration to a
58
+ * proven cause failing everything as the app goes down, and that is exactly the finding the wait
59
+ * must not sleep through.
55
60
  */
56
61
  const repeatingCauseNoted = new Set();
62
+ /**
63
+ * Task ids whose mid-run finding has already ridden a result as a note ({@link
64
+ * repeatingFailureNote} without an {@link earlyReturnCause}). Same rule as
65
+ * {@link repeatingCauseNoted} — news once, and the executor still has the ✘ rows in its context —
66
+ * but kept apart from it, because a task in this set keeps being looked at.
67
+ */
68
+ const midRunLeadNoted = new Set();
57
69
  /**
58
70
  * The shell command that stops a background task by pid: the pid the platform wrote when it
59
71
  * detached the run, plus every process under it.
@@ -209,7 +221,8 @@ function runningNote(clauses) {
209
221
  const joined = clauses.join('; ');
210
222
  return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
211
223
  }
212
- import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, emptyCallError, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isSuiteRunCommand, isValidGlob, linesWithinSerialized, literalDirPrefix, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
224
+ import { diagnosisTraceCommand } from './trace-artifacts.js';
225
+ import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, earlyReturnCause, emptyCallError, failureCausesNote, findPathArgs, findPatternArgs, fusedLine, htmlOutline, isCommandTimeout, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isSuiteRunCommand, isValidGlob, linesWithinSerialized, literalDirPrefix, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_HTML_INLINE_CHARS, MAX_OUTPUT_SIZE, MAX_READ_CALL_CHARS, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, READ_RESULT_ENVELOPE_CHARS, redactSecrets, redactSecretsInCode, repeatingFailure, repeatingFailureNote, resolvePath, RUNNING_FAILURE_LINES, runningCauseCommand, runningOutputSliceCommand, searchTimeoutError, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
213
226
  /**
214
227
  * How long exec_command waits for a command it backgrounded only because the
215
228
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -322,6 +335,40 @@ export function installedPackageWriteRefusal(path) {
322
335
  : '';
323
336
  return `Not written: ${path} is inside ${what} (node_modules). ${change}${restore}`;
324
337
  }
338
+ /** How many failing replacements one refusal names before it stops listing. */
339
+ const MAX_REPORTED_REPLACEMENT_FAILURES = 5;
340
+ /**
341
+ * The one refusal a batch `edit_file` gets, naming EVERY replacement that did not
342
+ * match — each with the same detail a lone failure gets (which replacement, the
343
+ * head of its old_string, where it diverges from the file).
344
+ *
345
+ * A batch is applied all-or-nothing, so one bad `old_string` means none of the
346
+ * others were applied either: they are not "the rest, which worked", they are
347
+ * unchecked work the model will otherwise re-send as-is. Naming one per round trip
348
+ * cost flash-m98 two full re-sends of a six-replacement batch — the second bad
349
+ * hunk was not reported until the first was fixed, 7 s later.
350
+ *
351
+ * @param path - The file the batch targets (nothing was written to it).
352
+ * @param total - How many replacements the batch carried.
353
+ * @param failures - Every failing replacement, in batch order.
354
+ * @returns The error message.
355
+ */
356
+ function batchFailureError(path, total, failures) {
357
+ // A single replacement keeps the message it has always had: "Replacement 1 of 1"
358
+ // is noise, and the failure of a lone edit is not a batch problem.
359
+ if (total === 1 && failures.length === 1)
360
+ return failures[0].message;
361
+ const shown = failures.slice(0, MAX_REPORTED_REPLACEMENT_FAILURES);
362
+ const omitted = failures.length - shown.length;
363
+ const blocks = shown.map((f) => `Replacement ${f.index} of ${total} failed` +
364
+ (f.start ? ` (its old_string starts "${f.start}")` : '') +
365
+ `:\n${f.message}`);
366
+ return (`edit_file (${path}): nothing was written — ${failures.length} of the ${total} replacements do not match the file, and the batch is all-or-nothing, so re-send it with every one of these fixed.\n\n` +
367
+ blocks.join('\n\n') +
368
+ (omitted > 0
369
+ ? `\n\n(${omitted} more replacement${omitted === 1 ? '' : 's'} also failed and are not shown.)`
370
+ : ''));
371
+ }
325
372
  /**
326
373
  * Build a complete set of AI agent tools bound to an execution backend.
327
374
  *
@@ -1080,6 +1127,13 @@ export function buildTools(backend, config) {
1080
1127
  // Replacements refused for changing the line break at old_string's boundary and
1081
1128
  // nothing else — reported by position, never applied (see below).
1082
1129
  const skipped = [];
1130
+ // Every replacement this batch refuses, collected rather than returned on the
1131
+ // first: a batch is atomic, so the other bad hunks are just as unreported, and
1132
+ // one-per-round-trip cost flash-m98 two full re-sends of a six-replacement
1133
+ // batch (the second bad hunk was not named until the first was fixed).
1134
+ const failures = [];
1135
+ /** How a failure names the replacement it is about: the head of its old_string. */
1136
+ const startOf = (text) => text.split('\n')[0].trim().slice(0, 80);
1083
1137
  // Where each applied replacement landed, in the coordinates of the
1084
1138
  // latest content — rendered into the result so the model sees the edit.
1085
1139
  let spans = [];
@@ -1112,9 +1166,12 @@ export function buildTools(backend, config) {
1112
1166
  // method access) or silently inserts the literal "undefined" (a missing
1113
1167
  // new_string). Fail fast with an actionable message instead.
1114
1168
  if (typeof oldString !== 'string' || oldString === '' || typeof newString !== 'string') {
1115
- return {
1116
- error: `edit_file: each replacement needs a non-empty string old_string and a string new_string (got old_string: ${oldString === '' ? 'empty' : typeof oldString}, new_string: ${typeof newString}). To delete text, pass new_string: "".`,
1117
- };
1169
+ failures.push({
1170
+ index: index + 1,
1171
+ start: typeof oldString === 'string' ? startOf(oldString) : '',
1172
+ message: `each replacement needs a non-empty string old_string and a string new_string (got old_string: ${oldString === '' ? 'empty' : typeof oldString}, new_string: ${typeof newString}). To delete text, pass new_string: "".`,
1173
+ });
1174
+ continue;
1118
1175
  }
1119
1176
  const count = content.split(oldString).length - 1;
1120
1177
  // The edit is ALREADY in the file: old_string is gone and a distinctive
@@ -1136,9 +1193,12 @@ export function buildTools(backend, config) {
1136
1193
  // match failed and hand back the file's actual content so it fixes in
1137
1194
  // one retry instead of many.
1138
1195
  if (oldString.includes('\\n')) {
1139
- return {
1140
- error: `old_string not found in ${path} (hint: old_string contains literal "\\n" — use actual newlines in JSON strings, not escaped \\n)`,
1141
- };
1196
+ failures.push({
1197
+ index: index + 1,
1198
+ start: startOf(oldString),
1199
+ message: `old_string not found in ${path} (hint: old_string contains literal "\\n" — use actual newlines in JSON strings, not escaped \\n)`,
1200
+ });
1201
+ continue;
1142
1202
  }
1143
1203
  // Whitespace-only mismatch: the text is there but indentation/spacing
1144
1204
  // differs (tabs vs spaces, trailing whitespace). If a UNIQUE line-run
@@ -1154,9 +1214,12 @@ export function buildTools(backend, config) {
1154
1214
  const normalize = (s) => s.replace(/\s+/g, ' ').trim();
1155
1215
  const normOld = normalize(oldString);
1156
1216
  if (normOld && normalize(content).includes(normOld)) {
1157
- return {
1158
- error: `old_string not found in ${path} — a match exists but whitespace/indentation differs and is not unique enough to apply automatically. Re-read the file and copy the exact text (tabs vs spaces, trailing spaces, blank lines must match), or include more surrounding context.`,
1159
- };
1217
+ failures.push({
1218
+ index: index + 1,
1219
+ start: startOf(oldString),
1220
+ message: `old_string not found in ${path} — a match exists but whitespace/indentation differs and is not unique enough to apply automatically. Re-read the file and copy the exact text (tabs vs spaces, trailing spaces, blank lines must match), or include more surrounding context.`,
1221
+ });
1222
+ continue;
1160
1223
  }
1161
1224
  // Anchor probe: locate the target region by the most DISTINCTIVE line
1162
1225
  // of old_string (longest non-trivial line first, requiring a UNIQUE
@@ -1174,9 +1237,7 @@ export function buildTools(backend, config) {
1174
1237
  .slice(start, idx + 8)
1175
1238
  .map((l, i) => `${start + i + 1}: ${l}`)
1176
1239
  .join('\n');
1177
- return {
1178
- error: `old_string not found in ${path}. The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
1179
- };
1240
+ return `old_string not found in ${path}. The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`;
1180
1241
  };
1181
1242
  // An import block the model rebuilt in its own order: the linter's
1182
1243
  // import sort owns that order, so name it and show the real block. X1
@@ -1193,12 +1254,15 @@ export function buildTools(backend, config) {
1193
1254
  .slice(start, at + 1)
1194
1255
  .map((l, i) => `${start + i + 1}: ${l}`)
1195
1256
  .join('\n');
1196
- return {
1197
- error: `old_string not found in ${path}: it is an import block, and the linter's import ` +
1257
+ failures.push({
1258
+ index: index + 1,
1259
+ start: startOf(oldString),
1260
+ message: `old_string not found in ${path}: it is an import block, and the linter's import ` +
1198
1261
  `sort decides its order, so do not rebuild it. The file's import from ` +
1199
1262
  `'${imported}' reads:\n${block}\nEdit one line of it, or add your import as a ` +
1200
1263
  'new line and let the linter sort it.',
1201
- };
1264
+ });
1265
+ continue;
1202
1266
  }
1203
1267
  }
1204
1268
  const distinctiveLines = [
@@ -1233,13 +1297,11 @@ export function buildTools(backend, config) {
1233
1297
  .slice(start, base + k + 6)
1234
1298
  .map((l, i) => `${start + i + 1}: ${l}`)
1235
1299
  .join('\n');
1236
- return {
1237
- error: `old_string not found in ${path} — it matches the file through line ${base + k}, ` +
1238
- `then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
1239
- `Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
1240
- `The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
1241
- `The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
1242
- };
1300
+ return (`old_string not found in ${path} — it matches the file through line ${base + k}, ` +
1301
+ `then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
1302
+ `Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
1303
+ `The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
1304
+ `The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`);
1243
1305
  };
1244
1306
  // The longest run of old_string lines that IS in the file decides which
1245
1307
  // alignment to report, so a later distinctive line can beat an earlier
@@ -1271,10 +1333,22 @@ export function buildTools(backend, config) {
1271
1333
  if (k > 0 && k < oldLines.length && (best === null || k > best.k))
1272
1334
  best = { base, k };
1273
1335
  }
1274
- if (best)
1275
- return showDivergence(best.base, best.k);
1276
- if (firstUniqueHit !== null)
1277
- return showSnippet(firstUniqueHit);
1336
+ if (best) {
1337
+ failures.push({
1338
+ index: index + 1,
1339
+ start: startOf(oldString),
1340
+ message: showDivergence(best.base, best.k),
1341
+ });
1342
+ continue;
1343
+ }
1344
+ if (firstUniqueHit !== null) {
1345
+ failures.push({
1346
+ index: index + 1,
1347
+ start: startOf(oldString),
1348
+ message: showSnippet(firstUniqueHit),
1349
+ });
1350
+ continue;
1351
+ }
1278
1352
  // Fallback: the first non-blank line, even if not unique (better than a
1279
1353
  // bare "re-read" when the model just needs to see nearby real content).
1280
1354
  const firstLine = oldString
@@ -1283,8 +1357,14 @@ export function buildTools(backend, config) {
1283
1357
  ?.trim();
1284
1358
  if (firstLine) {
1285
1359
  const idx = fileLines.findIndex((l) => l.includes(firstLine));
1286
- if (idx >= 0)
1287
- return showSnippet(idx);
1360
+ if (idx >= 0) {
1361
+ failures.push({
1362
+ index: index + 1,
1363
+ start: startOf(oldString),
1364
+ message: showSnippet(idx),
1365
+ });
1366
+ continue;
1367
+ }
1288
1368
  }
1289
1369
  // Nothing anchored. Say WHICH of the two situations this is — they
1290
1370
  // call for different next moves, and the old message implied the
@@ -1294,20 +1374,20 @@ export function buildTools(backend, config) {
1294
1374
  // "re-read and copy the exact text", which is wrong advice when the
1295
1375
  // text is not in the file at all.
1296
1376
  const anchorsPresent = distinctiveLines.some((anchor) => fileLines.some((l) => l.includes(anchor)));
1297
- if (!anchorsPresent) {
1298
- return {
1299
- error: `old_string not found in ${path} — and NONE of its lines appear anywhere in ` +
1377
+ failures.push({
1378
+ index: index + 1,
1379
+ start: startOf(oldString),
1380
+ message: !anchorsPresent
1381
+ ? `old_string not found in ${path} — and NONE of its lines appear anywhere in ` +
1300
1382
  `that file, so this is not a whitespace problem. Either the edit was already ` +
1301
1383
  `applied, or this is the wrong file. read_file it and look before trying ` +
1302
- `again; do not retry the same old_string.`,
1303
- };
1304
- }
1305
- return {
1306
- error: `old_string not found in ${path}. Parts of it ARE in the file but no single ` +
1307
- `line is distinctive enough to locate the target. read_file the region you ` +
1308
- `mean and copy old_string verbatim from what it returns — including ` +
1309
- `indentation — or include more surrounding context to make it unique.`,
1310
- };
1384
+ `again; do not retry the same old_string.`
1385
+ : `old_string not found in ${path}. Parts of it ARE in the file but no single ` +
1386
+ `line is distinctive enough to locate the target. read_file the region you ` +
1387
+ `mean and copy old_string verbatim from what it returns — including ` +
1388
+ `indentation — or include more surrounding context to make it unique.`,
1389
+ });
1390
+ continue;
1311
1391
  }
1312
1392
  if (count > 1 && (replaceAll === true || replaceAllCamel === true)) {
1313
1393
  // Every occurrence, by split/join — never String.replace, whose
@@ -1323,9 +1403,12 @@ export function buildTools(backend, config) {
1323
1403
  const lines = [];
1324
1404
  for (let at = content.indexOf(oldString); at >= 0 && lines.length < 10; at = content.indexOf(oldString, at + 1))
1325
1405
  lines.push(content.slice(0, at).split('\n').length);
1326
- return {
1327
- error: `old_string found ${count} times in ${path} (starting at lines ${lines.join(', ')}${count > lines.length ? ', …' : ''}) — must be unique. Include more surrounding context from the one you mean, or set replace_all: true on this replacement to change every occurrence.`,
1328
- };
1406
+ failures.push({
1407
+ index: index + 1,
1408
+ start: startOf(oldString),
1409
+ message: `old_string found ${count} times in ${path} (starting at lines ${lines.join(', ')}${count > lines.length ? ', …' : ''}) — must be unique. Include more surrounding context from the one you mean, or set replace_all: true on this replacement to change every occurrence.`,
1410
+ });
1411
+ continue;
1329
1412
  }
1330
1413
  // Splice by index rather than `content.replace(oldString, newString)`:
1331
1414
  // String.replace treats `$&`, `$$`, `` $` ``, `$'` in the REPLACEMENT as
@@ -1352,6 +1435,13 @@ export function buildTools(backend, config) {
1352
1435
  spans = addChangeSpan(spans, content, spliced);
1353
1436
  content = spliced;
1354
1437
  }
1438
+ // Every replacement was checked before this: a batch that refuses ANY of them
1439
+ // writes nothing, so the ones that DID match are just as unreported as the ones
1440
+ // that did not — and naming them one per round trip cost flash-m98 two full
1441
+ // re-sends of a six-replacement batch. Nothing is written here: the file on disk
1442
+ // is the original, and the model fixes all of them in one retry.
1443
+ if (failures.length)
1444
+ return { error: batchFailureError(path, replacements.length, failures) };
1355
1445
  // One line per refused replacement: what it would have done, and the two ways to
1356
1446
  // say it on purpose. The rest of the batch is applied — the second half of the pair
1357
1447
  // above (`comments.ts`'s third replacement, `articles.ts`'s own text edit) anchors
@@ -1687,7 +1777,7 @@ export function buildTools(backend, config) {
1687
1777
  }
1688
1778
  },
1689
1779
  async exec_command(input) {
1690
- const command = input.command;
1780
+ const rawCommand = input.command;
1691
1781
  const cwd = input.cwd ? resolve(input.cwd, 'cwd') : root;
1692
1782
  // A model that sent the command under another name (`cmd`, `script`, …)
1693
1783
  // has already been normalized by guardToolExecute; reaching here with
@@ -1697,21 +1787,32 @@ export function buildTools(backend, config) {
1697
1787
  // undefined (reading 'match')" — the executor read that as a broken shell,
1698
1788
  // stopped verifying, and ended the turn claiming all checks passed while
1699
1789
  // seven acceptance checks failed (X0 rehearsal 83, 2026-09-16).
1700
- if (typeof command !== 'string' || command.trim() === '') {
1790
+ if (typeof rawCommand !== 'string' || rawCommand.trim() === '') {
1701
1791
  return { error: missingParamError(TOOL_SCHEMAS.exec_command, input, ['command']) };
1702
1792
  }
1703
1793
  if (blockDangerousCommands) {
1704
- const blocked = checkBlockedCommand(command);
1794
+ const blocked = checkBlockedCommand(rawCommand);
1705
1795
  if (blocked)
1706
1796
  return { error: blocked };
1707
1797
  }
1708
1798
  // Consumer-specific guard (see ToolBuildConfig.blockCommand) — an environment can
1709
1799
  // veto commands the generic dangerous-command check can't know about.
1710
1800
  if (blockCommand) {
1711
- const blocked = blockCommand(command, cwd);
1801
+ const blocked = blockCommand(rawCommand, cwd);
1712
1802
  if (blocked)
1713
1803
  return { error: blocked };
1714
1804
  }
1805
+ // A diagnosis run of a suite whose config records no trace gets one recorded anyway
1806
+ // (`diagnosisTraceCommand`, trace-artifacts.ts): the failing tests' own network log is what
1807
+ // tells a swallowed request error from a selector, and X1 xr-molecule-flash-m100 spent the
1808
+ // last minutes of its run hand-writing Chromium probes for what the trace it never wrote
1809
+ // down would have said. The whole rule is that function's; the proof run is never touched.
1810
+ const traced = await diagnosisTraceCommand({
1811
+ command: rawCommand,
1812
+ cwd,
1813
+ readFile: (path) => backend.readFile(path),
1814
+ });
1815
+ const command = traced.command;
1715
1816
  // Detached: start it, hand back a handle, let the turn continue. A
1716
1817
  // command that outlives the tool's ceiling is otherwise simply lost —
1717
1818
  // measured across six agent runs, ten of them were killed at the
@@ -1759,6 +1860,10 @@ export function buildTools(backend, config) {
1759
1860
  // Keep the log growing while it runs (see streamingBackgroundCommand):
1760
1861
  // an empty log is what wait_for_task reads as stuck.
1761
1862
  const streaming = streamingBackgroundCommand(command);
1863
+ // …and the trace flag this tool added (above), which the streaming rewrite never saw:
1864
+ // its changes are what this branch reports the run's own settings in.
1865
+ if (traced.change)
1866
+ streaming.changes.unshift(traced.change);
1762
1867
  // A whole-suite run starves the sandbox it shares with the app and with
1763
1868
  // every command the executor sends next, so it starts at lower CPU and
1764
1869
  // IO priority (see suiteRunPriorityLaunch). Foreground runs are never
@@ -1926,7 +2031,9 @@ export function buildTools(backend, config) {
1926
2031
  stdout,
1927
2032
  stderr,
1928
2033
  exitCode: result.exitCode,
1929
- ...(ceilingNote ? { note: ceilingNote } : {}),
2034
+ ...(ceilingNote || traced.change
2035
+ ? { note: [ceilingNote, traced.change].filter(Boolean).join(' ') }
2036
+ : {}),
1930
2037
  };
1931
2038
  }
1932
2039
  catch (e) {
@@ -2092,9 +2199,19 @@ export function buildTools(backend, config) {
2092
2199
  // and then fixed it in 51 s. The task is NOT stopped — the executor is handed the cause and
2093
2200
  // decides, and the note tells it to let the run finish because the run's other causes only
2094
2201
  // surface in it (X1 flash-m83 stopped eight runs at their first repeating cause and never
2095
- // finished one). Only a cause that is failing the WHOLE run — every failure sharing it,
2096
- // across `UNIVERSAL_CAUSE_FILES` files or more, with nothing passing since the first —
2097
- // comes with a stop command.
2202
+ // finished one).
2203
+ //
2204
+ // WHAT ENDS THIS WAIT is the whole of the executor's next move, so only ONE finding does:
2205
+ // `earlyReturnCause` — a PROVEN cause (an error repeating verbatim, never a shared duration)
2206
+ // that is failing the WHOLE run (every failure the log names, across `UNIVERSAL_CAUSE_FILES`
2207
+ // files or more, with nothing passing since the first). Anything else is a LEAD: the wait
2208
+ // keeps going to the caller's own timeout, and the finding is attached to the result of this
2209
+ // wait as a note. X1 xr-molecule-flash-m99 (2026-10-16) was cut short twice by a status that
2210
+ // returned early on three tests sharing the suite's own `timeout: 15_000` — a signature the
2211
+ // note it printed called "a lead, not a cause" — and read the early return as "stop waiting,
2212
+ // edit now", voiding a run that was still walking both times (the second wait, 224 s of a
2213
+ // 280 s budget, ended with nothing at all).
2214
+ let midRunNote = null;
2098
2215
  let lastCauseLookAt = startedAt - repeatingCauseLookMs();
2099
2216
  let exitCode = await readExit();
2100
2217
  while (exitCode === null && Date.now() - startedAt < budgetMs) {
@@ -2123,29 +2240,46 @@ export function buildTools(backend, config) {
2123
2240
  const found = repeatingFailure(causeLog);
2124
2241
  if (!found)
2125
2242
  continue;
2126
- // Only a cause that is failing the whole run gets a stop command — and only then is the
2127
- // pid walk paid for.
2128
- const universal = universalCause(causeLog, found);
2243
+ // The ONE predicate behind this decision and `repeatingFailureNote`'s wording, so the two
2244
+ // cannot come to different verdicts.
2245
+ const stopCause = earlyReturnCause(found, universalCause(causeLog, found));
2129
2246
  // How this run was started, so the note can hand over the re-run to send once it ends
2130
2247
  // instead of one for now — the platform refuses a second run against the same app and
2131
2248
  // database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
2132
2249
  // and its overlap hold refused that exact run 23 s later).
2133
2250
  const started = runByTask.get(taskId);
2134
- repeatingCauseNoted.add(taskId);
2135
- const earlyMs = Date.now() - startedAt;
2136
- waitedByTask.set(taskId, waitedBefore + earlyMs);
2137
- return {
2138
- taskId,
2139
- status: 'repeating_failure',
2140
- waitedMs: earlyMs,
2141
- note: repeatingFailureNote(found, {
2251
+ if (stopCause) {
2252
+ // …and only now is the pid walk paid for.
2253
+ repeatingCauseNoted.add(taskId);
2254
+ const earlyMs = Date.now() - startedAt;
2255
+ waitedByTask.set(taskId, waitedBefore + earlyMs);
2256
+ return {
2257
+ taskId,
2258
+ status: 'repeating_failure',
2259
+ waitedMs: earlyMs,
2260
+ note: repeatingFailureNote(found, {
2261
+ taskId,
2262
+ log,
2263
+ stop: await taskStopCommand(backend, taskId),
2264
+ universal: stopCause,
2265
+ ...(started ? { run: started } : {}),
2266
+ }),
2267
+ };
2268
+ }
2269
+ // A lead: this wait goes on, and the finding rides its result as a note — the executor has
2270
+ // to keep waiting for the run's own result, so it must not be told one in the status. The
2271
+ // look keeps running for the same reason: an app that goes down mid-run turns a shared
2272
+ // duration into a proven whole-run cause, and that one is worth returning early for.
2273
+ if (!midRunLeadNoted.has(taskId)) {
2274
+ midRunLeadNoted.add(taskId);
2275
+ midRunNote = repeatingFailureNote(found, {
2142
2276
  taskId,
2143
2277
  log,
2144
- stop: universal ? await taskStopCommand(backend, taskId) : '',
2145
- universal,
2278
+ stop: '',
2279
+ universal: null,
2146
2280
  ...(started ? { run: started } : {}),
2147
- }),
2148
- };
2281
+ });
2282
+ }
2149
2283
  }
2150
2284
  const waitedMs = Date.now() - startedAt;
2151
2285
  waitedByTask.set(taskId, waitedBefore + waitedMs);
@@ -2188,6 +2322,13 @@ export function buildTools(backend, config) {
2188
2322
  waitedMs: waitedBefore + waitedMs,
2189
2323
  }));
2190
2324
  const sliceField = soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {};
2325
+ // What this wait found mid-run, when it was not a reason to end early: it rides the result
2326
+ // the wait is about to hand back, because that result is where the executor decides what to
2327
+ // do next and a lead it never saw is a lead it cannot weigh. Not on the `stuck` answer
2328
+ // below — that verdict is about the command having stopped PRINTING and carries its own
2329
+ // instruction — and not on a finished run's result, where `failureCausesNote` reads the
2330
+ // whole log instead of the 40 KB tail a mid-run look is given.
2331
+ const midRun = midRunNote ? ` ${midRunNote}` : '';
2191
2332
  if (stillWriting && waitedBefore + waitedMs >= stuckAfterMs()) {
2192
2333
  waitedByTask.set(taskId, 0);
2193
2334
  return {
@@ -2196,7 +2337,7 @@ export function buildTools(backend, config) {
2196
2337
  waitedMs,
2197
2338
  ...sliceField,
2198
2339
  note: `${progress} Call wait_for_task again to keep waiting; peek with exec_command: ` +
2199
- `tail ${log}.`,
2340
+ `tail ${log}.${midRun}`,
2200
2341
  };
2201
2342
  }
2202
2343
  if (waitedBefore + waitedMs >= stuckAfterMs()) {
@@ -2223,9 +2364,11 @@ export function buildTools(backend, config) {
2223
2364
  (soFar.trim()
2224
2365
  ? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
2225
2366
  `the log, not the whole run — \`grep -n '✘' ${log} | head -40\` lists every failure, ` +
2226
- `\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error. `
2367
+ `\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error: this run writes ` +
2368
+ `each failure's own report into the log as that test fails, right under its ✘ line, ` +
2369
+ `not only in the summary at the end. `
2227
2370
  : `Nothing has been printed yet — \`grep -n '✘' ${log} | head -40\` lists failures as they appear. `) +
2228
- `Meanwhile do work that leaves the code under test alone (read the failures so far, plan the fixes): editing app/src or api/src now changes the code the run is testing, and its result no longer counts.`,
2371
+ `Meanwhile do work that leaves the code under test alone (read the failures so far, plan the fixes): editing app/src or api/src now changes the code the run is testing, and its result no longer counts.${midRun}`,
2229
2372
  };
2230
2373
  }
2231
2374
  let output;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * What a failed Playwright run's own trace files say about the requests its page made — read
3
+ * headlessly, in the sandbox, from the `trace.zip` files the run left behind.
4
+ *
5
+ * X1 xr-molecule-flash-m100 (+51:19 → +53:00, 2026-10-16) ended with three failures against the
6
+ * deployed URL — `social.spec.ts:22` and `:153` waited for a "Unfollow" button that never appeared
7
+ * after the click, and `url-navigation.spec.ts:234` was bounced to `/login` mid-test — and nothing
8
+ * in the run's result said why. The app's own `toggleFollow` swallowed the error of its request
9
+ * with a bare `catch` and a comment, so the page showed nothing; the ONE artifact
10
+ * that held the answer, the failing tests' own traces, was never read. The executor spent the
11
+ * last minutes of the run writing throwaway Chromium probes against localhost — where the same
12
+ * tests passed — and finished chasing an artifact of its own probe
13
+ * (`waitForURL('/')` resolved against a `baseURL` its script did not have).
14
+ *
15
+ * A trace is not a debugging aid that has to be enabled by hand: Playwright writes the request
16
+ * log and the console into `test-results/<test>/trace.zip` whenever a run retains traces, and the
17
+ * evidence a swallowed failure hides is exactly one line of it — `POST /api/articles/x/favorite →
18
+ * 401`. So the platform reads it (bounded, per test) and puts it in the run's result
19
+ * ({@link failureTraceNote}), instead of leaving the executor to build its own browser to find out.
20
+ *
21
+ * Everything here is text in, text out: the sandbox runs {@link traceArtifactsScript} (Node, no
22
+ * dependencies — Playwright's zip reader is not importable from a project) and prints one
23
+ * `TRACE <json>` line per trace; the platform parses those lines ({@link parseTraceArtifacts}) and
24
+ * writes the note ({@link failureTraceNote}). Both halves are covered by a test that runs the
25
+ * script against a `trace.zip` produced by a real Playwright run, in the sandbox's own shape.
26
+ *
27
+ * @module
28
+ */
29
+ /** One request a traced page made, as `*.network` records it. */
30
+ export interface TraceRequest {
31
+ /** The HTTP method (`POST`). */
32
+ method: string;
33
+ /** The request's path (`/api/articles/x/favorite`), or the whole URL when it is cross-origin. */
34
+ path: string;
35
+ /** The response status, or 0 when the request never got a response. */
36
+ status: number;
37
+ }
38
+ /** What one trace says: the test it belongs to, the requests it made, and its console errors. */
39
+ export interface TraceArtifact {
40
+ /** The failure's spec file, relative to the run's directory, when the trace names one. */
41
+ spec: string | null;
42
+ /** The line of the test's last traced step, 0 when the trace names no spec. */
43
+ line: number;
44
+ /** The requests the page made, the interesting ones first ({@link traceArtifactsScript}). */
45
+ requests: TraceRequest[];
46
+ /** The console errors and page errors the page raised. */
47
+ console: string[];
48
+ }
49
+ /** How many traces one read reports, newest first. */
50
+ export declare const MAX_TRACE_ARTIFACTS = 12;
51
+ /** How many requests one test's trace contributes. */
52
+ export declare const MAX_TRACED_REQUESTS = 6;
53
+ /** How many console errors one test's trace contributes. */
54
+ export declare const MAX_TRACED_CONSOLE = 4;
55
+ /** How many tests the note in a run's result names. */
56
+ export declare const MAX_TRACE_NOTE_TESTS = 5;
57
+ /** How many requests the note names per test. */
58
+ export declare const MAX_TRACE_NOTE_REQUESTS = 4;
59
+ /** How long a URL the note prints, before the middle is cut. */
60
+ export declare const TRACE_NOTE_URL_CHARS = 96;
61
+ /**
62
+ * The Node program the sandbox runs to read a failed run's traces: `node <script> <dir>` prints one
63
+ * `TRACE <json>` line per `trace.zip` under `<dir>` (newest first, {@link MAX_TRACE_ARTIFACTS} of
64
+ * them), each with the test it belongs to, the requests its page made and its console errors.
65
+ *
66
+ * It is a standalone program, not an import, for the reason `@molecule/api-code-sandbox` runs it:
67
+ * Playwright's own zip reader is not reachable from a project's `node_modules`, and a dependency
68
+ * on `unzip` would make the read fail on every sandbox that has none. The zip is read with the
69
+ * central directory and `zlib.inflateRawSync` — about forty lines, and the only thing that has to
70
+ * be true of a `trace.zip` is that it IS one.
71
+ *
72
+ * A request is kept over a static asset when it failed (`status >= 400`) or never answered
73
+ * (`status === 0`), and over anything else when it is the page talking to the app
74
+ * (`fetch`/`xhr`/`document`): a failing test's trace holds the whole page load, and the answer is
75
+ * always at the end of it.
76
+ *
77
+ * @returns The program's source, to write to a file and run.
78
+ */
79
+ export declare const traceArtifactsScript: () => string;
80
+ /**
81
+ * The traces {@link traceArtifactsScript} printed, parsed.
82
+ *
83
+ * A line that is not a complete `TRACE <json>` (a truncated read, a program that died mid-write) is
84
+ * dropped: a partial trace is not evidence about the test it came from.
85
+ *
86
+ * @param stdout - The program's output.
87
+ * @returns One entry per trace it printed.
88
+ */
89
+ export declare function parseTraceArtifacts(stdout: string): TraceArtifact[];
90
+ /**
91
+ * The note a failed suite run's result carries: what each failing test's page actually sent, and
92
+ * what came back.
93
+ *
94
+ * This is the answer to the question a swallowed error hides — a click that changed nothing is
95
+ * usually a request that failed — so the note leads with the failing requests and says what to do
96
+ * with them: read the handler the route reaches, not the selector. It is BOUNDED to
97
+ * {@link MAX_TRACE_NOTE_TESTS} tests with {@link MAX_TRACE_NOTE_REQUESTS} requests each, because it
98
+ * rides in a result the model pays for.
99
+ *
100
+ * @param items - The traces {@link parseTraceArtifacts} read.
101
+ * @param target - Where the run went — the deployed host, as the note names it.
102
+ * @returns The note, or null when no trace held a request or a console error.
103
+ */
104
+ export declare function failureTraceNote(items: readonly TraceArtifact[], target: string): string | null;
105
+ /**
106
+ * Whether a Playwright config records traces for the tests that fail.
107
+ *
108
+ * Only `on` and `retain-on-failure` keep a `trace.zip` for a failure whatever the retries do: the
109
+ * two retry modes need a retry to happen at all, and a diagnosis run is given `--retries=0`
110
+ * (`streamingBackgroundCommand`), so on X1 xr-molecule-flash-m100 the run's own
111
+ * `trace: 'on-first-retry'` recorded nothing — which is what left the failing tests' network log
112
+ * unreadable. A config that sets no `trace:` at all records nothing either (Playwright's default
113
+ * is off); so does one that cannot be read.
114
+ *
115
+ * @param configText - The config's source, or '' when none could be read.
116
+ * @returns True when the config records traces for failures.
117
+ */
118
+ export declare function suiteRecordsTraces(configText: string): boolean;
119
+ /** The `--trace` flag this platform adds, and the only value it adds it with. */
120
+ export declare const DIAGNOSIS_TRACE_FLAG = "--trace=retain-on-failure";
121
+ /** A command with the trace flag this platform decided to add, and the sentence to report it in. */
122
+ export interface DiagnosisTraceRewrite {
123
+ /** The command to run. */
124
+ command: string;
125
+ /** One sentence naming the flag and why, or null when nothing was added. */
126
+ change: string | null;
127
+ }
128
+ /**
129
+ * The command a DIAGNOSIS run needs to leave a readable trace behind, when the suite's config
130
+ * would record none.
131
+ *
132
+ * The failing tests' requests are only readable if the run wrote them down (see the module doc),
133
+ * and the config is where the decision about that lives — the platform does not edit the suite's
134
+ * config, so it passes the flag instead, and only where it can do no harm: on a run narrowed to a
135
+ * part of the suite or capped ({@link isDiagnosisRun}), never on the whole-suite proof run, and
136
+ * never over a `--trace` the executor wrote itself. A capped walk is exactly the run whose
137
+ * failures someone is about to work on; the proof run is graded as it is.
138
+ *
139
+ * @param args - The command, the directory it starts in, and the backend's file read.
140
+ * @returns The command to run and the sentence to report, or the command unchanged.
141
+ */
142
+ export declare function diagnosisTraceCommand(args: {
143
+ command: string;
144
+ cwd: string;
145
+ readFile: (path: string) => Promise<string>;
146
+ }): Promise<DiagnosisTraceRewrite>;
147
+ //# sourceMappingURL=trace-artifacts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAKH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED,iGAAiG;AACjG,MAAM,WAAW,aAAa;IAC5B,0FAA0F;IAC1F,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAA;IACZ,6FAA6F;IAC7F,QAAQ,EAAE,YAAY,EAAE,CAAA;IACxB,0DAA0D;IAC1D,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,KAAK,CAAA;AAErC,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MA+LvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAyBnE;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4B/F;AAaD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA;AAuE/D,oGAAoG;AACpG,MAAM,WAAW,qBAAqB;IACpC,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,4EAA4E;IAC5E,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5C,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAgBjC"}