From 21e34cec88356acf25b7c9816d864e4982c49ae7 Mon Sep 17 00:00:00 2001 From: vietnamican Date: Sun, 10 May 2026 21:05:53 +0700 Subject: [PATCH 1/4] adaptive reasoning effort gpt --- build.sh | 4 + ...5-09-openai-compatible-reasoning-design.md | 129 ++ package.json | 2 +- scripts/capture_roocode_requests.py | 335 +++++ .../__tests__/openai-compatible.spec.ts | 216 +++ src/api/providers/openai-compatible.ts | 56 +- ...ti-search-replace-trailing-newline.spec.ts | 163 --- .../__tests__/multi-search-replace.spec.ts | 1207 ----------------- .../diff/strategies/multi-search-replace.ts | 546 -------- src/package.json | 2 +- .../chat/__tests__/Announcement.spec.tsx | 4 +- 11 files changed, 741 insertions(+), 1923 deletions(-) create mode 100755 build.sh create mode 100644 docs/superpowers/specs/2026-05-09-openai-compatible-reasoning-design.md create mode 100644 scripts/capture_roocode_requests.py create mode 100644 src/api/providers/__tests__/openai-compatible.spec.ts delete mode 100644 src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts delete mode 100644 src/core/diff/strategies/__tests__/multi-search-replace.spec.ts delete mode 100644 src/core/diff/strategies/multi-search-replace.ts diff --git a/build.sh b/build.sh new file mode 100755 index 00000000000..ea533d32676 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +corepack prepare pnpm@10.8.1 --activate +pnpm -v +cd src +pnpm vsix diff --git a/docs/superpowers/specs/2026-05-09-openai-compatible-reasoning-design.md b/docs/superpowers/specs/2026-05-09-openai-compatible-reasoning-design.md new file mode 100644 index 00000000000..c5f949dcb71 --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-openai-compatible-reasoning-design.md @@ -0,0 +1,129 @@ +# OpenAI-compatible reasoning payload shim + +Date: 2026-05-09 + +## Problem + +OpenAI-compatible request path currently sends `reasoningEffort` into AI SDK, but it does not add Responses-style `reasoning` payload. + +User expectation for OpenAI-compatible requests with reasoning enabled: + +- keep `reasoning_effort` +- also send `reasoning: { effort, summary: "auto" }` + +This should match OpenAI Codex / OpenAI Native behavior shape, while staying inside OpenAI-compatible chat completions path. + +## Goals + +- When resolved model params include `reasoningEffort`, send both: + - `reasoning_effort` + - `reasoning: { effort, summary: "auto" }` +- Apply same behavior for: + - `createMessage` + - `completePrompt` +- Keep `reasoning_effort` value unchanged, including extended values like `xhigh` or `none` when current model/settings resolve them. +- Leave request unchanged when reasoning is off. + +## Non-goals + +- No change to `openai.ts`. +- No change to `openai-native.ts` or `openai-codex.ts`. +- No UI/settings changes. +- No new provider capability detection. + +## Design + +### 1) Pass reasoning to AI SDK provider + +`OpenAICompatibleHandler` will build `providerOptions` for `streamText` and `generateText`. + +Use generic AI SDK key: + +```ts +providerOptions: { + openaiCompatible: { + reasoningEffort: model.reasoningEffort, + }, +} +``` + +Only include this block when `model.reasoningEffort` is defined. + +### 2) Add request-body transform hook + +Extend `OpenAICompatibleConfig` with AI SDK `transformRequestBody` hook and pass it into `createOpenAICompatible(...)`. + +Hook behavior: + +- if body has no `reasoning_effort`, return body unchanged +- if body has `reasoning_effort`, add/update: + ```ts + reasoning: { + effort: body.reasoning_effort, + summary: "auto", + } + ``` +- keep `reasoning_effort` in body +- preserve other fields + +This hook must apply to both streaming and non-streaming calls, because AI SDK uses same provider transform for both. + +### 3) Keep logic localized + +Implement inside `src/api/providers/openai-compatible.ts` only. + +Reason: + +- current `OpenAICompatibleHandler` is only consumer path +- current OpenAI-compatible provider family is Moonshot +- local change keeps scope tight and avoids touching shared reasoning transforms for unrelated providers + +## Data flow + +1. `MoonshotHandler.getModel()` resolves final `reasoningEffort` from settings/model defaults. +2. `OpenAICompatibleHandler.createMessage()` / `completePrompt()` pass `providerOptions.openaiCompatible.reasoningEffort` into AI SDK. +3. AI SDK serializes top-level `reasoning_effort`. +4. `transformRequestBody` adds `reasoning: { effort, summary: "auto" }` when `reasoning_effort` exists. +5. Provider receives both fields. + +## Fallback behavior + +- No reasoning selected: no `providerOptions`, no `reasoning`, no `reasoning_effort`. +- Disabled reasoning: same as above. +- Extended values: pass through unchanged. +- If body already contains `reasoning`, overwrite `effort` and `summary` only; do not drop unrelated keys. + +## Tests + +Add tests around current Moonshot/OpenAI-compatible path: + +1. **Constructor wiring** + + - `createOpenAICompatible` receives `transformRequestBody`. + +2. **Streaming request** + + - `createMessage()` passes `providerOptions.openaiCompatible.reasoningEffort` when enabled. + - transform adds both `reasoning_effort` and `reasoning`. + +3. **Non-streaming request** + + - `completePrompt()` uses same providerOptions path. + - transform adds both fields. + +4. **Disabled path** + + - when reasoning is disabled, request has neither `reasoning_effort` nor `reasoning`. + +5. **Extended effort path** + - `xhigh` stays `xhigh` in both fields. + +Prefer a small pure helper or captured transform callback in test so request-body mapping is asserted directly, not through SDK internals. + +## Acceptance criteria + +- OpenAI-compatible requests with reasoning enabled send both fields. +- `reasoning_effort` still exists. +- `reasoning.summary` is always `auto`. +- Behavior matches in stream and completion paths. +- No changes to unrelated provider paths. diff --git a/package.json b/package.json index de8dff751cb..c00c55f06a3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "roo-code", "packageManager": "pnpm@10.8.1", "engines": { - "node": "20.19.2" + "node": ">=20.19.2" }, "scripts": { "preinstall": "node scripts/bootstrap.mjs", diff --git a/scripts/capture_roocode_requests.py b/scripts/capture_roocode_requests.py new file mode 100644 index 00000000000..f82330dd29c --- /dev/null +++ b/scripts/capture_roocode_requests.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import base64 +import http.client +import json +import threading +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Optional +from urllib.parse import urlsplit + +HOP_BY_HOP_HEADERS = { + "connection", + "proxy-connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", +} + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def normalize_path(path: str) -> str: + if not path.startswith("/"): + return "/" + path + return path + + +def join_paths(base_path: str, suffix_path: str) -> str: + base_path = normalize_path(base_path or "/") + suffix_path = normalize_path(suffix_path or "/") + + if base_path != "/" and base_path.endswith("/"): + base_path = base_path[:-1] + if suffix_path == "/": + return base_path + if base_path == "/": + return suffix_path + return base_path + suffix_path + + +def is_textual_content(content_type: Optional[str]) -> bool: + if not content_type: + return False + ct = content_type.lower() + return any( + marker in ct + for marker in ( + "application/json", + "text/", + "application/xml", + "application/x-www-form-urlencoded", + "application/javascript", + "application/graphql", + "text/event-stream", + ) + ) + + +def format_body(body: bytes, content_type: Optional[str]) -> str: + if not body: + return "" + + if is_textual_content(content_type): + try: + text = body.decode("utf-8") + except UnicodeDecodeError: + text = None + + if text is not None: + if content_type and "json" in content_type.lower(): + try: + return json.dumps(json.loads(text), indent=2, ensure_ascii=False) + except json.JSONDecodeError: + return text + return text + + return ( + f"\n" + f"base64:\n{base64.b64encode(body).decode('ascii')}" + ) + + +class RequestLogger: + def __init__(self, log_file: Path): + self.log_file = log_file + self.lock = threading.Lock() + self.counter = 0 + self.log_file.parent.mkdir(parents=True, exist_ok=True) + + def write(self, entry: str) -> None: + with self.lock: + with self.log_file.open("a", encoding="utf-8") as fp: + fp.write(entry) + if not entry.endswith("\n"): + fp.write("\n") + fp.flush() + + def next_id(self) -> int: + with self.lock: + self.counter += 1 + return self.counter + + +class ProxyHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + upstream_scheme = "https" + upstream_host = "api.openai.com" + upstream_port = 443 + upstream_base_path = "/v1" + local_prefix = "/v1" + logger: RequestLogger + + def log_message(self, format: str, *args) -> None: # noqa: A003 + return + + def do_GET(self) -> None: # noqa: N802 + self._handle() + + def do_POST(self) -> None: # noqa: N802 + self._handle() + + def do_PUT(self) -> None: # noqa: N802 + self._handle() + + def do_PATCH(self) -> None: # noqa: N802 + self._handle() + + def do_DELETE(self) -> None: # noqa: N802 + self._handle() + + def do_OPTIONS(self) -> None: # noqa: N802 + self._handle() + + def _read_body(self) -> bytes: + transfer_encoding = (self.headers.get("Transfer-Encoding") or "").lower() + if "chunked" in transfer_encoding: + chunks: list[bytes] = [] + while True: + line = self.rfile.readline().strip() + if not line: + continue + size = int(line.split(b";", 1)[0], 16) + if size == 0: + while True: + trailer = self.rfile.readline() + if trailer in (b"\r\n", b"\n", b""): + break + break + chunks.append(self.rfile.read(size)) + self.rfile.read(2) # trailing CRLF + return b"".join(chunks) + + content_length = self.headers.get("Content-Length") + if not content_length: + return b"" + + try: + length = int(content_length) + except ValueError: + return b"" + + return self.rfile.read(length) + + def _target_path(self) -> str: + parsed = urlsplit(self.path) + incoming_path = normalize_path(parsed.path or "/") + + prefix = self.local_prefix or "" + if prefix and incoming_path.startswith(prefix): + remaining = incoming_path[len(prefix) :] + if not remaining: + remaining = "/" + else: + remaining = incoming_path + + forward_path = join_paths(self.upstream_base_path, remaining) + if parsed.query: + forward_path += f"?{parsed.query}" + return forward_path + + def _forward_headers(self) -> dict[str, str]: + headers: dict[str, str] = {} + for key, value in self.headers.items(): + if key.lower() in HOP_BY_HOP_HEADERS: + continue + headers[key] = value + + headers["Host"] = ( + self.upstream_host + if self.upstream_port in (80, 443) + else f"{self.upstream_host}:{self.upstream_port}" + ) + headers["Accept-Encoding"] = "identity" + headers.pop("Content-Length", None) + return headers + + def _send_chunk(self, data: bytes) -> None: + if not data: + return + self.wfile.write(f"{len(data):X}\r\n".encode("ascii")) + self.wfile.write(data) + self.wfile.write(b"\r\n") + + def _handle(self) -> None: + request_id = self.logger.next_id() + body = self._read_body() + parsed = urlsplit(self.path) + forward_path = self._target_path() + forward_headers = self._forward_headers() + + request_header_lines = "\n".join(f" {k}: {v}" for k, v in self.headers.items()) or " " + body_text = format_body(body, self.headers.get("Content-Type")) + + log_entry = ( + "=" * 80 + + f"\nREQUEST #{request_id}\n" + + f"Time: {utc_now()}\n" + + f"Client: {self.client_address[0]}:{self.client_address[1]}\n" + + f"Method: {self.command}\n" + + f"Incoming path: {self.path}\n" + + f"Forward to: {self.upstream_scheme}://{self.upstream_host}:{self.upstream_port}{forward_path}\n" + + "\nHeaders:\n" + + f"{request_header_lines}\n" + + "\nBody:\n" + + f"{body_text}\n" + + "\n" + ) + self.logger.write(log_entry) + + conn_cls = http.client.HTTPSConnection if self.upstream_scheme == "https" else http.client.HTTPConnection + conn = conn_cls(self.upstream_host, self.upstream_port, timeout=120) + + try: + conn.request(self.command, forward_path, body=body if body else None, headers=forward_headers) + resp = conn.getresponse() + + self.send_response(resp.status, resp.reason) + for name, value in resp.getheaders(): + lower = name.lower() + if lower in HOP_BY_HOP_HEADERS or lower == "content-length": + continue + self.send_header(name, value) + + # Use chunked transfer so SSE / streaming responses stay live. + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + + while True: + chunk = resp.read(8192) + if not chunk: + break + self._send_chunk(chunk) + self.wfile.flush() + + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + resp.close() + finally: + conn.close() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Capture RooCode requests and write request headers/body to log.txt", + ) + parser.add_argument( + "--listen-host", + default="127.0.0.1", + help="Proxy listen host (default: 127.0.0.1)", + ) + parser.add_argument( + "--listen-port", + type=int, + default=8000, + help="Proxy listen port (default: 8000)", + ) + parser.add_argument( + "--upstream", + default="https://api.openai.com/v1", + help="Upstream base URL (default: https://api.openai.com/v1)", + ) + parser.add_argument( + "--local-prefix", + default="/v1", + help="Path prefix RooCode will send to this proxy (default: /v1)", + ) + parser.add_argument( + "--log-file", + default="log.txt", + help="Log file path (default: log.txt)", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + parsed = urlsplit(args.upstream) + if parsed.scheme not in {"http", "https"}: + raise SystemExit("--upstream must start with http:// or https://") + if not parsed.hostname: + raise SystemExit("--upstream must include a host") + + ProxyHandler.upstream_scheme = parsed.scheme + ProxyHandler.upstream_host = parsed.hostname + ProxyHandler.upstream_port = parsed.port or (443 if parsed.scheme == "https" else 80) + ProxyHandler.upstream_base_path = parsed.path or "/" + ProxyHandler.local_prefix = normalize_path(args.local_prefix) + ProxyHandler.logger = RequestLogger(Path(args.log_file).resolve()) + + server = ThreadingHTTPServer((args.listen_host, args.listen_port), ProxyHandler) + print(f"Proxy listening on http://{args.listen_host}:{args.listen_port}{ProxyHandler.local_prefix}") + print(f"Forwarding to {args.upstream}") + print(f"Logging to {Path(args.log_file).resolve()}") + print("Set RooCode base URL to http://127.0.0.1:/v1 and keep your real API key in RooCode.") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + return 0 + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/providers/__tests__/openai-compatible.spec.ts b/src/api/providers/__tests__/openai-compatible.spec.ts new file mode 100644 index 00000000000..93bc5c94786 --- /dev/null +++ b/src/api/providers/__tests__/openai-compatible.spec.ts @@ -0,0 +1,216 @@ +// npx vitest run src/api/providers/__tests__/openai-compatible.spec.ts + +const { mockStreamText, mockGenerateText, mockCreateOpenAICompatible } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateOpenAICompatible: vi.fn(), +})) + +let capturedProviderConfig: any + +vi.mock("ai", () => ({ + streamText: mockStreamText, + generateText: mockGenerateText, +})) + +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: mockCreateOpenAICompatible, +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import type { ModelInfo, ReasoningEffortExtended } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import type { OpenAICompatibleConfig } from "../openai-compatible" +import { OpenAICompatibleHandler } from "../openai-compatible" + +const testModelInfo: ModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.5, + outputPrice: 1.5, + supportsReasoningEffort: ["low", "medium", "high", "xhigh"], +} + +class TestOpenAICompatibleHandler extends OpenAICompatibleHandler { + private resolvedReasoningEffort: ReasoningEffortExtended | undefined + + constructor(options: ApiHandlerOptions, reasoningEffort?: ReasoningEffortExtended) { + const config: OpenAICompatibleConfig = { + providerName: "test-provider", + baseURL: "https://test.example.com/v1", + apiKey: "test-api-key", + modelId: "test-model", + modelInfo: testModelInfo, + temperature: 0, + } + + super(options, config) + this.resolvedReasoningEffort = reasoningEffort + } + + override getModel() { + return { + id: "test-model", + info: testModelInfo, + maxTokens: 2048, + temperature: 0, + reasoningEffort: this.resolvedReasoningEffort, + } + } +} + +const systemPrompt = "You are helpful." +const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "Hello", + }, + ], + }, +] + +function buildEmptyStreamResult() { + return { + fullStream: (async function* () { + // drain + })(), + usage: Promise.resolve(undefined), + } +} + +describe("OpenAICompatibleHandler reasoning payload", () => { + beforeEach(() => { + vi.clearAllMocks() + capturedProviderConfig = undefined + mockCreateOpenAICompatible.mockImplementation((config: any) => { + capturedProviderConfig = config + return vi.fn((modelId: string) => ({ + modelId, + provider: "test-provider", + })) + }) + mockStreamText.mockReturnValue(buildEmptyStreamResult()) + mockGenerateText.mockResolvedValue({ text: "done" }) + }) + + it("reasoning payload createMessage passes providerOptions and transform adds both fields", async () => { + const handler = new TestOpenAICompatibleHandler({ apiModelId: "test-model" } as ApiHandlerOptions, "high") + + for await (const _chunk of handler.createMessage(systemPrompt, messages)) { + // drain + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { + openaiCompatible: { + reasoningEffort: "high", + }, + }, + }), + ) + + const transformed = capturedProviderConfig.transformRequestBody({ + model: "test-model", + reasoning_effort: "high", + messages: [], + }) + + expect(transformed).toEqual( + expect.objectContaining({ + reasoning_effort: "high", + reasoning: { + effort: "high", + summary: "auto", + }, + }), + ) + }) + + it("reasoning payload completePrompt preserves xhigh in providerOptions and transform", async () => { + const handler = new TestOpenAICompatibleHandler({ apiModelId: "test-model" } as ApiHandlerOptions, "xhigh") + + await handler.completePrompt("Hello") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: { + openaiCompatible: { + reasoningEffort: "xhigh", + }, + }, + }), + ) + + const transformed = capturedProviderConfig.transformRequestBody({ + model: "test-model", + reasoning_effort: "xhigh", + prompt: "Hello", + }) + + expect(transformed).toEqual( + expect.objectContaining({ + reasoning_effort: "xhigh", + reasoning: { + effort: "xhigh", + summary: "auto", + }, + }), + ) + }) + + it("reasoning payload omits providerOptions and leaves body unchanged when reasoning disabled", async () => { + const handler = new TestOpenAICompatibleHandler({ apiModelId: "test-model" } as ApiHandlerOptions) + + for await (const _chunk of handler.createMessage(systemPrompt, messages)) { + // drain + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeUndefined() + + const inputBody = { + model: "test-model", + messages: [], + } + const transformed = capturedProviderConfig.transformRequestBody(inputBody) + expect(transformed).toEqual(inputBody) + expect(transformed.reasoning_effort).toBeUndefined() + expect(transformed.reasoning).toBeUndefined() + }) + + it("reasoning payload overwrites effort and summary but keeps existing reasoning keys", () => { + new TestOpenAICompatibleHandler({ apiModelId: "test-model" } as ApiHandlerOptions, "high") + + const transformed = capturedProviderConfig.transformRequestBody({ + model: "test-model", + reasoning_effort: "high", + reasoning: { + foo: "bar", + effort: "low", + summary: "manual", + }, + messages: [], + }) + + expect(transformed).toEqual( + expect.objectContaining({ + reasoning_effort: "high", + reasoning: { + foo: "bar", + effort: "high", + summary: "auto", + }, + messages: [], + }), + ) + }) +}) diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index d129e72452f..b0f287b072a 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -8,7 +8,7 @@ import OpenAI from "openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import { streamText, generateText, LanguageModel, ToolSet } from "ai" -import type { ModelInfo } from "@roo-code/types" +import type { ModelInfo, ReasoningEffortExtended } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -19,6 +19,35 @@ import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +type OpenAICompatibleResolvedModel = { + id: string + info: ModelInfo + maxTokens?: number + temperature?: number + reasoningEffort?: ReasoningEffortExtended +} + +type OpenAICompatibleProviderOptions = NonNullable[0]["providerOptions"]> & { + openaiCompatible?: { + reasoningEffort?: ReasoningEffortExtended + } +} + +function transformOpenAICompatibleReasoningBody(body: Record): Record { + if (body.reasoning_effort === undefined) { + return body + } + + return { + ...body, + reasoning: { + ...(typeof body.reasoning === "object" && body.reasoning !== null ? body.reasoning : {}), + effort: body.reasoning_effort, + summary: "auto" as const, + }, + } +} + /** * Configuration options for creating an OpenAI-compatible provider. */ @@ -41,6 +70,8 @@ export interface OpenAICompatibleConfig { modelMaxTokens?: number /** Temperature setting */ temperature?: number + /** Optional request transformer applied by AI SDK before sending */ + transformRequestBody?: (args: Record) => Record } /** @@ -66,6 +97,7 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si ...DEFAULT_HEADERS, ...(config.headers || {}), }, + transformRequestBody: config.transformRequestBody ?? transformOpenAICompatibleReasoningBody, }) } @@ -76,10 +108,22 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si return this.provider(this.config.modelId) } + protected getProviderOptions(model: OpenAICompatibleResolvedModel): OpenAICompatibleProviderOptions | undefined { + if (!model.reasoningEffort) { + return undefined + } + + return { + openaiCompatible: { + reasoningEffort: model.reasoningEffort, + }, + } + } + /** * Get the model information. Must be implemented by subclasses. */ - abstract override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } + abstract override getModel(): OpenAICompatibleResolvedModel /** * Process usage metrics from the AI SDK response. @@ -165,6 +209,8 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si const openAiTools = this.convertToolsForOpenAI(metadata?.tools) const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + const providerOptions = this.getProviderOptions(model) + // Build the request options const requestOptions: Parameters[0] = { model: languageModel, @@ -174,6 +220,7 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si maxOutputTokens: this.getMaxOutputTokens(), tools: aiSdkTools, toolChoice: this.mapToolChoice(metadata?.tool_choice), + ...(providerOptions ? { providerOptions } : {}), } // Use streamText for streaming responses @@ -198,13 +245,16 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si * Complete a prompt using the AI SDK generateText. */ async completePrompt(prompt: string): Promise { + const model = this.getModel() const languageModel = this.getLanguageModel() + const providerOptions = this.getProviderOptions(model) const { text } = await generateText({ model: languageModel, prompt, maxOutputTokens: this.getMaxOutputTokens(), - temperature: this.config.temperature ?? 0, + temperature: model.temperature ?? this.config.temperature ?? 0, + ...(providerOptions ? { providerOptions } : {}), }) return text diff --git a/src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts b/src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts deleted file mode 100644 index 95512193941..00000000000 --- a/src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { MultiSearchReplaceDiffStrategy } from "../multi-search-replace" - -describe("MultiSearchReplaceDiffStrategy - trailing newline preservation", () => { - let strategy: MultiSearchReplaceDiffStrategy - - beforeEach(() => { - strategy = new MultiSearchReplaceDiffStrategy() - }) - - it("should preserve trailing newlines in SEARCH content with line numbers", async () => { - // This test verifies the fix for issue #8020 - // The regex should not consume trailing newlines, allowing stripLineNumbers to work correctly - const originalContent = `class Example { - constructor() { - this.value = 0; - } -}` - const diffContent = `<<<<<<< SEARCH -1 | class Example { -2 | constructor() { -3 | this.value = 0; -4 | } -5 | } -======= -class Example { - constructor() { - this.value = 1; - } -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`class Example { - constructor() { - this.value = 1; - } -}`) - } - }) - - it("should handle Windows line endings with trailing newlines and line numbers", async () => { - const originalContent = "function test() {\r\n return true;\r\n}\r\n" - const diffContent = `<<<<<<< SEARCH -1 | function test() { -2 | return true; -3 | } -======= -function test() { - return false; -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - // Should preserve Windows line endings - expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n") - } - }) - - it("should handle multiple search/replace blocks with trailing newlines", async () => { - const originalContent = `function one() { - return 1; -} - -function two() { - return 2; -}` - const diffContent = `<<<<<<< SEARCH -1 | function one() { -2 | return 1; -3 | } -======= -function one() { - return 10; -} ->>>>>>> REPLACE - -<<<<<<< SEARCH -5 | function two() { -6 | return 2; -7 | } -======= -function two() { - return 20; -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`function one() { - return 10; -} - -function two() { - return 20; -}`) - } - }) - - it("should handle content with line numbers at the last line", async () => { - // This specifically tests the scenario from the bug report - const originalContent = ` List addressInfoList = new ArrayList<>(CollectionUtils.size(repairInfoList) > 10 ? 10 - : CollectionUtils.size(repairInfoList) + CollectionUtils.size(homeAddressInfoList) - + CollectionUtils.size(idNoAddressInfoList) + CollectionUtils.size(workAddressInfoList) - + CollectionUtils.size(personIdentityInfoList));` - - const diffContent = `<<<<<<< SEARCH -1476 | List addressInfoList = new ArrayList<>(CollectionUtils.size(repairInfoList) > 10 ? 10 -1477 | : CollectionUtils.size(repairInfoList) + CollectionUtils.size(homeAddressInfoList) -1478 | + CollectionUtils.size(idNoAddressInfoList) + CollectionUtils.size(workAddressInfoList) -1479 | + CollectionUtils.size(personIdentityInfoList)); -======= - - // Filter addresses if optimization is enabled - if (isAddressDisplayOptimizeEnabled()) { - homeAddressInfoList = filterAddressesByThreeYearRule(homeAddressInfoList); - personIdentityInfoList = filterAddressesByThreeYearRule(personIdentityInfoList); - idNoAddressInfoList = filterAddressesByThreeYearRule(idNoAddressInfoList); - workAddressInfoList = filterAddressesByThreeYearRule(workAddressInfoList); - } - - List addressInfoList = new ArrayList<>(CollectionUtils.size(repairInfoList) > 10 ? 10 - : CollectionUtils.size(repairInfoList) + CollectionUtils.size(homeAddressInfoList) - + CollectionUtils.size(idNoAddressInfoList) + CollectionUtils.size(workAddressInfoList) - + CollectionUtils.size(personIdentityInfoList)); ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toContain("// Filter addresses if optimization is enabled") - expect(result.content).toContain("if (isAddressDisplayOptimizeEnabled())") - // Verify the last line doesn't have line numbers - expect(result.content).not.toContain("1488 |") - expect(result.content).not.toContain("1479 |") - } - }) - - it("should correctly strip line numbers even when last line has no trailing newline", async () => { - const originalContent = "line 1\nline 2\nline 3" // No trailing newline - const diffContent = `<<<<<<< SEARCH -1 | line 1 -2 | line 2 -3 | line 3 -======= -line 1 -modified line 2 -line 3 ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe("line 1\nmodified line 2\nline 3") - // Verify no line numbers remain - expect(result.content).not.toContain(" | ") - } - }) -}) diff --git a/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts b/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts deleted file mode 100644 index f06f3f406fb..00000000000 --- a/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts +++ /dev/null @@ -1,1207 +0,0 @@ -import { MultiSearchReplaceDiffStrategy } from "../multi-search-replace" - -describe("MultiSearchReplaceDiffStrategy", () => { - describe("validateMarkerSequencing", () => { - let strategy: MultiSearchReplaceDiffStrategy - - beforeEach(() => { - strategy = new MultiSearchReplaceDiffStrategy() - }) - - it("validates correct marker sequence", () => { - const diff = "<<<<<<< SEARCH\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" - expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) - }) - - it("validates correct marker sequence with extra > in SEARCH", () => { - const diff = "<<<<<<< SEARCH>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" - expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) - }) - - it("validates correct marker sequence with multiple > in SEARCH", () => { - const diff = "<<<<<<< SEARCH>>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" - expect(strategy["validateMarkerSequencing"](diff).success).toBe(false) - }) - - it("validates mixed cases with and without extra > in the same diff", () => { - const diff = - "<<<<<<< SEARCH>\n" + - "content1\n" + - "=======\n" + - "new1\n" + - ">>>>>>> REPLACE\n\n" + - "<<<<<<< SEARCH\n" + - "content2\n" + - "=======\n" + - "new2\n" + - ">>>>>>> REPLACE" - expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) - }) - - it("validates extra > with whitespace variations", () => { - const diff1 = "<<<<<<< SEARCH> \n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" - expect(strategy["validateMarkerSequencing"](diff1).success).toBe(true) - - const diff2 = "<<<<<<< SEARCH >\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" - expect(strategy["validateMarkerSequencing"](diff2).success).toBe(false) - }) - - it("validates extra > with line numbers", () => { - const diff = - "<<<<<<< SEARCH>\n" + - ":start_line:10\n" + - "-------\n" + - "content1\n" + - "=======\n" + - "new1\n" + - ">>>>>>> REPLACE" - expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) - }) - - it("validates multiple correct marker sequences", () => { - const diff = - "<<<<<<< SEARCH\n" + - "content1\n" + - "=======\n" + - "new1\n" + - ">>>>>>> REPLACE\n\n" + - "<<<<<<< SEARCH\n" + - "content2\n" + - "=======\n" + - "new2\n" + - ">>>>>>> REPLACE" - expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) - }) - - it("validates multiple correct marker sequences with line numbers", () => { - const diff = - "<<<<<<< SEARCH\n" + - ":start_line:10\n" + - "-------\n" + - "content1\n" + - "=======\n" + - "new1\n" + - ">>>>>>> REPLACE\n\n" + - "<<<<<<< SEARCH\n" + - ":start_line:10\n" + - "-------\n" + - "content2\n" + - "=======\n" + - "new2\n" + - ">>>>>>> REPLACE" - expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) - }) - - it("detects separator before search", () => { - const diff = "=======\n" + "content\n" + ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("'=======' found in your diff content") - expect(result.error).toContain("Diff block is malformed") - }) - - it("detects missing separator", () => { - const diff = "<<<<<<< SEARCH\n" + "content\n" + ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("'>>>>>>> REPLACE' found in your diff content") - expect(result.error).toContain("Diff block is malformed") - }) - - it("detects two separators", () => { - const diff = "<<<<<<< SEARCH\n" + "content\n" + "=======\n" + "=======\n" + ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("'=======' found in your diff content") - expect(result.error).toContain("When removing merge conflict markers") - }) - - it("detects replace before separator (merge conflict message)", () => { - const diff = "<<<<<<< SEARCH\n" + "content\n" + ">>>>>>>" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("'>>>>>>>' found in your diff content") - expect(result.error).toContain("When removing merge conflict markers") - }) - - it("detects incomplete sequence", () => { - const diff = "<<<<<<< SEARCH\n" + "content\n" + "=======\n" + "new content" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("Expected '>>>>>>> REPLACE' was not found") - }) - - describe("exact matching", () => { - let strategy: MultiSearchReplaceDiffStrategy - - beforeEach(() => { - strategy = new MultiSearchReplaceDiffStrategy(1.0, 5) // Default 1.0 threshold for exact matching, 5 line buffer for tests - }) - - it("should replace matching content", async () => { - const originalContent = 'function hello() {\n console.log("hello")\n}\n' - const diffContent = `test.ts -<<<<<<< SEARCH -function hello() { - console.log("hello") -} -======= -function hello() { - console.log("hello world") -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe('function hello() {\n console.log("hello world")\n}\n') - } - }) - - it("should replace matching content in multiple blocks", async () => { - const originalContent = 'function hello() {\n console.log("hello")\n}\n' - const diffContent = `test.ts -<<<<<<< SEARCH -function hello() { -======= -function helloWorld() { ->>>>>>> REPLACE -<<<<<<< SEARCH - console.log("hello") -======= - console.log("hello world") ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe('function helloWorld() {\n console.log("hello world")\n}\n') - } - }) - - it("should replace matching content in multiple blocks with line numbers", async () => { - const originalContent = 'function hello() {\n console.log("hello")\n}\n' - const diffContent = `test.ts -<<<<<<< SEARCH -:start_line:1 -------- -function hello() { -======= -function helloWorld() { ->>>>>>> REPLACE -<<<<<<< SEARCH -:start_line:2 -------- - console.log("hello") -======= - console.log("hello world") ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe('function helloWorld() {\n console.log("hello world")\n}\n') - } - }) - - it("should replace matching content when end_line is passed in", async () => { - const originalContent = 'function hello() {\n console.log("hello")\n}\n' - const diffContent = `test.ts -<<<<<<< SEARCH -:start_line:1 -:end_line:1 -------- -function hello() { -======= -function helloWorld() { ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe('function helloWorld() {\n console.log("hello")\n}\n') - } - }) - - it("should match content with different surrounding whitespace", async () => { - const originalContent = "\nfunction example() {\n return 42;\n}\n\n" - const diffContent = `test.ts -<<<<<<< SEARCH -function example() { - return 42; -} -======= -function example() { - return 43; -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe("\nfunction example() {\n return 43;\n}\n\n") - } - }) - - it("should match content with different indentation in search block", async () => { - const originalContent = " function test() {\n return true;\n }\n" - const diffContent = `test.ts -<<<<<<< SEARCH -function test() { - return true; -} -======= -function test() { - return false; -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(" function test() {\n return false;\n }\n") - } - }) - - it("should handle tab-based indentation", async () => { - const originalContent = "function test() {\n\treturn true;\n}\n" - const diffContent = `test.ts -<<<<<<< SEARCH -function test() { -\treturn true; -} -======= -function test() { -\treturn false; -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe("function test() {\n\treturn false;\n}\n") - } - }) - - it("should preserve mixed tabs and spaces", async () => { - const originalContent = "\tclass Example {\n\t constructor() {\n\t\tthis.value = 0;\n\t }\n\t}" - const diffContent = `test.ts -<<<<<<< SEARCH -\tclass Example { -\t constructor() { -\t\tthis.value = 0; -\t } -\t} -======= -\tclass Example { -\t constructor() { -\t\tthis.value = 1; -\t } -\t} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe( - "\tclass Example {\n\t constructor() {\n\t\tthis.value = 1;\n\t }\n\t}", - ) - } - }) - - it("should handle additional indentation with tabs", async () => { - const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" - const diffContent = `test.ts -<<<<<<< SEARCH -function test() { -\treturn true; -} -======= -function test() { -\t// Add comment -\treturn false; -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe("\tfunction test() {\n\t\t// Add comment\n\t\treturn false;\n\t}") - } - }) - - it("should preserve exact indentation characters when adding lines", async () => { - const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" - const diffContent = `test.ts -<<<<<<< SEARCH -\tfunction test() { -\t\treturn true; -\t} -======= -\tfunction test() { -\t\t// First comment -\t\t// Second comment -\t\treturn true; -\t} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe( - "\tfunction test() {\n\t\t// First comment\n\t\t// Second comment\n\t\treturn true;\n\t}", - ) - } - }) - - it("should handle Windows-style CRLF line endings", async () => { - const originalContent = "function test() {\r\n return true;\r\n}\r\n" - const diffContent = `test.ts -<<<<<<< SEARCH -function test() { - return true; -} -======= -function test() { - return false; -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n") - } - }) - - it("should return false if search content does not match", async () => { - const originalContent = 'function hello() {\n console.log("hello")\n}\n' - const diffContent = `test.ts -<<<<<<< SEARCH -function hello() { - console.log("wrong") -} -======= -function hello() { - console.log("hello world") -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(false) - }) - - it("should return false if diff format is invalid", async () => { - const originalContent = 'function hello() {\n console.log("hello")\n}\n' - const diffContent = `test.ts\nInvalid diff format` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(false) - }) - - it("should handle multiple lines with proper indentation", async () => { - const originalContent = - "class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n return this.value\n }\n}\n" - const diffContent = `test.ts -<<<<<<< SEARCH - getValue() { - return this.value - } -======= - getValue() { - // Add logging - console.log("Getting value") - return this.value - } ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe( - 'class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n // Add logging\n console.log("Getting value")\n return this.value\n }\n}\n', - ) - } - }) - - it("should preserve whitespace exactly in the output", async () => { - const originalContent = " indented\n more indented\n back\n" - const diffContent = `test.ts -<<<<<<< SEARCH - indented - more indented - back -======= - modified - still indented - end ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(" modified\n still indented\n end\n") - } - }) - - it("should preserve indentation when adding new lines after existing content", async () => { - const originalContent = " onScroll={() => updateHighlights()}" - const diffContent = `test.ts -<<<<<<< SEARCH - onScroll={() => updateHighlights()} -======= - onScroll={() => updateHighlights()} - onDragOver={(e) => { - e.preventDefault() - e.stopPropagation() - }} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe( - " onScroll={() => updateHighlights()}\n onDragOver={(e) => {\n e.preventDefault()\n e.stopPropagation()\n }}", - ) - } - }) - - it("should handle varying indentation levels correctly", async () => { - const originalContent = ` -class Example { - constructor() { - this.value = 0; - if (true) { - this.init(); - } - } -}`.trim() - - const diffContent = `test.ts -<<<<<<< SEARCH - class Example { - constructor() { - this.value = 0; - if (true) { - this.init(); - } - } - } -======= - class Example { - constructor() { - this.value = 1; - if (true) { - this.init(); - this.setup(); - this.validate(); - } - } - } ->>>>>>> REPLACE`.trim() - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe( - ` -class Example { - constructor() { - this.value = 1; - if (true) { - this.init(); - this.setup(); - this.validate(); - } - } -}`.trim(), - ) - } - }) - - it("should handle mixed indentation styles in the same file", async () => { - const originalContent = `class Example { - constructor() { - this.value = 0; - if (true) { - this.init(); - } - } -}`.trim() - const diffContent = `test.ts -<<<<<<< SEARCH - constructor() { - this.value = 0; - if (true) { - this.init(); - } - } -======= - constructor() { - this.value = 1; - if (true) { - this.init(); - this.validate(); - } - } ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`class Example { - constructor() { - this.value = 1; - if (true) { - this.init(); - this.validate(); - } - } -}`) - } - }) - - it("should handle Python-style significant whitespace", async () => { - const originalContent = `def example(): - if condition: - do_something() - for item in items: - process(item) - return True`.trim() - const diffContent = `test.ts -<<<<<<< SEARCH - if condition: - do_something() - for item in items: - process(item) -======= - if condition: - do_something() - while items: - item = items.pop() - process(item) ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`def example(): - if condition: - do_something() - while items: - item = items.pop() - process(item) - return True`) - } - }) - - it("should preserve empty lines with indentation", async () => { - const originalContent = `function test() { - const x = 1; - - if (x) { - return true; - } -}`.trim() - const diffContent = `test.ts -<<<<<<< SEARCH - const x = 1; - - if (x) { -======= - const x = 1; - - // Check x - if (x) { ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`function test() { - const x = 1; - - // Check x - if (x) { - return true; - } -}`) - } - }) - - it("should handle indentation when replacing entire blocks", async () => { - const originalContent = `class Test { - method() { - if (true) { - console.log("test"); - } - } -}`.trim() - const diffContent = `test.ts -<<<<<<< SEARCH - method() { - if (true) { - console.log("test"); - } - } -======= - method() { - try { - if (true) { - console.log("test"); - } - } catch (e) { - console.error(e); - } - } ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`class Test { - method() { - try { - if (true) { - console.log("test"); - } - } catch (e) { - console.error(e); - } - } -}`) - } - }) - - it("should handle negative indentation relative to search content", async () => { - const originalContent = `class Example { - constructor() { - if (true) { - this.init(); - this.setup(); - } - } -}`.trim() - const diffContent = `test.ts -<<<<<<< SEARCH - this.init(); - this.setup(); -======= - this.init(); - this.setup(); ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`class Example { - constructor() { - if (true) { - this.init(); - this.setup(); - } - } -}`) - } - }) - - it("should handle extreme negative indentation (no indent)", async () => { - const originalContent = `class Example { - constructor() { - if (true) { - this.init(); - } - } -}`.trim() - const diffContent = `test.ts -<<<<<<< SEARCH - this.init(); -======= -this.init(); ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`class Example { - constructor() { - if (true) { -this.init(); - } - } -}`) - } - }) - - it("should handle mixed indentation changes in replace block", async () => { - const originalContent = `class Example { - constructor() { - if (true) { - this.init(); - this.setup(); - this.validate(); - } - } -}`.trim() - const diffContent = `test.ts -<<<<<<< SEARCH - this.init(); - this.setup(); - this.validate(); -======= - this.init(); - this.setup(); - this.validate(); ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`class Example { - constructor() { - if (true) { - this.init(); - this.setup(); - this.validate(); - } - } -}`) - } - }) - - it("should find matches from middle out", async () => { - const originalContent = ` -function one() { - return "target"; -} - -function two() { - return "target"; -} - -function three() { - return "target"; -} - -function four() { - return "target"; -} - -function five() { - return "target"; -}`.trim() - - const diffContent = `test.ts -<<<<<<< SEARCH - return "target"; -======= - return "updated"; ->>>>>>> REPLACE` - - // Search around the middle (function three) - // Even though all functions contain the target text, - // it should match the one closest to line 9 first - const result = await strategy.applyDiff(originalContent, diffContent, 9) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`function one() { - return "target"; -} - -function two() { - return "target"; -} - -function three() { - return "updated"; -} - -function four() { - return "target"; -} - -function five() { - return "target"; -}`) - } - }) - }) - }) - - describe("fuzzy matching", () => { - let strategy: MultiSearchReplaceDiffStrategy - beforeEach(() => { - strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) // 90% similarity threshold, 5 line buffer for tests - }) - - it("should match content with small differences (>90% similar)", async () => { - const originalContent = - "function getData() {\n const results = fetchData();\n return results.filter(Boolean);\n}\n" - const diffContent = `test.ts -<<<<<<< SEARCH -function getData() { - const result = fetchData(); - return results.filter(Boolean); -} -======= -function getData() { - const data = fetchData(); - return data.filter(Boolean); -} ->>>>>>> REPLACE` - - strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) // Use 5 line buffer for tests - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe( - "function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n", - ) - } - }) - - it("should not match when content is too different (<90% similar)", async () => { - const originalContent = "function processUsers(data) {\n return data.map(user => user.name);\n}\n" - const diffContent = `test.ts -<<<<<<< SEARCH -function handleItems(items) { - return items.map(item => item.username); -} -======= -function processData(data) { - return data.map(d => d.value); -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(false) - }) - - it("should match content with extra whitespace", async () => { - const originalContent = "function sum(a, b) {\n return a + b;\n}" - const diffContent = `test.ts -<<<<<<< SEARCH -function sum(a, b) { - return a + b; -} -======= -function sum(a, b) { - return a + b + 1; -} ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe("function sum(a, b) {\n return a + b + 1;\n}") - } - }) - - it("should match content with smart quotes", async () => { - const originalContent = - "**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can't wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding!" - const diffContent = `test.ts -<<<<<<< SEARCH -**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can't wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding! -======= -**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can't wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding! - -You're still here? ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe( - "**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can't wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding!\n\nYou're still here?", - ) - } - }) - - it("should not exact match empty lines", async () => { - const originalContent = "function sum(a, b) {\n\n return a + b;\n}" - const diffContent = `test.ts -<<<<<<< SEARCH -function sum(a, b) { -======= -import { a } from "a"; -function sum(a, b) { ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe('import { a } from "a";\nfunction sum(a, b) {\n\n return a + b;\n}') - } - }) - }) - - describe("deletion", () => { - let strategy: MultiSearchReplaceDiffStrategy - - beforeEach(() => { - strategy = new MultiSearchReplaceDiffStrategy() - }) - - it("should delete code when replace block is empty", async () => { - const originalContent = `function test() { - console.log("hello"); - // Comment to remove - console.log("world"); -}` - const diffContent = `test.ts -<<<<<<< SEARCH - // Comment to remove -======= ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`function test() { - console.log("hello"); - console.log("world"); -}`) - } - }) - - it("should delete multiple lines when replace block is empty", async () => { - const originalContent = `class Example { - constructor() { - // Initialize - this.value = 0; - // Set defaults - this.name = ""; - // End init - } -}` - const diffContent = `test.ts -<<<<<<< SEARCH - // Initialize - this.value = 0; - // Set defaults - this.name = ""; - // End init -======= ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`class Example { - constructor() { - } -}`) - } - }) - - it("should preserve indentation when deleting nested code", async () => { - const originalContent = `function outer() { - if (true) { - // Remove this - console.log("test"); - // And this - } - return true; -}` - const diffContent = `test.ts -<<<<<<< SEARCH - // Remove this - console.log("test"); - // And this -======= ->>>>>>> REPLACE` - - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe(`function outer() { - if (true) { - } - return true; -}`) - } - }) - - it("should delete a line when search block has line number prefix and replace is empty", async () => { - const originalContent = "line 1\nline to delete\nline 3" - const diffContent = ` -<<<<<<< SEARCH -:start_line:2 -------- -2 | line to delete -======= ->>>>>>> REPLACE` - const result = await strategy.applyDiff(originalContent, diffContent) - expect(result.success).toBe(true) - if (result.success) { - expect(result.content).toBe("line 1\nline 3") - } - }) - }) - - describe("line marker validation in REPLACE sections", () => { - let strategy: MultiSearchReplaceDiffStrategy - - beforeEach(() => { - strategy = new MultiSearchReplaceDiffStrategy() - }) - - it("should reject start_line marker in REPLACE section", () => { - const diff = - "<<<<<<< SEARCH\n" + - "content to find\n" + - "=======\n" + - ":start_line:5\n" + - "replacement content\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("Invalid line marker ':start_line:' found in REPLACE section") - expect(result.error).toContain( - "Line markers (:start_line: and :end_line:) are only allowed in SEARCH sections", - ) - }) - - it("should reject end_line marker in REPLACE section", () => { - const diff = - "<<<<<<< SEARCH\n" + - "content to find\n" + - "=======\n" + - ":end_line:10\n" + - "replacement content\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("Invalid line marker ':end_line:' found in REPLACE section") - expect(result.error).toContain( - "Line markers (:start_line: and :end_line:) are only allowed in SEARCH sections", - ) - }) - - it("should reject both line markers in REPLACE section", () => { - const diff = - "<<<<<<< SEARCH\n" + - "content to find\n" + - "=======\n" + - ":start_line:5\n" + - ":end_line:10\n" + - "replacement content\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("Invalid line marker ':start_line:' found in REPLACE section") - }) - - it("should reject line markers in multiple diff blocks where one has invalid markers", () => { - const diff = - "<<<<<<< SEARCH\n" + - ":start_line:1\n" + - "content1\n" + - "=======\n" + - "replacement1\n" + - ">>>>>>> REPLACE\n\n" + - "<<<<<<< SEARCH\n" + - "content2\n" + - "=======\n" + - ":start_line:5\n" + - "replacement2\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("Invalid line marker ':start_line:' found in REPLACE section") - }) - - it("should allow valid markers in SEARCH section with content in REPLACE", () => { - const diff = - "<<<<<<< SEARCH\n" + - ":start_line:5\n" + - ":end_line:10\n" + - "-------\n" + - "content to find\n" + - "=======\n" + - "replacement content\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(true) - }) - - it("should allow escaped line markers in REPLACE content", () => { - const diff = - "<<<<<<< SEARCH\n" + - "content to find\n" + - "=======\n" + - "replacement content\n" + - "\\:start_line:5\n" + - "more content\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(true) - }) - - it("should allow escaped end_line markers in REPLACE content", () => { - const diff = - "<<<<<<< SEARCH\n" + - "content to find\n" + - "=======\n" + - "replacement content\n" + - "\\:end_line:10\n" + - "more content\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(true) - }) - - it("should allow both escaped line markers in REPLACE content", () => { - const diff = - "<<<<<<< SEARCH\n" + - "content to find\n" + - "=======\n" + - "replacement content\n" + - "\\:start_line:5\n" + - "\\:end_line:10\n" + - "more content\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(true) - }) - - it("should reject line markers with whitespace in REPLACE section", () => { - const diff = - "<<<<<<< SEARCH\n" + - "content to find\n" + - "=======\n" + - " :start_line:5 \n" + - "replacement content\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("Invalid line marker ':start_line:' found in REPLACE section") - }) - - it("should reject line markers in middle of REPLACE content", () => { - const diff = - "<<<<<<< SEARCH\n" + - "content to find\n" + - "=======\n" + - "some replacement\n" + - ":end_line:15\n" + - "more replacement\n" + - ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("Invalid line marker ':end_line:' found in REPLACE section") - }) - - it("should provide helpful error message format", () => { - const diff = - "<<<<<<< SEARCH\n" + "content\n" + "=======\n" + ":start_line:5\n" + "replacement\n" + ">>>>>>> REPLACE" - const result = strategy["validateMarkerSequencing"](diff) - expect(result.success).toBe(false) - expect(result.error).toContain("CORRECT FORMAT:") - expect(result.error).toContain("INCORRECT FORMAT:") - expect(result.error).toContain(":start_line:5 <-- Invalid location") - }) - }) -}) diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts deleted file mode 100644 index f43bbee0dc9..00000000000 --- a/src/core/diff/strategies/multi-search-replace.ts +++ /dev/null @@ -1,546 +0,0 @@ -import { distance } from "fastest-levenshtein" - -import { ToolProgressStatus } from "@roo-code/types" - -import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" -import { ToolUse, DiffStrategy, DiffResult } from "../../../shared/tools" -import { normalizeString } from "../../../utils/text-normalization" - -const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches - -function getSimilarity(original: string, search: string): number { - // Empty searches are no longer supported - if (search === "") { - return 0 - } - - // Use the normalizeString utility to handle smart quotes and other special characters - const normalizedOriginal = normalizeString(original) - const normalizedSearch = normalizeString(search) - - if (normalizedOriginal === normalizedSearch) { - return 1 - } - - // Calculate Levenshtein distance using fastest-levenshtein's distance function - const dist = distance(normalizedOriginal, normalizedSearch) - - // Calculate similarity ratio (0 to 1, where 1 is an exact match) - const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length) - return 1 - dist / maxLength -} - -/** - * Performs a "middle-out" search of `lines` (between [startIndex, endIndex]) to find - * the slice that is most similar to `searchChunk`. Returns the best score, index, and matched text. - */ -function fuzzySearch(lines: string[], searchChunk: string, startIndex: number, endIndex: number) { - let bestScore = 0 - let bestMatchIndex = -1 - let bestMatchContent = "" - const searchLen = searchChunk.split(/\r?\n/).length - - // Middle-out from the midpoint - const midPoint = Math.floor((startIndex + endIndex) / 2) - let leftIndex = midPoint - let rightIndex = midPoint + 1 - - while (leftIndex >= startIndex || rightIndex <= endIndex - searchLen) { - if (leftIndex >= startIndex) { - const originalChunk = lines.slice(leftIndex, leftIndex + searchLen).join("\n") - const similarity = getSimilarity(originalChunk, searchChunk) - if (similarity > bestScore) { - bestScore = similarity - bestMatchIndex = leftIndex - bestMatchContent = originalChunk - } - leftIndex-- - } - - if (rightIndex <= endIndex - searchLen) { - const originalChunk = lines.slice(rightIndex, rightIndex + searchLen).join("\n") - const similarity = getSimilarity(originalChunk, searchChunk) - if (similarity > bestScore) { - bestScore = similarity - bestMatchIndex = rightIndex - bestMatchContent = originalChunk - } - rightIndex++ - } - } - - return { bestScore, bestMatchIndex, bestMatchContent } -} - -export class MultiSearchReplaceDiffStrategy implements DiffStrategy { - private fuzzyThreshold: number - private bufferLines: number - - getName(): string { - return "MultiSearchReplace" - } - - constructor(fuzzyThreshold?: number, bufferLines?: number) { - // Use provided threshold or default to exact matching (1.0) - // Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9) - // so we use it directly here - this.fuzzyThreshold = fuzzyThreshold ?? 1.0 - this.bufferLines = bufferLines ?? BUFFER_LINES - } - - private unescapeMarkers(content: string): string { - return content - .replace(/^\\<<<<<<>>>>>>/gm, ">>>>>>>") - .replace(/^\\-------/gm, "-------") - .replace(/^\\:end_line:/gm, ":end_line:") - .replace(/^\\:start_line:/gm, ":start_line:") - } - - private validateMarkerSequencing(diffContent: string): { success: boolean; error?: string } { - enum State { - START, - AFTER_SEARCH, - AFTER_SEPARATOR, - } - const state = { current: State.START, line: 0 } - - // Pattern allows optional '>' after SEARCH to handle AI-generated diffs - // (e.g., Sonnet 4 sometimes adds an extra '>') - const SEARCH_PATTERN = /^<<<<<<< SEARCH>?$/ - const SEARCH = SEARCH_PATTERN.source.replace(/[\^$]/g, "") // Remove regex anchors for display - const SEP = "=======" - const REPLACE = ">>>>>>> REPLACE" - const SEARCH_PREFIX = "<<<<<<<" - const REPLACE_PREFIX = ">>>>>>>" - - const reportMergeConflictError = (found: string, _expected: string) => ({ - success: false, - error: - `ERROR: Special marker '${found}' found in your diff content at line ${state.line}:\n` + - "\n" + - `When removing merge conflict markers like '${found}' from files, you MUST escape them\n` + - "in your SEARCH section by prepending a backslash (\\) at the beginning of the line:\n" + - "\n" + - "CORRECT FORMAT:\n\n" + - "<<<<<<< SEARCH\n" + - "content before\n" + - `\\${found} <-- Note the backslash here in this example\n` + - "content after\n" + - "=======\n" + - "replacement content\n" + - ">>>>>>> REPLACE\n" + - "\n" + - "Without escaping, the system confuses your content with diff syntax markers.\n" + - "You may use multiple diff blocks in a single diff request, but ANY of ONLY the following separators that occur within SEARCH or REPLACE content must be escaped, as follows:\n" + - `\\${SEARCH}\n` + - `\\${SEP}\n` + - `\\${REPLACE}\n`, - }) - - const reportInvalidDiffError = (found: string, expected: string) => ({ - success: false, - error: - `ERROR: Diff block is malformed: marker '${found}' found in your diff content at line ${state.line}. Expected: ${expected}\n` + - "\n" + - "CORRECT FORMAT:\n\n" + - "<<<<<<< SEARCH\n" + - ":start_line: (required) The line number of original content where the search block starts.\n" + - "-------\n" + - "[exact content to find including whitespace]\n" + - "=======\n" + - "[new content to replace with]\n" + - ">>>>>>> REPLACE\n", - }) - - const reportLineMarkerInReplaceError = (marker: string) => ({ - success: false, - error: - `ERROR: Invalid line marker '${marker}' found in REPLACE section at line ${state.line}\n` + - "\n" + - "Line markers (:start_line: and :end_line:) are only allowed in SEARCH sections.\n" + - "\n" + - "CORRECT FORMAT:\n" + - "<<<<<<< SEARCH\n" + - ":start_line:5\n" + - "content to find\n" + - "=======\n" + - "replacement content\n" + - ">>>>>>> REPLACE\n" + - "\n" + - "INCORRECT FORMAT:\n" + - "<<<<<<< SEARCH\n" + - "content to find\n" + - "=======\n" + - ":start_line:5 <-- Invalid location\n" + - "replacement content\n" + - ">>>>>>> REPLACE\n", - }) - - const lines = diffContent.split("\n") - const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length - const sepCount = lines.filter((l) => l.trim() === SEP).length - const replaceCount = lines.filter((l) => l.trim() === REPLACE).length - - const likelyBadStructure = searchCount !== replaceCount || sepCount < searchCount - - for (const line of diffContent.split("\n")) { - state.line++ - const marker = line.trim() - - // Check for line markers in REPLACE sections (but allow escaped ones) - if (state.current === State.AFTER_SEPARATOR) { - if (marker.startsWith(":start_line:") && !line.trim().startsWith("\\:start_line:")) { - return reportLineMarkerInReplaceError(":start_line:") - } - if (marker.startsWith(":end_line:") && !line.trim().startsWith("\\:end_line:")) { - return reportLineMarkerInReplaceError(":end_line:") - } - } - - switch (state.current) { - case State.START: - if (marker === SEP) - return likelyBadStructure - ? reportInvalidDiffError(SEP, SEARCH) - : reportMergeConflictError(SEP, SEARCH) - if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH) - if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) - if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH - else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) - break - - case State.AFTER_SEARCH: - if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, SEP) - if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) - if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP) - if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) - if (marker === SEP) state.current = State.AFTER_SEPARATOR - break - - case State.AFTER_SEPARATOR: - if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, REPLACE) - if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE) - if (marker === SEP) - return likelyBadStructure - ? reportInvalidDiffError(SEP, REPLACE) - : reportMergeConflictError(SEP, REPLACE) - if (marker === REPLACE) state.current = State.START - else if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, REPLACE) - break - } - } - - return state.current === State.START - ? { success: true } - : { - success: false, - error: `ERROR: Unexpected end of sequence: Expected '${ - state.current === State.AFTER_SEARCH ? "=======" : ">>>>>>> REPLACE" - }' was not found.`, - } - } - - async applyDiff( - originalContent: string, - diffContent: string, - _paramStartLine?: number, - _paramEndLine?: number, - ): Promise { - const validseq = this.validateMarkerSequencing(diffContent) - if (!validseq.success) { - return { - success: false, - error: validseq.error!, - } - } - - /* - Regex parts: - - 1. (?:^|\n) - Ensures the first marker starts at the beginning of the file or right after a newline. - - 2. (?>>>>>> REPLACE)(?=\n|$) - Matches the final ">>>>>>> REPLACE" marker on its own line (and requires a following newline or the end of file). - */ - - let matches = [ - ...diffContent.matchAll( - /(?:^|\n)(??\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?>>>>>> REPLACE)(?=\n|$)/g, - ), - ] - - if (matches.length === 0) { - return { - success: false, - error: `Invalid diff format - missing required sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n:start_line: start line\\n-------\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include start_line/SEARCH/=======/REPLACE sections with correct markers on new lines`, - } - } - // Detect line ending from original content - const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n" - let resultLines = originalContent.split(/\r?\n/) - let delta = 0 - let diffResults: DiffResult[] = [] - let appliedCount = 0 - const replacements = matches - .map((match) => ({ - startLine: Number(match[2] ?? 0), - searchContent: match[6], - replaceContent: match[7], - })) - .sort((a, b) => a.startLine - b.startLine) - - for (const replacement of replacements) { - let { searchContent, replaceContent } = replacement - let startLine = replacement.startLine + (replacement.startLine === 0 ? 0 : delta) - - // First unescape any escaped markers in the content - searchContent = this.unescapeMarkers(searchContent) - replaceContent = this.unescapeMarkers(replaceContent) - - // Strip line numbers from search and replace content if every line starts with a line number - const hasAllLineNumbers = - (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) || - (everyLineHasLineNumbers(searchContent) && replaceContent.trim() === "") - - if (hasAllLineNumbers && startLine === 0) { - startLine = parseInt(searchContent.split("\n")[0].split("|")[0]) - } - - if (hasAllLineNumbers) { - searchContent = stripLineNumbers(searchContent) - replaceContent = stripLineNumbers(replaceContent) - } - - // Validate that search and replace content are not identical - if (searchContent === replaceContent) { - diffResults.push({ - success: false, - error: - `Search and replace content are identical - no changes would be made\n\n` + - `Debug Info:\n` + - `- Search and replace must be different to make changes\n` + - `- Use read_file to verify the content you want to change`, - }) - continue - } - - // Split content into lines, handling both \n and \r\n - let searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/) - let replaceLines = replaceContent === "" ? [] : replaceContent.split(/\r?\n/) - - // Validate that search content is not empty - if (searchLines.length === 0) { - diffResults.push({ - success: false, - error: `Empty search content is not allowed\n\nDebug Info:\n- Search content cannot be empty\n- For insertions, provide a specific line using :start_line: and include content to search for\n- For example, match a single line to insert before/after it`, - }) - continue - } - - let endLine = replacement.startLine + searchLines.length - 1 - - // Initialize search variables - let matchIndex = -1 - let bestMatchScore = 0 - let bestMatchContent = "" - let searchChunk = searchLines.join("\n") - - // Determine search bounds - let searchStartIndex = 0 - let searchEndIndex = resultLines.length - - // Validate and handle line range if provided - if (startLine) { - // Convert to 0-based index - const exactStartIndex = startLine - 1 - const searchLen = searchLines.length - const exactEndIndex = exactStartIndex + searchLen - 1 - - // Try exact match first - const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n") - const similarity = getSimilarity(originalChunk, searchChunk) - if (similarity >= this.fuzzyThreshold) { - matchIndex = exactStartIndex - bestMatchScore = similarity - bestMatchContent = originalChunk - } else { - // Set bounds for buffered search - searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1)) - searchEndIndex = Math.min(resultLines.length, startLine + searchLines.length + this.bufferLines) - } - } - - // If no match found yet, try middle-out search within bounds - if (matchIndex === -1) { - const { - bestScore, - bestMatchIndex, - bestMatchContent: midContent, - } = fuzzySearch(resultLines, searchChunk, searchStartIndex, searchEndIndex) - matchIndex = bestMatchIndex - bestMatchScore = bestScore - bestMatchContent = midContent - } - - // Try aggressive line number stripping as a fallback if regular matching fails - if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) { - // Strip both search and replace content once (simultaneously) - const aggressiveSearchContent = stripLineNumbers(searchContent, true) - const aggressiveReplaceContent = stripLineNumbers(replaceContent, true) - - const aggressiveSearchLines = aggressiveSearchContent ? aggressiveSearchContent.split(/\r?\n/) : [] - const aggressiveSearchChunk = aggressiveSearchLines.join("\n") - - // Try middle-out search again with aggressive stripped content (respecting the same search bounds) - const { - bestScore, - bestMatchIndex, - bestMatchContent: aggContent, - } = fuzzySearch(resultLines, aggressiveSearchChunk, searchStartIndex, searchEndIndex) - if (bestMatchIndex !== -1 && bestScore >= this.fuzzyThreshold) { - matchIndex = bestMatchIndex - bestMatchScore = bestScore - bestMatchContent = aggContent - // Replace the original search/replace with their stripped versions - searchContent = aggressiveSearchContent - replaceContent = aggressiveReplaceContent - searchLines = aggressiveSearchLines - replaceLines = replaceContent ? replaceContent.split(/\r?\n/) : [] - } else { - // No match found with either method - const originalContentSection = - startLine !== undefined && endLine !== undefined - ? `\n\nOriginal Content:\n${addLineNumbers( - resultLines - .slice( - Math.max(0, startLine - 1 - this.bufferLines), - Math.min(resultLines.length, endLine + this.bufferLines), - ) - .join("\n"), - Math.max(1, startLine - this.bufferLines), - )}` - : `\n\nOriginal Content:\n${addLineNumbers(resultLines.join("\n"))}` - - const bestMatchSection = bestMatchContent - ? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}` - : `\n\nBest Match Found:\n(no match)` - - const lineRange = startLine ? ` at line: ${startLine}` : "" - - diffResults.push({ - success: false, - error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine ? `starting at line ${startLine}` : "start to end"}\n- Tried both standard and aggressive line number stripping\n- Tip: Use the read_file tool to get the latest content of the file before attempting to use the apply_diff tool again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`, - }) - continue - } - } - - // Get the matched lines from the original content - const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length) - - // Get the exact indentation (preserving tabs/spaces) of each line - const originalIndents = matchedLines.map((line) => { - const match = line.match(/^[\t ]*/) - return match ? match[0] : "" - }) - - // Get the exact indentation of each line in the search block - const searchIndents = searchLines.map((line) => { - const match = line.match(/^[\t ]*/) - return match ? match[0] : "" - }) - - // Apply the replacement while preserving exact indentation - const indentedReplaceLines = replaceLines.map((line) => { - // Get the matched line's exact indentation - const matchedIndent = originalIndents[0] || "" - - // Get the current line's indentation relative to the search content - const currentIndentMatch = line.match(/^[\t ]*/) - const currentIndent = currentIndentMatch ? currentIndentMatch[0] : "" - const searchBaseIndent = searchIndents[0] || "" - - // Calculate the relative indentation level - const searchBaseLevel = searchBaseIndent.length - const currentLevel = currentIndent.length - const relativeLevel = currentLevel - searchBaseLevel - - // If relative level is negative, remove indentation from matched indent - // If positive, add to matched indent - const finalIndent = - relativeLevel < 0 - ? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel)) - : matchedIndent + currentIndent.slice(searchBaseLevel) - - return finalIndent + line.trim() - }) - - // Construct the final content - const beforeMatch = resultLines.slice(0, matchIndex) - const afterMatch = resultLines.slice(matchIndex + searchLines.length) - resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch] - delta = delta - matchedLines.length + replaceLines.length - appliedCount++ - } - const finalContent = resultLines.join(lineEnding) - if (appliedCount === 0) { - return { - success: false, - failParts: diffResults, - } - } - return { - success: true, - content: finalContent, - failParts: diffResults, - } - } - - getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus { - const diffContent = toolUse.params.diff - if (diffContent) { - const icon = "diff-multiple" - if (toolUse.partial) { - if (Math.floor(diffContent.length / 10) % 10 === 0) { - const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length - return { icon, text: `${searchBlockCount}` } - } - } else if (result) { - const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length - if (result.failParts?.length) { - return { - icon, - text: `${searchBlockCount - result.failParts.length}/${searchBlockCount}`, - } - } else { - return { icon, text: `${searchBlockCount}` } - } - } - } - return {} - } -} diff --git a/src/package.json b/src/package.json index cb3b93d1602..d994edaae54 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.53.0", + "version": "3.53.1", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", diff --git a/webview-ui/src/components/chat/__tests__/Announcement.spec.tsx b/webview-ui/src/components/chat/__tests__/Announcement.spec.tsx index 84254ae9e9c..112bbede979 100644 --- a/webview-ui/src/components/chat/__tests__/Announcement.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/Announcement.spec.tsx @@ -12,7 +12,7 @@ vi.mock("@src/utils/vscode", () => ({ vi.mock("@roo/package", () => ({ Package: { - version: "3.53.0", + version: "3.53.1", }, })) @@ -55,7 +55,7 @@ describe("Announcement", () => { it("renders the v3.53.0 announcement title and highlights", () => { render() - expect(screen.getByText("Roo Code 3.53.0 Released")).toBeInTheDocument() + expect(screen.getByText("Roo Code 3.53.1 Released")).toBeInTheDocument() expect( screen.getByText( "GPT-5.5 via OpenAI Codex: Added GPT-5.5 support in the OpenAI Codex provider so you can use the latest model straight from Roo Code.", From 6879ca3d99cc095c20231e2fcdadddbcd43659c3 Mon Sep 17 00:00:00 2001 From: vietnamican Date: Sun, 10 May 2026 21:27:31 +0700 Subject: [PATCH 2/4] remove check before commit --- .husky/pre-commit | 27 ------ .husky/pre-push | 42 --------- ...05-10-disable-commit-push-checks-design.md | 89 +++++++++++++++++++ package.json | 5 -- 4 files changed, 89 insertions(+), 74 deletions(-) delete mode 100644 .husky/pre-commit delete mode 100644 .husky/pre-push create mode 100644 docs/superpowers/specs/2026-05-10-disable-commit-push-checks-design.md diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index a0e3a53df53..00000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,27 +0,0 @@ -branch="$(git rev-parse --abbrev-ref HEAD)" - -if [ "$branch" = "main" ]; then - echo "You can't commit directly to main - please check out a branch." - exit 1 -fi - -# Detect if running on Windows and use pnpm.cmd, otherwise use pnpm. -if [ "$OS" = "Windows_NT" ]; then - pnpm_cmd="pnpm.cmd" -else - if command -v pnpm >/dev/null 2>&1; then - pnpm_cmd="pnpm" - else - pnpm_cmd="npx pnpm" - fi -fi - -# Detect if running on Windows and use npx.cmd, otherwise use npx. -if [ "$OS" = "Windows_NT" ]; then - npx_cmd="npx.cmd" -else - npx_cmd="npx" -fi - -$npx_cmd lint-staged -$pnpm_cmd lint diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100644 index 4cf91d95800..00000000000 --- a/.husky/pre-push +++ /dev/null @@ -1,42 +0,0 @@ -branch="$(git rev-parse --abbrev-ref HEAD)" - -if [ "$branch" = "main" ]; then - echo "You can't push directly to main - please check out a branch." - exit 1 -fi - -# Detect if running on Windows and use pnpm.cmd, otherwise use pnpm. -if [ "$OS" = "Windows_NT" ]; then - pnpm_cmd="pnpm.cmd" -else - if command -v pnpm >/dev/null 2>&1; then - pnpm_cmd="pnpm" - else - pnpm_cmd="npx pnpm" - fi -fi - -$pnpm_cmd run check-types - -# Use dotenvx to securely load .env.local and run commands that depend on it -if [ -f ".env.local" ]; then - # Check if RUN_TESTS_ON_PUSH is set to true and run tests with dotenvx - if npx dotenvx get RUN_TESTS_ON_PUSH -f .env.local 2>/dev/null | grep -q "^true$"; then - npx dotenvx run -f .env.local -- $pnpm_cmd run test - fi -else - # Fallback: run tests if RUN_TESTS_ON_PUSH is set in regular environment - if [ "$RUN_TESTS_ON_PUSH" = "true" ]; then - $pnpm_cmd run test - fi -fi - -# Check for new changesets. -NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ') -echo "Changeset files: $NEW_CHANGESETS" - -if [ "$NEW_CHANGESETS" = "0" ]; then - echo "-------------------------------------------------------------------------------------" - echo "Changes detected. Please run 'pnpm changeset' to create a changeset if applicable." - echo "-------------------------------------------------------------------------------------" -fi diff --git a/docs/superpowers/specs/2026-05-10-disable-commit-push-checks-design.md b/docs/superpowers/specs/2026-05-10-disable-commit-push-checks-design.md new file mode 100644 index 00000000000..5acabafaf4d --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-disable-commit-push-checks-design.md @@ -0,0 +1,89 @@ +# Disable local commit/push checks design + +Date: 2026-05-10 + +## Problem +Repo currently enforces local Git checks through Husky hooks: +- `.husky/pre-commit` blocks commits to `main` +- `.husky/pre-commit` runs `lint-staged` +- `.husky/pre-commit` runs `pnpm lint` +- `.husky/pre-push` blocks pushes to `main` +- `.husky/pre-push` runs `check-types` +- `.husky/pre-push` may run tests +- `.husky/pre-push` prints changeset reminder + +User wants all local commit/push enforcement removed. +User also wants `lint-staged` config removed from `package.json`. + +## Goals +- Remove all local Husky checks from commit and push workflow. +- Remove branch protection logic from local Husky hooks. +- Remove `lint-staged` configuration from root `package.json`. +- Keep normal repo scripts (`lint`, `check-types`, `test`, etc.) available for manual use. + +## Non-goals +- No CI workflow changes. +- No package script removals for `lint`, `check-types`, `test`, or `build`. +- No Husky uninstall. +- No change to hooks other than commit/push hooks. + +## Design +### 1) Delete commit/push hook entrypoints +Delete: +- `.husky/pre-commit` +- `.husky/pre-push` + +Result: +- no local commit blocking on `main` +- no local push blocking on `main` +- no automatic `lint-staged`, `lint`, `check-types`, test, or changeset reminder during commit/push + +### 2) Keep Husky base wiring intact +Keep: +- `.husky/_/**` +- root `package.json` script: `"prepare": "husky"` + +Reason: +- smallest change for requested scope +- avoids changing global Git hook bootstrap behavior +- commit/push checks disappear because entrypoint hook files are gone + +### 3) Remove lint-staged config from package.json +Delete root-level `lint-staged` block from `package.json`. + +Reason: +- user explicitly asked to remove it +- after deleting `.husky/pre-commit`, this config is unused +- removes stale config from repo root + +## Data flow after change +Before: +1. `git commit` -> Husky runs `.husky/pre-commit` +2. hook blocks `main`, runs `lint-staged`, runs `pnpm lint` +3. `git push` -> Husky runs `.husky/pre-push` +4. hook blocks `main`, runs `check-types`, optional tests, changeset reminder + +After: +1. `git commit` -> no repo-defined pre-commit entrypoint +2. `git push` -> no repo-defined pre-push entrypoint +3. manual checks still possible via package scripts + +## Risks +- Developers can commit/push broken code locally without warning. +- Developers can commit/push directly to `main` locally unless remote branch protections exist. +- Formatting on staged files will no longer run automatically. + +These risks are accepted by request. + +## Testing +- Verify `.husky/pre-commit` no longer exists. +- Verify `.husky/pre-push` no longer exists. +- Verify `package.json` no longer contains `lint-staged` block. +- Optional smoke check: `git status` and `git commit` path should no longer invoke repo checks. + +## Acceptance criteria +- `.husky/pre-commit` deleted. +- `.husky/pre-push` deleted. +- root `package.json` no longer contains `lint-staged` config. +- `prepare: husky` remains unchanged. +- local commit/push no longer run repo-defined checks. \ No newline at end of file diff --git a/package.json b/package.json index c00c55f06a3..4e7be84c66c 100644 --- a/package.json +++ b/package.json @@ -49,11 +49,6 @@ "turbo": "^2.5.6", "typescript": "5.8.3" }, - "lint-staged": { - "*.{js,jsx,ts,tsx,json,css,md}": [ - "prettier --write" - ] - }, "pnpm": { "onlyBuiltDependencies": [ "@vscode/ripgrep" From d5cb177da76869537aeadff3ae6def6c737bf04c Mon Sep 17 00:00:00 2001 From: vietnamican Date: Sun, 10 May 2026 22:00:30 +0700 Subject: [PATCH 3/4] remove publish marketplace --- .github/workflows/marketplace-publish.yml | 8 -- .github/workflows/nightly-publish.yml | 8 -- ...-05-10-remove-vsix-publish-steps-design.md | 94 +++++++++++++++++++ 3 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-10-remove-vsix-publish-steps-design.md diff --git a/.github/workflows/marketplace-publish.yml b/.github/workflows/marketplace-publish.yml index aef91b2d323..113d4758617 100644 --- a/.github/workflows/marketplace-publish.yml +++ b/.github/workflows/marketplace-publish.yml @@ -57,14 +57,6 @@ jobs: git tag -a "v${current_package_version}" -m "Release v${current_package_version}" git push origin "v${current_package_version}" --no-verify echo "Successfully created and pushed git tag v${current_package_version}" - - name: Publish Extension - env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} - OVSX_PAT: ${{ secrets.OVSX_PAT }} - run: | - current_package_version=$(node -p "require('./src/package.json').version") - pnpm --filter roo-cline publish:marketplace - echo "Successfully published version $current_package_version to VS Code Marketplace" - name: Create GitHub Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/nightly-publish.yml b/.github/workflows/nightly-publish.yml index e25bdba990a..cebe01a0b5c 100644 --- a/.github/workflows/nightly-publish.yml +++ b/.github/workflows/nightly-publish.yml @@ -42,11 +42,3 @@ jobs: EOF - name: Build VSIX run: pnpm vsix:nightly # Produces bin/roo-code-nightly-0.0.[count].vsix - - name: Publish to VS Code Marketplace - env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} - run: npx vsce publish --packagePath "bin/$(/bin/ls bin | head -n1)" - - name: Publish to Open VSX Registry - env: - OVSX_PAT: ${{ secrets.OVSX_PAT }} - run: npx ovsx publish "bin/$(ls bin | head -n1)" diff --git a/docs/superpowers/specs/2026-05-10-remove-vsix-publish-steps-design.md b/docs/superpowers/specs/2026-05-10-remove-vsix-publish-steps-design.md new file mode 100644 index 00000000000..6a7f83d3686 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-remove-vsix-publish-steps-design.md @@ -0,0 +1,94 @@ +# Remove VSIX publish steps design + +Date: 2026-05-10 + +## Problem +Two GitHub workflows still do local packaging *and* publish to registries: +- `.github/workflows/marketplace-publish.yml` +- `.github/workflows/nightly-publish.yml` + +User wants to keep: +- `.vsix` build +- git tag creation +- GitHub Release creation + +User wants to remove only registry publish steps: +- VS Code Marketplace publish +- Open VSX publish + +## Goals +- Keep VSIX packaging in both workflows. +- Keep git tag creation in `marketplace-publish.yml`. +- Keep GitHub Release creation in `marketplace-publish.yml`. +- Remove all registry publish steps from both workflows. + +## Non-goals +- No changes to `.vsix` packaging commands. +- No changes to git tag logic. +- No changes to GitHub Release logic. +- No changes to workflow triggers or job permissions unless needed by removed steps. +- No changes to app code or package scripts. + +## Design +### 1) Remove Marketplace publish from release workflow +In `.github/workflows/marketplace-publish.yml`, delete the `Publish Extension` step that runs: +- `pnpm --filter roo-cline publish:marketplace` + +Keep: +- `Package Extension` +- `Create and Push Git Tag` +- `Create GitHub Release` + +### 2) Remove registry publish from nightly workflow +In `.github/workflows/nightly-publish.yml`, delete both publishing steps: +- `Publish to VS Code Marketplace` +- `Publish to Open VSX Registry` + +Keep: +- checkout +- node/pnpm setup +- nightly version patching +- `Build VSIX` + +### 3) Preserve build artifacts +Both workflows should still leave the packaged `.vsix` in `bin/` so it can be used by: +- GitHub Release assets in the release workflow +- local inspection or later upload steps if added later + +## Data flow after change +### marketplace-publish.yml +1. Trigger on release-related PR close or manual dispatch. +2. Checkout code. +3. Setup Node/pnpm. +4. Package extension into `.vsix`. +5. Create/push git tag. +6. Create GitHub Release with `.vsix` attached. +7. No registry publish happens. + +### nightly-publish.yml +1. Trigger on `main` push or manual dispatch. +2. Checkout code. +3. Setup Node/pnpm. +4. Patch nightly version. +5. Build `.vsix`. +6. No registry publish happens. + +## Risks +- Nightly workflow no longer publishes to registries automatically. +- Release workflow no longer publishes to Marketplace automatically. +- Release distribution now depends on GitHub Release only. + +These risks are accepted by request. + +## Testing +- Verify `marketplace-publish.yml` no longer contains `publish:marketplace`. +- Verify `nightly-publish.yml` no longer contains Marketplace/Open VSX publish commands. +- Verify both workflows still contain packaging/build steps. +- Verify `marketplace-publish.yml` still contains git tag and GitHub Release steps. + +## Acceptance criteria +- `marketplace-publish.yml` no longer publishes to VS Code Marketplace. +- `nightly-publish.yml` no longer publishes to VS Code Marketplace or Open VSX. +- Both workflows still build `.vsix`. +- `marketplace-publish.yml` still tags and creates GitHub Release. +- No app code or package script changes required. \ No newline at end of file From edc1149680e587e319f35f67945a9726d527bb6c Mon Sep 17 00:00:00 2001 From: vietnamican Date: Sun, 10 May 2026 22:20:35 +0700 Subject: [PATCH 4/4] restore multisearch file --- ...ti-search-replace-trailing-newline.spec.ts | 163 +++ .../__tests__/multi-search-replace.spec.ts | 1207 +++++++++++++++++ .../diff/strategies/multi-search-replace.ts | 546 ++++++++ 3 files changed, 1916 insertions(+) create mode 100644 src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts create mode 100644 src/core/diff/strategies/__tests__/multi-search-replace.spec.ts create mode 100644 src/core/diff/strategies/multi-search-replace.ts diff --git a/src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts b/src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts new file mode 100644 index 00000000000..95512193941 --- /dev/null +++ b/src/core/diff/strategies/__tests__/multi-search-replace-trailing-newline.spec.ts @@ -0,0 +1,163 @@ +import { MultiSearchReplaceDiffStrategy } from "../multi-search-replace" + +describe("MultiSearchReplaceDiffStrategy - trailing newline preservation", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + it("should preserve trailing newlines in SEARCH content with line numbers", async () => { + // This test verifies the fix for issue #8020 + // The regex should not consume trailing newlines, allowing stripLineNumbers to work correctly + const originalContent = `class Example { + constructor() { + this.value = 0; + } +}` + const diffContent = `<<<<<<< SEARCH +1 | class Example { +2 | constructor() { +3 | this.value = 0; +4 | } +5 | } +======= +class Example { + constructor() { + this.value = 1; + } +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + this.value = 1; + } +}`) + } + }) + + it("should handle Windows line endings with trailing newlines and line numbers", async () => { + const originalContent = "function test() {\r\n return true;\r\n}\r\n" + const diffContent = `<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + // Should preserve Windows line endings + expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n") + } + }) + + it("should handle multiple search/replace blocks with trailing newlines", async () => { + const originalContent = `function one() { + return 1; +} + +function two() { + return 2; +}` + const diffContent = `<<<<<<< SEARCH +1 | function one() { +2 | return 1; +3 | } +======= +function one() { + return 10; +} +>>>>>>> REPLACE + +<<<<<<< SEARCH +5 | function two() { +6 | return 2; +7 | } +======= +function two() { + return 20; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 10; +} + +function two() { + return 20; +}`) + } + }) + + it("should handle content with line numbers at the last line", async () => { + // This specifically tests the scenario from the bug report + const originalContent = ` List addressInfoList = new ArrayList<>(CollectionUtils.size(repairInfoList) > 10 ? 10 + : CollectionUtils.size(repairInfoList) + CollectionUtils.size(homeAddressInfoList) + + CollectionUtils.size(idNoAddressInfoList) + CollectionUtils.size(workAddressInfoList) + + CollectionUtils.size(personIdentityInfoList));` + + const diffContent = `<<<<<<< SEARCH +1476 | List addressInfoList = new ArrayList<>(CollectionUtils.size(repairInfoList) > 10 ? 10 +1477 | : CollectionUtils.size(repairInfoList) + CollectionUtils.size(homeAddressInfoList) +1478 | + CollectionUtils.size(idNoAddressInfoList) + CollectionUtils.size(workAddressInfoList) +1479 | + CollectionUtils.size(personIdentityInfoList)); +======= + + // Filter addresses if optimization is enabled + if (isAddressDisplayOptimizeEnabled()) { + homeAddressInfoList = filterAddressesByThreeYearRule(homeAddressInfoList); + personIdentityInfoList = filterAddressesByThreeYearRule(personIdentityInfoList); + idNoAddressInfoList = filterAddressesByThreeYearRule(idNoAddressInfoList); + workAddressInfoList = filterAddressesByThreeYearRule(workAddressInfoList); + } + + List addressInfoList = new ArrayList<>(CollectionUtils.size(repairInfoList) > 10 ? 10 + : CollectionUtils.size(repairInfoList) + CollectionUtils.size(homeAddressInfoList) + + CollectionUtils.size(idNoAddressInfoList) + CollectionUtils.size(workAddressInfoList) + + CollectionUtils.size(personIdentityInfoList)); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toContain("// Filter addresses if optimization is enabled") + expect(result.content).toContain("if (isAddressDisplayOptimizeEnabled())") + // Verify the last line doesn't have line numbers + expect(result.content).not.toContain("1488 |") + expect(result.content).not.toContain("1479 |") + } + }) + + it("should correctly strip line numbers even when last line has no trailing newline", async () => { + const originalContent = "line 1\nline 2\nline 3" // No trailing newline + const diffContent = `<<<<<<< SEARCH +1 | line 1 +2 | line 2 +3 | line 3 +======= +line 1 +modified line 2 +line 3 +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("line 1\nmodified line 2\nline 3") + // Verify no line numbers remain + expect(result.content).not.toContain(" | ") + } + }) +}) diff --git a/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts b/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts new file mode 100644 index 00000000000..f06f3f406fb --- /dev/null +++ b/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts @@ -0,0 +1,1207 @@ +import { MultiSearchReplaceDiffStrategy } from "../multi-search-replace" + +describe("MultiSearchReplaceDiffStrategy", () => { + describe("validateMarkerSequencing", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + it("validates correct marker sequence", () => { + const diff = "<<<<<<< SEARCH\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) + }) + + it("validates correct marker sequence with extra > in SEARCH", () => { + const diff = "<<<<<<< SEARCH>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) + }) + + it("validates correct marker sequence with multiple > in SEARCH", () => { + const diff = "<<<<<<< SEARCH>>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(false) + }) + + it("validates mixed cases with and without extra > in the same diff", () => { + const diff = + "<<<<<<< SEARCH>\n" + + "content1\n" + + "=======\n" + + "new1\n" + + ">>>>>>> REPLACE\n\n" + + "<<<<<<< SEARCH\n" + + "content2\n" + + "=======\n" + + "new2\n" + + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) + }) + + it("validates extra > with whitespace variations", () => { + const diff1 = "<<<<<<< SEARCH> \n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff1).success).toBe(true) + + const diff2 = "<<<<<<< SEARCH >\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff2).success).toBe(false) + }) + + it("validates extra > with line numbers", () => { + const diff = + "<<<<<<< SEARCH>\n" + + ":start_line:10\n" + + "-------\n" + + "content1\n" + + "=======\n" + + "new1\n" + + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) + }) + + it("validates multiple correct marker sequences", () => { + const diff = + "<<<<<<< SEARCH\n" + + "content1\n" + + "=======\n" + + "new1\n" + + ">>>>>>> REPLACE\n\n" + + "<<<<<<< SEARCH\n" + + "content2\n" + + "=======\n" + + "new2\n" + + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) + }) + + it("validates multiple correct marker sequences with line numbers", () => { + const diff = + "<<<<<<< SEARCH\n" + + ":start_line:10\n" + + "-------\n" + + "content1\n" + + "=======\n" + + "new1\n" + + ">>>>>>> REPLACE\n\n" + + "<<<<<<< SEARCH\n" + + ":start_line:10\n" + + "-------\n" + + "content2\n" + + "=======\n" + + "new2\n" + + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) + }) + + it("detects separator before search", () => { + const diff = "=======\n" + "content\n" + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("'=======' found in your diff content") + expect(result.error).toContain("Diff block is malformed") + }) + + it("detects missing separator", () => { + const diff = "<<<<<<< SEARCH\n" + "content\n" + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("'>>>>>>> REPLACE' found in your diff content") + expect(result.error).toContain("Diff block is malformed") + }) + + it("detects two separators", () => { + const diff = "<<<<<<< SEARCH\n" + "content\n" + "=======\n" + "=======\n" + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("'=======' found in your diff content") + expect(result.error).toContain("When removing merge conflict markers") + }) + + it("detects replace before separator (merge conflict message)", () => { + const diff = "<<<<<<< SEARCH\n" + "content\n" + ">>>>>>>" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("'>>>>>>>' found in your diff content") + expect(result.error).toContain("When removing merge conflict markers") + }) + + it("detects incomplete sequence", () => { + const diff = "<<<<<<< SEARCH\n" + "content\n" + "=======\n" + "new content" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("Expected '>>>>>>> REPLACE' was not found") + }) + + describe("exact matching", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy(1.0, 5) // Default 1.0 threshold for exact matching, 5 line buffer for tests + }) + + it("should replace matching content", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +function hello() { + console.log("hello") +} +======= +function hello() { + console.log("hello world") +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function hello() {\n console.log("hello world")\n}\n') + } + }) + + it("should replace matching content in multiple blocks", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +function hello() { +======= +function helloWorld() { +>>>>>>> REPLACE +<<<<<<< SEARCH + console.log("hello") +======= + console.log("hello world") +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function helloWorld() {\n console.log("hello world")\n}\n') + } + }) + + it("should replace matching content in multiple blocks with line numbers", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:1 +------- +function hello() { +======= +function helloWorld() { +>>>>>>> REPLACE +<<<<<<< SEARCH +:start_line:2 +------- + console.log("hello") +======= + console.log("hello world") +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function helloWorld() {\n console.log("hello world")\n}\n') + } + }) + + it("should replace matching content when end_line is passed in", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:1 +:end_line:1 +------- +function hello() { +======= +function helloWorld() { +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function helloWorld() {\n console.log("hello")\n}\n') + } + }) + + it("should match content with different surrounding whitespace", async () => { + const originalContent = "\nfunction example() {\n return 42;\n}\n\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function example() { + return 42; +} +======= +function example() { + return 43; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("\nfunction example() {\n return 43;\n}\n\n") + } + }) + + it("should match content with different indentation in search block", async () => { + const originalContent = " function test() {\n return true;\n }\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { + return true; +} +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(" function test() {\n return false;\n }\n") + } + }) + + it("should handle tab-based indentation", async () => { + const originalContent = "function test() {\n\treturn true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { +\treturn true; +} +======= +function test() { +\treturn false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n\treturn false;\n}\n") + } + }) + + it("should preserve mixed tabs and spaces", async () => { + const originalContent = "\tclass Example {\n\t constructor() {\n\t\tthis.value = 0;\n\t }\n\t}" + const diffContent = `test.ts +<<<<<<< SEARCH +\tclass Example { +\t constructor() { +\t\tthis.value = 0; +\t } +\t} +======= +\tclass Example { +\t constructor() { +\t\tthis.value = 1; +\t } +\t} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "\tclass Example {\n\t constructor() {\n\t\tthis.value = 1;\n\t }\n\t}", + ) + } + }) + + it("should handle additional indentation with tabs", async () => { + const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { +\treturn true; +} +======= +function test() { +\t// Add comment +\treturn false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("\tfunction test() {\n\t\t// Add comment\n\t\treturn false;\n\t}") + } + }) + + it("should preserve exact indentation characters when adding lines", async () => { + const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" + const diffContent = `test.ts +<<<<<<< SEARCH +\tfunction test() { +\t\treturn true; +\t} +======= +\tfunction test() { +\t\t// First comment +\t\t// Second comment +\t\treturn true; +\t} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "\tfunction test() {\n\t\t// First comment\n\t\t// Second comment\n\t\treturn true;\n\t}", + ) + } + }) + + it("should handle Windows-style CRLF line endings", async () => { + const originalContent = "function test() {\r\n return true;\r\n}\r\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { + return true; +} +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n") + } + }) + + it("should return false if search content does not match", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +function hello() { + console.log("wrong") +} +======= +function hello() { + console.log("hello world") +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should return false if diff format is invalid", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts\nInvalid diff format` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should handle multiple lines with proper indentation", async () => { + const originalContent = + "class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n return this.value\n }\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH + getValue() { + return this.value + } +======= + getValue() { + // Add logging + console.log("Getting value") + return this.value + } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + 'class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n // Add logging\n console.log("Getting value")\n return this.value\n }\n}\n', + ) + } + }) + + it("should preserve whitespace exactly in the output", async () => { + const originalContent = " indented\n more indented\n back\n" + const diffContent = `test.ts +<<<<<<< SEARCH + indented + more indented + back +======= + modified + still indented + end +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(" modified\n still indented\n end\n") + } + }) + + it("should preserve indentation when adding new lines after existing content", async () => { + const originalContent = " onScroll={() => updateHighlights()}" + const diffContent = `test.ts +<<<<<<< SEARCH + onScroll={() => updateHighlights()} +======= + onScroll={() => updateHighlights()} + onDragOver={(e) => { + e.preventDefault() + e.stopPropagation() + }} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + " onScroll={() => updateHighlights()}\n onDragOver={(e) => {\n e.preventDefault()\n e.stopPropagation()\n }}", + ) + } + }) + + it("should handle varying indentation levels correctly", async () => { + const originalContent = ` +class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +}`.trim() + + const diffContent = `test.ts +<<<<<<< SEARCH + class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } + } +======= + class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } + } +>>>>>>> REPLACE`.trim() + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + ` +class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`.trim(), + ) + } + }) + + it("should handle mixed indentation styles in the same file", async () => { + const originalContent = `class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +======= + constructor() { + this.value = 1; + if (true) { + this.init(); + this.validate(); + } + } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.validate(); + } + } +}`) + } + }) + + it("should handle Python-style significant whitespace", async () => { + const originalContent = `def example(): + if condition: + do_something() + for item in items: + process(item) + return True`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + if condition: + do_something() + for item in items: + process(item) +======= + if condition: + do_something() + while items: + item = items.pop() + process(item) +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`def example(): + if condition: + do_something() + while items: + item = items.pop() + process(item) + return True`) + } + }) + + it("should preserve empty lines with indentation", async () => { + const originalContent = `function test() { + const x = 1; + + if (x) { + return true; + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + const x = 1; + + if (x) { +======= + const x = 1; + + // Check x + if (x) { +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + const x = 1; + + // Check x + if (x) { + return true; + } +}`) + } + }) + + it("should handle indentation when replacing entire blocks", async () => { + const originalContent = `class Test { + method() { + if (true) { + console.log("test"); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + method() { + if (true) { + console.log("test"); + } + } +======= + method() { + try { + if (true) { + console.log("test"); + } + } catch (e) { + console.error(e); + } + } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Test { + method() { + try { + if (true) { + console.log("test"); + } + } catch (e) { + console.error(e); + } + } +}`) + } + }) + + it("should handle negative indentation relative to search content", async () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); + this.setup(); +======= + this.init(); + this.setup(); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + } + } +}`) + } + }) + + it("should handle extreme negative indentation (no indent)", async () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); +======= +this.init(); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { +this.init(); + } + } +}`) + } + }) + + it("should handle mixed indentation changes in replace block", async () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); + this.setup(); + this.validate(); +======= + this.init(); + this.setup(); + this.validate(); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`) + } + }) + + it("should find matches from middle out", async () => { + const originalContent = ` +function one() { + return "target"; +} + +function two() { + return "target"; +} + +function three() { + return "target"; +} + +function four() { + return "target"; +} + +function five() { + return "target"; +}`.trim() + + const diffContent = `test.ts +<<<<<<< SEARCH + return "target"; +======= + return "updated"; +>>>>>>> REPLACE` + + // Search around the middle (function three) + // Even though all functions contain the target text, + // it should match the one closest to line 9 first + const result = await strategy.applyDiff(originalContent, diffContent, 9) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return "target"; +} + +function two() { + return "target"; +} + +function three() { + return "updated"; +} + +function four() { + return "target"; +} + +function five() { + return "target"; +}`) + } + }) + }) + }) + + describe("fuzzy matching", () => { + let strategy: MultiSearchReplaceDiffStrategy + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) // 90% similarity threshold, 5 line buffer for tests + }) + + it("should match content with small differences (>90% similar)", async () => { + const originalContent = + "function getData() {\n const results = fetchData();\n return results.filter(Boolean);\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function getData() { + const result = fetchData(); + return results.filter(Boolean); +} +======= +function getData() { + const data = fetchData(); + return data.filter(Boolean); +} +>>>>>>> REPLACE` + + strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) // Use 5 line buffer for tests + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n", + ) + } + }) + + it("should not match when content is too different (<90% similar)", async () => { + const originalContent = "function processUsers(data) {\n return data.map(user => user.name);\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function handleItems(items) { + return items.map(item => item.username); +} +======= +function processData(data) { + return data.map(d => d.value); +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should match content with extra whitespace", async () => { + const originalContent = "function sum(a, b) {\n return a + b;\n}" + const diffContent = `test.ts +<<<<<<< SEARCH +function sum(a, b) { + return a + b; +} +======= +function sum(a, b) { + return a + b + 1; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function sum(a, b) {\n return a + b + 1;\n}") + } + }) + + it("should match content with smart quotes", async () => { + const originalContent = + "**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can't wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding!" + const diffContent = `test.ts +<<<<<<< SEARCH +**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can't wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding! +======= +**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can't wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding! + +You're still here? +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can't wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding!\n\nYou're still here?", + ) + } + }) + + it("should not exact match empty lines", async () => { + const originalContent = "function sum(a, b) {\n\n return a + b;\n}" + const diffContent = `test.ts +<<<<<<< SEARCH +function sum(a, b) { +======= +import { a } from "a"; +function sum(a, b) { +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('import { a } from "a";\nfunction sum(a, b) {\n\n return a + b;\n}') + } + }) + }) + + describe("deletion", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + it("should delete code when replace block is empty", async () => { + const originalContent = `function test() { + console.log("hello"); + // Comment to remove + console.log("world"); +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Comment to remove +======= +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + console.log("hello"); + console.log("world"); +}`) + } + }) + + it("should delete multiple lines when replace block is empty", async () => { + const originalContent = `class Example { + constructor() { + // Initialize + this.value = 0; + // Set defaults + this.name = ""; + // End init + } +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Initialize + this.value = 0; + // Set defaults + this.name = ""; + // End init +======= +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + } +}`) + } + }) + + it("should preserve indentation when deleting nested code", async () => { + const originalContent = `function outer() { + if (true) { + // Remove this + console.log("test"); + // And this + } + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Remove this + console.log("test"); + // And this +======= +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function outer() { + if (true) { + } + return true; +}`) + } + }) + + it("should delete a line when search block has line number prefix and replace is empty", async () => { + const originalContent = "line 1\nline to delete\nline 3" + const diffContent = ` +<<<<<<< SEARCH +:start_line:2 +------- +2 | line to delete +======= +>>>>>>> REPLACE` + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("line 1\nline 3") + } + }) + }) + + describe("line marker validation in REPLACE sections", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + it("should reject start_line marker in REPLACE section", () => { + const diff = + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + ":start_line:5\n" + + "replacement content\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid line marker ':start_line:' found in REPLACE section") + expect(result.error).toContain( + "Line markers (:start_line: and :end_line:) are only allowed in SEARCH sections", + ) + }) + + it("should reject end_line marker in REPLACE section", () => { + const diff = + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + ":end_line:10\n" + + "replacement content\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid line marker ':end_line:' found in REPLACE section") + expect(result.error).toContain( + "Line markers (:start_line: and :end_line:) are only allowed in SEARCH sections", + ) + }) + + it("should reject both line markers in REPLACE section", () => { + const diff = + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + ":start_line:5\n" + + ":end_line:10\n" + + "replacement content\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid line marker ':start_line:' found in REPLACE section") + }) + + it("should reject line markers in multiple diff blocks where one has invalid markers", () => { + const diff = + "<<<<<<< SEARCH\n" + + ":start_line:1\n" + + "content1\n" + + "=======\n" + + "replacement1\n" + + ">>>>>>> REPLACE\n\n" + + "<<<<<<< SEARCH\n" + + "content2\n" + + "=======\n" + + ":start_line:5\n" + + "replacement2\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid line marker ':start_line:' found in REPLACE section") + }) + + it("should allow valid markers in SEARCH section with content in REPLACE", () => { + const diff = + "<<<<<<< SEARCH\n" + + ":start_line:5\n" + + ":end_line:10\n" + + "-------\n" + + "content to find\n" + + "=======\n" + + "replacement content\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(true) + }) + + it("should allow escaped line markers in REPLACE content", () => { + const diff = + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + "replacement content\n" + + "\\:start_line:5\n" + + "more content\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(true) + }) + + it("should allow escaped end_line markers in REPLACE content", () => { + const diff = + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + "replacement content\n" + + "\\:end_line:10\n" + + "more content\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(true) + }) + + it("should allow both escaped line markers in REPLACE content", () => { + const diff = + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + "replacement content\n" + + "\\:start_line:5\n" + + "\\:end_line:10\n" + + "more content\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(true) + }) + + it("should reject line markers with whitespace in REPLACE section", () => { + const diff = + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + " :start_line:5 \n" + + "replacement content\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid line marker ':start_line:' found in REPLACE section") + }) + + it("should reject line markers in middle of REPLACE content", () => { + const diff = + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + "some replacement\n" + + ":end_line:15\n" + + "more replacement\n" + + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid line marker ':end_line:' found in REPLACE section") + }) + + it("should provide helpful error message format", () => { + const diff = + "<<<<<<< SEARCH\n" + "content\n" + "=======\n" + ":start_line:5\n" + "replacement\n" + ">>>>>>> REPLACE" + const result = strategy["validateMarkerSequencing"](diff) + expect(result.success).toBe(false) + expect(result.error).toContain("CORRECT FORMAT:") + expect(result.error).toContain("INCORRECT FORMAT:") + expect(result.error).toContain(":start_line:5 <-- Invalid location") + }) + }) +}) diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts new file mode 100644 index 00000000000..f43bbee0dc9 --- /dev/null +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -0,0 +1,546 @@ +import { distance } from "fastest-levenshtein" + +import { ToolProgressStatus } from "@roo-code/types" + +import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" +import { ToolUse, DiffStrategy, DiffResult } from "../../../shared/tools" +import { normalizeString } from "../../../utils/text-normalization" + +const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches + +function getSimilarity(original: string, search: string): number { + // Empty searches are no longer supported + if (search === "") { + return 0 + } + + // Use the normalizeString utility to handle smart quotes and other special characters + const normalizedOriginal = normalizeString(original) + const normalizedSearch = normalizeString(search) + + if (normalizedOriginal === normalizedSearch) { + return 1 + } + + // Calculate Levenshtein distance using fastest-levenshtein's distance function + const dist = distance(normalizedOriginal, normalizedSearch) + + // Calculate similarity ratio (0 to 1, where 1 is an exact match) + const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length) + return 1 - dist / maxLength +} + +/** + * Performs a "middle-out" search of `lines` (between [startIndex, endIndex]) to find + * the slice that is most similar to `searchChunk`. Returns the best score, index, and matched text. + */ +function fuzzySearch(lines: string[], searchChunk: string, startIndex: number, endIndex: number) { + let bestScore = 0 + let bestMatchIndex = -1 + let bestMatchContent = "" + const searchLen = searchChunk.split(/\r?\n/).length + + // Middle-out from the midpoint + const midPoint = Math.floor((startIndex + endIndex) / 2) + let leftIndex = midPoint + let rightIndex = midPoint + 1 + + while (leftIndex >= startIndex || rightIndex <= endIndex - searchLen) { + if (leftIndex >= startIndex) { + const originalChunk = lines.slice(leftIndex, leftIndex + searchLen).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity > bestScore) { + bestScore = similarity + bestMatchIndex = leftIndex + bestMatchContent = originalChunk + } + leftIndex-- + } + + if (rightIndex <= endIndex - searchLen) { + const originalChunk = lines.slice(rightIndex, rightIndex + searchLen).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity > bestScore) { + bestScore = similarity + bestMatchIndex = rightIndex + bestMatchContent = originalChunk + } + rightIndex++ + } + } + + return { bestScore, bestMatchIndex, bestMatchContent } +} + +export class MultiSearchReplaceDiffStrategy implements DiffStrategy { + private fuzzyThreshold: number + private bufferLines: number + + getName(): string { + return "MultiSearchReplace" + } + + constructor(fuzzyThreshold?: number, bufferLines?: number) { + // Use provided threshold or default to exact matching (1.0) + // Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9) + // so we use it directly here + this.fuzzyThreshold = fuzzyThreshold ?? 1.0 + this.bufferLines = bufferLines ?? BUFFER_LINES + } + + private unescapeMarkers(content: string): string { + return content + .replace(/^\\<<<<<<>>>>>>/gm, ">>>>>>>") + .replace(/^\\-------/gm, "-------") + .replace(/^\\:end_line:/gm, ":end_line:") + .replace(/^\\:start_line:/gm, ":start_line:") + } + + private validateMarkerSequencing(diffContent: string): { success: boolean; error?: string } { + enum State { + START, + AFTER_SEARCH, + AFTER_SEPARATOR, + } + const state = { current: State.START, line: 0 } + + // Pattern allows optional '>' after SEARCH to handle AI-generated diffs + // (e.g., Sonnet 4 sometimes adds an extra '>') + const SEARCH_PATTERN = /^<<<<<<< SEARCH>?$/ + const SEARCH = SEARCH_PATTERN.source.replace(/[\^$]/g, "") // Remove regex anchors for display + const SEP = "=======" + const REPLACE = ">>>>>>> REPLACE" + const SEARCH_PREFIX = "<<<<<<<" + const REPLACE_PREFIX = ">>>>>>>" + + const reportMergeConflictError = (found: string, _expected: string) => ({ + success: false, + error: + `ERROR: Special marker '${found}' found in your diff content at line ${state.line}:\n` + + "\n" + + `When removing merge conflict markers like '${found}' from files, you MUST escape them\n` + + "in your SEARCH section by prepending a backslash (\\) at the beginning of the line:\n" + + "\n" + + "CORRECT FORMAT:\n\n" + + "<<<<<<< SEARCH\n" + + "content before\n" + + `\\${found} <-- Note the backslash here in this example\n` + + "content after\n" + + "=======\n" + + "replacement content\n" + + ">>>>>>> REPLACE\n" + + "\n" + + "Without escaping, the system confuses your content with diff syntax markers.\n" + + "You may use multiple diff blocks in a single diff request, but ANY of ONLY the following separators that occur within SEARCH or REPLACE content must be escaped, as follows:\n" + + `\\${SEARCH}\n` + + `\\${SEP}\n` + + `\\${REPLACE}\n`, + }) + + const reportInvalidDiffError = (found: string, expected: string) => ({ + success: false, + error: + `ERROR: Diff block is malformed: marker '${found}' found in your diff content at line ${state.line}. Expected: ${expected}\n` + + "\n" + + "CORRECT FORMAT:\n\n" + + "<<<<<<< SEARCH\n" + + ":start_line: (required) The line number of original content where the search block starts.\n" + + "-------\n" + + "[exact content to find including whitespace]\n" + + "=======\n" + + "[new content to replace with]\n" + + ">>>>>>> REPLACE\n", + }) + + const reportLineMarkerInReplaceError = (marker: string) => ({ + success: false, + error: + `ERROR: Invalid line marker '${marker}' found in REPLACE section at line ${state.line}\n` + + "\n" + + "Line markers (:start_line: and :end_line:) are only allowed in SEARCH sections.\n" + + "\n" + + "CORRECT FORMAT:\n" + + "<<<<<<< SEARCH\n" + + ":start_line:5\n" + + "content to find\n" + + "=======\n" + + "replacement content\n" + + ">>>>>>> REPLACE\n" + + "\n" + + "INCORRECT FORMAT:\n" + + "<<<<<<< SEARCH\n" + + "content to find\n" + + "=======\n" + + ":start_line:5 <-- Invalid location\n" + + "replacement content\n" + + ">>>>>>> REPLACE\n", + }) + + const lines = diffContent.split("\n") + const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length + const sepCount = lines.filter((l) => l.trim() === SEP).length + const replaceCount = lines.filter((l) => l.trim() === REPLACE).length + + const likelyBadStructure = searchCount !== replaceCount || sepCount < searchCount + + for (const line of diffContent.split("\n")) { + state.line++ + const marker = line.trim() + + // Check for line markers in REPLACE sections (but allow escaped ones) + if (state.current === State.AFTER_SEPARATOR) { + if (marker.startsWith(":start_line:") && !line.trim().startsWith("\\:start_line:")) { + return reportLineMarkerInReplaceError(":start_line:") + } + if (marker.startsWith(":end_line:") && !line.trim().startsWith("\\:end_line:")) { + return reportLineMarkerInReplaceError(":end_line:") + } + } + + switch (state.current) { + case State.START: + if (marker === SEP) + return likelyBadStructure + ? reportInvalidDiffError(SEP, SEARCH) + : reportMergeConflictError(SEP, SEARCH) + if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH) + if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) + if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH + else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) + break + + case State.AFTER_SEARCH: + if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, SEP) + if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) + if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP) + if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) + if (marker === SEP) state.current = State.AFTER_SEPARATOR + break + + case State.AFTER_SEPARATOR: + if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, REPLACE) + if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE) + if (marker === SEP) + return likelyBadStructure + ? reportInvalidDiffError(SEP, REPLACE) + : reportMergeConflictError(SEP, REPLACE) + if (marker === REPLACE) state.current = State.START + else if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, REPLACE) + break + } + } + + return state.current === State.START + ? { success: true } + : { + success: false, + error: `ERROR: Unexpected end of sequence: Expected '${ + state.current === State.AFTER_SEARCH ? "=======" : ">>>>>>> REPLACE" + }' was not found.`, + } + } + + async applyDiff( + originalContent: string, + diffContent: string, + _paramStartLine?: number, + _paramEndLine?: number, + ): Promise { + const validseq = this.validateMarkerSequencing(diffContent) + if (!validseq.success) { + return { + success: false, + error: validseq.error!, + } + } + + /* + Regex parts: + + 1. (?:^|\n) + Ensures the first marker starts at the beginning of the file or right after a newline. + + 2. (?>>>>>> REPLACE)(?=\n|$) + Matches the final ">>>>>>> REPLACE" marker on its own line (and requires a following newline or the end of file). + */ + + let matches = [ + ...diffContent.matchAll( + /(?:^|\n)(??\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?>>>>>> REPLACE)(?=\n|$)/g, + ), + ] + + if (matches.length === 0) { + return { + success: false, + error: `Invalid diff format - missing required sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n:start_line: start line\\n-------\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include start_line/SEARCH/=======/REPLACE sections with correct markers on new lines`, + } + } + // Detect line ending from original content + const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n" + let resultLines = originalContent.split(/\r?\n/) + let delta = 0 + let diffResults: DiffResult[] = [] + let appliedCount = 0 + const replacements = matches + .map((match) => ({ + startLine: Number(match[2] ?? 0), + searchContent: match[6], + replaceContent: match[7], + })) + .sort((a, b) => a.startLine - b.startLine) + + for (const replacement of replacements) { + let { searchContent, replaceContent } = replacement + let startLine = replacement.startLine + (replacement.startLine === 0 ? 0 : delta) + + // First unescape any escaped markers in the content + searchContent = this.unescapeMarkers(searchContent) + replaceContent = this.unescapeMarkers(replaceContent) + + // Strip line numbers from search and replace content if every line starts with a line number + const hasAllLineNumbers = + (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) || + (everyLineHasLineNumbers(searchContent) && replaceContent.trim() === "") + + if (hasAllLineNumbers && startLine === 0) { + startLine = parseInt(searchContent.split("\n")[0].split("|")[0]) + } + + if (hasAllLineNumbers) { + searchContent = stripLineNumbers(searchContent) + replaceContent = stripLineNumbers(replaceContent) + } + + // Validate that search and replace content are not identical + if (searchContent === replaceContent) { + diffResults.push({ + success: false, + error: + `Search and replace content are identical - no changes would be made\n\n` + + `Debug Info:\n` + + `- Search and replace must be different to make changes\n` + + `- Use read_file to verify the content you want to change`, + }) + continue + } + + // Split content into lines, handling both \n and \r\n + let searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/) + let replaceLines = replaceContent === "" ? [] : replaceContent.split(/\r?\n/) + + // Validate that search content is not empty + if (searchLines.length === 0) { + diffResults.push({ + success: false, + error: `Empty search content is not allowed\n\nDebug Info:\n- Search content cannot be empty\n- For insertions, provide a specific line using :start_line: and include content to search for\n- For example, match a single line to insert before/after it`, + }) + continue + } + + let endLine = replacement.startLine + searchLines.length - 1 + + // Initialize search variables + let matchIndex = -1 + let bestMatchScore = 0 + let bestMatchContent = "" + let searchChunk = searchLines.join("\n") + + // Determine search bounds + let searchStartIndex = 0 + let searchEndIndex = resultLines.length + + // Validate and handle line range if provided + if (startLine) { + // Convert to 0-based index + const exactStartIndex = startLine - 1 + const searchLen = searchLines.length + const exactEndIndex = exactStartIndex + searchLen - 1 + + // Try exact match first + const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity >= this.fuzzyThreshold) { + matchIndex = exactStartIndex + bestMatchScore = similarity + bestMatchContent = originalChunk + } else { + // Set bounds for buffered search + searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1)) + searchEndIndex = Math.min(resultLines.length, startLine + searchLines.length + this.bufferLines) + } + } + + // If no match found yet, try middle-out search within bounds + if (matchIndex === -1) { + const { + bestScore, + bestMatchIndex, + bestMatchContent: midContent, + } = fuzzySearch(resultLines, searchChunk, searchStartIndex, searchEndIndex) + matchIndex = bestMatchIndex + bestMatchScore = bestScore + bestMatchContent = midContent + } + + // Try aggressive line number stripping as a fallback if regular matching fails + if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) { + // Strip both search and replace content once (simultaneously) + const aggressiveSearchContent = stripLineNumbers(searchContent, true) + const aggressiveReplaceContent = stripLineNumbers(replaceContent, true) + + const aggressiveSearchLines = aggressiveSearchContent ? aggressiveSearchContent.split(/\r?\n/) : [] + const aggressiveSearchChunk = aggressiveSearchLines.join("\n") + + // Try middle-out search again with aggressive stripped content (respecting the same search bounds) + const { + bestScore, + bestMatchIndex, + bestMatchContent: aggContent, + } = fuzzySearch(resultLines, aggressiveSearchChunk, searchStartIndex, searchEndIndex) + if (bestMatchIndex !== -1 && bestScore >= this.fuzzyThreshold) { + matchIndex = bestMatchIndex + bestMatchScore = bestScore + bestMatchContent = aggContent + // Replace the original search/replace with their stripped versions + searchContent = aggressiveSearchContent + replaceContent = aggressiveReplaceContent + searchLines = aggressiveSearchLines + replaceLines = replaceContent ? replaceContent.split(/\r?\n/) : [] + } else { + // No match found with either method + const originalContentSection = + startLine !== undefined && endLine !== undefined + ? `\n\nOriginal Content:\n${addLineNumbers( + resultLines + .slice( + Math.max(0, startLine - 1 - this.bufferLines), + Math.min(resultLines.length, endLine + this.bufferLines), + ) + .join("\n"), + Math.max(1, startLine - this.bufferLines), + )}` + : `\n\nOriginal Content:\n${addLineNumbers(resultLines.join("\n"))}` + + const bestMatchSection = bestMatchContent + ? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}` + : `\n\nBest Match Found:\n(no match)` + + const lineRange = startLine ? ` at line: ${startLine}` : "" + + diffResults.push({ + success: false, + error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine ? `starting at line ${startLine}` : "start to end"}\n- Tried both standard and aggressive line number stripping\n- Tip: Use the read_file tool to get the latest content of the file before attempting to use the apply_diff tool again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`, + }) + continue + } + } + + // Get the matched lines from the original content + const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length) + + // Get the exact indentation (preserving tabs/spaces) of each line + const originalIndents = matchedLines.map((line) => { + const match = line.match(/^[\t ]*/) + return match ? match[0] : "" + }) + + // Get the exact indentation of each line in the search block + const searchIndents = searchLines.map((line) => { + const match = line.match(/^[\t ]*/) + return match ? match[0] : "" + }) + + // Apply the replacement while preserving exact indentation + const indentedReplaceLines = replaceLines.map((line) => { + // Get the matched line's exact indentation + const matchedIndent = originalIndents[0] || "" + + // Get the current line's indentation relative to the search content + const currentIndentMatch = line.match(/^[\t ]*/) + const currentIndent = currentIndentMatch ? currentIndentMatch[0] : "" + const searchBaseIndent = searchIndents[0] || "" + + // Calculate the relative indentation level + const searchBaseLevel = searchBaseIndent.length + const currentLevel = currentIndent.length + const relativeLevel = currentLevel - searchBaseLevel + + // If relative level is negative, remove indentation from matched indent + // If positive, add to matched indent + const finalIndent = + relativeLevel < 0 + ? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel)) + : matchedIndent + currentIndent.slice(searchBaseLevel) + + return finalIndent + line.trim() + }) + + // Construct the final content + const beforeMatch = resultLines.slice(0, matchIndex) + const afterMatch = resultLines.slice(matchIndex + searchLines.length) + resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch] + delta = delta - matchedLines.length + replaceLines.length + appliedCount++ + } + const finalContent = resultLines.join(lineEnding) + if (appliedCount === 0) { + return { + success: false, + failParts: diffResults, + } + } + return { + success: true, + content: finalContent, + failParts: diffResults, + } + } + + getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus { + const diffContent = toolUse.params.diff + if (diffContent) { + const icon = "diff-multiple" + if (toolUse.partial) { + if (Math.floor(diffContent.length / 10) % 10 === 0) { + const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length + return { icon, text: `${searchBlockCount}` } + } + } else if (result) { + const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length + if (result.failParts?.length) { + return { + icon, + text: `${searchBlockCount - result.failParts.length}/${searchBlockCount}`, + } + } else { + return { icon, text: `${searchBlockCount}` } + } + } + } + return {} + } +}