@molecule/api-ai-tools 1.8.2 → 1.9.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/dist/tools.js CHANGED
@@ -10,6 +10,7 @@ import { commandCutRun, CUT_AT_BUDGET, cutRunSentence, suiteRunExitCode } from '
10
10
  import { addChangeSpan, changedSpan, countLines, renderEditExcerpt, renderSmallFile, } from './edit-excerpt.js';
11
11
  import { guardToolExecute, missingParamError, ToolInputError } from './input-normalizer.js';
12
12
  import { TOOL_SCHEMAS } from './schemas.js';
13
+ import { leadingSleepPeek } from './sleep-peek.js';
13
14
  /**
14
15
  * Total waiting, per background task, after which wait_for_task reports it
15
16
  * stuck instead of waiting again. Env-tunable so a test can exercise it.
@@ -44,6 +45,36 @@ const runByTask = new Map();
44
45
  const FILTERED_EXIT_NOTE = 'No exit code is reported for this run: a stage after the test runner produced the pipeline’s ' +
45
46
  'code (or the code says 0 beside a failure in the tally), so read the pass/fail tally in the ' +
46
47
  'log instead.';
48
+ /**
49
+ * What a finished run says when its suite sits inside a `sh -c '<command>'` string: the stages are
50
+ * positions in THAT string, so the platform cannot point at one in the command's own text. Said
51
+ * instead of {@link FILTERED_EXIT_NOTE} — that note names a stage after the runner, and this run
52
+ * cannot be read as having one; a command with nothing piped after the runner keeps its code.
53
+ */
54
+ const UNPLACED_KEPT_NOTE = 'The suite sits inside a `sh -c` string, so its stages cannot be placed in the command’s own ' +
55
+ 'text; nothing follows the runner there, so the exit code above is the run’s own — read the ' +
56
+ 'pass/fail tally in the log for the verdict.';
57
+ /** {@link UNPLACED_KEPT_NOTE} when between them a stage after the runner produced the code. */
58
+ const UNPLACED_PIPED_NOTE = 'The suite sits inside a `sh -c` string, so its stages cannot be placed in the command’s own ' +
59
+ 'text, and a stage after the runner may have produced the pipeline’s code: none is reported, ' +
60
+ 'so read the pass/fail tally in the log instead.';
61
+ /**
62
+ * The sentence a finished suite run's result carries about its exit code, or null when the code it
63
+ * reports is the run's own and needs no explanation.
64
+ *
65
+ * {@link FILTERED_EXIT_NOTE} names a stage after the runner, so it is said only when the parse
66
+ * found one; a run whose stages cannot be placed says THAT instead, and keeps the command's own
67
+ * code when nothing follows the runner.
68
+ *
69
+ * @param placement - Where the code came from ({@link suiteRunPipelinePlacement}).
70
+ * @param reported - The code the result reports, or null when it reports none.
71
+ * @returns The note, or null.
72
+ */
73
+ function suiteExitCodeNote(placement, reported) {
74
+ if (placement.unplaced)
75
+ return reported === null ? UNPLACED_PIPED_NOTE : UNPLACED_KEPT_NOTE;
76
+ return reported === null ? FILTERED_EXIT_NOTE : null;
77
+ }
47
78
  /**
48
79
  * Task ids whose own-output file has already been looked for this process.
49
80
  * {@link outputFileByTask} is empty for every task after a restart — it is
@@ -229,8 +260,106 @@ function runningNote(clauses) {
229
260
  const joined = clauses.join('; ');
230
261
  return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
231
262
  }
263
+ /**
264
+ * The background task a leading `sleep` was about to peek at, and its state now — or null when the
265
+ * command has no task of this process's to report, in which case the sleep runs as written (the
266
+ * tool's own budget bounds it, as before).
267
+ *
268
+ * X1 xr-molecule-flash-m105 (+08:49 → +10:53, 2026-10-17) hand-polled with `sleep N; tail …`
269
+ * three times. The state the executor wanted was always one the platform already held: two tasks
270
+ * had ended 0.1 s in (exit 127, and `Error: No tests found`) and the third was live at 72 of 139
271
+ * with failures the log already carried. So a command that opens with a long sleep is answered
272
+ * from the same reads `wait_for_task` makes — the exit file, and the failure-lines-plus-tail slice
273
+ * of the log — and the sleep is dropped.
274
+ *
275
+ * The command names WHAT it is peeking at (`/tmp/<id>.log`, or the file the task redirects its own
276
+ * output to), and that task is the answer whether it has ended or not: the two dead peeks are
277
+ * exactly this shape. With no name to follow, the newest LIVE task is the answer — the point of
278
+ * the rule is that a sleep never outlives a run it could have been waiting on — and with no live
279
+ * task there is nothing to report.
280
+ *
281
+ * @param backend - The execution backend the task runs on.
282
+ * @param command - The command whose leading sleep is being replaced.
283
+ * @param seconds - The seconds that leading sleep asked for.
284
+ * @param ceilingMs - The wait ceiling a `wait_for_task` would have, so the ETA it states is the
285
+ * one an actual wait would act on.
286
+ * @returns One sentence naming the task's state and how to get its result, plus the log slice the
287
+ * caller attaches as `outputSoFar`; null when no task answers this command.
288
+ */
289
+ async function backgroundTaskState(backend, command, seconds, ceilingMs) {
290
+ const ids = [...runByTask.keys()];
291
+ if (!ids.length)
292
+ return null;
293
+ const exitCodeOf = async (id) => {
294
+ try {
295
+ const raw = await backend.readFile(`/tmp/${id}.exit`);
296
+ const code = Number.parseInt(raw.trim(), 10);
297
+ return Number.isFinite(code) ? code : 0;
298
+ }
299
+ catch (_error) {
300
+ // No exit file yet: the task is still running. Not an error.
301
+ return null;
302
+ }
303
+ };
304
+ const names = (id) => {
305
+ if (command.includes(`/tmp/${id}.log`))
306
+ return true;
307
+ const own = outputFileByTask.get(id);
308
+ return own !== undefined && command.includes(own);
309
+ };
310
+ const newestFirst = [...ids].reverse();
311
+ const namedId = newestFirst.find(names);
312
+ let target = namedId
313
+ ? { id: namedId, exitCode: await exitCodeOf(namedId) }
314
+ : null;
315
+ if (!target) {
316
+ // Bounded: a long session can leave many finished tasks behind, and each look costs a read.
317
+ for (const id of newestFirst.slice(0, 8)) {
318
+ const exitCode = await exitCodeOf(id);
319
+ if (exitCode === null) {
320
+ target = { id, exitCode };
321
+ break;
322
+ }
323
+ }
324
+ }
325
+ if (!target)
326
+ return null;
327
+ let slice = '';
328
+ try {
329
+ const r = await backend.run(runningOutputSliceCommand(`/tmp/${target.id}.log`, outputFileByTask.get(target.id)), { timeout: 5_000 });
330
+ slice = r.stdout ?? '';
331
+ }
332
+ catch (_error) {
333
+ // No slice: the note still says the task's state and how to get its result.
334
+ }
335
+ const secondsText = `Did not sleep ${Math.round(seconds)} s: background task \`${target.id}\``;
336
+ if (target.exitCode === null) {
337
+ const facts = waitProgressClauses({
338
+ taskId: target.id,
339
+ slice,
340
+ stillWriting: false,
341
+ bytes: null,
342
+ ceilingMs,
343
+ waitedMs: 0,
344
+ });
345
+ const failures = slice.trim() ? '; the failures already in its log are in outputSoFar' : '';
346
+ return {
347
+ note: `${secondsText} is live (${facts.join('; ')})${failures} — ` +
348
+ `\`wait_for_task ${target.id}\` returns its result the moment it ends.`,
349
+ slice,
350
+ };
351
+ }
352
+ const counter = logProgress(slice);
353
+ const progress = counter ? `, at ${counter.done} of ${counter.total} in its log` : '';
354
+ const failures = slice.trim() ? '; the failures in its log are in outputSoFar' : '';
355
+ return {
356
+ note: `${secondsText} had already ended (exit code ${target.exitCode})${progress}${failures} — ` +
357
+ `\`wait_for_task ${target.id}\` returns its result now.`,
358
+ slice,
359
+ };
360
+ }
232
361
  import { diagnosisTraceCommand } from './trace-artifacts.js';
