@vybestack/llxprt-code-tools 0.10.0 → 0.11.0-nightly.260728.ffaa8d5d7

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.
Files changed (56) hide show
  1. package/dist/src/index.d.ts +3 -1
  2. package/dist/src/index.js +2 -0
  3. package/dist/src/index.js.map +1 -1
  4. package/dist/src/interfaces/IShellToolHost.d.ts +2 -1
  5. package/dist/src/tools/ast-edit/ast-edit-invocation.d.ts +3 -2
  6. package/dist/src/tools/ast-edit/ast-edit-invocation.js +38 -11
  7. package/dist/src/tools/ast-edit/ast-edit-invocation.js.map +1 -1
  8. package/dist/src/tools/ast-edit/ast-read-file-invocation.d.ts +2 -2
  9. package/dist/src/tools/ast-edit/ast-read-file-invocation.js.map +1 -1
  10. package/dist/src/tools/ast-edit/edit-calculator.d.ts +6 -3
  11. package/dist/src/tools/ast-edit/edit-calculator.js +24 -6
  12. package/dist/src/tools/ast-edit/edit-calculator.js.map +1 -1
  13. package/dist/src/tools/ast-edit/validation-categorizer.d.ts +58 -0
  14. package/dist/src/tools/ast-edit/validation-categorizer.js +207 -0
  15. package/dist/src/tools/ast-edit/validation-categorizer.js.map +1 -0
  16. package/dist/src/tools/codesearch.js.map +1 -1
  17. package/dist/src/tools/direct-web-fetch.js.map +1 -1
  18. package/dist/src/tools/exa-web-search.js.map +1 -1
  19. package/dist/src/tools/shell.d.ts +2 -2
  20. package/dist/src/tools/shell.js +17 -3
  21. package/dist/src/tools/shell.js.map +1 -1
  22. package/dist/src/tools/todo-pause.d.ts +2 -2
  23. package/dist/src/tools/todo-pause.js +1 -1
  24. package/dist/src/tools/todo-pause.js.map +1 -1
  25. package/dist/src/tools/todo-read.d.ts +2 -2
  26. package/dist/src/tools/todo-read.js +1 -1
  27. package/dist/src/tools/todo-read.js.map +1 -1
  28. package/dist/src/tools/todo-write.d.ts +2 -2
  29. package/dist/src/tools/todo-write.js +1 -1
  30. package/dist/src/tools/todo-write.js.map +1 -1
  31. package/dist/src/tools/tool-registry.d.ts +3 -3
  32. package/dist/src/tools/tool-registry.js +6 -43
  33. package/dist/src/tools/tool-registry.js.map +1 -1
  34. package/dist/src/tools/tools.d.ts +6 -5
  35. package/dist/src/tools/tools.js.map +1 -1
  36. package/dist/src/utils/fileUtils.js +8 -1
  37. package/dist/src/utils/fileUtils.js.map +1 -1
  38. package/dist/src/utils/terminalSerializer.d.ts +12 -0
  39. package/package.json +151 -1
  40. package/src/index.ts +11 -0
  41. package/src/interfaces/IShellToolHost.ts +3 -1
  42. package/src/tools/ast-edit/ast-edit-invocation.ts +82 -12
  43. package/src/tools/ast-edit/ast-read-file-invocation.ts +2 -2
  44. package/src/tools/ast-edit/edit-calculator.ts +26 -6
  45. package/src/tools/ast-edit/validation-categorizer.ts +263 -0
  46. package/src/tools/codesearch.ts +2 -1
  47. package/src/tools/direct-web-fetch.ts +2 -1
  48. package/src/tools/exa-web-search.ts +2 -1
  49. package/src/tools/shell.ts +19 -6
  50. package/src/tools/todo-pause.ts +7 -2
  51. package/src/tools/todo-read.ts +7 -2
  52. package/src/tools/todo-write.ts +7 -2
  53. package/src/tools/tool-registry.ts +16 -82
  54. package/src/tools/tools.ts +10 -6
  55. package/src/utils/fileUtils.ts +11 -1
  56. package/src/utils/terminalSerializer.ts +9 -0
