@agi-cli/sdk 0.1.81 → 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,759 +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
- // Auto-correct: treat lines without prefix as context (with implicit space)
355
- // This makes the parser more forgiving for AI-generated patches
356
- currentHunk.lines.push({ kind: 'context', content: line });
357
- }
358
- }
359
-
360
- if (!encounteredEnd) {
361
- throw new Error('Missing "*** End Patch" marker.');
362
- }
363
-
364
- return operations;
365
- }
366
-
367
- function resolveProjectPath(projectRoot: string, filePath: string): string {
368
- const fullPath = resolve(projectRoot, filePath);
369
- const relativePath = relative(projectRoot, fullPath);
370
- if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
371
- throw new Error(`Patch path escapes project root: ${filePath}`);
372
- }
373
- return fullPath;
374
- }
375
-
376
- async function applyAddOperation(
377
- projectRoot: string,
378
- operation: PatchAddOperation,
379
- ): Promise<AppliedOperationRecord> {
380
- const targetPath = resolveProjectPath(projectRoot, operation.filePath);
381
- await mkdir(dirname(targetPath), { recursive: true });
382
- const linesForWrite = [...operation.lines];
383
- ensureTrailingNewline(linesForWrite);
384
- await writeFile(targetPath, joinLines(linesForWrite, '\n'), 'utf-8');
385
-
386
- const hunkLines: PatchHunkLine[] = operation.lines.map((line) => ({
387
- kind: 'add',
388
- content: line,
389
- }));
390
-
391
- return {
392
- 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) => ({
393
15
  filePath: operation.filePath,
394
- operation,
395
- stats: {
396
- additions: hunkLines.length,
397
- deletions: 0,
398
- },
399
- hunks: [
400
- {
401
- header: {},
402
- lines: hunkLines,
403
- oldStart: 0,
404
- oldLines: 0,
405
- newStart: 1,
406
- newLines: hunkLines.length,
407
- additions: hunkLines.length,
408
- deletions: 0,
409
- },
410
- ],
411
- };
412
- }
413
-
414
- async function applyDeleteOperation(
415
- projectRoot: string,
416
- operation: PatchDeleteOperation,
417
- ): Promise<AppliedOperationRecord> {
418
- const targetPath = resolveProjectPath(projectRoot, operation.filePath);
419
- let existingContent = '';
420
- try {
421
- existingContent = await readFile(targetPath, 'utf-8');
422
- } catch (error) {
423
- if (isErrnoException(error) && error.code === 'ENOENT') {
424
- throw new Error(`File not found for deletion: ${operation.filePath}`);
425
- }
426
- throw error;
427
- }
428
-
429
- const { lines } = splitLines(existingContent);
430
- await unlink(targetPath);
431
-
432
- const hunkLines: PatchHunkLine[] = lines.map((line) => ({
433
- kind: 'remove',
434
- 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
+ })),
435
26
  }));
