@molecule/api-ai-tools 1.6.2 → 1.6.5
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/README.md +231 -3
- package/dist/command-position.d.ts +1 -1
- package/dist/command-position.d.ts.map +1 -1
- package/dist/command-position.js +55 -21
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/suite-overlap.d.ts +132 -0
- package/dist/suite-overlap.d.ts.map +1 -0
- package/dist/suite-overlap.js +208 -0
- package/dist/tools.d.ts +43 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +288 -42
- package/dist/utilities.d.ts +38 -2
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +82 -12
- package/package.json +2 -2
package/dist/tools.js
CHANGED
|
@@ -25,6 +25,16 @@ const waitedByTask = new Map();
|
|
|
25
25
|
const logBytesByTask = new Map();
|
|
26
26
|
/** The file a background command writes its own output to, when it redirects it. */
|
|
27
27
|
const outputFileByTask = new Map();
|
|
28
|
+
/**
|
|
29
|
+
* How each background task was started: the command as it was actually run (after
|
|
30
|
+
* {@link streamingBackgroundCommand} rewrote it) and the directory it ran in.
|
|
31
|
+
*
|
|
32
|
+
* A mid-run note has to hand over the re-run of a test that just failed, and a re-run is composed
|
|
33
|
+
* from the command that is still walking ({@link rerunOfRunningSuite}) — a whole-suite re-run is
|
|
34
|
+
* what the executor must not be handed for now, and a re-run narrowed for the wrong directory
|
|
35
|
+
* resolves the failing test's `file:line` against the wrong suite.
|
|
36
|
+
*/
|
|
37
|
+
const runByTask = new Map();
|
|
28
38
|
/**
|
|
29
39
|
* Task ids whose own-output file has already been looked for this process.
|
|
30
40
|
* {@link outputFileByTask} is empty for every task after a restart — it is
|
|
@@ -63,13 +73,21 @@ const taskPidsCommand = (taskId) => `R=$(cat /tmp/${taskId}.pid 2>/dev/null); `
|
|
|
63
73
|
/**
|
|
64
74
|
* The command that stops a running background task, read from the sandbox's own pid files.
|
|
65
75
|
*
|
|
76
|
+
* THE one builder for it, so every note that hands the executor a way to stop a run hands over the
|
|
77
|
+
* same pid-only form: a `pkill -f <something>` matches the shell running it (its argv IS the
|
|
78
|
+
* command), and `molecule-dev/api/src/ai/pattern-kill-guard.ts` holds exactly that — X1
|
|
79
|
+
* xr-molecule-flash-m87's stuck note suggested `kill $(cat /tmp/<id>.pid) 2>/dev/null; pkill -f
|
|
80
|
+
* <id>.sh`, which the platform's own guard then refused, and the same shape cost a refused call at
|
|
81
|
+
* +21:57. The pid tree is the whole of what the pattern was for: the detached shell and every
|
|
82
|
+
* process under it.
|
|
83
|
+
*
|
|
66
84
|
* @param backend - The execution backend.
|
|
67
85
|
* @param taskId - The task's `mol-bg-…` handle.
|
|
68
86
|
* @returns The command to hand the executor — `kill <pids>` — falling back to the task's own pid
|
|
69
87
|
* file when the walk comes back empty (a sandbox without `pgrep`, or a pid file already gone,
|
|
70
88
|
* in which case the pid-file form is still the one the platform's guard names).
|
|
71
89
|
*/
|
|
72
|
-
async function taskStopCommand(backend, taskId) {
|
|
90
|
+
export async function taskStopCommand(backend, taskId) {
|
|
73
91
|
const fallback = `kill $(cat /tmp/${taskId}.pid)`;
|
|
74
92
|
try {
|
|
75
93
|
const r = await backend.run(taskPidsCommand(taskId), { timeout: 10_000 });
|
|
@@ -122,7 +140,8 @@ function logProgress(log) {
|
|
|
122
140
|
/**
|
|
123
141
|
* What a wait knows about the command BESIDES "it has not exited": how long it
|
|
124
142
|
* has been running, whether its output is still growing, how far its own log
|
|
125
|
-
* says it has got, and how much longer that implies
|
|
143
|
+
* says it has got, and how much longer that implies — one fact per clause, built
|
|
144
|
+
* here so the note carrying them cannot state one of them twice.
|
|
126
145
|
*
|
|
127
146
|
* X1 flash-m62 (2026-10-16) spent three 290 s waits on one 8.3-minute suite and
|
|
128
147
|
* every answer read only "Still running after 280s": the run could not tell a
|
|
@@ -131,35 +150,63 @@ function logProgress(log) {
|
|
|
131
150
|
* waiting. The elapsed time comes from the task id, the progress from the log
|
|
132
151
|
* this wait already read, and the time left is the rate so far, said as such.
|
|
133
152
|
*
|
|
153
|
+
* X1 flash-m87 §+47:20 (2026-10-16): that suite's answer read "Still running
|
|
154
|
+
* after 342s of waiting, and its output is still growing (71839 bytes) — slow,
|
|
155
|
+
* not stuck." and then this sentence, which opens with the same growth clause
|
|
156
|
+
* and gives the same elapsed time in its other format — one answer, two facts,
|
|
157
|
+
* each said twice. The clauses ARE the facts, and {@link runningNote} is the
|
|
158
|
+
* only place they become a sentence, so a lead the caller writes itself cannot
|
|
159
|
+
* repeat one (the way `deployOwedNote` returns one sentence rather than a join).
|
|
160
|
+
*
|
|
134
161
|
* @param o - The wait's facts: `taskId`, the `slice` of the log it read,
|
|
135
162
|
* `stillWriting` (the log grew during this wait), `bytes` (the log's size, null
|
|
136
|
-
* when unreadable),
|
|
137
|
-
*
|
|
163
|
+
* when unreadable), `ceilingMs` (this call's ceiling, for "one more wait"), and
|
|
164
|
+
* `waitedMs` (this call's own wait, the elapsed figure when the id carries no
|
|
165
|
+
* start stamp).
|
|
166
|
+
* @returns The clauses in reading order. The first states the elapsed time, so a
|
|
167
|
+
* sentence built from them always says something.
|
|
138
168
|
*/
|
|
139
|
-
function
|
|
169
|
+
function waitProgressClauses(o) {
|
|
140
170
|
const startedAt = taskStartedAt(o.taskId);
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
171
|
+
const sinceStart = startedAt === null ? null : Math.max(0, Date.now() - startedAt);
|
|
172
|
+
// A task id this process minted always carries its start stamp; a synthetic one
|
|
173
|
+
// does not, and the time this call spent waiting is then the floor for how long
|
|
174
|
+
// the command has been running — it ran throughout that wait.
|
|
175
|
+
const clauses = [
|
|
176
|
+
sinceStart === null
|
|
177
|
+
? `it has been running at least ${durationText(o.waitedMs)}`
|
|
178
|
+
: `it has been running ${durationText(sinceStart)}`,
|
|
179
|
+
];
|
|
145
180
|
if (o.stillWriting)
|
|
146
|
-
|
|
181
|
+
clauses.push(`its output is still growing (${o.bytes} bytes) — slow, not stuck`);
|
|
147
182
|
else if (o.bytes !== null)
|
|
148
|
-
|
|
183
|
+
clauses.push(`its log has not grown during this wait (${o.bytes} bytes)`);
|
|
149
184
|
const counter = logProgress(o.slice);
|
|
150
185
|
if (counter) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
186
|
+
clauses.push(`its log is at ${counter.done} of ${counter.total}`);
|
|
187
|
+
// The rate is the task's own elapsed time, never this call's wait: with no
|
|
188
|
+
// start stamp, "34 tests in the 290 s I just waited" would be reported as the
|
|
189
|
+
// speed of a suite that has been walking for ten minutes.
|
|
190
|
+
if (sinceStart !== null && counter.done > 0 && counter.done < counter.total) {
|
|
191
|
+
const leftMs = Math.round((sinceStart * (counter.total - counter.done)) / counter.done);
|
|
192
|
+
clauses.push(leftMs <= o.ceilingMs
|
|
155
193
|
? `about ${durationText(leftMs)} left at that rate — one more wait covers it`
|
|
156
194
|
: `about ${durationText(leftMs)} left at that rate (${Math.ceil(leftMs / o.ceilingMs)} more waits)`);
|
|
157
195
|
}
|
|
158
196
|
}
|
|
159
|
-
|
|
197
|
+
return clauses;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* One sentence from clauses: joined by "; ", capitalised and punctuated once.
|
|
201
|
+
*
|
|
202
|
+
* @param clauses - The facts to state, each in its own clause.
|
|
203
|
+
* @returns The sentence, or '' when there is nothing to say.
|
|
204
|
+
*/
|
|
205
|
+
function runningNote(clauses) {
|
|
206
|
+
if (!clauses.length)
|
|
160
207
|
return '';
|
|
161
|
-
const
|
|
162
|
-
return
|
|
208
|
+
const joined = clauses.join('; ');
|
|
209
|
+
return `${joined[0].toUpperCase()}${joined.slice(1)}.`;
|
|
163
210
|
}
|
|
164
211
|
import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, emptyCallError, failureCausesNote, findPatternArgs, fusedLine, htmlOutline, isEnvFilePath, isOutsideRoot, isPlaywrightRunCommand, isValidGlob, linesWithinSerialized, 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, serializedChars, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, suiteRunPriorityLaunch, truncateMiddle, universalCause, whitespaceTolerantReplace, } from './utilities.js';
|
|
165
212
|
/**
|
|
@@ -197,6 +244,52 @@ export function moleculeDistReadNote(path) {
|
|
|
197
244
|
? ' The declarations above are generated from it.'
|
|
198
245
|
: ' For one signature, read the matching .d.ts file.'));
|
|
199
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* The refusal for a write whose target is inside an installed package, or null
|
|
249
|
+
* when the path is not under a `node_modules` directory.
|
|
250
|
+
*
|
|
251
|
+
* `node_modules` is not the project's code: nothing written there survives the
|
|
252
|
+
* next install, no deploy carries it, and a sandbox whose dependency tree is
|
|
253
|
+
* shared (the pre-installed superset) breaks every other run through it. X0
|
|
254
|
+
* xr-molecule-flash-m91 (+04:43) called `write_file` on
|
|
255
|
+
* `node_modules/@molecule/api-resource-comment/dist/numbers.d.ts` while reading
|
|
256
|
+
* the package's declarations and clobbered its runtime module a call later
|
|
257
|
+
* (`module.exports = {}` over the ESM handler its own code imports) — the write
|
|
258
|
+
* answered `ok: true`, and 18 s went to noticing, `npm pack`-ing the package and
|
|
259
|
+
* putting the file back. Reading these files stays allowed (that is how the
|
|
260
|
+
* declarations above are read at all).
|
|
261
|
+
*
|
|
262
|
+
* The restore route is named because the model that has ALREADY clobbered a file
|
|
263
|
+
* (this refusal is too late for it) is the one who needs it, and because the
|
|
264
|
+
* obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
|
|
265
|
+
* sandbox. `npm pack` + a `tar` extraction over the package directory is what
|
|
266
|
+
* m91 used, and what both guards leave alone.
|
|
267
|
+
*
|
|
268
|
+
* @param path - An absolute path a tool is about to write.
|
|
269
|
+
* @returns The one-line refusal naming the package, or null when nothing is installed there.
|
|
270
|
+
*/
|
|
271
|
+
export function installedPackageWriteRefusal(path) {
|
|
272
|
+
// A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
|
|
273
|
+
// hoisted root one: both are installed packages.
|
|
274
|
+
const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
|
|
275
|
+
if (!at)
|
|
276
|
+
return null;
|
|
277
|
+
const named = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path);
|
|
278
|
+
const name = named?.[1] ?? null;
|
|
279
|
+
const scoped = at[0].startsWith('/') ? path.slice(0, at.index) : '';
|
|
280
|
+
const dir = name ? `${scoped}/node_modules/${name}` : null;
|
|
281
|
+
const what = name ? `the installed package ${name}` : 'an installed package';
|
|
282
|
+
const change = 'Installed packages are read-only: the next install replaces that file and a deploy never ' +
|
|
283
|
+
'carries it. Change its behaviour from your app instead (its options, your own handler, a ' +
|
|
284
|
+
'bond), and where the package itself is wrong, report it rather than patching it here. ' +
|
|
285
|
+
'Reading these files is fine.';
|
|
286
|
+
const restore = name && dir
|
|
287
|
+
? ` If you have already overwritten one there, restore the package before anything else: ` +
|
|
288
|
+
`\`npm pack ${name}@<version>\` (the version is in ${dir}/package.json), then ` +
|
|
289
|
+
`\`tar -xzf <the tarball npm pack printed> --strip-components=1 -C ${dir}\` — never a hand-edit.`
|
|
290
|
+
: '';
|
|
291
|
+
return `Not written: ${path} is inside ${what} (node_modules). ${change}${restore}`;
|
|
292
|
+
}
|
|
200
293
|
/**
|
|
201
294
|
* Build a complete set of AI agent tools bound to an execution backend.
|
|
202
295
|
*
|
|
@@ -299,6 +392,22 @@ export function buildTools(backend, config) {
|
|
|
299
392
|
}
|
|
300
393
|
return pathGuards ? resolvePath(path, root) : path;
|
|
301
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Resolve the path a WRITING tool was given, refusing a target inside an
|
|
397
|
+
* installed package ({@link installedPackageWriteRefusal}). Reads are not sent
|
|
398
|
+
* through this: reading `node_modules` is how a package's declarations are
|
|
399
|
+
* read at all, and only the write is unrecoverable.
|
|
400
|
+
*
|
|
401
|
+
* @param path - Relative or absolute path requested by the tool input.
|
|
402
|
+
* @returns The normalized path, when writing it is allowed.
|
|
403
|
+
*/
|
|
404
|
+
function resolveWrite(path) {
|
|
405
|
+
const resolved = resolve(path);
|
|
406
|
+
const refusal = installedPackageWriteRefusal(resolved);
|
|
407
|
+
if (refusal)
|
|
408
|
+
throw new ToolInputError(refusal);
|
|
409
|
+
return resolved;
|
|
410
|
+
}
|
|
302
411
|
/** Paths read with a window in this tool set, to tell paging from one targeted read. */
|
|
303
412
|
const windowedReads = new Set();
|
|
304
413
|
/** The text each path was last handed back WHOLE as, so that correction fires once per version. */
|
|
@@ -850,7 +959,7 @@ export function buildTools(backend, config) {
|
|
|
850
959
|
const argErr = pathArgError(input.path, 'write_file');
|
|
851
960
|
if (argErr)
|
|
852
961
|
return { error: argErr };
|
|
853
|
-
const path =
|
|
962
|
+
const path = resolveWrite(input.path);
|
|
854
963
|
const content = input.content;
|
|
855
964
|
if (content.length > MAX_WRITE_SIZE)
|
|
856
965
|
return {
|
|
@@ -899,7 +1008,7 @@ export function buildTools(backend, config) {
|
|
|
899
1008
|
const argErr = pathArgError(input.path, 'edit_file');
|
|
900
1009
|
if (argErr)
|
|
901
1010
|
return { error: argErr };
|
|
902
|
-
const path =
|
|
1011
|
+
const path = resolveWrite(input.path);
|
|
903
1012
|
// Support both formats: { replacements: [...] } (batch) and { old_string, new_string } (single, backwards-compatible)
|
|
904
1013
|
let replacements = input.replacements;
|
|
905
1014
|
// Accept the single format when BOTH are strings — `typeof` (not truthy) so
|
|
@@ -936,13 +1045,35 @@ export function buildTools(backend, config) {
|
|
|
936
1045
|
}
|
|
937
1046
|
const oldContent = content;
|
|
938
1047
|
let alreadyApplied = 0;
|
|
1048
|
+
// Replacements refused for changing the line break at old_string's boundary and
|
|
1049
|
+
// nothing else — reported by position, never applied (see below).
|
|
1050
|
+
const skipped = [];
|
|
939
1051
|
// Where each applied replacement landed, in the coordinates of the
|
|
940
1052
|
// latest content — rendered into the result so the model sees the edit.
|
|
941
1053
|
let spans = [];
|
|
942
|
-
|
|
1054
|
+
// A replacement whose ONLY effect is the line break at old_string's boundary:
|
|
1055
|
+
// `new_string` is `old_string` minus its trailing newline (applying it runs the
|
|
1056
|
+
// line after it onto the last line) or `old_string` plus one (applying it moves
|
|
1057
|
+
// the rest of that line down). A weaker model sends the first as an "anchor" — it
|
|
1058
|
+
// repeats a line it means to keep, minus its `\n` — and the join is silent when
|
|
1059
|
+
// it still parses: flash-m28 merged two lines three times in one run, flash-m89
|
|
1060
|
+
// twice in two consecutive calls (comments.ts, then articles.ts), flash-m55,
|
|
1061
|
+
// m62 and m89 all paid a repair round trip for it. A real edit changes some other
|
|
1062
|
+
// character, so this costs nothing.
|
|
1063
|
+
const boundaryOnly = (oldText, newText) => {
|
|
1064
|
+
const oldBody = oldText.replace(/\r?\n$/, '');
|
|
1065
|
+
// `newText !== ''` keeps a deletion out of this: `old_string: "\n"` → `""` is a
|
|
1066
|
+
// real request to remove a blank line, and the notes below already cover it.
|
|
1067
|
+
if (newText !== '' && oldBody !== oldText && newText === oldBody)
|
|
1068
|
+
return 'removed';
|
|
1069
|
+
if (!oldText.endsWith('\n') && newText === `${oldText}\n`)
|
|
1070
|
+
return 'added';
|
|
1071
|
+
return null;
|
|
1072
|
+
};
|
|
1073
|
+
for (const [index, { old_string: oldString, new_string: newString, replace_all: replaceAll,
|
|
943
1074
|
// Models sometimes send the camelCase spelling inside an item, where
|
|
944
1075
|
// the input normalizer (top-level keys only) does not reach.
|
|
945
|
-
replaceAll: replaceAllCamel, } of replacements) {
|
|
1076
|
+
replaceAll: replaceAllCamel, },] of replacements.entries()) {
|
|
946
1077
|
// Validate each replacement is well-formed BEFORE touching content. A
|
|
947
1078
|
// weak model sometimes emits a batch element missing a field or with a
|
|
948
1079
|
// non-string value; left unchecked that either crashes (`undefined`
|
|
@@ -1044,15 +1175,74 @@ export function buildTools(backend, config) {
|
|
|
1044
1175
|
.map((l) => l.trim())
|
|
1045
1176
|
.filter((l) => l.length >= 8)),
|
|
1046
1177
|
].sort((a, b) => b.length - a.length);
|
|
1178
|
+
const oldLines = oldString.split('\n');
|
|
1179
|
+
// Whitespace is the one difference the model cannot see in its own
|
|
1180
|
+
// text, so make it visible when that is the whole difference.
|
|
1181
|
+
const visible = (l) => l.replace(/ /g, '·').replace(/\t/g, '⇥');
|
|
1182
|
+
// Report where old_string and the file STOP agreeing, not where the
|
|
1183
|
+
// match starts. A snippet centred on the anchor is the part the model
|
|
1184
|
+
// already has right, so it re-sends the same old_string and fails the
|
|
1185
|
+
// same way (X0 m89: two failed edits, a whole-file re-read and a
|
|
1186
|
+
// rewrite, 9 s, for a divergence 35 lines below the anchor).
|
|
1187
|
+
const showDivergence = (base, k) => {
|
|
1188
|
+
const expected = oldLines[k];
|
|
1189
|
+
const actual = fileLines[base + k] ?? '(end of file)';
|
|
1190
|
+
const whitespaceOnly = expected.replace(/\s/g, '') === actual.replace(/\s/g, '');
|
|
1191
|
+
let col = 0;
|
|
1192
|
+
while (col < expected.length && col < actual.length && expected[col] === actual[col])
|
|
1193
|
+
col++;
|
|
1194
|
+
const howFar = whitespaceOnly
|
|
1195
|
+
? ' (whitespace only — · is a space, ⇥ a tab)'
|
|
1196
|
+
: col > 0
|
|
1197
|
+
? ` (first character difference at column ${col + 1})`
|
|
1198
|
+
: '';
|
|
1199
|
+
const start = Math.max(0, base + k - 2);
|
|
1200
|
+
const snippet = fileLines
|
|
1201
|
+
.slice(start, base + k + 6)
|
|
1202
|
+
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
1203
|
+
.join('\n');
|
|
1204
|
+
return {
|
|
1205
|
+
error: `old_string not found in ${path} — it matches the file through line ${base + k}, ` +
|
|
1206
|
+
`then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
|
|
1207
|
+
`Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
|
|
1208
|
+
`The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
|
|
1209
|
+
`The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
|
|
1210
|
+
};
|
|
1211
|
+
};
|
|
1212
|
+
// The longest run of old_string lines that IS in the file decides which
|
|
1213
|
+
// alignment to report, so a later distinctive line can beat an earlier
|
|
1214
|
+
// one. k === 0 means nothing beyond the anchor itself lines up; only
|
|
1215
|
+
// then fall back to the old anchor snippet.
|
|
1216
|
+
let best = null;
|
|
1217
|
+
let firstUniqueHit = null;
|
|
1047
1218
|
for (const anchor of distinctiveLines) {
|
|
1048
1219
|
const hits = fileLines.reduce((acc, l, i) => {
|
|
1049
1220
|
if (l.includes(anchor))
|
|
1050
1221
|
acc.push(i);
|
|
1051
1222
|
return acc;
|
|
1052
1223
|
}, []);
|
|
1053
|
-
if (hits.length
|
|
1054
|
-
|
|
1224
|
+
if (hits.length !== 1)
|
|
1225
|
+
continue;
|
|
1226
|
+
if (firstUniqueHit === null)
|
|
1227
|
+
firstUniqueHit = hits[0];
|
|
1228
|
+
const base = hits[0] - oldLines.findIndex((l) => l.trim() === anchor);
|
|
1229
|
+
if (base < 0)
|
|
1230
|
+
continue;
|
|
1231
|
+
// Exact comparison: a line that differs only in trailing whitespace
|
|
1232
|
+
// is itself a reason the exact match failed, and it is invisible in
|
|
1233
|
+
// the model's own text — report it as the divergence (the message
|
|
1234
|
+
// renders it with · for space).
|
|
1235
|
+
let k = 0;
|
|
1236
|
+
while (k < oldLines.length && fileLines[base + k] === oldLines[k]) {
|
|
1237
|
+
k++;
|
|
1238
|
+
}
|
|
1239
|
+
if (k > 0 && k < oldLines.length && (best === null || k > best.k))
|
|
1240
|
+
best = { base, k };
|
|
1055
1241
|
}
|
|
1242
|
+
if (best)
|
|
1243
|
+
return showDivergence(best.base, best.k);
|
|
1244
|
+
if (firstUniqueHit !== null)
|
|
1245
|
+
return showSnippet(firstUniqueHit);
|
|
1056
1246
|
// Fallback: the first non-blank line, even if not unique (better than a
|
|
1057
1247
|
// bare "re-read" when the model just needs to see nearby real content).
|
|
1058
1248
|
const firstLine = oldString
|
|
@@ -1112,10 +1302,36 @@ export function buildTools(backend, config) {
|
|
|
1112
1302
|
// a self-inflicted syntax error written with ok:true that the executor
|
|
1113
1303
|
// then can't locate. count === 1 here, so indexOf is the unique site.
|
|
1114
1304
|
const at = content.indexOf(oldString);
|
|
1305
|
+
// Refuse a replacement whose only effect is the line break at the boundary,
|
|
1306
|
+
// INSTEAD of applying it (flash-m89 +08:32 left `function pageLimit(req): number
|
|
1307
|
+
// { const raw = …` on one line and +07:25 left a comment handler unparseable,
|
|
1308
|
+
// twice in two calls, and the API was down between the pair). The file's own
|
|
1309
|
+
// next character is the evidence that decides it: text on the same line means
|
|
1310
|
+
// the break is moving across lines (a join, or a split), which the shape of the
|
|
1311
|
+
// replacement says the model did not ask for. At a line boundary — a blank line
|
|
1312
|
+
// after it, or the end of the file — nothing crosses lines and the edit stands.
|
|
1313
|
+
const boundary = boundaryOnly(oldString, newString);
|
|
1314
|
+
const after = content[at + oldString.length];
|
|
1315
|
+
if (boundary !== null && after !== undefined && after !== '\n') {
|
|
1316
|
+
skipped.push({ position: index + 1, shape: boundary });
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1115
1319
|
const spliced = content.slice(0, at) + newString + content.slice(at + oldString.length);
|
|
1116
1320
|
spans = addChangeSpan(spans, content, spliced);
|
|
1117
1321
|
content = spliced;
|
|
1118
1322
|
}
|
|
1323
|
+
// One line per refused replacement: what it would have done, and the two ways to
|
|
1324
|
+
// say it on purpose. The rest of the batch is applied — the second half of the pair
|
|
1325
|
+
// above (`comments.ts`'s third replacement, `articles.ts`'s own text edit) anchors
|
|
1326
|
+
// on text a joined file no longer has, so applying the join takes the batch down
|
|
1327
|
+
// with it.
|
|
1328
|
+
const skippedNote = skipped.length === 0
|
|
1329
|
+
? null
|
|
1330
|
+
: skipped
|
|
1331
|
+
.map(({ position, shape }) => shape === 'removed'
|
|
1332
|
+
? `replacement ${position} was skipped, nothing was changed for it: its new_string is its old_string minus the trailing newline, so the only thing it changes is the line break between that text and the line after it — applying it would run the two lines together. To join two lines on purpose, include the next line in old_string.`
|
|
1333
|
+
: `replacement ${position} was skipped, nothing was changed for it: its new_string is its old_string plus a trailing newline, so the only thing it changes is the line break after that text — applying it would move the rest of that line down. To split a line on purpose, include the text that follows it in old_string.`)
|
|
1334
|
+
.join('\n');
|
|
1119
1335
|
const alreadyNote = alreadyApplied > 0
|
|
1120
1336
|
? `${alreadyApplied} of these edits ${alreadyApplied === 1 ? 'was' : 'were'} already in the file (its new_string is there and its old_string is not) — nothing to do for ${alreadyApplied === 1 ? 'it' : 'them'}.`
|
|
1121
1337
|
: null;
|
|
@@ -1127,7 +1343,16 @@ export function buildTools(backend, config) {
|
|
|
1127
1343
|
(identical > 0
|
|
1128
1344
|
? `Nothing changed: ${identical === 1 ? 'the replacement’s new_string is' : `${identical} replacements’ new_string are`} identical to old_string, so the file is exactly as it was. Write the text you want in new_string.`
|
|
1129
1345
|
: 'Nothing changed: the file is exactly as it was.');
|
|
1130
|
-
|
|
1346
|
+
// A refused boundary-newline replacement is why nothing changed: "the file is
|
|
1347
|
+
// exactly as it was" would read as "your edit landed".
|
|
1348
|
+
return {
|
|
1349
|
+
path,
|
|
1350
|
+
ok: true,
|
|
1351
|
+
replacementsApplied: 0,
|
|
1352
|
+
alreadyApplied,
|
|
1353
|
+
note: skippedNote ?? note,
|
|
1354
|
+
...(skippedNote ? { skippedNote } : {}),
|
|
1355
|
+
};
|
|
1131
1356
|
}
|
|
1132
1357
|
if (onFileDiff)
|
|
1133
1358
|
onFileDiff({ path, oldContent, newContent: content });
|
|
@@ -1153,10 +1378,13 @@ export function buildTools(backend, config) {
|
|
|
1153
1378
|
const excerpt = renderEditExcerpt(finalContent, spans);
|
|
1154
1379
|
// A replacement whose old text ended in a newline and whose new text does not
|
|
1155
1380
|
// joins the line after it onto its last line. Applied as written, but said:
|
|
1156
|
-
// flash-m28 saw two lines merged only one round trip later.
|
|
1381
|
+
// flash-m28 saw two lines merged only one round trip later. The refused ones
|
|
1382
|
+
// above were never applied, so what the file holds is not their work.
|
|
1383
|
+
const refused = new Set(skipped.map((s) => s.position));
|
|
1157
1384
|
const dropped = replacements
|
|
1158
1385
|
.map((r, i) => ({ ...r, position: i + 1 }))
|
|
1159
|
-
.filter((r) =>
|
|
1386
|
+
.filter((r) => !refused.has(r.position) &&
|
|
1387
|
+
typeof r.old_string === 'string' &&
|
|
1160
1388
|
typeof r.new_string === 'string' &&
|
|
1161
1389
|
r.old_string.endsWith('\n') &&
|
|
1162
1390
|
r.new_string !== '' &&
|
|
@@ -1195,7 +1423,7 @@ export function buildTools(backend, config) {
|
|
|
1195
1423
|
return {
|
|
1196
1424
|
path,
|
|
1197
1425
|
ok: true,
|
|
1198
|
-
replacementsApplied: replacements.length - alreadyApplied,
|
|
1426
|
+
replacementsApplied: replacements.length - alreadyApplied - skipped.length,
|
|
1199
1427
|
...(fusedNote
|
|
1200
1428
|
? { syntaxError: fusedNote }
|
|
1201
1429
|
: {
|
|
@@ -1214,6 +1442,7 @@ export function buildTools(backend, config) {
|
|
|
1214
1442
|
: {}),
|
|
1215
1443
|
...syntax,
|
|
1216
1444
|
}),
|
|
1445
|
+
...(skippedNote ? { skippedNote } : {}),
|
|
1217
1446
|
...(alreadyApplied > 0 ? { alreadyApplied, note: alreadyNote } : {}),
|
|
1218
1447
|
totalLines: countLines(finalContent),
|
|
1219
1448
|
...(excerpt ? { excerpt: sanitizeFileContent(excerpt, path) } : {}),
|
|
@@ -1332,7 +1561,7 @@ export function buildTools(backend, config) {
|
|
|
1332
1561
|
}
|
|
1333
1562
|
},
|
|
1334
1563
|
async create_directory(input) {
|
|
1335
|
-
const path =
|
|
1564
|
+
const path = resolveWrite(input.path);
|
|
1336
1565
|
const parentPath = path.substring(0, path.lastIndexOf('/')) || root;
|
|
1337
1566
|
const symlinkErr = await checkSymlink(parentPath);
|
|
1338
1567
|
if (symlinkErr)
|
|
@@ -1348,8 +1577,10 @@ export function buildTools(backend, config) {
|
|
|
1348
1577
|
}
|
|
1349
1578
|
},
|
|
1350
1579
|
async rename_file(input) {
|
|
1351
|
-
|
|
1352
|
-
|
|
1580
|
+
// Both ends: moving a package's file OUT is as much a change to the installed
|
|
1581
|
+
// package as moving one in.
|
|
1582
|
+
const oldPath = resolveWrite(input.old_path);
|
|
1583
|
+
const newPath = resolveWrite(input.new_path);
|
|
1353
1584
|
const oldSymlinkErr = await checkSymlink(oldPath);
|
|
1354
1585
|
if (oldSymlinkErr)
|
|
1355
1586
|
return { error: oldSymlinkErr };
|
|
@@ -1371,7 +1602,7 @@ export function buildTools(backend, config) {
|
|
|
1371
1602
|
}
|
|
1372
1603
|
},
|
|
1373
1604
|
async delete_file(input) {
|
|
1374
|
-
const path =
|
|
1605
|
+
const path = resolveWrite(input.path);
|
|
1375
1606
|
const symlinkErr = await checkSymlink(path);
|
|
1376
1607
|
if (symlinkErr)
|
|
1377
1608
|
return { error: symlinkErr };
|
|
@@ -1471,6 +1702,7 @@ export function buildTools(backend, config) {
|
|
|
1471
1702
|
: null;
|
|
1472
1703
|
if (ownFile)
|
|
1473
1704
|
outputFileByTask.set(id, ownFile);
|
|
1705
|
+
runByTask.set(id, { command: streaming.command, cwd });
|
|
1474
1706
|
await backend.writeFile(script, `${streaming.command}\n`);
|
|
1475
1707
|
await backend.run(`nohup sh -c ${shellQuote(prioritized.launch)} ` +
|
|
1476
1708
|
`> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
|
|
@@ -1739,8 +1971,8 @@ export function buildTools(backend, config) {
|
|
|
1739
1971
|
status: 'stuck',
|
|
1740
1972
|
waitedMs: waitedBefore,
|
|
1741
1973
|
note: `You have already waited ${Math.round(waitedBefore / 1000)}s on this command and it ` +
|
|
1742
|
-
`has not exited — it is stuck, not slow. Kill it (exec_command:
|
|
1743
|
-
|
|
1974
|
+
`has not exited — it is stuck, not slow. Kill it (\`exec_command: ` +
|
|
1975
|
+
`${await taskStopCommand(backend, taskId)}\`) and move on; do not wait on it again.`,
|
|
1744
1976
|
};
|
|
1745
1977
|
}
|
|
1746
1978
|
// The id names files under /tmp; only an id this tool minted may be used
|
|
@@ -1811,6 +2043,11 @@ export function buildTools(backend, config) {
|
|
|
1811
2043
|
// Only a cause that is failing the whole run gets a stop command — and only then is the
|
|
1812
2044
|
// pid walk paid for.
|
|
1813
2045
|
const universal = universalCause(causeLog, found);
|
|
2046
|
+
// How this run was started, so the note can hand over the re-run to send once it ends
|
|
2047
|
+
// instead of one for now — the platform refuses a second run against the same app and
|
|
2048
|
+
// database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
|
|
2049
|
+
// and its overlap hold refused that exact run 23 s later).
|
|
2050
|
+
const started = runByTask.get(taskId);
|
|
1814
2051
|
repeatingCauseNoted.add(taskId);
|
|
1815
2052
|
const earlyMs = Date.now() - startedAt;
|
|
1816
2053
|
waitedByTask.set(taskId, waitedBefore + earlyMs);
|
|
@@ -1823,6 +2060,7 @@ export function buildTools(backend, config) {
|
|
|
1823
2060
|
log,
|
|
1824
2061
|
stop: universal ? await taskStopCommand(backend, taskId) : '',
|
|
1825
2062
|
universal,
|
|
2063
|
+
...(started ? { run: started } : {}),
|
|
1826
2064
|
}),
|
|
1827
2065
|
};
|
|
1828
2066
|
}
|
|
@@ -1853,13 +2091,19 @@ export function buildTools(backend, config) {
|
|
|
1853
2091
|
// No slice: the notes still say how to peek, and the task id still
|
|
1854
2092
|
// carries how long the command has been running.
|
|
1855
2093
|
}
|
|
1856
|
-
|
|
2094
|
+
// ONE sentence of facts, each said once, then what to do next: the lead
|
|
2095
|
+
// this branch used to write ("Still running after 342s of waiting, and its
|
|
2096
|
+
// output is still growing (71839 bytes) — slow, not stuck") restated the
|
|
2097
|
+
// elapsed time and the growth clause these clauses already hold — the same
|
|
2098
|
+
// sentence, twice (X1 flash-m87 §+47:20).
|
|
2099
|
+
const progress = runningNote(waitProgressClauses({
|
|
1857
2100
|
taskId,
|
|
1858
2101
|
slice: soFar,
|
|
1859
2102
|
stillWriting,
|
|
1860
2103
|
bytes: grownTo,
|
|
1861
2104
|
ceilingMs: ceiling,
|
|
1862
|
-
|
|
2105
|
+
waitedMs: waitedBefore + waitedMs,
|
|
2106
|
+
}));
|
|
1863
2107
|
const sliceField = soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {};
|
|
1864
2108
|
if (stillWriting && waitedBefore + waitedMs >= stuckAfterMs()) {
|
|
1865
2109
|
waitedByTask.set(taskId, 0);
|
|
@@ -1868,9 +2112,8 @@ export function buildTools(backend, config) {
|
|
|
1868
2112
|
status: 'running',
|
|
1869
2113
|
waitedMs,
|
|
1870
2114
|
...sliceField,
|
|
1871
|
-
note:
|
|
1872
|
-
`
|
|
1873
|
-
`again to keep waiting; peek with exec_command: tail ${log}.`,
|
|
2115
|
+
note: `${progress} Call wait_for_task again to keep waiting; peek with exec_command: ` +
|
|
2116
|
+
`tail ${log}.`,
|
|
1874
2117
|
};
|
|
1875
2118
|
}
|
|
1876
2119
|
if (waitedBefore + waitedMs >= stuckAfterMs()) {
|
|
@@ -1880,8 +2123,8 @@ export function buildTools(backend, config) {
|
|
|
1880
2123
|
waitedMs: waitedBefore + waitedMs,
|
|
1881
2124
|
note: `${Math.round((waitedBefore + waitedMs) / 1000)}s of waiting and it has not exited — it is ` +
|
|
1882
2125
|
`stuck, not slow. See what it printed with exec_command (tail /tmp/${taskId}.log — ` +
|
|
1883
|
-
`read_file cannot reach /tmp), kill it (exec_command: ` +
|
|
1884
|
-
|
|
2126
|
+
`read_file cannot reach /tmp), kill it (\`exec_command: ` +
|
|
2127
|
+
`${await taskStopCommand(backend, taskId)}\`) and move on; do not wait on it again.`,
|
|
1885
2128
|
};
|
|
1886
2129
|
}
|
|
1887
2130
|
return {
|
|
@@ -1889,7 +2132,10 @@ export function buildTools(backend, config) {
|
|
|
1889
2132
|
status: 'running',
|
|
1890
2133
|
waitedMs,
|
|
1891
2134
|
...sliceField,
|
|
1892
|
-
|
|
2135
|
+
// No "Still running after Ns" lead here either: the clauses already say
|
|
2136
|
+
// how long it has been running, and the lead said it a second time in
|
|
2137
|
+
// the other format (X1 flash-m87 §+47:20).
|
|
2138
|
+
note: `${progress} Call wait_for_task again to ` +
|
|
1893
2139
|
`keep waiting — its result carries the log output. ` +
|
|
1894
2140
|
(soFar.trim()
|
|
1895
2141
|
? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
|
package/dist/utilities.d.ts
CHANGED
|
@@ -691,6 +691,27 @@ export declare function failuresByCause(log: string): FailureCause[];
|
|
|
691
691
|
* @returns The note, or null.
|
|
692
692
|
*/
|
|
693
693
|
export declare function failureCausesNote(log: string): string | null;
|
|
694
|
+
/**
|
|
695
|
+
* The command that re-runs the tests at `locations` the way the run IN FLIGHT ran them: the same
|
|
696
|
+
* invocation, in the same directory, narrowed to those tests — or null when the running command
|
|
697
|
+
* is not a Playwright run this can narrow.
|
|
698
|
+
*
|
|
699
|
+
* The `locations` are the paths the run's own log printed, which are relative to the directory
|
|
700
|
+
* that run started in, so a re-run has to resolve them there: the command names that directory
|
|
701
|
+
* unless it already changes into one of its own, because the executor's next command starts
|
|
702
|
+
* wherever it starts. The returned command is for AFTER the run ends — it is a second run against
|
|
703
|
+
* the same app and database, and that is exactly what is refused while the run walks
|
|
704
|
+
* ({@link suiteRunInTheWay}).
|
|
705
|
+
*
|
|
706
|
+
* @param run - The run in flight: the command it was started with, as it was run, and the
|
|
707
|
+
* directory that command ran in.
|
|
708
|
+
* @param locations - The failing `file:line`s to re-run, as the run's log printed them.
|
|
709
|
+
* @returns The re-run command, or null when there is no suite to narrow.
|
|
710
|
+
*/
|
|
711
|
+
export declare function rerunOfRunningSuite(run: {
|
|
712
|
+
command: string;
|
|
713
|
+
cwd?: string;
|
|
714
|
+
}, locations: readonly string[]): string | null;
|
|
694
715
|
/**
|
|
695
716
|
* The sentence that names a cause repeating among a running suite's failures, mid-run.
|
|
696
717
|
*
|
|
@@ -705,10 +726,21 @@ export declare function failureCausesNote(log: string): string | null;
|
|
|
705
726
|
* stopped eight runs at their first repeating cause in 14.5 minutes and never finished one, so
|
|
706
727
|
* the note must not say to stop when the run still has other causes to name.
|
|
707
728
|
*
|
|
729
|
+
* The run is STILL WALKING while this note is written — both callers produce it mid-run — so a
|
|
730
|
+
* re-run of one of its tests is a second run against the same app and database, and the platform
|
|
731
|
+
* refuses that ({@link suiteRunInTheWay}, the same rule its overlap hold answers with):
|
|
732
|
+
* xr-molecule-flash-m89 (2026-10-16) read *"Start on them now, while the run keeps walking.
|
|
733
|
+
* `null-fields.spec.ts:53` alone reproduces the first one."* and sent exactly that re-run 23 s
|
|
734
|
+
* later, where the hold answered *"A playwright suite is already running against this app and
|
|
735
|
+
* database … Wait for it."* The note therefore hands over the read-and-fix to do NOW and the
|
|
736
|
+
* re-run for after the run ends — the exact command where the caller knows how the run was
|
|
737
|
+
* started ({@link rerunOfRunningSuite}), the timing alone where it does not.
|
|
738
|
+
*
|
|
708
739
|
* @param found - What {@link repeatingFailure} found.
|
|
709
740
|
* @param o - The run's facts: its `taskId`, its `log` path, `stop` — the shell command that
|
|
710
|
-
* stops it, printed only for a {@link UniversalCause} —
|
|
711
|
-
* {@link universalCause} found (absent means the run is not failing whole)
|
|
741
|
+
* stops it, printed only for a {@link UniversalCause} — `universal`, what
|
|
742
|
+
* {@link universalCause} found (absent means the run is not failing whole), and `run`, how the
|
|
743
|
+
* run was started ({@link rerunOfRunningSuite}) when the caller knows it.
|
|
712
744
|
* @returns The note.
|
|
713
745
|
*/
|
|
714
746
|
export declare function repeatingFailureNote(found: RepeatingFailure, o: {
|
|
@@ -716,6 +748,10 @@ export declare function repeatingFailureNote(found: RepeatingFailure, o: {
|
|
|
716
748
|
log: string;
|
|
717
749
|
stop: string;
|
|
718
750
|
universal?: UniversalCause | null;
|
|
751
|
+
run?: {
|
|
752
|
+
command: string;
|
|
753
|
+
cwd?: string;
|
|
754
|
+
};
|
|
719
755
|
}): string;
|
|
720
756
|
/**
|
|
721
757
|
* Attempt a whitespace-tolerant replacement when an exact `old_string` match
|