mirror of
https://github.com/openai/codex.git
synced 2026-04-28 18:32:04 +03:00
20 KiB
20 KiB
PR #2596: [apply-patch] Handle multiple context lines
- URL: https://github.com/openai/codex/pull/2596
- Author: dylan-hurd-oai
- Created: 2025-08-22 22:45:19 UTC
- Updated: 2025-08-25 23:36:20 UTC
- Changes: +218/-53, Files changed: 4, Commits: 1
Description
Summary
Fixes #2578 - the following example would be considered incorrect, despite it being valid according to our system instructions and our official Lark grammar.
*** Begin Patch
*** Update File: src/app.py
@@ class BaseClass
@@ def method():
- old_line
+ new_line
*** End Patch
Testing
- Added unit tests
Full Diff
diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs
index 84cb91201f..1f9301b121 100644
--- a/codex-rs/apply-patch/src/lib.rs
+++ b/codex-rs/apply-patch/src/lib.rs
@@ -532,32 +532,51 @@ fn compute_replacements(
let mut line_index: usize = 0;
for chunk in chunks {
- // If a chunk has a `change_context`, we use seek_sequence to find it, then
- // adjust our `line_index` to continue from there.
- if let Some(ctx_line) = &chunk.change_context {
- if let Some(idx) = seek_sequence::seek_sequence(
- original_lines,
- std::slice::from_ref(ctx_line),
- line_index,
- false,
- ) {
- line_index = idx + 1;
- } else {
- return Err(ApplyPatchError::ComputeReplacements(format!(
- "Failed to find context '{}' in {}",
- ctx_line,
- path.display()
- )));
+ // If a chunk has context lines, we use seek_sequence to find each in order,
+ // then adjust our `line_index` to continue from there.
+ if !chunk.context_lines.is_empty() {
+ let total = chunk.context_lines.len();
+ for (i, ctx_line) in chunk.context_lines.iter().enumerate() {
+ if let Some(idx) = seek_sequence::seek_sequence(
+ original_lines,
+ std::slice::from_ref(ctx_line),
+ line_index,
+ false,
+ ) {
+ line_index = idx + 1;
+ } else {
+ return Err(ApplyPatchError::ComputeReplacements(format!(
+ "Failed to find context {}/{}: '{}' in {}",
+ i + 1,
+ total,
+ ctx_line,
+ path.display()
+ )));
+ }
}
}
if chunk.old_lines.is_empty() {
- // Pure addition (no old lines). We'll add them at the end or just
- // before the final empty line if one exists.
- let insertion_idx = if original_lines.last().is_some_and(|s| s.is_empty()) {
- original_lines.len() - 1
+ // Pure addition (no old lines).
+ // Prefer to insert at the matched context anchor if one exists and
+ // the hunk is not explicitly marked as end-of-file.
+ let insertion_idx = if chunk.is_end_of_file {
+ if original_lines.last().is_some_and(|s| s.is_empty()) {
+ original_lines.len() - 1
+ } else {
+ original_lines.len()
+ }
+ } else if !chunk.context_lines.is_empty() {
+ // Insert immediately after the last matched context line.
+ line_index
} else {
- original_lines.len()
+ // No context provided: fall back to appending at the end (before
+ // the trailing empty line if present).
+ if original_lines.last().is_some_and(|s| s.is_empty()) {
+ original_lines.len() - 1
+ } else {
+ original_lines.len()
+ }
};
replacements.push((insertion_idx, 0, chunk.new_lines.clone()));
continue;
@@ -1270,6 +1289,57 @@ g
);
}
+ #[test]
+ fn test_insert_addition_after_single_context_anchor() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("single_ctx.txt");
+ fs::write(&path, "class BaseClass:\n def method():\nline1\nline2\n").unwrap();
+
+ let patch = wrap_patch(&format!(
+ r#"*** Update File: {}
+@@ class BaseClass:
++INSERTED
+"#,
+ path.display()
+ ));
+
+ let mut stdout = Vec::new();
+ let mut stderr = Vec::new();
+ apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
+
+ let contents = fs::read_to_string(path).unwrap();
+ assert_eq!(
+ contents,
+ "class BaseClass:\nINSERTED\n def method():\nline1\nline2\n"
+ );
+ }
+
+ #[test]
+ fn test_insert_addition_after_multi_context_anchor() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("multi_ctx.txt");
+ fs::write(&path, "class BaseClass:\n def method():\nline1\nline2\n").unwrap();
+
+ let patch = wrap_patch(&format!(
+ r#"*** Update File: {}
+@@ class BaseClass:
+@@ def method():
++INSERTED
+"#,
+ path.display()
+ ));
+
+ let mut stdout = Vec::new();
+ let mut stderr = Vec::new();
+ apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
+
+ let contents = fs::read_to_string(path).unwrap();
+ assert_eq!(
+ contents,
+ "class BaseClass:\n def method():\nINSERTED\nline1\nline2\n"
+ );
+ }
+
#[test]
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
let session_dir = tempdir().unwrap();
diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs
index ff9dfd6f8f..1e2aeb946d 100644
--- a/codex-rs/apply-patch/src/parser.rs
+++ b/codex-rs/apply-patch/src/parser.rs
@@ -69,7 +69,7 @@ pub enum Hunk {
path: PathBuf,
move_path: Option<PathBuf>,
- /// Chunks should be in order, i.e. the `change_context` of one chunk
+ /// Chunks should be in order, i.e. the first context line of one chunk
/// should occur later in the file than the previous chunk.
chunks: Vec<UpdateFileChunk>,
},
@@ -89,12 +89,13 @@ use Hunk::*;
#[derive(Debug, PartialEq, Clone)]
pub struct UpdateFileChunk {
- /// A single line of context used to narrow down the position of the chunk
- /// (this is usually a class, method, or function definition.)
- pub change_context: Option<String>,
+ /// Context lines used to narrow down the position of the chunk.
+ /// Each entry is searched sequentially to progressively restrict the
+ /// search to the desired region (e.g. class → method).
+ pub context_lines: Vec<String>,
/// A contiguous block of lines that should be replaced with `new_lines`.
- /// `old_lines` must occur strictly after `change_context`.
+ /// `old_lines` must occur strictly after the context.
pub old_lines: Vec<String>,
pub new_lines: Vec<String>,
@@ -344,32 +345,38 @@ fn parse_update_file_chunk(
line_number,
});
}
- // If we see an explicit context marker @@ or @@ <context>, consume it; otherwise, optionally
- // allow treating the chunk as starting directly with diff lines.
- let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
- (None, 1)
- } else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
- (Some(context.to_string()), 1)
- } else {
- if !allow_missing_context {
- return Err(InvalidHunkError {
- message: format!(
- "Expected update hunk to start with a @@ context marker, got: '{}'",
- lines[0]
- ),
- line_number,
- });
+ let mut context_lines = Vec::new();
+ let mut start_index = 0;
+ let mut saw_context_marker = false;
+ while start_index < lines.len() {
+ if lines[start_index] == EMPTY_CHANGE_CONTEXT_MARKER {
+ saw_context_marker = true;
+ start_index += 1;
+ } else if let Some(context) = lines[start_index].strip_prefix(CHANGE_CONTEXT_MARKER) {
+ saw_context_marker = true;
+ context_lines.push(context.to_string());
+ start_index += 1;
+ } else {
+ break;
}
- (None, 0)
- };
+ }
+ if !saw_context_marker && !allow_missing_context {
+ return Err(InvalidHunkError {
+ message: format!(
+ "Expected update hunk to start with a @@ context marker, got: '{}'",
+ lines[0]
+ ),
+ line_number,
+ });
+ }
if start_index >= lines.len() {
return Err(InvalidHunkError {
message: "Update hunk does not contain any lines".to_string(),
- line_number: line_number + 1,
+ line_number: line_number + start_index,
});
}
let mut chunk = UpdateFileChunk {
- change_context,
+ context_lines,
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
@@ -381,7 +388,7 @@ fn parse_update_file_chunk(
if parsed_lines == 0 {
return Err(InvalidHunkError {
message: "Update hunk does not contain any lines".to_string(),
- line_number: line_number + 1,
+ line_number: line_number + start_index,
});
}
chunk.is_end_of_file = true;
@@ -411,7 +418,7 @@ fn parse_update_file_chunk(
message: format!(
"Unexpected line found in update hunk: '{line_contents}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
),
- line_number: line_number + 1,
+ line_number: line_number + start_index,
});
}
// Assume this is the start of the next hunk.
@@ -491,7 +498,7 @@ fn test_parse_patch() {
path: PathBuf::from("path/update.py"),
move_path: Some(PathBuf::from("path/update2.py")),
chunks: vec![UpdateFileChunk {
- change_context: Some("def f():".to_string()),
+ context_lines: vec!["def f():".to_string()],
old_lines: vec![" pass".to_string()],
new_lines: vec![" return 123".to_string()],
is_end_of_file: false
@@ -518,7 +525,7 @@ fn test_parse_patch() {
path: PathBuf::from("file.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
- change_context: None,
+ context_lines: Vec::new(),
old_lines: vec![],
new_lines: vec!["line".to_string()],
is_end_of_file: false
@@ -548,7 +555,7 @@ fn test_parse_patch() {
path: PathBuf::from("file2.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
- change_context: None,
+ context_lines: Vec::new(),
old_lines: vec!["import foo".to_string()],
new_lines: vec!["import foo".to_string(), "bar".to_string()],
is_end_of_file: false,
@@ -568,7 +575,7 @@ fn test_parse_patch_lenient() {
path: PathBuf::from("file2.py"),
move_path: None,
chunks: vec![UpdateFileChunk {
- change_context: None,
+ context_lines: Vec::new(),
old_lines: vec!["import foo".to_string()],
new_lines: vec!["import foo".to_string(), "bar".to_string()],
is_end_of_file: false,
@@ -701,7 +708,7 @@ fn test_update_file_chunk() {
),
Ok((
(UpdateFileChunk {
- change_context: Some("change_context".to_string()),
+ context_lines: vec!["change_context".to_string()],
old_lines: vec![
"".to_string(),
"context".to_string(),
@@ -723,7 +730,7 @@ fn test_update_file_chunk() {
parse_update_file_chunk(&["@@", "+line", "*** End of File"], 123, false),
Ok((
(UpdateFileChunk {
- change_context: None,
+ context_lines: Vec::new(),
old_lines: vec![],
new_lines: vec!["line".to_string()],
is_end_of_file: true
@@ -731,4 +738,29 @@ fn test_update_file_chunk() {
3
))
);
+ assert_eq!(
+ parse_update_file_chunk(
+ &[
+ "@@ class BaseClass",
+ "@@ def method()",
+ " context",
+ "-old",
+ "+new",
+ ],
+ 123,
+ false
+ ),
+ Ok((
+ (UpdateFileChunk {
+ context_lines: vec![
+ "class BaseClass".to_string(),
+ " def method()".to_string()
+ ],
+ old_lines: vec!["context".to_string(), "old".to_string()],
+ new_lines: vec!["context".to_string(), "new".to_string()],
+ is_end_of_file: false
+ }),
+ 5
+ ))
+ );
}
diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_context_update.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_context_update.json
new file mode 100644
index 0000000000..886fe64686
--- /dev/null
+++ b/codex-rs/exec/tests/fixtures/sse_apply_patch_context_update.json
@@ -0,0 +1,25 @@
+[
+ {
+ "type": "response.output_item.done",
+ "item": {
+ "type": "custom_tool_call",
+ "name": "apply_patch",
+ "input": "*** Begin Patch\n*** Update File: app.py\n@@ class BaseClass:\n@@ def method():\n- return False\n+ return True\n*** End Patch",
+ "call_id": "__ID__"
+ }
+ },
+ {
+ "type": "response.completed",
+ "response": {
+ "id": "__ID__",
+ "usage": {
+ "input_tokens": 0,
+ "input_tokens_details": null,
+ "output_tokens": 0,
+ "output_tokens_details": null,
+ "total_tokens": 0
+ },
+ "output": []
+ }
+ }
+]
\ No newline at end of file
diff --git a/codex-rs/exec/tests/suite/apply_patch.rs b/codex-rs/exec/tests/suite/apply_patch.rs
index 5537853b02..a02f757f75 100644
--- a/codex-rs/exec/tests/suite/apply_patch.rs
+++ b/codex-rs/exec/tests/suite/apply_patch.rs
@@ -106,3 +106,41 @@ async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> {
);
Ok(())
}
+
+#[cfg(not(target_os = "windows"))]
+#[tokio::test]
+async fn test_apply_patch_context() -> anyhow::Result<()> {
+ use crate::suite::common::run_e2e_exec_test;
+ use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
+
+ if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
+ println!(
+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
+ );
+ return Ok(());
+ }
+
+ let tmp_cwd = tempdir().expect("failed to create temp dir");
+ run_e2e_exec_test(
+ tmp_cwd.path(),
+ vec![
+ include_str!("../fixtures/sse_apply_patch_freeform_add.json").to_string(),
+ include_str!("../fixtures/sse_apply_patch_context_update.json").to_string(),
+ include_str!("../fixtures/sse_response_completed.json").to_string(),
+ ],
+ )
+ .await;
+
+ // Verify final file contents
+ let final_path = tmp_cwd.path().join("app.py");
+ let contents = std::fs::read_to_string(&final_path)
+ .unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
+ assert_eq!(
+ contents,
+ r#"class BaseClass:
+ def method():
+ return True
+"#
+ );
+ Ok(())
+}
Review Comments
codex-rs/apply-patch/src/lib.rs
- Created: 2025-08-25 23:32:52 UTC | Link: https://github.com/openai/codex/pull/2596#discussion_r2299356487
@@ -532,32 +532,51 @@ fn compute_replacements(
let mut line_index: usize = 0;
for chunk in chunks {
- // If a chunk has a `change_context`, we use seek_sequence to find it, then
- // adjust our `line_index` to continue from there.
- if let Some(ctx_line) = &chunk.change_context {
- if let Some(idx) = seek_sequence::seek_sequence(
- original_lines,
- std::slice::from_ref(ctx_line),
- line_index,
- false,
- ) {
- line_index = idx + 1;
- } else {
- return Err(ApplyPatchError::ComputeReplacements(format!(
- "Failed to find context '{}' in {}",
- ctx_line,
- path.display()
- )));
+ // If a chunk has context lines, we use seek_sequence to find each in order,
+ // then adjust our `line_index` to continue from there.
+ if !chunk.context_lines.is_empty() {
+ let total = chunk.context_lines.len();
+ for (i, ctx_line) in chunk.context_lines.iter().enumerate() {
+ if let Some(idx) = seek_sequence::seek_sequence(
+ original_lines,
+ std::slice::from_ref(ctx_line),
+ line_index,
+ false,
+ ) {
+ line_index = idx + 1;
+ } else {
+ return Err(ApplyPatchError::ComputeReplacements(format!(
+ "Failed to find context {}/{}: '{}' in {}",
+ i + 1,
+ total,
+ ctx_line,
+ path.display()
+ )));
+ }
}
}
if chunk.old_lines.is_empty() {
- // Pure addition (no old lines). We'll add them at the end or just
- // before the final empty line if one exists.
- let insertion_idx = if original_lines.last().is_some_and(|s| s.is_empty()) {
- original_lines.len() - 1
+ // Pure addition (no old lines).
+ // Prefer to insert at the matched context anchor if one exists and
+ // the hunk is not explicitly marked as end-of-file.
+ let insertion_idx = if chunk.is_end_of_file {
+ if original_lines.last().is_some_and(|s| s.is_empty()) {
+ original_lines.len() - 1
+ } else {
+ original_lines.len()
+ }
+ } else if !chunk.context_lines.is_empty() {
+ // Insert immediately after the last matched context line.
+ line_index
Is this correct?
let insertion_idx = if !chunk.is_end_of_file && !chunk.context_lines.is_empty() { // Insert immediately after the last matched context line. line_index } else if original_lines.last().is_some_and(|s| s.is_empty()) { original_lines.len() - 1 } else { original_lines.len() };
codex-rs/exec/tests/fixtures/sse_apply_patch_context_update.json
- Created: 2025-08-25 23:35:39 UTC | Link: https://github.com/openai/codex/pull/2596#discussion_r2299359432
@@ -0,0 +1,25 @@
+[
+ {
+ "type": "response.output_item.done",
+ "item": {
+ "type": "custom_tool_call",
+ "name": "apply_patch",
+ "input": "*** Begin Patch\n*** Update File: app.py\n@@ class BaseClass:\n@@ def method():\n- return False\n+ return True\n*** End Patch",
+ "call_id": "__ID__"
+ }
+ },
+ {
+ "type": "response.completed",
+ "response": {
+ "id": "__ID__",
+ "usage": {
+ "input_tokens": 0,
+ "input_tokens_details": null,
+ "output_tokens": 0,
+ "output_tokens_details": null,
+ "total_tokens": 0
+ },
+ "output": []
+ }
+ }
+]
newline at eof?
codex-rs/exec/tests/suite/apply_patch.rs
- Created: 2025-08-25 23:35:53 UTC | Link: https://github.com/openai/codex/pull/2596#discussion_r2299359662
@@ -106,3 +106,41 @@ async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> {
);
Ok(())
}
+
+#[cfg(not(target_os = "windows"))]
Are you sure these don't work on Windows?