436
-
437
- return {
438
- kind: 'delete',
439
- filePath: operation.filePath,
440
- operation,
441
- stats: {
442
- additions: 0,
443
- deletions: hunkLines.length,
444
- },
445
- hunks: [
446
- {
447
- header: {},
448
- lines: hunkLines,
449
- oldStart: 1,
450
- oldLines: hunkLines.length,
451
- newStart: 0,
452
- newLines: 0,
453
- additions: 0,
454
- deletions: hunkLines.length,
455
- },
456
- ],
457
- };
458
- }
459
-
460
- function applyHunksToLines(
461
- originalLines: string[],
462
- hunks: PatchHunk[],
463
- filePath: string,
464
- useFuzzy: boolean = false,
465
- ): { lines: string[]; applied: AppliedHunkResult[] } {
466
- const lines = [...originalLines];
467
- let searchIndex = 0;
468
- let lineOffset = 0;
469
- const applied: AppliedHunkResult[] = [];
470
-
471
- for (const hunk of hunks) {
472
- const expected: string[] = [];
473
- const replacement: string[] = [];
474
- let additions = 0;
475
- let deletions = 0;
476
-
477
- for (const line of hunk.lines) {
478
- if (line.kind !== 'add') {
479
- expected.push(line.content);
480
- }
481
- if (line.kind !== 'remove') {
482
- replacement.push(line.content);
483
- }
484
- if (line.kind === 'add') additions += 1;
485
- if (line.kind === 'remove') deletions += 1;
486
- }
487
-
488
- const hasExpected = expected.length > 0;
489
- const hint =
490
- typeof hunk.header.oldStart === 'number'
491
- ? Math.max(0, hunk.header.oldStart - 1 + lineOffset)
492
- : searchIndex;
493
-
494
- let matchIndex = hasExpected
495
- ? findSubsequenceWithFuzzy(
496
- lines,
497
- expected,
498
- Math.max(0, hint - 3),
499
- useFuzzy,
500
- )
501
- : -1;
502
-
503
- if (hasExpected && matchIndex === -1) {
504
- matchIndex = findSubsequenceWithFuzzy(lines, expected, 0, useFuzzy);
505
- }
506
-
507
- if (matchIndex === -1 && hasExpected && hunk.header.context) {
508
- const contextIndex = findSubsequence(lines, [hunk.header.context], 0);
509
- if (contextIndex !== -1) {
510
- const positionInExpected = expected.indexOf(hunk.header.context);
511
- matchIndex =
512
- positionInExpected >= 0
513
- ? Math.max(0, contextIndex - positionInExpected)
514
- : contextIndex;
515
- }
516
- }
517
-
518
- if (!hasExpected) {
519
- matchIndex = computeInsertionIndex(lines, hint, hunk.header);
520
- }
521
-
522
- if (matchIndex === -1) {
523
- const contextInfo = hunk.header.context
524
- ? ` near context '${hunk.header.context}'`
525
- : '';
526
-
527
- // Provide helpful error with nearby context
528
- const nearbyStart = Math.max(0, hint - 2);
529
- const nearbyEnd = Math.min(lines.length, hint + 5);
530
- const nearbyLines = lines.slice(nearbyStart, nearbyEnd);
531
- const lineNumberInfo =
532
- nearbyStart > 0 ? ` (around line ${nearbyStart + 1})` : '';
533
-
534
- let errorMsg = `Failed to apply patch hunk in ${filePath}${contextInfo}.\n`;
535
- errorMsg += `Expected to find:\n${expected.map((l) => ` ${l}`).join('\n')}\n`;
536
- errorMsg += `Nearby context${lineNumberInfo}:\n${nearbyLines.map((l, idx) => ` ${nearbyStart + idx + 1}: ${l}`).join('\n')}\n`;
537
- errorMsg += `Hint: Check for whitespace differences (tabs vs spaces). Try enabling fuzzyMatch option.`;
538
-
539
- throw new Error(errorMsg);
540
- }
541
-
542
- const deleteCount = hasExpected ? expected.length : 0;
543
- const originalIndex = matchIndex - lineOffset;
544
- const normalizedOriginalIndex = Math.min(
545
- Math.max(0, originalIndex),
546
- originalLines.length,
547
- );
548
- const oldStart = normalizedOriginalIndex + 1;
549
- const newStart = matchIndex + 1;
550
-
551
- lines.splice(matchIndex, deleteCount, ...replacement);
552
- searchIndex = matchIndex + replacement.length;
553
- lineOffset += replacement.length - deleteCount;
554
-
555
- applied.push({
556
- header: { ...hunk.header },
557
- lines: hunk.lines.map((line) => ({ ...line })),
558
- oldStart,
559
- oldLines: deleteCount,
560
- newStart,
561
- newLines: replacement.length,
562
- additions,
563
- deletions,
564
- });
565
- }
566
-
567
- return { lines, applied };
568
27
  }
569
28
 
