apply_patch: fuzzy seek_sequence matching silently corrupts context line indentation

Open 💬 1 comment Opened Jun 29, 2026 by kemier

Summary

apply_patch silently corrupts the indentation of context lines (lines starting with in the patch) when the model emits them with slightly wrong leading whitespace. The patch succeeds and a diff is shown, but context lines that should be unchanged get their indentation overwritten with the model's version.

Root Cause

The bug is in seek_sequence (codex-rs/apply-patch/src/seek_sequence.rs), which has 4 matching passes of decreasing strictness:

  1. Exact match (line 35)
  2. rstrip match (line 44) — lines[i].trim_end() == pat.trim_end()
  3. Trim both sides (line 55) — lines[i].trim() == pat.trim()problematic
  4. Unicode normalize + trim (line 77) — normalise(lines[i]) == normalise(pat)

When the model emits context lines with different leading whitespace than the file (e.g. 3 spaces instead of 4), passes 1–2 fail, but pass 3 succeeds because trim() strips all leading whitespace.

The problem is that context lines are stored in both old_lines and new_lines (streaming_parser.rs lines 341–342):

chunk.old_lines.push(line_to_add.to_string());
chunk.new_lines.push(line_to_add.to_string());

When seek_sequence returns a fuzzy match position, compute_replacements (lib.rs line 788) schedules:

replacements.push((start_idx, pattern.len(), new_slice.to_vec()));

Then apply_replacements (lib.rs line 806) removes the correctly-indented file lines and inserts new_lines, which contains the model's incorrectly-indented context lines. The original file's indentation is lost.

Reproduction

Minimal Rust reproduction:

// seek_sequence returns Some(0) even though the pattern has 3-space indent
// while the file has 4-space indent — because trim() strips the difference.

fn seek_sequence(lines: &[String], pattern: &[String], start: usize, eof: bool) -> Option<usize> {
    if pattern.is_empty() { return Some(start); }
    if pattern.len() > lines.len() { return None; }
    let search_start = if eof && lines.len() >= pattern.len() {
        lines.len() - pattern.len()
    } else { start };
    // Exact match
    for i in search_start..=lines.len().saturating_sub(pattern.len()) {
        if lines[i..i + pattern.len()] == *pattern { return Some(i); }
    }
    // rstrip match
    for i in search_start..=lines.len().saturating_sub(pattern.len()) {
        let mut ok = true;
        for (p_idx, pat) in pattern.iter().enumerate() {
            if lines[i + p_idx].trim_end() != pat.trim_end() { ok = false; break; }
        }
        if ok { return Some(i); }
    }
    // trim both sides — THIS PASS CAUSES THE BUG
    for i in search_start..=lines.len().saturating_sub(pattern.len()) {
        let mut ok = true;
        for (p_idx, pat) in pattern.iter().enumerate() {
            if lines[i + p_idx].trim() != pat.trim() { ok = false; break; }
        }
        if ok { return Some(i); }
    }
    None
}

fn main() {
    // File content (4-space indent)
    let file_lines: Vec<String> = vec![
        "    if lower.startswith(\"kimi-\"):".into(),
        "        return f\"Kimi\"".into(),
        "    if lower.startswith(\"minimax-\"):".into(),
        "        return f\"MiniMax\"".into(),
    ];

    // Context lines from patch (3-space indent — model mistake)
    let old_lines: Vec<String> = vec![
        "   if lower.startswith(\"kimi-\"):".into(),
        "       return f\"Kimi\"".into(),
    ];

    // Pass 1 (exact): fails (3 vs 4 spaces)
    // Pass 2 (rstrip): fails (trailing whitespace not the issue)
    // Pass 3 (trim both sides): SUCCEEDS — "if lower.startswith(...)" == "if lower.startswith(...)"
    //
    // Result: seek_sequence returns Some(0)
    // apply_replacements removes lines 0..2 (correctly indented) and
    // inserts new_lines containing the 3-space context lines.
    // The kimi/minimax lines silently lose their 4-space indent.
    let found = seek_sequence(&file_lines, &old_lines, 0, false);
    println!("seek_sequence returned: {:?}", found);
    // Output: seek_sequence returned: Some(0)
}

Concrete example

Original file:

    if lower.startswith("kimi-"):
        return f"Kimi {slug[5:].upper()}"
    if lower.startswith("minimax-"):
        return f"MiniMax {slug[8:].upper()}"

Model emits patch (context lines with 3-space indent):

*** Update File: example.py
@@
   if lower.startswith("kimi-"):
       return f"Kimi {slug[5:].upper()}"
+    if lower.startswith("qwen"):
+        return f"Qwen {slug[4:].upper()}"
    if lower.startswith("minimax-"):
        return f"MiniMax {slug[8:].upper()}"
*** End Patch

Result after apply_patch (kimi line lost its 4-space indent → 3-space):

   if lower.startswith("kimi-"):
       return f"Kimi {slug[5:].upper()}"
    if lower.startswith("qwen"):
        return f"Qwen {slug[4:].upper()}"
    if lower.startswith("minimax-"):
        return f"MiniMax {slug[8:].upper()}"

The diff does show the indentation change (the -/+ lines differ), but since apply_patch succeeds and shows a normal-looking diff, the corruption is easy to miss — especially in large patches where context lines are far from the actual changes.

Suggested Fix

When a fuzzy (trim) match succeeds in seek_sequence, apply_replacements should preserve the original file's leading whitespace for context lines rather than overwriting them with the model's version. One approach:

  • In compute_replacements, when the match was found via a fuzzy pass (not exact), detect which old_lines are context lines (present in both old_lines and new_lines at the same position) and replace their new_lines entry with the actual file line at the matched position, preserving correct indentation.

Alternatively, seek_sequence could return a flag indicating which match pass succeeded, and apply_replacements could use the original file lines for context in the fuzzy case.

Environment

  • Codex commit: a0d5fd7 (latest on main)
  • File: codex-rs/apply-patch/src/seek_sequence.rs
  • Key lines: seek_sequence.rs#L53-L61 (trim-both-sides pass), streaming_parser.rs#L341-L342 (context stored in both old+new), lib.rs#L788 (replacement scheduled with new_slice)

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