package/src/index.ts CHANGED
@@ -236,6 +236,7 @@ export type {
236
236
  ToolInfoConfirmationDetails,
237
237
  ToolCallConfirmationDetails,
238
238
  ToolLocation,
239
+ LiveOutputUpdate,
239
240
  } from './tools/tools.js';
240
241
 
241
242
  export {
@@ -294,6 +295,7 @@ export {
294
295
  OUTPUT_UPDATE_INTERVAL_MS,
295
296
  type ShellToolParams,
296
297
  } from './tools/shell.js';
298
+ export { buildCommandToExecute } from './tools/shell-helpers.js';
297
299
  export {
298
300
  DISCOVERED_TOOL_PREFIX,
299
301
  ToolRegistry,
@@ -428,6 +430,15 @@ export { ASTConfig } from './tools/ast-edit/ast-config.js';
428
430
  export { ASTQueryExtractor } from './tools/ast-edit/ast-query-extractor.js';
429
431
  export { RepositoryContextProvider } from './tools/ast-edit/repository-context-provider.js';
430
432
  export { validateASTSyntax } from './tools/ast-edit/edit-calculator.js';
433
+ export {
434
+ summarizeAstValidation,
435
+ computeLineDelta,
436
+ findEditStartLine,
437
+ extractErrorLineNumber,
438
+ formatValidationLineLabel,
439
+ type AstValidationResult,
440
+ type AstValidationSummary,
441
+ } from './tools/ast-edit/validation-categorizer.js';
431
442
  export {
432
443
  detectLanguage,
433
444
  extractImports,
@@ -9,6 +9,8 @@
9
9
  * SPDX-License-Identifier: Apache-2.0
10
10
  */
11
11
 
12
+ import type { AnsiOutput } from '../utils/terminalSerializer.js';
13
+
12
14
  /**
13
15
  * Tools-owned interface for shell tool host dependencies.
14
16
  *
@@ -210,6 +212,6 @@ export interface IShellToolHost {
210
212
  */
211
213
  export interface ShellOutputEvent {
212
214
  type: 'data' | 'binary_detected' | 'binary_progress';
213
- chunk?: string;
215
+ chunk?: string | AnsiOutput;
214
216
  bytesReceived?: number;
215
217
  }
@@ -32,7 +32,7 @@ import { toIdeConnectionStatus } from '../edit-utils.js';
32
32
  import { hasLspCap } from '../../interfaces/host-capabilities.js';
33
33
  import { DEFAULT_CREATE_PATCH_OPTIONS } from '../../utils/diffOptions.js';
34
34
  import { collectLspDiagnosticsBlock } from '../../utils/lsp-diagnostics-helper.js';
35
- import type { AnsiOutput } from '../../utils/terminalSerializer.js';
35
+ import type { LiveOutputUpdate } from '../../utils/terminalSerializer.js';
36
36
  import { ensureParentDirectoriesExist } from '../../utils/ensure-dirs.js';
37
37
 
38
38
  import type { ASTEditToolParams } from './types.js';
@@ -44,6 +44,13 @@ import {
44
44
  getFileLastModified,
45
45
  type CalculatedEdit,
46
46
  } from './edit-calculator.js';
47
+ import {
48
+ summarizeAstValidation,
49
+ computeLineDelta,
50
+ findEditStartLine,
51
+ formatValidationLineLabel,
52
+ type AstValidationResult,
53
+ } from './validation-categorizer.js';
47
54
 
48
55
  function normalizeSeverity(severity: unknown): string {
49
56
  if (typeof severity !== 'number') {
@@ -144,6 +151,7 @@ export class ASTEditToolInvocation
144
151
  ideConfirmation,
145
152
  metadata: {
146
153
  astValidation: editData.astValidation,
154
+ preEditValidation: editData.preEditValidation,
147
155
  fileFreshness: editData.fileFreshness,
148
156
  },
149
157
  };
@@ -178,7 +186,7 @@ export class ASTEditToolInvocation
178
186
 
179
187
  async execute(
180
188
  signal: AbortSignal,
181
- _updateOutput?: (output: string | AnsiOutput) => void,
189
+ _updateOutput?: (update: LiveOutputUpdate) => void,
182
190
  _terminalColumns?: number,
183
191
  _terminalRows?: number,
184
192
  _setPidCallback?: (pid: number) => void,
@@ -217,9 +225,8 @@ export class ASTEditToolInvocation
217
225
  workspaceRoot,
218
226
  );
219
227
 
220
- const astValidation =
221
- editData.astValidation ??
222
- this.validateASTSyntax(this.params.file_path, editData.newContent);
228
+ const { astValidation, preEditValidation, lineDelta, editStartLine } =
229
+ this.computeValidationContext(editData);
223
230
 
224
231
  const fileName = path.basename(this.params.file_path);
225
232
  const fileDiff = Diff.createPatch(
@@ -234,6 +241,9 @@ export class ASTEditToolInvocation
234
241
  const editPreviewLlmContent = this.buildPreviewLlmContent(
235
242
  enhancedContext,
236
243
  astValidation,
244
+ preEditValidation,
245
+ lineDelta,
246
+ editStartLine,
237
247
  currentMtime,
238
248
  workspaceRoot,
239
249
  );
@@ -243,7 +253,7 @@ export class ASTEditToolInvocation
243
253
  fileName,
244
254
  originalContent: currentContent,
245
255
  newContent: editData.newContent,
246
- metadata: { astValidation, currentMtime },
256
+ metadata: { astValidation, preEditValidation, currentMtime },
247
257
  };
248
258
 
249
259
  return {
@@ -263,20 +273,60 @@ export class ASTEditToolInvocation
263
273
  }
264
274
  }
265
275
 
276
+ private computeValidationContext(editData: CalculatedEdit): {
277
+ astValidation: { valid: boolean; errors: string[] };
278
+ preEditValidation: AstValidationResult | undefined;
279
+ lineDelta: number;
280
+ editStartLine: number | null;
281
+ } {
282
+ const astValidation =
283
+ editData.astValidation ??
284
+ this.validateASTSyntax(this.params.file_path, editData.newContent);
285
+ const preEditValidation: AstValidationResult | undefined =
286
+ editData.preEditValidation ??
287
+ (editData.currentContent !== null
288
+ ? this.validateASTSyntax(this.params.file_path, editData.currentContent)
289
+ : undefined);
290
+ const lineDelta = computeLineDelta(
291
+ this.params.old_string,
292
+ this.params.new_string,
293
+ );
294
+ const editStartLine = findEditStartLine(
295
+ editData.currentContent,
296
+ this.params.old_string,
297
+ );
298
+ return { astValidation, preEditValidation, lineDelta, editStartLine };
299
+ }
300
+
266
301
  private buildPreviewLlmContent(
267
302
  enhancedContext: Awaited<
268
303
  ReturnType<ASTContextCollector['collectEnhancedContext']>
269
304
  >,
270
305
  astValidation: { valid: boolean; errors: string[] },
306
+ preEditValidation: { valid: boolean; errors: string[] } | undefined,
307
+ lineDelta: number,
308
+ editStartLine: number | null,
271
309
  currentMtime: number | null,
272
310
  workspaceRoot: string,
273
311
  ): string {
312
+ const summary = summarizeAstValidation(
313
+ preEditValidation,
314
+ astValidation,
315
+ lineDelta,
316
+ editStartLine,
317
+ );
274
318
  return [
275
319
  `LLXPRT EDIT PREVIEW: ${this.params.file_path}`,
276
320
  `- Context: ${enhancedContext.language} file with ${enhancedContext.declarations.length} declarations`,
277
321
  `- Functions: ${enhancedContext.languageContext.functions.length}`,
278
322
  `- Classes: ${enhancedContext.languageContext.classes.length}`,
279
- `- AST validation: ${astValidation.valid ? 'PASSED' : 'FAILED'}`,
323
+ `- AST validation: ${summary.label}`,
324
+ preEditValidation && !preEditValidation.valid
325
+ ? `- Pre-existing syntax errors: Yes${formatValidationLineLabel(preEditValidation.errors)}`
326
+ : '',
327
+ !astValidation.valid
328
+ ? `- AST errors: ${astValidation.errors.join(', ')}`
329
+ : '',
280
330
  `- Relevant snippets: ${enhancedContext.relevantSnippets.length} found`,
281
331
  enhancedContext.repositoryContext
282
332
  ? `- Repository: ${enhancedContext.repositoryContext.gitUrl}`
@@ -285,9 +335,6 @@ export class ASTEditToolInvocation
285
335
  ? `- Related files: ${enhancedContext.relatedFiles.length}`
286
336
  : '',
287
337
  this.formatConnectedFilesContext(enhancedContext, workspaceRoot),
288
- !astValidation.valid
289
- ? `- AST errors: ${astValidation.errors.join(', ')}`
290
- : '',
291
338
  currentMtime !== null ? `- Timestamp: ${currentMtime}` : '',
292
339
  '',
293
340
  'ENHANCED CONTEXT ANALYSIS:',
@@ -408,21 +455,44 @@ export class ASTEditToolInvocation
408
455
  DEFAULT_CREATE_PATCH_OPTIONS,
409
456
  );
410
457
 
458
+ const { astValidation, preEditValidation, lineDelta, editStartLine } =
459
+ this.computeValidationContext(editData);
411
460
  const displayResult = {
412
461
  fileDiff,
413
462
  fileName,
414
463
  originalContent: editData.currentContent,
415
464
  newContent: contentToWrite,
416
465
  applied: true,
417
- metadata: { astValidation: editData.astValidation },
466
+ metadata: {
467
+ astValidation,
468
+ preEditValidation,
469
+ },
418
470
  };
471
+ const summary = summarizeAstValidation(
472
+ preEditValidation,
473
+ astValidation,
474
+ lineDelta,
475
+ editStartLine,
476
+ );
419
477
 
420
478
  const llmSuccessMessageParts: string[] = [
421
479
  `Successfully applied edit to: ${this.params.file_path}`,
422
480
  `- Changes: ${editData.occurrences} replacement(s) applied`,
423
- `- AST validation: ${editData.astValidation?.valid === true ? 'PASSED' : 'FAILED'}`,
481
+ `- AST validation: ${summary.label}`,
424
482
  ];
425
483
 
484
+ if (preEditValidation && !preEditValidation.valid) {
485
+ llmSuccessMessageParts.push(
486
+ `- Pre-existing syntax errors: Yes${formatValidationLineLabel(preEditValidation.errors)}${summary.newlyIntroduced ? '' : ' (not introduced by this edit)'}`,
487
+ );
488
+ }
489
+
490
+ if (summary.newlyIntroduced) {
491
+ llmSuccessMessageParts.push(
492
+ `- AST errors: ${astValidation.errors.join(', ')}`,
493
+ );
494
+ }
495
+
426
496
  await this.appendLspDiagnostics(llmSuccessMessageParts);
427
497
 
428
498
  return {
@@ -18,7 +18,7 @@ import { makeRelative, shortenPath } from '../../utils/paths.js';
18
18
  import { isNodeError } from '../../utils/errors.js';
19
19
  import { countLines } from '../../utils/fileUtils.js';
20
20
  import type { IToolHost } from '../../interfaces/index.js';
21
- import type { AnsiOutput } from '../../utils/terminalSerializer.js';
21
+ import type { LiveOutputUpdate } from '../../utils/terminalSerializer.js';
22
22
 
23
23
  import type { ASTReadFileToolParams } from './types.js';
24
24
  import { ASTConfig } from './ast-config.js';
@@ -51,7 +51,7 @@ export class ASTReadFileToolInvocation
51
51
 
52
52
  async execute(
53
53
  _signal?: AbortSignal,
54
- _updateOutput?: (output: string | AnsiOutput) => void,
54
+ _updateOutput?: (update: LiveOutputUpdate) => void,
55
55
  _terminalColumns?: number,
56
56
  _terminalRows?: number,
57
57
  _setPidCallback?: (pid: number) => void,
@@ -26,6 +26,7 @@ export interface CalculatedEdit {
26
26
  error?: { display: string; raw: string; type: ToolErrorType };
27
27
  isNewFile: boolean;
28
28
  astValidation?: { valid: boolean; errors: string[] };
29
+ preEditValidation?: { valid: boolean; errors: string[] };
29
30
  fileFreshness?: number | null;
30
31
  }
31
32
 
@@ -86,6 +87,14 @@ export async function calculateEdit(
86
87
  params.file_path,
87
88
  );
88
89
 
90
+ // Validate the original (pre-edit) content so post-edit errors can be
91
+ // categorized as pre-existing vs newly-introduced (issue #2124).
92
+ // Skipped for new files where there is no prior content to baseline.
93
+ const preEditValidation =
94
+ !noChangeError && currentContent !== null
95
+ ? validateASTSyntax(params.file_path, currentContent)
96
+ : undefined;
97
+
89
98
  let astValidation: { valid: boolean; errors: string[] } | undefined;
90
99
  if (!noChangeError) {
91
100
  astValidation = validateASTSyntax(params.file_path, newContent);
@@ -98,6 +107,7 @@ export async function calculateEdit(
98
107
  error: noChangeError,
99
108
  isNewFile,
100
109
  astValidation,
110
+ preEditValidation,
101
111
  fileFreshness: currentMtime,
102
112
  };
103
113
  }
@@ -164,6 +174,7 @@ function checkFreshness(
164
174
  },
165
175
  isNewFile: false,
166
176
  astValidation: undefined,
177
+ preEditValidation: undefined,
167
178
  fileFreshness: currentMtime,
168
179
  };
169
180
  }
@@ -203,6 +214,12 @@ function validateEditParams(
203
214
  raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. No edits made.`,
204
215
  type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND,
205
216
  };
217
+ } else if (occurrences > 1) {
218
+ error = {
219
+ display: `old_string appears ${occurrences} times. Include more surrounding context to target a specific occurrence.`,
220
+ raw: `old_string appears ${occurrences} times in ${params.file_path}. Provide more surrounding context to disambiguate.`,
221
+ type: ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH,
222
+ };
206
223
  } else if (normalizedOldString === normalizedNewString) {
207
224
  error = {
208
225
  display: `No changes to apply. The old_string and new_string are identical.`,
@@ -234,12 +251,11 @@ function checkNoChange(
234
251
  }
235
252
 
236
253
  /**
237
- * Counts occurrences that will be replaced (0 or 1).
238
- * Returns 0/1 not true count, aligned with String.replace() single-replacement semantics.
254
+ * Returns the true number of occurrences of searchString in content.
239
255
  *
240
256
  * @param content - File content
241
257
  * @param searchString - String to search for
242
- * @returns 0 if not found, 1 if found
258
+ * @returns Number of occurrences (0 if not found)
243
259
  */
244
260
  export function countOccurrences(
245
261
  content: string,
@@ -247,9 +263,13 @@ export function countOccurrences(
247
263
  ): number {
248
264
  if (!searchString) return 0;
249
265
 
250
- // Since applyReplacement uses String.replace (single replacement),
251
- // count occurrences that will actually be replaced (0 or 1)
252
- return content.includes(searchString) ? 1 : 0;
266
+ let count = 0;
267
+ let idx = content.indexOf(searchString);
268
+ while (idx !== -1) {
269
+ count++;
270
+ idx = content.indexOf(searchString, idx + searchString.length);
271
+ }
272
+ return count;
253
273
  }
254
274
 
255
275
  /**
@@ -0,0 +1,263 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Vybestack LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ *
6
+ * Categorizes AST validation results into pre-existing vs newly-introduced
7
+ * errors by comparing the pre-edit and post-edit parse trees.
8
+ *
9
+ * Background (issue #2124): tree-sitter is error-recovering and will report
10
+ * ERROR/MISSING nodes for any pre-existing syntax weirdness in a file. Without
11
+ * a baseline, every post-edit validation failure looks like the edit's fault,
12
+ * causing the LLM to either blindly force the edit or abandon it for a less
13
+ * capable tool. Comparing against the pre-edit baseline lets us tell the LLM
14
+ * whether the failure was already there.
15
+ */
16
+
17
+ export type AstValidationResult = { valid: boolean; errors: string[] };
18
+
19
+ export interface AstValidationSummary {
20
+ /** Post-edit validation status. */
21
+ status: 'PASSED' | 'FAILED' | 'SKIPPED';
22
+ /** True when the post-edit error(s) also existed in the pre-edit content. */
23
+ preExisting: boolean;
24
+ /** True when the edit likely introduced an error that did not exist before. */
25
+ newlyIntroduced: boolean;
26
+ /** One-line label suitable for LLM-facing output. */
27
+ label: string;
28
+ }
29
+
30
+ /**
31
+ * Extracts the leading line number from a validation error message such as
32
+ * "Syntax error at line 173, column 5". Returns null when no line is found.
33
+ */
34
+ export function extractErrorLineNumber(error: string): number | null {
35
+ const match = /line (\d+)/i.exec(error);
36
+ return match ? parseInt(match[1], 10) : null;
37
+ }
38
+
39
+ interface ErrorLocation {
40
+ line: number;
41
+ column: number;
42
+ }
43
+
44
+ function extractErrorLocation(error: string): ErrorLocation | null {
45
+ const match = /line (\d+), column (\d+)/i.exec(error);
46
+ if (!match) return null;
47
+ return {
48
+ line: parseInt(match[1], 10),
49
+ column: parseInt(match[2], 10),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Finds the 1-based line number where an edit begins in the original content.
55
+ * Returns null when the position cannot be determined (e.g. new file) or when
56
+ * oldString appears more than once (ambiguous — indexOf would return the first
57
+ * match, but the actual edit could target a different occurrence, making the
58
+ * computed line unreliable for shift matching).
59
+ */
60
+ export function findEditStartLine(
61
+ content: string | null,
62
+ oldString: string,
63
+ ): number | null {
64
+ if (!content || !oldString) return null;
65
+ const firstIdx = content.indexOf(oldString);
66
+ if (firstIdx === -1) return null;
67
+ // Ambiguous: oldString appears more than once, so we cannot reliably
68
+ // determine which occurrence the edit targets. Return null to fall back
69
+ // to the less precise (but safe) lineDelta-based matching.
70
+ if (content.indexOf(oldString, firstIdx + 1) !== -1) return null;
71
+ return content.substring(0, firstIdx).split('\n').length;
72
+ }
73
+
74
+ /** Counts newline delimiters, which determine shifts below an edit. */
75
+ function countLineBreaks(s: string | undefined): number {
76
+ if (!s) return 0;
77
+ return s.split('\n').length - 1;
78
+ }
79
+
80
+ /**
81
+ * Computes the net line delta an edit introduces. A positive value means the
82
+ * edit added lines (shifting errors below it downward); negative means removed.
83
+ */
84
+ export function computeLineDelta(
85
+ oldString: string | undefined,
86
+ newString: string | undefined,
87
+ ): number {
88
+ return countLineBreaks(newString) - countLineBreaks(oldString);
89
+ }
90
+
91
+ /**
92
+ * Determines whether a pre-edit error line and a post-edit error line refer to
93
+ * the same location, accounting for line shifts caused by the edit.
94
+ *
95
+ * When the edit's start line is known, errors at or below the edit must have
96
+ * shifted by `lineDelta`, while errors above it did not shift — this avoids the
97
+ * false positive of matching a newly-introduced error to a nearby pre-existing
98
+ * one. The validator emits deterministic line and column coordinates, so both
99
+ * must match exactly after applying the known line shift.
100
+ *
101
+ * When the edit position is unknown, the two cases are made mutually exclusive
102
+ * based on whether `lineDelta` is zero, which is the safest fallback.
103
+ */
104
+ function locationsMatch(
105
+ pre: ErrorLocation,
106
+ post: ErrorLocation,
107
+ lineDelta: number,
108
+ editStartLine: number | null,
109
+ ): boolean {
110
+ if (pre.column !== post.column) return false;
111
+
112
+ if (editStartLine !== null) {
113
+ if (pre.line > editStartLine) {
114
+ return pre.line + lineDelta === post.line;
115
+ }
116
+ if (pre.line === editStartLine) {
117
+ return false;
118
+ }
119
+ return pre.line === post.line;
120
+ }
121
+
122
+ return lineDelta === 0 && pre.line === post.line;
123
+ }
124
+
125
+ /**
126
+ * Categorizes post-edit AST validation results relative to the pre-edit
127
+ * baseline so the LLM understands whether failures are its fault.
128
+ *
129
+ * @param preEdit - Validation result of the original file content.
130
+ * @param postEdit - Validation result of the edited file content.
131
+ * @param lineDelta - Net lines added/removed by the edit (for shift matching).
132
+ * @param editStartLine - 1-based line where the edit begins (for precise matching).
133
+ */
134
+ export function summarizeAstValidation(
135
+ preEdit: AstValidationResult | undefined,
136
+ postEdit: AstValidationResult | undefined,
137
+ lineDelta = 0,
138
+ editStartLine: number | null = null,
139
+ ): AstValidationSummary {
140
+ if (!postEdit) {
141
+ return {
142
+ status: 'SKIPPED',
143
+ preExisting: false,
144
+ newlyIntroduced: false,
145
+ label: 'SKIPPED (validation not performed)',
146
+ };
147
+ }
148
+
149
+ // Post-edit is valid.
150
+ if (postEdit.valid) {
151
+ if (preEdit && !preEdit.valid) {
152
+ return {
153
+ status: 'PASSED',
154
+ preExisting: false,
155
+ newlyIntroduced: false,
156
+ label: `PASSED (edit may have resolved pre-existing error${formatLineLabel(preEdit.errors)})`,
157
+ };
158
+ }
159
+ return {
160
+ status: 'PASSED',
161
+ preExisting: false,
162
+ newlyIntroduced: false,
163
+ label: 'PASSED',
164
+ };
165
+ }
166
+
167
+ // Post-edit is invalid.
168
+ const postErrors = postEdit.errors;
169
+ const preHadErrors = preEdit !== undefined && !preEdit.valid;
170
+
171
+ // Pre-edit was clean (or no baseline / new file) → the edit introduced the error.
172
+ if (!preHadErrors) {
173
+ return {
174
+ status: 'FAILED',
175
+ preExisting: false,
176
+ newlyIntroduced: true,
177
+ label: `FAILED (new error introduced by this edit${formatLineLabel(postErrors)})`,
178
+ };
179
+ }
180
+
181
+ // Both pre- and post-edit are invalid. Determine whether every post-edit
182
+ // error corresponds to the same location as a pre-edit one. Iterate over the
183
+ // raw error strings (not just those with parseable line numbers) so that
184
+ // unparseable post-edit errors are conservatively treated as unmatched
185
+ // rather than silently dropped.
186
+ const unmatchedPreLocations = preEdit.errors
187
+ .map(extractErrorLocation)
188
+ .filter((location): location is ErrorLocation => location !== null);
189
+ let matchedCount = 0;
190
+ let allMatched = postErrors.length > 0;
191
+ for (const postErr of postErrors) {
192
+ const postLocation = extractErrorLocation(postErr);
193
+ const matchingIndex =
194
+ postLocation === null
195
+ ? -1
196
+ : unmatchedPreLocations.findIndex((preLocation) =>
197
+ locationsMatch(preLocation, postLocation, lineDelta, editStartLine),
198
+ );
199
+ if (matchingIndex === -1) {
200
+ allMatched = false;
201
+ break;
202
+ }
203
+ matchedCount++;
204
+ unmatchedPreLocations.splice(matchingIndex, 1);
205
+ }
206
+
207
+ if (allMatched) {
208
+ return {
209
+ status: 'FAILED',
210
+ preExisting: true,
211
+ newlyIntroduced: false,
212
+ label: `FAILED (pre-existing error${formatLineLabel(postErrors)} — present before this edit)`,
213
+ };
214
+ }
215
+
216
+ // File was already broken, but the post-edit errors don't line up with the
217
+ // pre-existing ones. They may be cascading from the same root cause or newly
218
+ // introduced; surface both for the LLM to judge.
219
+ return formatMixedValidationSummary(
220
+ preEdit.errors,
221
+ postErrors,
222
+ matchedCount > 0,
223
+ );
224
+ }
225
+
226
+ function formatMixedValidationSummary(
227
+ preErrors: string[],
228
+ postErrors: string[],
229
+ hasMatchedError: boolean,
230
+ ): AstValidationSummary {
231
+ const preErrorNoun = preErrors.length === 1 ? 'error' : 'errors';
232
+ let postErrorDescription =
233
+ 'post-edit validation failed without error details';
234
+ if (postErrors.length > 0) {
235
+ const postErrorNoun = postErrors.length === 1 ? 'error' : 'errors';
236
+ postErrorDescription = `post-edit ${postErrorNoun}${formatLineLabel(postErrors)} may be newly introduced`;
237
+ }
238
+ return {
239
+ status: 'FAILED',
240
+ preExisting: hasMatchedError,
241
+ newlyIntroduced: true,
242
+ label: `FAILED (file had pre-existing ${preErrorNoun}${formatLineLabel(preErrors)}; ${postErrorDescription})`,
243
+ };
244
+ }
245
+
246
+ function extractErrorLineNumbers(errors: string[]): number[] {
247
+ const lines: number[] = [];
248
+ for (const err of errors) {
249
+ const line = extractErrorLineNumber(err);
250
+ if (line !== null) lines.push(line);
251
+ }
252
+ return lines;
253
+ }
254
+
255
+ export function formatValidationLineLabel(errors: string[]): string {
256
+ const lines = extractErrorLineNumbers(errors);
257
+ if (lines.length === 0) return '';
258
+ return ` at line${lines.length > 1 ? 's' : ''} ${lines.join(', ')}`;
259
+ }
260
+
261
+ function formatLineLabel(errors: string[]): string {
262
+ return formatValidationLineLabel(errors);
263
+ }
@@ -23,6 +23,7 @@ import {
23
23
  Kind,
24
24
  type ToolInvocation,
25
25
  type ToolResult,
26
+ type LiveOutputUpdate,
26
27
  } from './tools.js';
27
28
 
28
29
  const API_CONFIG = {
@@ -140,7 +141,7 @@ class CodeSearchToolInvocation extends BaseToolInvocation<
140
141
 
141
142
  async execute(
142
143
  signal: AbortSignal,
143
- _updateOutput?: (output: string) => void,
144
+ _updateOutput?: (update: LiveOutputUpdate) => void,
144
145
  ): Promise<ToolResult> {
145
146
  const codeRequest: McpCodeRequest = {
146
147
  jsonrpc: '2.0',
@@ -14,6 +14,7 @@ import {
14
14
  Kind,
15
15
  type ToolInvocation,
16
16
  type ToolResult,
17
+ type LiveOutputUpdate,
17
18
  } from './tools.js';
18
19
  import { DIRECT_WEB_FETCH_TOOL } from '../types/tool-names.js';
19
20
  import { ToolErrorType } from '../types/tool-error.js';
@@ -103,7 +104,7 @@ class DirectWebFetchToolInvocation extends BaseToolInvocation<
103
104
 
104
105
  async execute(
105
106
  signal: AbortSignal,
106
- _updateOutput?: (output: string) => void,
107
+ _updateOutput?: (update: LiveOutputUpdate) => void,
107
108
  ): Promise<ToolResult> {
108
109
  const protocolError = this.validateUrlProtocol();
109
110
  if (protocolError) return protocolError;
@@ -19,6 +19,7 @@ import {
19
19
  Kind,
20
20
  type ToolInvocation,
21
21
  type ToolResult,
22
+ type LiveOutputUpdate,
22
23
  } from './tools.js';
23
24
 
24
25
  const API_CONFIG = {
@@ -154,7 +155,7 @@ class ExaWebSearchToolInvocation extends BaseToolInvocation<
154
155
 
155
156
  async execute(
156
157
  signal: AbortSignal,
157
- _updateOutput?: (output: string) => void,
158
+ _updateOutput?: (update: LiveOutputUpdate) => void,
158
159
  ): Promise<ToolResult> {
159
160
  const searchRequest: McpSearchRequest = {
160
161
  jsonrpc: '2.0',