Compare commits

...

2 Commits

Author SHA1 Message Date
Owen Lin
67b5ef9cb0 fix 2025-12-02 14:51:06 -08:00
Owen Lin
ce7fc27f89 [app-server] feat: add old_content and new_content to FileChange update patches 2025-12-02 14:28:32 -08:00
12 changed files with 221 additions and 6 deletions

View File

@@ -1146,7 +1146,11 @@ pub struct FileUpdateChange {
pub enum PatchChangeKind {
Add,
Delete,
Update { move_path: Option<PathBuf> },
Update {
move_path: Option<PathBuf>,
old_content: String,
new_content: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View File

@@ -968,8 +968,15 @@ fn map_patch_change_kind(change: &CoreFileChange) -> V2PatchChangeKind {
match change {
CoreFileChange::Add { .. } => V2PatchChangeKind::Add,
CoreFileChange::Delete { .. } => V2PatchChangeKind::Delete,
CoreFileChange::Update { move_path, .. } => V2PatchChangeKind::Update {
CoreFileChange::Update {
move_path,
old_content,
new_content,
..
} => V2PatchChangeKind::Update {
move_path: move_path.clone(),
old_content: old_content.clone(),
new_content: new_content.clone(),
},
}
}
@@ -981,6 +988,7 @@ fn format_file_change_diff(change: &CoreFileChange) -> String {
CoreFileChange::Update {
unified_diff,
move_path,
..
} => {
if let Some(path) = move_path {
format!("{unified_diff}\n\nMoved to: {}", path.display())

View File

@@ -629,10 +629,19 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
*** Add File: README.md
+new line
*** End Patch
"#;
let update_patch = r#"*** Begin Patch
*** Update File: README.md
@@
-new line
+updated line
*** End Patch
"#;
let responses = vec![
create_apply_patch_sse_response(patch, "patch-call")?,
create_final_assistant_message_sse_response("patch applied")?,
create_apply_patch_sse_response(update_patch, "patch-call-2")?,
create_final_assistant_message_sse_response("patch updated")?,
];
let server = create_mock_chat_completions_server(responses).await;
create_config_toml(&codex_home, &server.uri(), "untrusted")?;
@@ -707,8 +716,8 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
assert_eq!(params.item_id, "patch-call");
assert_eq!(params.thread_id, thread.id);
assert_eq!(params.turn_id, turn.id);
let expected_readme_path = workspace.join("README.md");
let expected_readme_path = expected_readme_path.to_string_lossy().into_owned();
let readme_path = workspace.join("README.md");
let expected_readme_path = readme_path.to_string_lossy().into_owned();
pretty_assertions::assert_eq!(
started_changes,
vec![codex_app_server_protocol::FileUpdateChange {
@@ -775,9 +784,134 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
)
.await??;
let readme_contents = std::fs::read_to_string(expected_readme_path)?;
let readme_contents = std::fs::read_to_string(&readme_path)?;
assert_eq!(readme_contents, "new line\n");
let second_turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "update patch".into(),
}],
approval_policy: Some(codex_app_server_protocol::AskForApproval::Never),
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess),
model: Some("mock-model".to_string()),
effort: Some(ReasoningEffort::Medium),
summary: Some(ReasoningSummary::Auto),
cwd: Some(workspace.clone()),
})
.await?;
let second_turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)),
)
.await??;
let TurnStartResponse { turn: second_turn } =
to_response::<TurnStartResponse>(second_turn_resp)?;
let started_update_file_change = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started_notif = mcp
.read_stream_until_notification_message("item/started")
.await?;
let started: ItemStartedNotification =
serde_json::from_value(started_notif.params.clone().expect("item/started params"))?;
if let ThreadItem::FileChange { .. } = started.item {
return Ok::<ThreadItem, anyhow::Error>(started.item);
}
}
})
.await??;
let ThreadItem::FileChange {
ref id,
status,
ref changes,
} = started_update_file_change
else {
unreachable!("loop ensures we break on file change items");
};
assert_eq!(id, "patch-call-2");
assert_eq!(status, PatchApplyStatus::InProgress);
let update_changes = changes.clone();
let update_change = update_changes
.first()
.expect("expected a single update change");
assert_eq!(update_change.path, expected_readme_path);
match &update_change.kind {
PatchChangeKind::Update {
move_path,
old_content,
new_content,
} => {
assert!(move_path.is_none());
assert_eq!(old_content, "new line\n");
assert_eq!(new_content, "updated line\n");
}
other => panic!("expected PatchChangeKind::Update, got: {other:?}"),
}
assert!(
update_change.diff.contains("-new line"),
"expected diff to include removal of original line, got: {}",
update_change.diff
);
assert!(
update_change.diff.contains("+updated line"),
"expected diff to include addition of updated line, got: {}",
update_change.diff
);
let output_delta_notif = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/fileChange/outputDelta"),
)
.await??;
let output_delta: FileChangeOutputDeltaNotification = serde_json::from_value(
output_delta_notif
.params
.clone()
.expect("item/fileChange/outputDelta params"),
)?;
assert_eq!(output_delta.thread_id, thread.id);
assert_eq!(output_delta.turn_id, second_turn.id);
assert_eq!(output_delta.item_id, "patch-call-2");
assert!(
!output_delta.delta.is_empty(),
"expected delta to be non-empty, got: {}",
output_delta.delta
);
let completed_file_change = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let completed_notif = mcp
.read_stream_until_notification_message("item/completed")
.await?;
let completed: ItemCompletedNotification = serde_json::from_value(
completed_notif
.params
.clone()
.expect("item/completed params"),
)?;
if let ThreadItem::FileChange { .. } = completed.item {
return Ok::<ThreadItem, anyhow::Error>(completed.item);
}
}
})
.await??;
let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else {
unreachable!("loop ensures we break on file change items");
};
assert_eq!(id, "patch-call-2");
assert_eq!(status, PatchApplyStatus::Completed);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
let updated_readme_contents = std::fs::read_to_string(&readme_path)?;
assert_eq!(updated_readme_contents, "updated line\n");
Ok(())
}