570
- function computeInsertionIndex(
571
- lines: string[],
572
- hint: number,
573
- header: PatchHunkHeader,
574
- ): number {
575
- if (header.context) {
576
- const contextIndex = findSubsequence(lines, [header.context], 0);
577
- if (contextIndex !== -1) {
578
- return contextIndex + 1;
579
- }
580
- }
581
-
582
- if (typeof header.oldStart === 'number') {
583
- const zeroBased = Math.max(0, header.oldStart - 1);
584
- return Math.min(lines.length, zeroBased);
585
- }
586
-
587
- if (typeof header.newStart === 'number') {
588
- const zeroBased = Math.max(0, header.newStart - 1);
589
- return Math.min(lines.length, zeroBased);
590
- }
591
-
592
- return Math.min(lines.length, Math.max(0, hint));
593
- }
594
-
595
- async function applyUpdateOperation(
596
- projectRoot: string,
597
- operation: PatchUpdateOperation,
598
- useFuzzy: boolean = false,
599
- ): Promise<AppliedOperationRecord> {
600
- const targetPath = resolveProjectPath(projectRoot, operation.filePath);
601
- let originalContent: string;
602
- try {
603
- originalContent = await readFile(targetPath, 'utf-8');
604
- } catch (error) {
605
- if (isErrnoException(error) && error.code === 'ENOENT') {
606
- throw new Error(`File not found: ${operation.filePath}`);
607
- }
608
- throw error;
609
- }
610
-
611
- const { lines: originalLines, newline } = splitLines(originalContent);
612
- const { lines: updatedLines, applied } = applyHunksToLines(
613
- originalLines,
614
- operation.hunks,
615
- operation.filePath,
616
- useFuzzy,
617
- );
618
- ensureTrailingNewline(updatedLines);
619
- await writeFile(targetPath, joinLines(updatedLines, newline), 'utf-8');
620
-
621
- const stats = applied.reduce<PatchStats>(
622
- (acc, hunk) => ({
623
- additions: acc.additions + hunk.additions,
624
- deletions: acc.deletions + hunk.deletions,
625
- }),
626
- { additions: 0, deletions: 0 },
627
- );
628
-
629
- return {
630
- kind: 'update',
631
- filePath: operation.filePath,
632
- operation,
633
- stats,
634
- hunks: applied,
635
- };
636
- }
637
-
638
- function summarizeOperations(operations: AppliedOperationRecord[]) {
639
- const summary = operations.reduce(
640
- (acc, op) => ({
641
- files: acc.files + 1,
642
- additions: acc.additions + op.stats.additions,
643
- deletions: acc.deletions + op.stats.deletions,
644
- }),
645
- { files: 0, additions: 0, deletions: 0 },
646
- );
647
- return {
648
- files: Math.max(summary.files, operations.length > 0 ? 1 : 0),
649
- additions: summary.additions,
650
- deletions: summary.deletions,
651
- };
652
- }
653
-
654
- function formatRange(start: number, count: number) {
655
- const normalizedStart = Math.max(0, start);
656
- if (count === 0) return `${normalizedStart},0`;
657
- if (count === 1) return `${normalizedStart}`;
658
- return `${normalizedStart},${count}`;
659
- }
660
-
661
- function formatHunkHeader(applied: AppliedHunkResult) {
662
- const oldRange = formatRange(applied.oldStart, applied.oldLines);
663
- const newRange = formatRange(applied.newStart, applied.newLines);
664
- const context = applied.header.context?.trim();
665
- return context
666
- ? `@@ -${oldRange} +${newRange} @@ ${context}`
667
- : `@@ -${oldRange} +${newRange} @@`;
668
- }
669
-
670
- function serializePatchLine(line: PatchHunkLine): string {
671
- switch (line.kind) {
672
- case 'add':
673
- return `+${line.content}`;
674
- case 'remove':
675
- return `-${line.content}`;
676
- default:
677
- return ` ${line.content}`;
678
- }
679
- }
680
-
681
- function formatNormalizedPatch(operations: AppliedOperationRecord[]): string {
682
- const lines: string[] = [PATCH_BEGIN_MARKER];
683
-
684
- for (const op of operations) {
685
- switch (op.kind) {
686
- case 'add': {
687
- lines.push(`${PATCH_ADD_PREFIX} ${op.filePath}`);
688
- for (const hunk of op.hunks) {
689
- lines.push(formatHunkHeader(hunk));
690
- for (const line of hunk.lines) {
691
- lines.push(serializePatchLine(line));
692
- }
693
- }
694
- break;
695
- }
696
- case 'delete': {
697
- lines.push(`${PATCH_DELETE_PREFIX} ${op.filePath}`);
698
- for (const hunk of op.hunks) {
699
- lines.push(formatHunkHeader(hunk));
700
- for (const line of hunk.lines) {
701
- lines.push(serializePatchLine(line));
702
- }
703
- }
704
- break;
705
- }
706
- case 'update': {
707
- lines.push(`${PATCH_UPDATE_PREFIX} ${op.filePath}`);
708
- const updateOp = op.operation as PatchUpdateOperation;
709
- for (let i = 0; i < updateOp.hunks.length; i++) {
710
- const originalHunk = updateOp.hunks[i];
711
- const appliedHunk = op.hunks[i];
712
- const header: AppliedHunkResult = {
713
- ...appliedHunk,
714
- header: {
715
- ...appliedHunk.header,
716
- context: originalHunk.header.context,
717
- },
718
- };
719
- lines.push(formatHunkHeader(header));
720
- for (const line of originalHunk.lines) {
721
- lines.push(serializePatchLine(line));
722
- }
723
- }
724
- break;
725
- }
726
- }
727
- }
728
-
729
- lines.push(PATCH_END_MARKER);
730
- return lines.join('\n');
731
- }
732
-
733
- async function applyEnvelopedPatch(
734
- projectRoot: string,
735
- patch: string,
736
- useFuzzy: boolean = false,
737
- ) {
738
- const operations = parseEnvelopedPatch(patch);
739
- const applied: AppliedOperationRecord[] = [];
740
-
741
- for (const operation of operations) {
742
- if (operation.kind === 'add') {
743
- applied.push(await applyAddOperation(projectRoot, operation));
744
- } else if (operation.kind === 'delete') {
745
- applied.push(await applyDeleteOperation(projectRoot, operation));
746
- } else {
747
- applied.push(
748
- await applyUpdateOperation(projectRoot, operation, useFuzzy),
749
- );
750
- }
751
- }
752
-
753
- return {
754
- operations: applied,
755
- normalizedPatch: formatNormalizedPatch(applied),
756
- };
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
+ }));
757
50
  }