233
- 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, suiteRunPipedAfterRunner, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
362
+ 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, suiteRunPipelinePlacement, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
234
363
  /**
235
364
  * How long exec_command waits for a command it backgrounded only because the
236
365
  * command asked for more than the tool's ceiling, before handing back a task id.
@@ -298,8 +427,51 @@ export function moleculeDistReadNote(path) {
298
427
  : ' For one signature, read the matching .d.ts file.'));
299
428
  }
300
429
  /**
301
- * The refusal for a write whose target is inside an installed package, or null
302
- * when the path is not under a `node_modules` directory.
430
+ * The `node_modules` entry a path goes through, and the package name after it
431
+ * (`null` when the path IS the entry).
432
+ *
433
+ * @param path - An absolute path.
434
+ * @returns The entry and the name, or null when no `node_modules` component is in the path.
435
+ */
436
+ function nodeModulesParts(path) {
437
+ // A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
438
+ // hoisted root one: both can be installed trees.
439
+ const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
440
+ if (!at)
441
+ return null;
442
+ const head = path.slice(0, at.index);
443
+ const entry = `${head}${head === '' || head.endsWith('/') ? '' : '/'}node_modules`;
444
+ const named = /node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path.slice(at.index));
445
+ return { entry, name: named?.[1] ?? null };
446
+ }
447
+ /**
448
+ * Build an {@link InstalledPackageProbe} from a shell runner (a backend's
449
+ * `run`, or a sandbox's `exec`): one command reports whether the entry is a
450
+ * link and whether a `package.json` resolves under it.
451
+ *
452
+ * @param run - Runs a command where the path lives.
453
+ * @returns The probe.
454
+ */
455
+ export function shellInstalledPackageProbe(run) {
456
+ return async (path) => {
457
+ const parts = nodeModulesParts(path);
458
+ if (!parts)
459
+ return { symlink: false, installed: false };
460
+ const { entry, name } = parts;
461
+ // A bare `@scope` is not a package, but removing it takes every package under
462
+ // it: treat the scope directory like the entry itself and look beneath it.
463
+ const scopeOnly = name !== null && /^@[^/]+$/.test(name);
464
+ const install = name !== null && !scopeOnly
465
+ ? `if [ -e ${shellQuote(`${entry}/${name}/package.json`)} ]; then echo installed; fi`
466
+ : `if find ${shellQuote(name === null ? entry : `${entry}/${name}`)} -maxdepth 3 -name package.json -print -quit 2>/dev/null | grep -q .; then echo installed; fi`;
467
+ const out = String((await run(`if [ -L ${shellQuote(entry)} ]; then echo symlink; fi; ${install}`))?.stdout ??
468
+ '');
469
+ return { symlink: out.includes('symlink'), installed: out.includes('installed') };
470
+ };
471
+ }
472
+ /**
473
+ * The refusal for a write whose target is inside an INSTALLED package, or null
474
+ * when the path is not under `node_modules` or nothing is installed there.
303
475
  *
304
476
  * `node_modules` is not the project's code: nothing written there survives the
305
477
  * next install, no deploy carries it, and a sandbox whose dependency tree is
@@ -312,6 +484,19 @@ export function moleculeDistReadNote(path) {
312
484
  * putting the file back. Reading these files stays allowed (that is how the
313
485
  * declarations above are read at all).
314
486
  *
487
+ * What is protected is the INSTALL, not the spelling: the decision comes from
488
+ * {@link InstalledPackageFacts}, never from the path alone (m106 above). Three
489
+ * things are the run's own to remove or replace —
490
+ *
491
+ * - a `node_modules` that is a SYMLINK: `rm`/`unlink` removes the link, never the
492
+ * tree it points at, so a link the run made cannot clobber anything;
493
+ * - anything under the run's own `.mol-scratch/` directory;
494
+ * - a `node_modules` with no `package.json` under it (nothing was ever installed).
495
+ *
496
+ * A path INSIDE the link is still decided by what resolves there: a link into a
497
+ * real tree is refused, because removing or writing through it reaches the
498
+ * install.
499
+ *
315
500
  * The restore route is named because the model that has ALREADY clobbered a file
316
501
  * (this refusal is too late for it) is the one who needs it, and because the
317
502
  * obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
@@ -319,19 +504,34 @@ export function moleculeDistReadNote(path) {
319
504
  * m91 used, and what both guards leave alone.
320
505
  *
321
506
  * @param path - An absolute path a tool is about to write.
507
+ * @param probe - Looks at the filesystem where the path lives.
322
508
  * @returns The one-line refusal naming the package, or null when nothing is installed there.
323
509
  */