View File

@@ -194,6 +194,8 @@ pub enum ApplyPatchFileChange {
move_path: Option<PathBuf>,
/// new_content that will result after the unified_diff is applied.
new_content: String,
/// original content before the diff was applied.
old_content: String,
},
}
@@ -329,6 +331,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
let ApplyPatchFileUpdate {
unified_diff,
content: contents,
old_content,
} = match unified_diff_from_chunks(&path, &chunks) {
Ok(diff) => diff,
Err(e) => {
@@ -341,6 +344,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
unified_diff,
move_path: move_path.map(|p| effective_cwd.join(p)),
new_content: contents,
old_content,
},
);
}
@@ -846,6 +850,7 @@ fn apply_replacements(
pub struct ApplyPatchFileUpdate {
unified_diff: String,
content: String,
old_content: String,
}
pub fn unified_diff_from_chunks(
@@ -869,6 +874,7 @@ pub fn unified_diff_from_chunks_with_context(
Ok(ApplyPatchFileUpdate {
unified_diff,
content: new_contents,
old_content: original_contents,
})
}
@@ -1467,6 +1473,7 @@ PATCH"#,
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nBAR\nbaz\nQUX\n".to_string(),
old_content: "foo\nbar\nbaz\nqux\n".to_string(),
};
assert_eq!(expected, diff);
}
@@ -1503,6 +1510,7 @@ PATCH"#,
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "FOO\nbar\nbaz\n".to_string(),
old_content: "foo\nbar\nbaz\n".to_string(),
};
assert_eq!(expected, diff);
}
@@ -1540,6 +1548,7 @@ PATCH"#,
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nbar\nBAZ\n".to_string(),
old_content: "foo\nbar\nbaz\n".to_string(),
};
assert_eq!(expected, diff);
}
@@ -1574,6 +1583,7 @@ PATCH"#,
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nbar\nbaz\nquux\n".to_string(),
old_content: "foo\nbar\nbaz\n".to_string(),
};
assert_eq!(expected, diff);
}
@@ -1629,6 +1639,7 @@ PATCH"#,
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
old_content: "a\nb\nc\nd\ne\nf\n".to_string(),
};
assert_eq!(expected, diff);
@@ -1688,6 +1699,7 @@ g
.to_string(),
move_path: None,
new_content: "updated session directory content\n".to_string(),
old_content: "session directory content\n".to_string(),
},
)]),
patch: argv[1].clone(),

View File

@@ -105,10 +105,13 @@ pub(crate) fn convert_apply_patch_to_protocol(
ApplyPatchFileChange::Update {
unified_diff,
move_path,
new_content: _new_content,
new_content,
old_content,
} => FileChange::Update {
unified_diff: unified_diff.clone(),
move_path: move_path.clone(),
old_content: old_content.clone(),
new_content: new_content.clone(),
},
};
result.insert(path.clone(), protocol_change);

View File