758
51
 
759
52
  export function buildApplyPatchTool(projectRoot: string): {
@@ -781,7 +74,8 @@ export function buildApplyPatchTool(projectRoot: string): {
781
74
  }),
782
75
  async execute({
783
76
  patch,
784
- fuzzyMatch,
77
+ allowRejects = false,
78
+ fuzzyMatch = true,
785
79
  }: {
786
80
  patch: string;
787
81
  allowRejects?: boolean;
@@ -791,6 +85,7 @@ export function buildApplyPatchTool(projectRoot: string): {
791
85
  output: string;
792
86
  changes: unknown[];
793
87
  artifact: unknown;
88
+ rejected?: unknown[];
794
89
  }>
795
90
  > {
796
91
  if (!patch || patch.trim().length === 0) {
@@ -805,53 +100,55 @@ export function buildApplyPatchTool(projectRoot: string): {
805
100
  );
806
101
  }
807
102
 
808
- if (
809
- !patch.includes(PATCH_BEGIN_MARKER) ||
810
- !patch.includes(PATCH_END_MARKER)
811
- ) {
812
- return createToolError(
813
- 'Only enveloped patch format is supported. Patch must start with "*** Begin Patch" and contain "*** Add File:", "*** Update File:", or "*** Delete File:" directives.',
814
- 'validation',
815
- {
816
- parameter: 'patch',
817
- suggestion:
818
- 'Use enveloped patch format starting with *** Begin Patch',
819
- },
820
- );
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
+ });
821
114
  }
822
115
 
823
116
  try {
824
- const { operations, normalizedPatch } = await applyEnvelopedPatch(
825
- projectRoot,
826
- patch,
827
- fuzzyMatch ?? true,
828
- );
829
- const summary = summarizeOperations(operations);
830
- const changes = operations.map((operation) => ({
831
- filePath: operation.filePath,
832
- kind: operation.kind,
833
- hunks: operation.hunks.map((hunk) => ({
834
- oldStart: hunk.oldStart,
835
- oldLines: hunk.oldLines,
836
- newStart: hunk.newStart,
837
- newLines: hunk.newLines,
838
- additions: hunk.additions,
839
- deletions: hunk.deletions,
840
- context: hunk.header.context,
841
- })),
842
- }));
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
+ }
843
139
 
844
140
  return {
845
141
  ok: true,
846
- output: 'Applied enveloped patch',
142
+ output: output.join('; '),
847
143
  changes,
848
144
  artifact: {
849
145
  kind: 'file_diff',
850
- patch: normalizedPatch,
851
- summary,
146
+ patch: result.normalizedPatch,
147
+ summary: result.summary,
852
148
  },
149
+ rejected,
853
150
  };
854
- } catch (error: unknown) {
151
+ } catch (error) {
855
152
  const errorMessage =
856
153
  error instanceof Error ? error.message : String(error);
857
154
  return createToolError(
@@ -865,5 +162,6 @@ export function buildApplyPatchTool(projectRoot: string): {
865
162
  }
866
163
  },
867
164
  });
165
+
868
166
  return { name: 'apply_patch', tool: applyPatch };
869
167
  }