Skip to content

Commit eadc5f3

Browse files
[apply-patch] Handle multiple context lines
1 parent 218d6c9 commit eadc5f3

File tree

4 files changed

+218
-53
lines changed

4 files changed

+218
-53
lines changed

codex-rs/apply-patch/src/lib.rs

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -529,32 +529,51 @@ fn compute_replacements(
529529
let mut line_index: usize = 0;
530530

531531
for chunk in chunks {
532-
// If a chunk has a `change_context`, we use seek_sequence to find it, then
533-
// adjust our `line_index` to continue from there.
534-
if let Some(ctx_line) = &chunk.change_context {
535-
if let Some(idx) = seek_sequence::seek_sequence(
536-
original_lines,
537-
std::slice::from_ref(ctx_line),
538-
line_index,
539-
false,
540-
) {
541-
line_index = idx + 1;
542-
} else {
543-
return Err(ApplyPatchError::ComputeReplacements(format!(
544-
"Failed to find context '{}' in {}",
545-
ctx_line,
546-
path.display()
547-
)));
532+
// If a chunk has context lines, we use seek_sequence to find each in order,
533+
// then adjust our `line_index` to continue from there.
534+
if !chunk.context_lines.is_empty() {
535+
let total = chunk.context_lines.len();
536+
for (i, ctx_line) in chunk.context_lines.iter().enumerate() {
537+
if let Some(idx) = seek_sequence::seek_sequence(
538+
original_lines,
539+
std::slice::from_ref(ctx_line),
540+
line_index,
541+
false,
542+
) {
543+
line_index = idx + 1;
544+
} else {
545+
return Err(ApplyPatchError::ComputeReplacements(format!(
546+
"Failed to find context {}/{}: '{}' in {}",
547+
i + 1,
548+
total,
549+
ctx_line,
550+
path.display()
551+
)));
552+
}
548553
}
549554
}
550555

551556
if chunk.old_lines.is_empty() {
552-
// Pure addition (no old lines). We'll add them at the end or just
553-
// before the final empty line if one exists.
554-
let insertion_idx = if original_lines.last().is_some_and(|s| s.is_empty()) {
555-
original_lines.len() - 1
557+
// Pure addition (no old lines).
558+
// Prefer to insert at the matched context anchor if one exists and
559+
// the hunk is not explicitly marked as end-of-file.
560+
let insertion_idx = if chunk.is_end_of_file {
561+
if original_lines.last().is_some_and(|s| s.is_empty()) {
562+
original_lines.len() - 1
563+
} else {
564+
original_lines.len()
565+
}
566+
} else if !chunk.context_lines.is_empty() {
567+
// Insert immediately after the last matched context line.
568+
line_index
556569
} else {
557-
original_lines.len()
570+
// No context provided: fall back to appending at the end (before
571+
// the trailing empty line if present).
572+
if original_lines.last().is_some_and(|s| s.is_empty()) {
573+
original_lines.len() - 1
574+
} else {
575+
original_lines.len()
576+
}
558577
};
559578
replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
560579
continue;
@@ -1267,6 +1286,57 @@ g
12671286
);
12681287
}
12691288

1289+
#[test]
1290+
fn test_insert_addition_after_single_context_anchor() {
1291+
let dir = tempdir().unwrap();
1292+
let path = dir.path().join("single_ctx.txt");
1293+
fs::write(&path, "class BaseClass:\n def method():\nline1\nline2\n").unwrap();
1294+
1295+
let patch = wrap_patch(&format!(
1296+
r#"*** Update File: {}
1297+
@@ class BaseClass:
1298+
+INSERTED
1299+
"#,
1300+
path.display()
1301+
));
1302+
1303+
let mut stdout = Vec::new();
1304+
let mut stderr = Vec::new();
1305+
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1306+
1307+
let contents = fs::read_to_string(path).unwrap();
1308+
assert_eq!(
1309+
contents,
1310+
"class BaseClass:\nINSERTED\n def method():\nline1\nline2\n"
1311+
);
1312+
}
1313+
1314+
#[test]
1315+
fn test_insert_addition_after_multi_context_anchor() {
1316+
let dir = tempdir().unwrap();
1317+
let path = dir.path().join("multi_ctx.txt");
1318+
fs::write(&path, "class BaseClass:\n def method():\nline1\nline2\n").unwrap();
1319+
1320+
let patch = wrap_patch(&format!(
1321+
r#"*** Update File: {}
1322+
@@ class BaseClass:
1323+
@@ def method():
1324+
+INSERTED
1325+
"#,
1326+
path.display()
1327+
));
1328+
1329+
let mut stdout = Vec::new();
1330+
let mut stderr = Vec::new();
1331+
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
1332+
1333+
let contents = fs::read_to_string(path).unwrap();
1334+
assert_eq!(
1335+
contents,
1336+
"class BaseClass:\n def method():\nINSERTED\nline1\nline2\n"
1337+
);
1338+
}
1339+
12701340
#[test]
12711341
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
12721342
let session_dir = tempdir().unwrap();

codex-rs/apply-patch/src/parser.rs

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ pub enum Hunk {
6969
path: PathBuf,
7070
move_path: Option<PathBuf>,
7171

72-
/// Chunks should be in order, i.e. the `change_context` of one chunk
72+
/// Chunks should be in order, i.e. the first context line of one chunk
7373
/// should occur later in the file than the previous chunk.
7474
chunks: Vec<UpdateFileChunk>,
7575
},
@@ -89,12 +89,13 @@ use Hunk::*;
8989

9090
#[derive(Debug, PartialEq, Clone)]
9191
pub struct UpdateFileChunk {
92-
/// A single line of context used to narrow down the position of the chunk
93-
/// (this is usually a class, method, or function definition.)
94-
pub change_context: Option<String>,
92+
/// Context lines used to narrow down the position of the chunk.
93+
/// Each entry is searched sequentially to progressively restrict the
94+
/// search to the desired region (e.g. class → method).
95+
pub context_lines: Vec<String>,
9596

9697
/// A contiguous block of lines that should be replaced with `new_lines`.
97-
/// `old_lines` must occur strictly after `change_context`.
98+
/// `old_lines` must occur strictly after the context.
9899
pub old_lines: Vec<String>,
99100
pub new_lines: Vec<String>,
100101

@@ -344,32 +345,38 @@ fn parse_update_file_chunk(
344345
line_number,
345346
});
346347
}
347-
// If we see an explicit context marker @@ or @@ <context>, consume it; otherwise, optionally
348-
// allow treating the chunk as starting directly with diff lines.
349-
let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
350-
(None, 1)
351-
} else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
352-
(Some(context.to_string()), 1)
353-
} else {
354-
if !allow_missing_context {
355-
return Err(InvalidHunkError {
356-
message: format!(
357-
"Expected update hunk to start with a @@ context marker, got: '{}'",
358-
lines[0]
359-
),
360-
line_number,
361-
});
348+
let mut context_lines = Vec::new();
349+
let mut start_index = 0;
350+
let mut saw_context_marker = false;
351+
while start_index < lines.len() {
352+
if lines[start_index] == EMPTY_CHANGE_CONTEXT_MARKER {
353+
saw_context_marker = true;
354+
start_index += 1;
355+
} else if let Some(context) = lines[start_index].strip_prefix(CHANGE_CONTEXT_MARKER) {
356+
saw_context_marker = true;
357+
context_lines.push(context.to_string());
358+
start_index += 1;
359+
} else {
360+
break;
362361
}
363-
(None, 0)
364-
};
362+
}
363+
if !saw_context_marker && !allow_missing_context {
364+
return Err(InvalidHunkError {
365+
message: format!(
366+
"Expected update hunk to start with a @@ context marker, got: '{}'",
367+
lines[0]
368+
),
369+
line_number,
370+
});
371+
}
365372
if start_index >= lines.len() {
366373
return Err(InvalidHunkError {
367374
message: "Update hunk does not contain any lines".to_string(),
368-
line_number: line_number + 1,
375+
line_number: line_number + start_index,
369376
});
370377
}
371378
let mut chunk = UpdateFileChunk {
372-
change_context,
379+
context_lines,
373380
old_lines: Vec::new(),
374381
new_lines: Vec::new(),
375382
is_end_of_file: false,
@@ -381,7 +388,7 @@ fn parse_update_file_chunk(
381388
if parsed_lines == 0 {
382389
return Err(InvalidHunkError {
383390
message: "Update hunk does not contain any lines".to_string(),
384-
line_number: line_number + 1,
391+
line_number: line_number + start_index,
385392
});
386393
}
387394
chunk.is_end_of_file = true;
@@ -411,7 +418,7 @@ fn parse_update_file_chunk(
411418
message: format!(
412419
"Unexpected line found in update hunk: '{line_contents}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
413420
),
414-
line_number: line_number + 1,
421+
line_number: line_number + start_index,
415422
});
416423
}
417424
// Assume this is the start of the next hunk.
@@ -491,7 +498,7 @@ fn test_parse_patch() {
491498
path: PathBuf::from("path/update.py"),
492499
move_path: Some(PathBuf::from("path/update2.py")),
493500
chunks: vec![UpdateFileChunk {
494-
change_context: Some("def f():".to_string()),
501+
context_lines: vec!["def f():".to_string()],
495502
old_lines: vec![" pass".to_string()],
496503
new_lines: vec![" return 123".to_string()],
497504
is_end_of_file: false
@@ -518,7 +525,7 @@ fn test_parse_patch() {
518525
path: PathBuf::from("file.py"),
519526
move_path: None,
520527
chunks: vec![UpdateFileChunk {
521-
change_context: None,
528+
context_lines: Vec::new(),
522529
old_lines: vec![],
523530
new_lines: vec!["line".to_string()],
524531
is_end_of_file: false
@@ -548,7 +555,7 @@ fn test_parse_patch() {
548555
path: PathBuf::from("file2.py"),
549556
move_path: None,
550557
chunks: vec![UpdateFileChunk {
551-
change_context: None,
558+
context_lines: Vec::new(),
552559
old_lines: vec!["import foo".to_string()],
553560
new_lines: vec!["import foo".to_string(), "bar".to_string()],
554561
is_end_of_file: false,
@@ -568,7 +575,7 @@ fn test_parse_patch_lenient() {
568575
path: PathBuf::from("file2.py"),
569576
move_path: None,
570577
chunks: vec![UpdateFileChunk {
571-
change_context: None,
578+
context_lines: Vec::new(),
572579
old_lines: vec!["import foo".to_string()],
573580
new_lines: vec!["import foo".to_string(), "bar".to_string()],
574581
is_end_of_file: false,
@@ -701,7 +708,7 @@ fn test_update_file_chunk() {
701708
),
702709
Ok((
703710
(UpdateFileChunk {
704-
change_context: Some("change_context".to_string()),
711+
context_lines: vec!["change_context".to_string()],
705712
old_lines: vec![
706713
"".to_string(),
707714
"context".to_string(),
@@ -723,12 +730,37 @@ fn test_update_file_chunk() {
723730
parse_update_file_chunk(&["@@", "+line", "*** End of File"], 123, false),
724731
Ok((
725732
(UpdateFileChunk {
726-
change_context: None,
733+
context_lines: Vec::new(),
727734
old_lines: vec![],
728735
new_lines: vec!["line".to_string()],
729736
is_end_of_file: true
730737
}),
731738
3
732739
))
733740
);
741+
assert_eq!(
742+
parse_update_file_chunk(
743+
&[
744+
"@@ class BaseClass",
745+
"@@ def method()",
746+
" context",
747+
"-old",
748+
"+new",
749+
],
750+
123,
751+
false
752+
),
753+
Ok((
754+
(UpdateFileChunk {
755+
context_lines: vec![
756+
"class BaseClass".to_string(),
757+
" def method()".to_string()
758+
],
759+
old_lines: vec!["context".to_string(), "old".to_string()],
760+
new_lines: vec!["context".to_string(), "new".to_string()],
761+
is_end_of_file: false
762+
}),
763+
5
764+
))
765+
);
734766
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[
2+
{
3+
"type": "response.output_item.done",
4+
"item": {
5+
"type": "custom_tool_call",
6+
"name": "apply_patch",
7+
"input": "*** Begin Patch\n*** Update File: app.py\n@@ class BaseClass:\n@@ def method():\n- return False\n+ return True\n*** End Patch",
8+
"call_id": "__ID__"
9+
}
10+
},
11+
{
12+
"type": "response.completed",
13+
"response": {
14+
"id": "__ID__",
15+
"usage": {
16+
"input_tokens": 0,
17+
"input_tokens_details": null,
18+
"output_tokens": 0,
19+
"output_tokens_details": null,
20+
"total_tokens": 0
21+
},
22+
"output": []
23+
}
24+
}
25+
]

codex-rs/exec/tests/suite/apply_patch.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,41 @@ async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> {
106106
);
107107
Ok(())
108108
}
109+
110+
#[cfg(not(target_os = "windows"))]
111+
#[tokio::test]
112+
async fn test_apply_patch_context() -> anyhow::Result<()> {
113+
use crate::suite::common::run_e2e_exec_test;
114+
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
115+
116+
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
117+
println!(
118+
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
119+
);
120+
return Ok(());
121+
}
122+
123+
let tmp_cwd = tempdir().expect("failed to create temp dir");
124+
run_e2e_exec_test(
125+
tmp_cwd.path(),
126+
vec![
127+
include_str!("../fixtures/sse_apply_patch_freeform_add.json").to_string(),
128+
include_str!("../fixtures/sse_apply_patch_context_update.json").to_string(),
129+
include_str!("../fixtures/sse_response_completed.json").to_string(),
130+
],
131+
)
132+
.await;
133+
134+
// Verify final file contents
135+
let final_path = tmp_cwd.path().join("app.py");
136+
let contents = std::fs::read_to_string(&final_path)
137+
.unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
138+
assert_eq!(
139+
contents,
140+
r#"class BaseClass:
141+
def method():
142+
return True
143+
"#
144+
);
145+
Ok(())
146+
}

0 commit comments

Comments
 (0)