@@ -545,6 +545,8 @@ index {ZERO_OID}..{right_oid}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: None,
old_content: "foo\n".to_owned(),
new_content: "foo\nbar\n".to_owned(),
},
)]);
acc.on_patch_begin(&update_changes);
@@ -620,6 +622,8 @@ index {left_oid}..{ZERO_OID}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: Some(dest.clone()),
old_content: "line\n".to_owned(),
new_content: "line2\n".to_owned(),
},
)]);
acc.on_patch_begin(&mv_changes);
@@ -660,6 +664,8 @@ index {left_oid}..{right_oid}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: Some(dest.clone()),
old_content: "same\n".to_owned(),
new_content: "same\n".to_owned(),
},
)]);
acc.on_patch_begin(&mv_changes);
@@ -682,6 +688,8 @@ index {left_oid}..{right_oid}
FileChange::Update {
unified_diff: "".into(),
move_path: Some(dest.clone()),
old_content: String::new(),
new_content: "hello\n".to_owned(),
},
)]);
acc.on_patch_begin(&mv);
@@ -722,6 +730,8 @@ index {ZERO_OID}..{right_oid}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: None,
old_content: "foo\n".to_owned(),
new_content: "foo\nbar\n".to_owned(),
},
)]);
acc.on_patch_begin(&update_a);
@@ -802,6 +812,8 @@ index {left_oid_b}..{ZERO_OID}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: None,
old_content: String::new(),
new_content: String::new(),
},
)]);
acc.on_patch_begin(&update_changes);
@@ -868,6 +880,8 @@ index {ZERO_OID}..{right_oid}
FileChange::Update {
unified_diff: "".to_owned(),
move_path: None,
old_content: "foo\n".to_owned(),
new_content: "foo\nbar\n".to_owned(),
},
)]);
acc.on_patch_begin(&update_changes);

View File

@@ -406,6 +406,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
FileChange::Update {
unified_diff,
move_path,
..
} => {
let header = if let Some(dest) = move_path {
format!(

View File

@@ -823,6 +823,8 @@ fn patch_apply_success_produces_item_completed_patchapply() {
FileChange::Update {
unified_diff: "--- c/modified.txt\n+++ c/modified.txt\n@@\n-old\n+new\n".to_string(),
move_path: Some(PathBuf::from("c/renamed.txt")),
old_content: "-old\n".to_string(),
new_content: "+new\n".to_string(),
},
);
@@ -895,6 +897,8 @@ fn patch_apply_failure_produces_item_completed_patchapply_failed() {
FileChange::Update {
unified_diff: "--- file.txt\n+++ file.txt\n@@\n-old\n+new\n".to_string(),
move_path: None,
old_content: "-old\n".to_string(),
new_content: "+new\n".to_string(),
},
);

View File

@@ -269,6 +269,8 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> {
FileChange::Update {
unified_diff: "@@ -1 +1 @@\n-original content\n+modified content\n".to_string(),
move_path: None,
old_content: "original content\n".to_string(),
new_content: "modified content\n".to_string(),
},
);

View File

@@ -1653,6 +1653,8 @@ pub enum FileChange {
Update {
unified_diff: String,
move_path: Option<PathBuf>,
old_content: String,
new_content: String,
},
}
@@ -1717,6 +1719,21 @@ mod tests {
assert!(event.as_legacy_events(false).is_empty());
}
#[test]
fn file_change_update_serializes_old_and_new_content() {
let change = FileChange::Update {
unified_diff: "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new\n".to_string(),
move_path: None,
old_content: "old\n".to_string(),
new_content: "new\n".to_string(),
};
let serialized = serde_json::to_value(change).expect("should serialize");
assert_eq!(serialized["old_content"], "old\n");
assert_eq!(serialized["new_content"], "new\n");
}
/// Serialize Event to verify that its JSON representation has the expected
/// amount of nesting.
#[test]

View File

@@ -1560,6 +1560,8 @@ impl ChatWidget {
FileChange::Update {
unified_diff: "+test\n-test2".to_string(),
move_path: None,
old_content: "test2".to_string(),
new_content: "test".to_string(),
},
),
]),

View File

@@ -484,6 +484,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: None,
old_content: original.to_string(),
new_content: modified.to_string(),
},
);
@@ -504,6 +506,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("new_name.rs")),
old_content: original.to_string(),
new_content: modified.to_string(),
},
);
@@ -524,6 +528,8 @@ mod tests {
FileChange::Update {
unified_diff: patch_a,
move_path: None,
old_content: "one\n".to_string(),
new_content: "one changed\n".to_string(),
},
);
@@ -590,6 +596,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: None,
old_content: original.to_string(),
new_content: modified.to_string(),
},
);
@@ -613,6 +621,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: None,
old_content: original.to_string(),
new_content: modified.to_string(),
},
);
@@ -640,6 +650,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: None,
old_content: original,
new_content: modified,
},
);
@@ -663,6 +675,8 @@ mod tests {
FileChange::Update {
unified_diff: patch,
move_path: Some(abs_new),
old_content: original.to_string(),
new_content: modified.to_string(),
},
);