import { createLogger } from '../utils/logger' const logger = createLogger('PatchResponseParser') export interface SearchReplacePatch { originalSnippet: string replacementSnippet: string } export interface SearchReplacePatchSet { patches: SearchReplacePatch[] } type NoopBehavior = 'skip' | 'error' export interface ParseSearchReplacePatchResponseOptions { responseLabel: string blockLabel: string emptyError: string noPatchBlocksError: string noopBehavior?: NoopBehavior noopError?: string allowMalformedTrailingBlock?: boolean } function stripCodeFence(text: string): string { return text .replace(/^```(?:json|text|python)?\s*/i, '') .replace(/\s*```$/i, '') .trim() } function trimPatchBoundary(text: string): string { return text .replace(/^\s*\r?\n/, '') .replace(/\r?\n\s*$/, '') .replace(/\r\n/g, '\n') } function buildMissingMarkerError(blockLabel: string, marker: string): string { return `${blockLabel} missing ${marker} marker` } function warnMalformedTrailingBlock( responseLabel: string, blockIndex: number, missingMarker: string, parsedPatchCount: number, remainderPreview: string ): void { logger.warn(`${responseLabel} ignored malformed trailing patch block`, { blockIndex, missingMarker, parsedPatchCount, remainderPreview }) } export function parseSearchReplacePatchResponse( text: string, options: ParseSearchReplacePatchResponseOptions ): SearchReplacePatchSet { const normalized = stripCodeFence(text) if (!normalized) { throw new Error(options.emptyError) } if (/^\s* 0) { warnMalformedTrailingBlock( options.responseLabel, blockCount, '[[SEARCH]]', patches.length, normalized.slice(patchStart, patchStart + 300) ) break } throw new Error(buildMissingMarkerError(options.blockLabel, '[[SEARCH]]')) } const replaceStart = normalized.indexOf('[[REPLACE]]', searchStart + '[[SEARCH]]'.length) if (replaceStart < 0) { if (options.allowMalformedTrailingBlock && patches.length > 0) { warnMalformedTrailingBlock( options.responseLabel, blockCount, '[[REPLACE]]', patches.length, normalized.slice(patchStart, patchStart + 300) ) break } throw new Error(buildMissingMarkerError(options.blockLabel, '[[REPLACE]]')) } const endStart = normalized.indexOf('[[END]]', replaceStart + '[[REPLACE]]'.length) if (endStart < 0) { if (options.allowMalformedTrailingBlock && patches.length > 0) { warnMalformedTrailingBlock( options.responseLabel, blockCount, '[[END]]', patches.length, normalized.slice(patchStart, patchStart + 300) ) break } throw new Error(buildMissingMarkerError(options.blockLabel, '[[END]]')) } const originalSnippet = trimPatchBoundary( normalized.slice(searchStart + '[[SEARCH]]'.length, replaceStart) ) const replacementSnippet = trimPatchBoundary( normalized.slice(replaceStart + '[[REPLACE]]'.length, endStart) ) if (!originalSnippet.trim()) { throw new Error(`${options.blockLabel} missing SEARCH content`) } if (originalSnippet === replacementSnippet) { if (options.noopBehavior === 'skip') { skippedNoopCount += 1 cursor = endStart + '[[END]]'.length continue } throw new Error(options.noopError || `${options.responseLabel} patch produced no change`) } patches.push({ originalSnippet, replacementSnippet }) cursor = endStart + '[[END]]'.length } if (skippedNoopCount > 0) { logger.warn(`${options.responseLabel} skipped no-op SEARCH/REPLACE blocks`, { skippedNoopCount }) } if (blockCount > 0) { return { patches } } if (hasPatchMarkers) { throw new Error(`${options.responseLabel} markers detected but no complete patch block could be parsed`) } throw new Error(options.noPatchBlocksError) }