324
- export function installedPackageWriteRefusal(path) {
325
- // A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
326
- // hoisted root one: both are installed packages.
327
- const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
328
- if (!at)
510
+ export async function installedPackageWriteRefusal(path, probe) {
511
+ const parts = nodeModulesParts(path);
512
+ if (!parts)
329
513
  return null;
330
- const named = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path);
331
- const name = named?.[1] ?? null;
332
- const scoped = at[0].startsWith('/') ? path.slice(0, at.index) : '';
333
- const dir = name ? `${scoped}/node_modules/${name}` : null;
334
- const what = name ? `the installed package ${name}` : 'an installed package';
514
+ const { entry, name } = parts;
515
+ // The run's own scratch directory is the run's to clean: the platform tells it
516
+ // to put throwaway work there (system prompt, "Throwaway probes go in
517
+ // `.mol-scratch/`"), so nothing in it is the pre-installed tree.
518
+ if (/(?:^|\/)\.mol-scratch(?:\/|$)/.test(path))
519
+ return null;
520
+ // No probe, or a probe that could not answer: keep the old, conservative
521
+ // reading (everything under node_modules is an install).
522
+ const facts = probe ? await probe(path).catch(() => null) : null;
523
+ const symlink = facts?.symlink ?? false;
524
+ const installed = facts?.installed ?? true;
525
+ // The entry itself — the link (or a tree with nothing in it) the run may take
526
+ // away. Removing a symlink deletes the LINK only; its target is untouched.
527
+ if (name === null && (symlink || !installed))
528
+ return null;
529
+ // A path under the entry: `installed` resolved through any link, so it is true
530
+ // exactly when a write here would land in a real installation.
531
+ if (name !== null && !installed)
532
+ return null;
533
+ const dir = name ? `${entry}/${name}` : null;
534
+ const what = name ? `the installed package ${name}` : 'an installed node_modules tree';
335
535
  const change = 'Installed packages are read-only: the next install replaces that file and a deploy never ' +
336
536
  'carries it. Change its behaviour from your app instead (its options, your own handler, a ' +
337
537
  'bond), and where the package itself is wrong, report it rather than patching it here. ' +
@@ -488,9 +688,11 @@ export function buildTools(backend, config) {
488
688
  * @param path - Relative or absolute path requested by the tool input.
489
689
  * @returns The normalized path, when writing it is allowed.
490
690
  */
491
- function resolveWrite(path) {
691
+ async function resolveWrite(path) {
492
692
  const resolved = resolve(path);
493
- const refusal = installedPackageWriteRefusal(resolved);
693
+ // The guard decides from what is installed where the path lives, so the
694
+ // backend is asked (one command, only for a path that names node_modules).
695
+ const refusal = await installedPackageWriteRefusal(resolved, shellInstalledPackageProbe((command) => backend.run(command, { timeout: 8000 })));
494
696
  if (refusal)
495
697
  throw new ToolInputError(refusal);
496
698
  return resolved;
@@ -1046,7 +1248,7 @@ export function buildTools(backend, config) {
1046
1248
  const argErr = pathArgError(input.path, 'write_file');
1047
1249
  if (argErr)
1048
1250
  return { error: argErr };
1049
- const path = resolveWrite(input.path);
1251
+ const path = await resolveWrite(input.path);
1050
1252
  const content = input.content;
1051
1253
  if (content.length > MAX_WRITE_SIZE)
1052
1254
  return {
@@ -1095,7 +1297,7 @@ export function buildTools(backend, config) {
1095
1297
  const argErr = pathArgError(input.path, 'edit_file');
1096
1298
  if (argErr)
1097
1299
  return { error: argErr };
1098
- const path = resolveWrite(input.path);
1300
+ const path = await resolveWrite(input.path);
1099
1301
  // Support both formats: { replacements: [...] } (batch) and { old_string, new_string } (single, backwards-compatible)
1100
1302
  let replacements = input.replacements;
1101
1303
  // Accept the single format when BOTH are strings — `typeof` (not truthy) so
@@ -1729,7 +1931,7 @@ export function buildTools(backend, config) {
1729
1931
  }
1730
1932
  },
1731
1933
  async create_directory(input) {
1732
- const path = resolveWrite(input.path);
1934
+ const path = await resolveWrite(input.path);
1733
1935
  const parentPath = path.substring(0, path.lastIndexOf('/')) || root;
1734
1936
  const symlinkErr = await checkSymlink(parentPath);
1735
1937
  if (symlinkErr)
@@ -1747,8 +1949,8 @@ export function buildTools(backend, config) {
1747
1949
  async rename_file(input) {
1748
1950
  // Both ends: moving a package's file OUT is as much a change to the installed
1749
1951
  // package as moving one in.
1750
- const oldPath = resolveWrite(input.old_path);
1751
- const newPath = resolveWrite(input.new_path);
1952
+ const oldPath = await resolveWrite(input.old_path);
1953
+ const newPath = await resolveWrite(input.new_path);
1752
1954
  const oldSymlinkErr = await checkSymlink(oldPath);
1753
1955
  if (oldSymlinkErr)
1754
1956
  return { error: oldSymlinkErr };
@@ -1770,7 +1972,7 @@ export function buildTools(backend, config) {
1770
1972
  }
1771
1973
  },
1772
1974
  async delete_file(input) {
1773
- const path = resolveWrite(input.path);
1975
+ const path = await resolveWrite(input.path);
1774
1976
  const symlinkErr = await checkSymlink(path);
1775
1977
  if (symlinkErr)
1776
1978
  return { error: symlinkErr };
@@ -1820,7 +2022,28 @@ export function buildTools(backend, config) {
1820
2022
  cwd,
1821
2023
  readFile: (path) => backend.readFile(path),
1822
2024
  });
1823
- const command = traced.command;
2025
+ let command = traced.command;
2026
+ // A leading `sleep N; <read of a background task's log>` is a hand-rolled poll, and the
2027
+ // platform knows the task's state better than the log's tail does (see
2028
+ // {@link backgroundTaskState}): X1 xr-molecule-flash-m105 slept 225 s this way, two of the
2029
+ // three on tasks that had already ended. The sleep is dropped and the rest of the command
2030
+ // runs at once; with no background task behind it, the sleep is left exactly as sent — the
2031
+ // tool's own budget is the existing bound.
2032
+ const sleepPeek = leadingSleepPeek(command);
2033
+ const peekedTask = sleepPeek
2034
+ ? await backgroundTaskState(backend, command, sleepPeek.seconds, Math.max(5_000, (commandBudgetMs ?? 290_000) - 10_000))
2035
+ : null;
2036
+ const sleepNote = peekedTask?.note ?? null;
2037
+ if (sleepPeek && peekedTask) {
2038
+ command = sleepPeek.rest;
2039
+ if (!command) {
2040
+ return {
2041
+ status: 'task_state',
2042
+ note: peekedTask.note,
2043
+ ...(peekedTask.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2044
+ };
2045
+ }
2046
+ }
1824
2047
  // Detached: start it, hand back a handle, let the turn continue. A
1825
2048
  // command that outlives the tool's ceiling is otherwise simply lost —
1826
2049
  // measured across six agent runs, ten of them were killed at the
@@ -1937,18 +2160,22 @@ export function buildTools(backend, config) {
1937
2160
  ...(output.length > MAX_OUTPUT_SIZE
1938
2161
  ? { truncated: true, fullOutput: log }
1939
2162
  : {}),
1940
- error: `${cutRunSentence(cut)} ` +
2163
+ error: (sleepNote ? `${sleepNote} ` : '') +
2164
+ `${cutRunSentence(cut)} ` +
1941
2165
  (isSuiteRunCommand(command) ? SUITE_OVER_BUDGET_ADVICE : SPLIT_WORK_ADVICE),
1942
2166
  };
1943
2167
  }
1944
2168
  // The exit file holds the PIPELINE's code: when a stage this rewrite could not
1945
2169
  // prove is output-only still follows the suite runner (`| tee run.log`), that code
1946
- // is the filter's — report none and let the log's tally decide (m101 +25:54).
1947
- const piped = suiteRunPipedAfterRunner(streaming.command);
1948
- const reported = suiteRunExitCode(code, output, piped);
2170
+ // is the filter's — report none and let the log's tally decide (m101 +25:54). The
2171
+ // note says this only when the parse found such a stage (m105 +08:12).
2172
+ const placement = suiteRunPipelinePlacement(streaming.command);
2173
+ const reported = suiteRunExitCode(code, output, placement.piped);
2174
+ const exitNote = suiteExitCodeNote(placement, reported);
1949
2175
  const notes = [
2176
+ ...(sleepNote ? [sleepNote] : []),
1950
2177
  ...streaming.changes,
1951
- ...(reported === null ? [FILTERED_EXIT_NOTE] : []),
2178
+ ...(exitNote === null ? [] : [exitNote]),
1952
2179
  ];
1953
2180
  return {
1954
2181
  stdout,
@@ -1965,22 +2192,25 @@ export function buildTools(backend, config) {
1965
2192
  taskId: id,
1966
2193
  log,
1967
2194
  exitFile,
1968
- note: (overCeiling
1969
- ? declaredSeconds > ceilingSeconds
1970
- ? `This command asks for ${declaredSeconds}s, more than this tool's ${ceilingSeconds}s, and its ` +
1971
- 'output only appears when it ends — so it was started in the background instead. '
1972
- : `A Playwright run can take longer than this tool's ${ceilingSeconds}s, and behind a pipe its ` +
1973
- 'output only appears when it ends — so it was started in the background instead. '
1974
- : '') +
2195
+ ...(peekedTask?.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2196
+ note: (sleepNote ? `${sleepNote} ` : '') +
2197
+ (overCeiling
2198
+ ? declaredSeconds > ceilingSeconds
2199
+ ? `This command asks for ${declaredSeconds}s, more than this tool's ${ceilingSeconds}s, and its ` +
2200
+ 'output only appears when it ends — so it was started in the background instead. '
2201
+ : `A Playwright run can take longer than this tool's ${ceilingSeconds}s, and behind a pipe its ` +
2202
+ 'output only appears when it ends — so it was started in the background instead. '
2203
+ : '') +
1975
2204
  `Started in the background. Its output is written to ${log} and comes back as the ` +
1976
- `stdout of wait_for_task when the command finishes (read_file cannot reach /tmp — ` +
1977
- `to peek mid-run, exec_command: tail ${log}). If you have other work, do it and ` +
1978
- `check back. If you need this result before you can continue, call wait_for_task ` +
1979
- `with this taskId — never sleep and read the log by hand.` +
2205
+ `stdout of wait_for_task when the command finishes (read_file cannot reach /tmp). If ` +
2206
+ `you have other work, do it and check back. If you need this result before you can ` +
2207
+ `continue, call wait_for_task with this taskId: it returns the moment the command ` +
2208
+ `ends, returns early when a failure cause repeats, and reports progress and the ` +
2209
+ `failures so far — never sleep and read the log by hand.` +
1980
2210
  (streaming.changes.length ? ` ${streaming.changes.join(' ')}` : '') +
1981
2211
  (prioritized.note ? ` ${prioritized.note}` : '') +
1982
2212
  (ownFile
1983
- ? ` This command writes its own output to ${ownFile}, so the task log stays empty: peek with exec_command: tail ${ownFile}.`
2213
+ ? ` This command writes its own output to ${ownFile}, so the task log stays empty: wait_for_task reads that file when the command ends and reports its progress while it runs — never sleep and tail it by hand.`
1984
2214
  : ''),
1985
2215
  };
1986
2216
  }
@@ -2063,7 +2293,8 @@ export function buildTools(backend, config) {
2063
2293
  // The facts, so a reader that can also see the suite itself (its own `.hurl` file
2064
2294
  // count) says the same sentence with the total the output alone cannot give.
2065
2295
  cutRun: cut,
2066
- error: `${cutLead} ` +
2296
+ error: (sleepNote ? `${sleepNote} ` : '') +
2297
+ `${cutLead} ` +
2067
2298
  // The tool's own budget stopping a run that printed nothing: keep the sentence that
2068
2299
  // says why the output is empty, since nothing else in the result can say it.
2069
2300
  (cut.stoppedBy === 'tool-budget'
@@ -2078,8 +2309,11 @@ export function buildTools(backend, config) {
2078
2309
  stdout,
2079
2310
  stderr,
2080
2311
  exitCode: result.exitCode,
2081
- ...(ceilingNote || traced.change
2082
- ? { note: [ceilingNote, traced.change].filter(Boolean).join(' ') }
2312
+ ...(peekedTask?.slice.trim() ? { outputSoFar: sanitizeOutput(peekedTask.slice) } : {}),
2313
+ ...(sleepNote || ceilingNote || traced.change
2314
+ ? {
2315
+ note: [sleepNote, ceilingNote, traced.change].filter(Boolean).join(' '),
2316
+ }
2083
2317
  : {}),
2084
2318
  };
2085
2319
  }
@@ -2383,8 +2617,8 @@ export function buildTools(backend, config) {
2383
2617
  status: 'running',
2384
2618
  waitedMs,
2385
2619
  ...sliceField,
2386
- note: `${progress} Call wait_for_task again to keep waiting; peek with exec_command: ` +
2387
- `tail ${log}.${midRun}`,
2620
+ note: `${progress} Call wait_for_task again to keep waiting — it returns the moment the ` +
2621
+ `command ends and reports the failures as they appear.${midRun}`,
2388
2622
  };
2389
2623
  }
2390
2624
  if (waitedBefore + waitedMs >= stuckAfterMs()) {
@@ -2484,10 +2718,13 @@ export function buildTools(backend, config) {
2484
2718
  // Same shaping as a foreground exec result, so the executor reads both
2485
2719
  // the same way. The exit file holds the PIPELINE's code: when a stage this rewrite could not
2486
2720
  // prove is output-only still follows the suite runner (`| tee run.log`), that code is the
2487
- // filter's — report none and let the log's tally decide (m101 +25:54).
2488
- const piped = run ? suiteRunPipedAfterRunner(run.command) : false;
2489
- const reported = suiteRunExitCode(exitCode, output, piped);
2490
- const noCodeNote = reported === null ? FILTERED_EXIT_NOTE : null;
2721
+ // filter's — report none and let the log's tally decide (m101 +25:54). The note says this
2722
+ // only when the parse found such a stage (m105 +08:12).
2723
+ const placement = run
2724
+ ? suiteRunPipelinePlacement(run.command)
2725
+ : { piped: false, unplaced: false };
2726
+ const reported = suiteRunExitCode(exitCode, output, placement.piped);
2727
+ const noCodeNote = suiteExitCodeNote(placement, reported);
2491
2728
  return {
2492
2729
  taskId,
2493
2730
  exitCode: reported,
@@ -32,8 +32,37 @@ export interface TraceRequest {
32
32
  method: string;
33
33
  /** The request's path (`/api/articles/x/favorite`), or the whole URL when it is cross-origin. */
34
34
  path: string;
35
- /** The response status, or 0 when the request never got a response. */
35
+ /**
36
+ * The response status as the trace recorded it. Playwright writes `-1` when a request was never
37
+ * answered (its HAR entry starts at `-1` and only a response event overwrites it); `0` is the
38
+ * same absence when the snapshot carried no response at all. Neither is a server failure.
39
+ */
36
40
  status: number;
41
+ /**
42
+ * Playwright's own error text for a request that failed WITHOUT a response
43
+ * (`net::ERR_CONNECTION_REFUSED`) — a real failure. A request that got no response and recorded
44
+ * no text was aborted or still in flight when the test ended (the page closed under it), so it
45
+ * says nothing about the server (xr-molecule-flash-m112 +30:45).
46
+ */
47
+ failure?: string;
48
+ /**
49
+ * What the page SENT, for a 4xx answered by the app's own API — bounded (see
50
+ * {@link MAX_TRACED_BODY_CHARS}). A body wrapped as `{"data":{…}}` is visible here in one line:
51
+ * app-http's verbs take the body as their second argument, so a helper that passes
52
+ * `{ data: body }` there ships the wrapper (xr-molecule-flash-m111 +11:36).
53
+ */
54
+ sent?: string;
55
+ /** What came back for that 4xx, bounded the same way — the API's own error body. */
56
+ got?: string;
57
+ }
58
+ /** One action the traced page performed, with the trace's own timing for it. */
59
+ export interface TraceAction {
60
+ /** The Playwright API the entry names: `Frame.goto`, `Locator.fill`, `Locator.click`. */
61
+ api: string;
62
+ /** When the action started, on the trace's own monotonic clock (ms). */
63
+ start: number;
64
+ /** When it finished, or null when the run ended before it did. */
65
+ end: number | null;
37
66
  }
38
67
  /** What one trace says: the test it belongs to, the requests it made, and its console errors. */
39
68
  export interface TraceArtifact {
@@ -45,6 +74,12 @@ export interface TraceArtifact {
45
74
  requests: TraceRequest[];
46
75
  /** The console errors and page errors the page raised. */
47
76
  console: string[];
77
+ /**
78
+ * The page's own actions in the order the trace recorded them, bounded — what a test that ran
79
+ * out of a per-test budget spends its time on. A test that repeats one action prints it once per
80
+ * repeat, so the gap between two starts is one repeat's measured cost.
81
+ */
82
+ actions: TraceAction[];
48
83
  }
49
84
  /** How many traces one read reports, newest first. */
50
85
  export declare const MAX_TRACE_ARTIFACTS = 12;
@@ -52,12 +87,20 @@ export declare const MAX_TRACE_ARTIFACTS = 12;
52
87
  export declare const MAX_TRACED_REQUESTS = 6;
53
88
  /** How many console errors one test's trace contributes. */
54
89
  export declare const MAX_TRACED_CONSOLE = 4;
90
+ /** How many of the page's own actions one test's trace contributes, the LAST ones. */
91
+ export declare const MAX_TRACED_ACTIONS = 40;
92
+ /** How much of a 4xx request's sent body (or its response body) the trace read keeps. */
93
+ export declare const MAX_TRACED_BODY_CHARS = 300;
55
94
  /** How many tests the note in a run's result names. */
56
95
  export declare const MAX_TRACE_NOTE_TESTS = 5;
57
96
  /** How many requests the note names per test. */
58
97
  export declare const MAX_TRACE_NOTE_REQUESTS = 4;
59
98
  /** How long a URL the note prints, before the middle is cut. */
60
99
  export declare const TRACE_NOTE_URL_CHARS = 96;
100
+ /** How much of the sent body the note prints — enough to see a `{"data":…}` wrapper. */
101
+ export declare const TRACE_NOTE_SENT_CHARS = 160;
102
+ /** How much of the response body the note prints — the API's first error line. */
103
+ export declare const TRACE_NOTE_GOT_CHARS = 120;
61
104
  /**
62
105
  * The Node program the sandbox runs to read a failed run's traces: `node <script> <dir>` prints one
63
106
  * `TRACE <json>` line per `trace.zip` under `<dir>` (newest first, {@link MAX_TRACE_ARTIFACTS} of
@@ -69,10 +112,17 @@ export declare const TRACE_NOTE_URL_CHARS = 96;
69
112
  * central directory and `zlib.inflateRawSync` — about forty lines, and the only thing that has to
70
113
  * be true of a `trace.zip` is that it IS one.
71
114
  *
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.
115
+ * A request is kept over a static asset when it failed — a `status >= 400`, or no response at all
116
+ * together with Playwright's own error text (`-1` plus `_failureText`, a refused connection) — and
117
+ * over anything else when it is the page talking to the app (`fetch`/`xhr`/`document`): a failing
118
+ * test's trace holds the whole page load. A request with no response and NO error text was aborted
119
+ * or still in flight when the test ended; it is kept too, but as what it is, not as a failure.
120
+ *
121
+ * A request answered 4xx by the app's OWN API also keeps what the page SENT and the first of what
122
+ * came back: Playwright stores either inline (`text`) or in a resource entry (`_file`), and both
123
+ * are read. That is where a wrapped body shows: xr-molecule-flash-m111's helper passed
124
+ * `{ data: body }` as app-http's second argument, so every POST/PUT was `{"data":{…}}` and the API
125
+ * validated `{}`. Status alone said only "422".
76
126
  *
77
127
  * @returns The program's source, to write to a file and run.
78
128
  */
@@ -97,11 +147,31 @@ export declare function parseTraceArtifacts(stdout: string): TraceArtifact[];
97
147
  * {@link MAX_TRACE_NOTE_TESTS} tests with {@link MAX_TRACE_NOTE_REQUESTS} requests each, because it
98
148
  * rides in a result the model pays for.
99
149
  *
150
+ * The three kinds are kept apart, because they mean different things. A FAILED request (4xx/5xx, or
151
+ * a network error with Playwright's own text) is a cause. A request with NO RESPONSE RECORDED —
152
+ * Playwright's `-1`, no error text — was still in flight when the test ended (the page closed under
153
+ * it); listing it as a failure sent a run hunting an API that never answered anything wrong
154
+ * (xr-molecule-flash-m112 +30:45). The rest are the page's last answered requests.
155
+ *
156
+ * A 4xx request carries what the page SENT beside its status, so a wrapped body is visible in one
157
+ * line rather than inferred from the status; when one of those bodies is an object whose only key
158
+ * is `data`, the note names the call shape that produces it (xr-molecule-flash-m111 +11:36).
159
+ *
160
+ * WHICH rows it spends is evidence, not artifact order (xr-molecule-flash-m112 +53:46): a row
161
+ * needs a recorded response, a real failure or a console error ({@link traceRowEvidence}), rows are
162
+ * deduplicated by the test's location, and they are ordered real failure → repeated from an
163
+ * earlier walk → the rest ({@link traceRowTier}). Without that the note spent all five rows on four
164
+ * traces of one `afterEach` line whose every request was `-1`, and left the favorite cause — the
165
+ * one failure that repeated in every walk — in its "… and N more".
166
+ *
100
167
  * @param items - The traces {@link parseTraceArtifacts} read.
101
168
  * @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.
169
+ * @param earlierFailures - The failing `file:line`s earlier walks of this suite left, so a repeat
170
+ * is ranked above a one-off. Empty when the caller knows of no earlier walk.
171
+ * @returns The note, or null when no trace held a recorded response, a real failure or a console
172
+ * error.
103
173
  */
104
- export declare function failureTraceNote(items: readonly TraceArtifact[], target: string): string | null;
174
+ export declare function failureTraceNote(items: readonly TraceArtifact[], target: string, earlierFailures?: readonly string[]): string | null;
105
175
  /**
106
176
  * Whether a Playwright config records traces for the tests that fail.
107
177
  *
@@ -1 +1 @@
1
- {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAMH,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;AAuG/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,CAyBjC"}
1
+ {"version":3,"file":"trace-artifacts.d.ts","sourceRoot":"","sources":["../src/trace-artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAMH,iEAAiE;AACjE,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAA;IACZ;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAA;IACd;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,oFAAoF;IACpF,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,yFAAyF;IACzF,GAAG,EAAE,MAAM,CAAA;IACX,wEAAwE;IACxE,KAAK,EAAE,MAAM,CAAA;IACb,kEAAkE;IAClE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CACnB;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;IACjB;;;;OAIG;IACH,OAAO,EAAE,WAAW,EAAE,CAAA;CACvB;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,sFAAsF;AACtF,eAAO,MAAM,kBAAkB,KAAK,CAAA;AAEpC,yFAAyF;AACzF,eAAO,MAAM,qBAAqB,MAAM,CAAA;AAExC,uDAAuD;AACvD,eAAO,MAAM,oBAAoB,IAAI,CAAA;AAErC,iDAAiD;AACjD,eAAO,MAAM,uBAAuB,IAAI,CAAA;AAExC,gEAAgE;AAChE,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC,wFAAwF;AACxF,eAAO,MAAM,qBAAqB,MAAM,CAAA;AAExC,kFAAkF;AAClF,eAAO,MAAM,oBAAoB,MAAM,CAAA;AAKvC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,oBAAoB,QAAO,MAiQvC,CAAA;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,EAAE,CAqCnE;AAwJD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,SAAS,aAAa,EAAE,EAC/B,MAAM,EAAE,MAAM,EACd,eAAe,GAAE,SAAS,MAAM,EAAO,GACtC,MAAM,GAAG,IAAI,CAmFf;AAaD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA;AA2G/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,CAyBjC"}