@agi-cli/sdk 0.1.80 → 0.1.82

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.
@@ -1,757 +1,52 @@
1
1
  import { tool, type Tool } from 'ai';
2
2
  import { z } from 'zod';
3
- import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
4
- import { dirname, resolve, relative, isAbsolute } from 'node:path';
5
3
  import DESCRIPTION from './patch.txt' with { type: 'text' };
6
4
  import { createToolError, type ToolResponse } from '../error.ts';
7
-
8
- interface PatchAddOperation {
9
- kind: 'add';
10
- filePath: string;
11
- lines: string[];
12
- }
13
-
14
- interface PatchDeleteOperation {
15
- kind: 'delete';
16
- filePath: string;
17
- }
18
-
19
- interface PatchUpdateOperation {
20
- kind: 'update';
21
- filePath: string;
22
- hunks: PatchHunk[];
23
- }
24
-
25
- type ParsedPatchOperation =
26
- | PatchAddOperation
27
- | PatchDeleteOperation
28
- | PatchUpdateOperation;
29
-
30
- type PatchLineKind = 'context' | 'add' | 'remove';
31
-
32
- interface PatchHunkLine {
33
- kind: PatchLineKind;
34
- content: string;
35
- }
36
-
37
- interface PatchHunkHeader {
38
- oldStart?: number;
39
- oldLines?: number;
40
- newStart?: number;
41
- newLines?: number;
42
- context?: string;
43
- }
44
-
45
- interface PatchHunk {
46
- header: PatchHunkHeader;
47
- lines: PatchHunkLine[];
48
- }
49
-
50
- interface PatchStats {
51
- additions: number;
52
- deletions: number;
53
- }
54
-
55
- interface AppliedHunkResult {
56
- header: PatchHunkHeader;
57
- lines: PatchHunkLine[];
58
- oldStart: number;
59
- oldLines: number;
60
- newStart: number;
61
- newLines: number;
62
- additions: number;
63
- deletions: number;
64
- }
65
-
66
- interface AppliedOperationRecord {
67
- kind: 'add' | 'delete' | 'update';
68
- filePath: string;
69
- operation: ParsedPatchOperation;
70
- stats: PatchStats;
71
- hunks: AppliedHunkResult[];
72
- }
73
-
74
- const PATCH_BEGIN_MARKER = '*** Begin Patch';
75
- const PATCH_END_MARKER = '*** End Patch';
76
- const PATCH_ADD_PREFIX = '*** Add File:';
77
- const PATCH_UPDATE_PREFIX = '*** Update File:';
78
- const PATCH_DELETE_PREFIX = '*** Delete File:';
79
-
80
- function isErrnoException(value: unknown): value is NodeJS.ErrnoException {
81
- return (
82
- value instanceof Error &&
83
- typeof (value as NodeJS.ErrnoException).code === 'string'
84
- );
85
- }
86
-
87
- function splitLines(value: string): { lines: string[]; newline: string } {
88
- const newline = value.includes('\r\n') ? '\r\n' : '\n';
89
- const normalized = newline === '\n' ? value : value.replace(/\r\n/g, '\n');
90
- const parts = normalized.split('\n');
91
-
92
- if (parts.length > 0 && parts[parts.length - 1] === '') {
93
- parts.pop();
94
- }
95
-
96
- return { lines: parts, newline };
97
- }
98
-
99
- function joinLines(lines: string[], newline: string): string {
100
- const base = lines.join('\n');
101
- return newline === '\n' ? base : base.replace(/\n/g, newline);
102
- }
103
-
104
- function ensureTrailingNewline(lines: string[]) {
105
- if (lines.length === 0 || lines[lines.length - 1] !== '') {
106
- lines.push('');
107
- }
108
- }
109
-
110
- /**
111
- * Normalize whitespace for fuzzy matching.
112
- * Converts tabs to spaces and trims leading/trailing whitespace.
113
- */
114
- function normalizeWhitespace(line: string): string {
115
- return line.replace(/\t/g, ' ').trim();
116
- }
117
-
118
- /**
119
- * Find subsequence with optional whitespace normalization for fuzzy matching.
120
- * Falls back to normalized matching if exact match fails.
121
- */
122
- function findSubsequenceWithFuzzy(
123
- lines: string[],
124
- pattern: string[],
125
- startIndex: number,
126
- useFuzzy: boolean,
127
- ): number {
128
- // Try exact match first
129
- const exactMatch = findSubsequence(lines, pattern, startIndex);
130
- if (exactMatch !== -1) return exactMatch;
131
-
132
- // If fuzzy matching is enabled and exact match failed, try normalized matching
133
- if (useFuzzy && pattern.length > 0) {
134
- const normalizedLines = lines.map(normalizeWhitespace);
135
- const normalizedPattern = pattern.map(normalizeWhitespace);
136
-
137
- const start = Math.max(0, startIndex);
138
- for (let i = start; i <= lines.length - pattern.length; i++) {
139
- let matches = true;
140
- for (let j = 0; j < pattern.length; j++) {
141
- if (normalizedLines[i + j] !== normalizedPattern[j]) {
142
- matches = false;
143
- break;
144
- }
145
- }
146
- if (matches) return i;
147
- }
148
- }
149
-
150
- return -1;
151
- }
152
-
153
- function findSubsequence(
154
- lines: string[],
155
- pattern: string[],
156
- startIndex: number,
157
- ): number {
158
- if (pattern.length === 0) return -1;
159
- const start = Math.max(0, startIndex);
160
- for (let i = start; i <= lines.length - pattern.length; i++) {
161
- let matches = true;
162
- for (let j = 0; j < pattern.length; j++) {
163
- if (lines[i + j] !== pattern[j]) {
164
- matches = false;
165
- break;
166
- }
167
- }
168
- if (matches) return i;
169
- }
170
- return -1;
171
- }
172
-
173
- function parseDirectivePath(line: string, prefix: string): string {
174
- const filePath = line.slice(prefix.length).trim();
175
- if (!filePath) {
176
- throw new Error(`Missing file path for directive: ${line}`);
177
- }
178
- if (filePath.startsWith('/') || isAbsolute(filePath)) {
179
- throw new Error('Patch file paths must be relative to the project root.');
180
- }
181
- return filePath;
182
- }
183
-
184
- function parseHunkHeader(raw: string): PatchHunkHeader {
185
- const numericMatch = raw.match(
186
- /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/,
187
- );
188
- if (numericMatch) {
189
- const [, oldStart, oldCount, newStart, newCount, context] = numericMatch;
190
- return {
191
- oldStart: Number.parseInt(oldStart, 10),
192
- oldLines: oldCount ? Number.parseInt(oldCount, 10) : undefined,
193
- newStart: Number.parseInt(newStart, 10),
194
- newLines: newCount ? Number.parseInt(newCount, 10) : undefined,
195
- context: context?.trim() || undefined,
196
- };
197
- }
198
-
199
- const context = raw.replace(/^@@/, '').trim();
200
- return context ? { context } : {};
201
- }
202
-
203
- function parseEnvelopedPatch(patch: string): ParsedPatchOperation[] {
204
- const normalized = patch.replace(/\r\n/g, '\n');
205
- const lines = normalized.split('\n');
206
- const operations: ParsedPatchOperation[] = [];
207
-
208
- type Builder =
209
- | (PatchAddOperation & { kind: 'add' })
210
- | (PatchDeleteOperation & { kind: 'delete' })
211
- | (PatchUpdateOperation & {
212
- kind: 'update';
213
- currentHunk: PatchHunk | null;
214
- });
215
-
216
- let builder: Builder | null = null;
217
- let insidePatch = false;
218
- let encounteredEnd = false;
219
-
220
- const flushBuilder = () => {
221
- if (!builder) return;
222
- if (builder.kind === 'update') {
223
- if (builder.currentHunk && builder.currentHunk.lines.length === 0) {
224
- builder.hunks.pop();
225
- }
226
- if (builder.hunks.length === 0) {
227
- throw new Error(
228
- `Update for ${builder.filePath} does not contain any diff hunks.`,
229
- );
230
- }
231
- operations.push({
232
- kind: 'update',
233
- filePath: builder.filePath,
234
- hunks: builder.hunks.map((hunk) => ({
235
- header: { ...hunk.header },
236
- lines: hunk.lines.map((line) => ({ ...line })),
237
- })),
238
- });
239
- } else if (builder.kind === 'add') {
240
- operations.push({
241
- kind: 'add',
242
- filePath: builder.filePath,
243
- lines: [...builder.lines],
244
- });
245
- } else {
246
- operations.push({ kind: 'delete', filePath: builder.filePath });
247
- }
248
- builder = null;
249
- };
250
-
251
- for (let i = 0; i < lines.length; i++) {
252
- const line = lines[i];
253
- if (!insidePatch) {
254
- if (line.trim() === '') continue;
255
- if (line.startsWith(PATCH_BEGIN_MARKER)) {
256
- insidePatch = true;
257
- continue;
258
- }
259
- throw new Error(
260
- 'Patch must start with "*** Begin Patch" and use the enveloped patch format.',
261
- );
262
- }
263
-
264
- if (line.startsWith(PATCH_BEGIN_MARKER)) {
265
- throw new Error('Nested "*** Begin Patch" markers are not supported.');
266
- }
267
-
268
- if (line.startsWith(PATCH_END_MARKER)) {
269
- flushBuilder();
270
- encounteredEnd = true;
271
- const remaining = lines.slice(i + 1).find((rest) => rest.trim() !== '');
272
- if (remaining) {
273
- throw new Error(
274
- 'Unexpected content found after "*** End Patch" marker.',
275
- );
276
- }
277
- break;
278
- }
279
-
280
- if (line.startsWith(PATCH_ADD_PREFIX)) {
281
- flushBuilder();
282
- builder = {
283
- kind: 'add',
284
- filePath: parseDirectivePath(line, PATCH_ADD_PREFIX),
285
- lines: [],
286
- };
287
- continue;
288
- }
289
-
290
- if (line.startsWith(PATCH_UPDATE_PREFIX)) {
291
- flushBuilder();
292
- builder = {
293
- kind: 'update',
294
- filePath: parseDirectivePath(line, PATCH_UPDATE_PREFIX),
295
- hunks: [],
296
- currentHunk: null,
297
- };
298
- continue;
299
- }
300
-
301
- if (line.startsWith(PATCH_DELETE_PREFIX)) {
302
- flushBuilder();
303
- builder = {
304
- kind: 'delete',
305
- filePath: parseDirectivePath(line, PATCH_DELETE_PREFIX),
306
- };
307
- continue;
308
- }
309
-
310
- if (!builder) {
311
- if (line.trim() === '') {
312
- continue;
313
- }
314
- throw new Error(`Unexpected content in patch: "${line}"`);
315
- }
316
-
317
- if (builder.kind === 'add') {
318
- const content = line.startsWith('+') ? line.slice(1) : line;
319
- builder.lines.push(content);
320
- continue;
321
- }
322
-
323
- if (builder.kind === 'delete') {
324
- if (line.trim() !== '') {
325
- throw new Error(
326
- `Delete directive for ${builder.filePath} should not contain additional lines.`,
327
- );
328
- }
329
- continue;
330
- }
331
-
332
- if (line.startsWith('@@')) {
333
- const hunk: PatchHunk = { header: parseHunkHeader(line), lines: [] };
334
- builder.hunks.push(hunk);
335
- builder.currentHunk = hunk;
336
- continue;
337
- }
338
-
339
- if (!builder.currentHunk) {
340
- const fallbackHunk: PatchHunk = { header: {}, lines: [] };
341
- builder.hunks.push(fallbackHunk);
342
- builder.currentHunk = fallbackHunk;
343
- }
344
-
345
- const currentHunk = builder.currentHunk;
346
- const prefix = line[0];
347
- if (prefix === '+') {
348
- currentHunk.lines.push({ kind: 'add', content: line.slice(1) });
349
- } else if (prefix === '-') {
350
- currentHunk.lines.push({ kind: 'remove', content: line.slice(1) });
351
- } else if (prefix === ' ') {
352
- currentHunk.lines.push({ kind: 'context', content: line.slice(1) });
353
- } else {
354
- throw new Error(`Unrecognized patch line: "${line}"`);
355
- }
356
- }
357
-
358
- if (!encounteredEnd) {
359
- throw new Error('Missing "*** End Patch" marker.');
360
- }
361
-
362
- return operations;
363
- }
364
-
365
- function resolveProjectPath(projectRoot: string, filePath: string): string {
366
- const fullPath = resolve(projectRoot, filePath);
367
- const relativePath = relative(projectRoot, fullPath);
368
- if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
369
- throw new Error(`Patch path escapes project root: ${filePath}`);
370
- }
371
- return fullPath;
372
- }
373
-
374
- async function applyAddOperation(
375
- projectRoot: string,
376
- operation: PatchAddOperation,
377
- ): Promise<AppliedOperationRecord> {
378
- const targetPath = resolveProjectPath(projectRoot, operation.filePath);
379
- await mkdir(dirname(targetPath), { recursive: true });
380
- const linesForWrite = [...operation.lines];
381
- ensureTrailingNewline(linesForWrite);
382
- await writeFile(targetPath, joinLines(linesForWrite, '\n'), 'utf-8');
383
-
384
- const hunkLines: PatchHunkLine[] = operation.lines.map((line) => ({
385
- kind: 'add',
386
- content: line,
387
- }));
388
-
389
- return {
390
- kind: 'add',
5
+ import { applyPatchOperations } from './patch/apply.ts';
6
+ import { parsePatchInput } from './patch/parse.ts';
7
+ import type {
8
+ AppliedPatchOperation,
9
+ PatchOperation,
10
+ RejectedPatch,
11
+ } from './patch/types.ts';
12
+
13
+ function serializeChanges(operations: AppliedPatchOperation[]) {
14
+ return operations.map((operation) => ({
391
15
  filePath: operation.filePath,
392
- operation,
393
- stats: {
394
- additions: hunkLines.length,
395
- deletions: 0,
396
- },
397
- hunks: [
398
- {
399
- header: {},
400
- lines: hunkLines,
401
- oldStart: 0,
402
- oldLines: 0,
403
- newStart: 1,
404
- newLines: hunkLines.length,
405
- additions: hunkLines.length,
406
- deletions: 0,
407
- },
408
- ],
409
- };
410
- }
411
-
412
- async function applyDeleteOperation(
413
- projectRoot: string,
414
- operation: PatchDeleteOperation,
415
- ): Promise<AppliedOperationRecord> {
416
- const targetPath = resolveProjectPath(projectRoot, operation.filePath);
417
- let existingContent = '';
418
- try {
419
- existingContent = await readFile(targetPath, 'utf-8');
420
- } catch (error) {
421
- if (isErrnoException(error) && error.code === 'ENOENT') {
422
- throw new Error(`File not found for deletion: ${operation.filePath}`);
423
- }
424
- throw error;
425
- }
426
-
427
- const { lines } = splitLines(existingContent);
428
- await unlink(targetPath);
429
-
430
- const hunkLines: PatchHunkLine[] = lines.map((line) => ({
431
- kind: 'remove',
432
- content: line,
16
+ kind: operation.kind,
17
+ hunks: operation.hunks.map((hunk) => ({
18
+ oldStart: hunk.oldStart,
19
+ oldLines: hunk.oldLines,
20
+ newStart: hunk.newStart,
21
+ newLines: hunk.newLines,
22
+ additions: hunk.additions,
23
+ deletions: hunk.deletions,
24
+ context: hunk.header.context,
25
+ })),
433
26
  }));
434
-
435
- return {
436
- kind: 'delete',
437
- filePath: operation.filePath,
438
- operation,
439
- stats: {
440
- additions: 0,
441
- deletions: hunkLines.length,
442
- },
443
- hunks: [
444
- {
445
- header: {},
446
- lines: hunkLines,
447
- oldStart: 1,
448
- oldLines: hunkLines.length,
449
- newStart: 0,
450
- newLines: 0,
451
- additions: 0,
452
- deletions: hunkLines.length,
453
- },
454
- ],
455
- };
456
- }
457
-
458
- function applyHunksToLines(
459
- originalLines: string[],
460
- hunks: PatchHunk[],
461
- filePath: string,
462
- useFuzzy: boolean = false,
463
- ): { lines: string[]; applied: AppliedHunkResult[] } {
464
- const lines = [...originalLines];
465
- let searchIndex = 0;
466
- let lineOffset = 0;
467
- const applied: AppliedHunkResult[] = [];
468
-
469
- for (const hunk of hunks) {
470
- const expected: string[] = [];
471
- const replacement: string[] = [];
472
- let additions = 0;
473
- let deletions = 0;
474
-
475
- for (const line of hunk.lines) {
476
- if (line.kind !== 'add') {
477
- expected.push(line.content);
478
- }
479
- if (line.kind !== 'remove') {
480
- replacement.push(line.content);
481
- }
482
- if (line.kind === 'add') additions += 1;
483
- if (line.kind === 'remove') deletions += 1;
484
- }
485
-
486
- const hasExpected = expected.length > 0;
487
- const hint =
488
- typeof hunk.header.oldStart === 'number'
489
- ? Math.max(0, hunk.header.oldStart - 1 + lineOffset)
490
- : searchIndex;
491
-
492
- let matchIndex = hasExpected
493
- ? findSubsequenceWithFuzzy(
494
- lines,
495
- expected,
496
- Math.max(0, hint - 3),
497
- useFuzzy,
498
- )
499
- : -1;
500
-
501
- if (hasExpected && matchIndex === -1) {
502
- matchIndex = findSubsequenceWithFuzzy(lines, expected, 0, useFuzzy);
503
- }
504
-
505
- if (matchIndex === -1 && hasExpected && hunk.header.context) {
506
- const contextIndex = findSubsequence(lines, [hunk.header.context], 0);
507
- if (contextIndex !== -1) {
508
- const positionInExpected = expected.indexOf(hunk.header.context);
509
- matchIndex =
510
- positionInExpected >= 0
511
- ? Math.max(0, contextIndex - positionInExpected)
512
- : contextIndex;
513
- }
514
- }
515
-
516
- if (!hasExpected) {
517
- matchIndex = computeInsertionIndex(lines, hint, hunk.header);
518
- }
519
-
520
- if (matchIndex === -1) {
521
- const contextInfo = hunk.header.context
522
- ? ` near context '${hunk.header.context}'`
523
- : '';
524
-
525
- // Provide helpful error with nearby context
526
- const nearbyStart = Math.max(0, hint - 2);
527
- const nearbyEnd = Math.min(lines.length, hint + 5);
528
- const nearbyLines = lines.slice(nearbyStart, nearbyEnd);
529
- const lineNumberInfo =
530
- nearbyStart > 0 ? ` (around line ${nearbyStart + 1})` : '';
531
-
532
- let errorMsg = `Failed to apply patch hunk in ${filePath}${contextInfo}.\n`;
533
- errorMsg += `Expected to find:\n${expected.map((l) => ` ${l}`).join('\n')}\n`;
534
- errorMsg += `Nearby context${lineNumberInfo}:\n${nearbyLines.map((l, idx) => ` ${nearbyStart + idx + 1}: ${l}`).join('\n')}\n`;
535
- errorMsg += `Hint: Check for whitespace differences (tabs vs spaces). Try enabling fuzzyMatch option.`;
536
-
537
- throw new Error(errorMsg);
538
- }
539
-
540
- const deleteCount = hasExpected ? expected.length : 0;
541
- const originalIndex = matchIndex - lineOffset;
542
- const normalizedOriginalIndex = Math.min(
543
- Math.max(0, originalIndex),
544
- originalLines.length,
545
- );
546
- const oldStart = normalizedOriginalIndex + 1;
547
- const newStart = matchIndex + 1;
548
-
549
- lines.splice(matchIndex, deleteCount, ...replacement);
550
- searchIndex = matchIndex + replacement.length;
551
- lineOffset += replacement.length - deleteCount;
552
-
553
- applied.push({
554
- header: { ...hunk.header },
555
- lines: hunk.lines.map((line) => ({ ...line })),
556
- oldStart,
557
- oldLines: deleteCount,
558
- newStart,
559
- newLines: replacement.length,
560
- additions,
561
- deletions,
562
- });
563
- }
564
-
565
- return { lines, applied };
566
27
  }
567
28
 
568
- function computeInsertionIndex(
569
- lines: string[],
570
- hint: number,
571
- header: PatchHunkHeader,
572
- ): number {
573
- if (header.context) {
574
- const contextIndex = findSubsequence(lines, [header.context], 0);
575
- if (contextIndex !== -1) {
576
- return contextIndex + 1;
577
- }
578
- }
579
-
580
- if (typeof header.oldStart === 'number') {
581
- const zeroBased = Math.max(0, header.oldStart - 1);
582
- return Math.min(lines.length, zeroBased);
583
- }
584
-
585
- if (typeof header.newStart === 'number') {
586
- const zeroBased = Math.max(0, header.newStart - 1);
587
- return Math.min(lines.length, zeroBased);
588
- }
589
-
590
- return Math.min(lines.length, Math.max(0, hint));
591
- }
592
-
593
- async function applyUpdateOperation(
594
- projectRoot: string,
595
- operation: PatchUpdateOperation,
596
- useFuzzy: boolean = false,
597
- ): Promise<AppliedOperationRecord> {
598
- const targetPath = resolveProjectPath(projectRoot, operation.filePath);
599
- let originalContent: string;
600
- try {
601
- originalContent = await readFile(targetPath, 'utf-8');
602
- } catch (error) {
603
- if (isErrnoException(error) && error.code === 'ENOENT') {
604
- throw new Error(`File not found: ${operation.filePath}`);
605
- }
606
- throw error;
607
- }
608
-
609
- const { lines: originalLines, newline } = splitLines(originalContent);
610
- const { lines: updatedLines, applied } = applyHunksToLines(
611
- originalLines,
612
- operation.hunks,
613
- operation.filePath,
614
- useFuzzy,
615
- );
616
- ensureTrailingNewline(updatedLines);
617
- await writeFile(targetPath, joinLines(updatedLines, newline), 'utf-8');
618
-
619
- const stats = applied.reduce<PatchStats>(
620
- (acc, hunk) => ({
621
- additions: acc.additions + hunk.additions,
622
- deletions: acc.deletions + hunk.deletions,
623
- }),
624
- { additions: 0, deletions: 0 },
625
- );
626
-
627
- return {
628
- kind: 'update',
629
- filePath: operation.filePath,
630
- operation,
631
- stats,
632
- hunks: applied,
633
- };
634
- }
635
-
636
- function summarizeOperations(operations: AppliedOperationRecord[]) {
637
- const summary = operations.reduce(
638
- (acc, op) => ({
639
- files: acc.files + 1,
640
- additions: acc.additions + op.stats.additions,
641
- deletions: acc.deletions + op.stats.deletions,
642
- }),
643
- { files: 0, additions: 0, deletions: 0 },
644
- );
645
- return {
646
- files: Math.max(summary.files, operations.length > 0 ? 1 : 0),
647
- additions: summary.additions,
648
- deletions: summary.deletions,
649
- };
650
- }
651
-
652
- function formatRange(start: number, count: number) {
653
- const normalizedStart = Math.max(0, start);
654
- if (count === 0) return `${normalizedStart},0`;
655
- if (count === 1) return `${normalizedStart}`;
656
- return `${normalizedStart},${count}`;
657
- }
658
-
659
- function formatHunkHeader(applied: AppliedHunkResult) {
660
- const oldRange = formatRange(applied.oldStart, applied.oldLines);
661
- const newRange = formatRange(applied.newStart, applied.newLines);
662
- const context = applied.header.context?.trim();
663
- return context
664
- ? `@@ -${oldRange} +${newRange} @@ ${context}`
665
- : `@@ -${oldRange} +${newRange} @@`;
666
- }
667
-
668
- function serializePatchLine(line: PatchHunkLine): string {
669
- switch (line.kind) {
670
- case 'add':
671
- return `+${line.content}`;
672
- case 'remove':
673
- return `-${line.content}`;
674
- default:
675
- return ` ${line.content}`;
676
- }
677
- }
678
-
679
- function formatNormalizedPatch(operations: AppliedOperationRecord[]): string {
680
- const lines: string[] = [PATCH_BEGIN_MARKER];
681
-
682
- for (const op of operations) {
683
- switch (op.kind) {
684
- case 'add': {
685
- lines.push(`${PATCH_ADD_PREFIX} ${op.filePath}`);
686
- for (const hunk of op.hunks) {
687
- lines.push(formatHunkHeader(hunk));
688
- for (const line of hunk.lines) {
689
- lines.push(serializePatchLine(line));
690
- }
691
- }
692
- break;
693
- }
694
- case 'delete': {
695
- lines.push(`${PATCH_DELETE_PREFIX} ${op.filePath}`);
696
- for (const hunk of op.hunks) {
697
- lines.push(formatHunkHeader(hunk));
698
- for (const line of hunk.lines) {
699
- lines.push(serializePatchLine(line));
700
- }
701
- }
702
- break;
703
- }
704
- case 'update': {
705
- lines.push(`${PATCH_UPDATE_PREFIX} ${op.filePath}`);
706
- const updateOp = op.operation as PatchUpdateOperation;
707
- for (let i = 0; i < updateOp.hunks.length; i++) {
708
- const originalHunk = updateOp.hunks[i];
709
- const appliedHunk = op.hunks[i];
710
- const header: AppliedHunkResult = {
711
- ...appliedHunk,
712
- header: {
713
- ...appliedHunk.header,
714
- context: originalHunk.header.context,
715
- },
716
- };
717
- lines.push(formatHunkHeader(header));
718
- for (const line of originalHunk.lines) {
719
- lines.push(serializePatchLine(line));
720
- }
721
- }
722
- break;
723
- }
724
- }
725
- }
726
-
727
- lines.push(PATCH_END_MARKER);
728
- return lines.join('\n');
729
- }
730
-
731
- async function applyEnvelopedPatch(
732
- projectRoot: string,
733
- patch: string,
734
- useFuzzy: boolean = false,
735
- ) {
736
- const operations = parseEnvelopedPatch(patch);
737
- const applied: AppliedOperationRecord[] = [];
738
-
739
- for (const operation of operations) {
740
- if (operation.kind === 'add') {
741
- applied.push(await applyAddOperation(projectRoot, operation));
742
- } else if (operation.kind === 'delete') {
743
- applied.push(await applyDeleteOperation(projectRoot, operation));
744
- } else {
745
- applied.push(
746
- await applyUpdateOperation(projectRoot, operation, useFuzzy),
747
- );
748
- }
749
- }
750
-
751
- return {
752
- operations: applied,
753
- normalizedPatch: formatNormalizedPatch(applied),
754
- };
29
+ function serializeRejected(rejected: RejectedPatch[]) {
30
+ if (rejected.length === 0) return undefined;
31
+ return rejected.map((item) => ({
32
+ filePath: item.filePath,
33
+ kind: item.kind,
34
+ reason: item.reason,
35
+ hunks:
36
+ item.operation.kind === 'update'
37
+ ? item.operation.hunks.map((hunk) => ({
38
+ oldStart: hunk.header.oldStart,
39
+ oldLines: hunk.header.oldLines,
40
+ newStart: hunk.header.newStart,
41
+ newLines: hunk.header.newLines,
42
+ context: hunk.header.context,
43
+ lines: hunk.lines.map((line) => ({
44
+ kind: line.kind,
45
+ content: line.content,
46
+ })),
47
+ }))
48
+ : undefined,
49
+ }));
755
50
  }
756
51
 
757
52
  export function buildApplyPatchTool(projectRoot: string): {
@@ -779,7 +74,8 @@ export function buildApplyPatchTool(projectRoot: string): {
779
74
  }),
780
75
  async execute({
781
76
  patch,
782
- fuzzyMatch,
77
+ allowRejects = false,
78
+ fuzzyMatch = true,
783
79
  }: {
784
80
  patch: string;
785
81
  allowRejects?: boolean;
@@ -789,6 +85,7 @@ export function buildApplyPatchTool(projectRoot: string): {
789
85
  output: string;
790
86
  changes: unknown[];
791
87
  artifact: unknown;
88
+ rejected?: unknown[];
792
89
  }>
793
90
  > {
794
91
  if (!patch || patch.trim().length === 0) {
@@ -803,53 +100,55 @@ export function buildApplyPatchTool(projectRoot: string): {
803
100
  );
804
101
  }
805
102
 
806
- if (
807
- !patch.includes(PATCH_BEGIN_MARKER) ||
808
- !patch.includes(PATCH_END_MARKER)
809
- ) {
810
- return createToolError(
811
- 'Only enveloped patch format is supported. Patch must start with "*** Begin Patch" and contain "*** Add File:", "*** Update File:", or "*** Delete File:" directives.',
812
- 'validation',
813
- {
814
- parameter: 'patch',
815
- suggestion:
816
- 'Use enveloped patch format starting with *** Begin Patch',
817
- },
818
- );
103
+ let operations: PatchOperation[];
104
+ try {
105
+ const parsed = parsePatchInput(patch);
106
+ operations = parsed.operations;
107
+ } catch (error) {
108
+ const message = error instanceof Error ? error.message : String(error);
109
+ return createToolError(message, 'validation', {
110
+ parameter: 'patch',
111
+ suggestion:
112
+ 'Provide patch content using the enveloped format (*** Begin Patch ... *** End Patch) or standard unified diff format (---/+++ headers).',
113
+ });
819
114
  }
820
115
 
821
116
  try {
822
- const { operations, normalizedPatch } = await applyEnvelopedPatch(
823
- projectRoot,
824
- patch,
825
- fuzzyMatch ?? true,
826
- );
827
- const summary = summarizeOperations(operations);
828
- const changes = operations.map((operation) => ({
829
- filePath: operation.filePath,
830
- kind: operation.kind,
831
- hunks: operation.hunks.map((hunk) => ({
832
- oldStart: hunk.oldStart,
833
- oldLines: hunk.oldLines,
834
- newStart: hunk.newStart,
835
- newLines: hunk.newLines,
836
- additions: hunk.additions,
837
- deletions: hunk.deletions,
838
- context: hunk.header.context,
839
- })),
840
- }));
117
+ const result = await applyPatchOperations(projectRoot, operations, {
118
+ useFuzzy: fuzzyMatch,
119
+ allowRejects,
120
+ });
121
+
122
+ const changes = serializeChanges(result.operations);
123
+ const rejected = serializeRejected(result.rejected);
124
+
125
+ const output: string[] = [];
126
+ if (result.operations.length > 0) {
127
+ output.push(
128
+ `Applied ${result.operations.length} operation${result.operations.length === 1 ? '' : 's'}`,
129
+ );
130
+ }
131
+ if (allowRejects && result.rejected.length > 0) {
132
+ output.push(
133
+ `Skipped ${result.rejected.length} operation${result.rejected.length === 1 ? '' : 's'} due to mismatches`,
134
+ );
135
+ }
136
+ if (output.length === 0) {
137
+ output.push('No changes applied');
138
+ }
841
139
 
842
140
  return {
843
141
  ok: true,
844
- output: 'Applied enveloped patch',
142
+ output: output.join('; '),
845
143
  changes,
846
144
  artifact: {
847
145
  kind: 'file_diff',
848
- patch: normalizedPatch,
849
- summary,
146
+ patch: result.normalizedPatch,
147
+ summary: result.summary,
850
148
  },
149
+ rejected,
851
150
  };
852
- } catch (error: unknown) {
151
+ } catch (error) {
853
152
  const errorMessage =
854
153
  error instanceof Error ? error.message : String(error);
855
154
  return createToolError(
@@ -863,5 +162,6 @@ export function buildApplyPatchTool(projectRoot: string): {
863
162
  }
864
163
  },
865
164
  });
165
+
866
166
  return { name: 'apply_patch', tool: applyPatch };
867
167
  }