Make turn diff tracker multi-env aware (#26433)

## Why

Turn diffs were tracked as one flat set of absolute paths. In
multi-environment turns, local and remote environments can report the
same path while representing different filesystems, so a single path key
can collapse distinct changes or attribute them to the wrong
environment.

The environment name is **NOT** included in the generated unified diff.
This can come later.
This commit is contained in:
pakrym-oai
2026-06-05 10:31:22 -07:00
committed by GitHub
Unverified
parent da490ba9de
commit 86a1ddd028
6 changed files with 400 additions and 119 deletions
+21 -16
View File
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::Ordering;
@@ -73,7 +74,6 @@ use codex_core_skills::injection::InjectedHostSkillPrompts;
use codex_extension_api::TurnInputContext;
use codex_extension_api::TurnInputEnvironment;
use codex_features::Feature;
use codex_git_utils::get_git_repo_root;
use codex_git_utils::get_git_repo_root_with_fs;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::ModeKind;
@@ -196,21 +196,11 @@ pub(crate) async fn run_turn(
let mut stop_hook_active = false;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
#[allow(deprecated)]
let display_root = match turn_context.environments.primary() {
Some(turn_environment) => get_git_repo_root_with_fs(
turn_environment.environment.get_filesystem().as_ref(),
&turn_environment.cwd,
)
.await
.unwrap_or_else(|| turn_environment.cwd.clone())
.into_path_buf(),
None => get_git_repo_root(turn_context.cwd.as_path())
.unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()),
};
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root(
display_root,
)));
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(
TurnDiffTracker::with_environment_display_roots(
turn_diff_display_roots(turn_context.as_ref()).await,
),
));
// `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse
// one instance across retries within this turn.
@@ -421,6 +411,21 @@ pub(crate) async fn run_turn(
last_agent_message
}
async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> {
let mut display_roots = Vec::new();
for turn_environment in &turn_context.environments.turn_environments {
let root = get_git_repo_root_with_fs(
turn_environment.environment.get_filesystem().as_ref(),
&turn_environment.cwd,
)
.await
.unwrap_or_else(|| turn_environment.cwd.clone())
.into_path_buf();
display_roots.push((turn_environment.environment_id.clone(), root));
}
display_roots
}
async fn run_hooks_and_record_inputs(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
+50 -18
View File
@@ -70,16 +70,25 @@ pub(crate) enum ToolEventFailure<'a> {
}
enum TurnDiffTrackerUpdate<'a> {
Track(&'a AppliedPatchDelta),
Track {
environment_id: Option<String>,
delta: &'a AppliedPatchDelta,
},
Invalidate,
None,
}
fn tracker_update_for_known_delta(delta: &AppliedPatchDelta) -> TurnDiffTrackerUpdate<'_> {
fn tracker_update_for_known_delta<'a>(
environment_id: Option<&str>,
delta: &'a AppliedPatchDelta,
) -> TurnDiffTrackerUpdate<'a> {
if delta.is_exact() && delta.is_empty() {
TurnDiffTrackerUpdate::None
} else {
TurnDiffTrackerUpdate::Track(delta)
TurnDiffTrackerUpdate::Track {
environment_id: environment_id.map(str::to_string),
delta,
}
}
}
@@ -120,6 +129,7 @@ pub(crate) enum ToolEmitter {
ApplyPatch {
changes: HashMap<PathBuf, FileChange>,
auto_approved: bool,
environment_id: Option<String>,
},
UnifiedExec {
command: Vec<String>,
@@ -141,10 +151,15 @@ impl ToolEmitter {
}
}
pub fn apply_patch(changes: HashMap<PathBuf, FileChange>, auto_approved: bool) -> Self {
pub fn apply_patch_for_environment(
changes: HashMap<PathBuf, FileChange>,
auto_approved: bool,
environment_id: String,
) -> Self {
Self::ApplyPatch {
changes,
auto_approved,
environment_id: Some(environment_id),
}
}
@@ -210,7 +225,11 @@ impl ToolEmitter {
.await;
}
(
Self::ApplyPatch { changes, .. },
Self::ApplyPatch {
changes,
environment_id,
..
},
ToolEventStage::Success {
output,
applied_patch_delta,
@@ -222,7 +241,7 @@ impl ToolEmitter {
PatchApplyStatus::Failed
};
let tracker_update = applied_patch_delta
.map(tracker_update_for_known_delta)
.map(|delta| tracker_update_for_known_delta(environment_id.as_deref(), delta))
.unwrap_or(TurnDiffTrackerUpdate::Invalidate);
emit_patch_end(
ctx,
@@ -267,7 +286,11 @@ impl ToolEmitter {
.await;
}
(
Self::ApplyPatch { changes, .. },
Self::ApplyPatch {
changes,
environment_id,
..
},
ToolEventStage::Failure(ToolEventFailure::Rejected {
message,
applied_patch_delta,
@@ -280,7 +303,9 @@ impl ToolEmitter {
(*message).to_string(),
PatchApplyStatus::Declined,
applied_patch_delta
.map(tracker_update_for_known_delta)
.map(|delta| {
tracker_update_for_known_delta(environment_id.as_deref(), delta)
})
.unwrap_or(TurnDiffTrackerUpdate::None),
)
.await;
@@ -565,8 +590,11 @@ async fn emit_patch_end(
let mut guard = tracker.lock().await;
let previous_diff = guard.get_unified_diff();
let tracker_changed = match tracker_update {
TurnDiffTrackerUpdate::Track(delta) => {
guard.track_delta(delta);
TurnDiffTrackerUpdate::Track {
environment_id,
delta,
} => {
guard.track_delta(environment_id.as_deref().unwrap_or_default(), delta);
true
}
TurnDiffTrackerUpdate::Invalidate => {
@@ -627,14 +655,18 @@ mod tests {
.await
.expect("apply patch");
ToolEmitter::apply_patch(HashMap::new(), /*auto_approved*/ false)
.finish(
ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)),
out,
Some(&delta),
)
.await
.expect_err("failed patch");
ToolEmitter::ApplyPatch {
changes: HashMap::new(),
auto_approved: false,
environment_id: None,
}
.finish(
ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)),
out,
Some(&delta),
)
.await
.expect_err("failed patch");
let completed = rx_event.recv().await.expect("item completed event");
assert!(matches!(
@@ -378,8 +378,11 @@ impl ToolExecutor<ToolInvocation> for ApplyPatchHandler {
}
InternalApplyPatchInvocation::DelegateToRuntime(apply) => {
let changes = convert_apply_patch_to_protocol(&apply.action);
let emitter =
ToolEmitter::apply_patch(changes.clone(), apply.auto_approved);
let emitter = ToolEmitter::apply_patch_for_environment(
changes.clone(),
apply.auto_approved,
turn_environment.environment_id.clone(),
);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
@@ -537,7 +540,11 @@ pub(crate) async fn intercept_apply_patch(
}
InternalApplyPatchInvocation::DelegateToRuntime(apply) => {
let changes = convert_apply_patch_to_protocol(&apply.action);
let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved);
let emitter = ToolEmitter::apply_patch_for_environment(
changes.clone(),
apply.auto_approved,
turn_environment.environment_id.clone(),
);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
+88 -60
View File
@@ -13,21 +13,36 @@ const ZERO_OID: &str = "0000000000000000000000000000000000000000";
const DEV_NULL: &str = "/dev/null";
const REGULAR_FILE_MODE: &str = "100644";
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct TrackedPath {
environment_id: String,
path: PathBuf,
}
impl TrackedPath {
fn new(environment_id: &str, path: &Path) -> Self {
Self {
environment_id: environment_id.to_string(),
path: path.to_path_buf(),
}
}
}
/// Tracks the net text diff for the current turn from committed apply_patch
/// mutations, without rereading the workspace filesystem.
pub struct TurnDiffTracker {
valid: bool,
display_root: Option<PathBuf>,
baseline_by_path: HashMap<PathBuf, String>,
current_by_path: HashMap<PathBuf, String>,
origin_by_current_path: HashMap<PathBuf, PathBuf>,
display_roots_by_environment: HashMap<String, PathBuf>,
baseline_by_path: HashMap<TrackedPath, String>,
current_by_path: HashMap<TrackedPath, String>,
origin_by_current_path: HashMap<TrackedPath, TrackedPath>,
}
impl Default for TurnDiffTracker {
fn default() -> Self {
Self {
valid: true,
display_root: None,
display_roots_by_environment: HashMap::new(),
baseline_by_path: HashMap::new(),
current_by_path: HashMap::new(),
origin_by_current_path: HashMap::new(),
@@ -40,20 +55,22 @@ impl TurnDiffTracker {
Self::default()
}
pub fn with_display_root(display_root: PathBuf) -> Self {
pub fn with_environment_display_roots(
display_roots: impl IntoIterator<Item = (String, PathBuf)>,
) -> Self {
let mut tracker = Self::new();
tracker.display_root = Some(display_root);
tracker.display_roots_by_environment = display_roots.into_iter().collect();
tracker
}
pub fn track_delta(&mut self, delta: &AppliedPatchDelta) {
pub fn track_delta(&mut self, environment_id: &str, delta: &AppliedPatchDelta) {
if !delta.is_exact() {
self.invalidate();
return;
}
for change in delta.changes() {
self.apply_change(change);
self.apply_change(environment_id, change);
}
}
@@ -106,8 +123,8 @@ impl TurnDiffTracker {
(!aggregated.is_empty()).then_some(aggregated)
}
fn apply_change(&mut self, change: &AppliedPatchChange) {
let source_path = change.path.as_path();
fn apply_change(&mut self, environment_id: &str, change: &AppliedPatchChange) {
let source_path = TrackedPath::new(environment_id, change.path.as_path());
match &change.change {
AppliedPatchFileChange::Add {
content,
@@ -119,85 +136,87 @@ impl TurnDiffTracker {
old_content,
overwritten_move_content,
new_content,
} => self.apply_update(
source_path,
move_path.as_deref(),
old_content,
overwritten_move_content.as_deref(),
new_content,
),
} => {
let move_path = move_path
.as_deref()
.map(|path| TrackedPath::new(environment_id, path));
self.apply_update(
source_path,
move_path,
old_content,
overwritten_move_content.as_deref(),
new_content,
)
}
}
}
fn apply_add(&mut self, path: &Path, content: &str, overwritten_content: Option<&str>) {
self.origin_by_current_path.remove(path);
if !self.current_by_path.contains_key(path)
&& !self.baseline_by_path.contains_key(path)
fn apply_add(&mut self, path: TrackedPath, content: &str, overwritten_content: Option<&str>) {
self.origin_by_current_path.remove(&path);
if !self.current_by_path.contains_key(&path)
&& !self.baseline_by_path.contains_key(&path)
&& let Some(overwritten_content) = overwritten_content
{
self.baseline_by_path
.insert(path.to_path_buf(), overwritten_content.to_string());
.insert(path.clone(), overwritten_content.to_string());
}
self.current_by_path
.insert(path.to_path_buf(), content.to_string());
self.current_by_path.insert(path, content.to_string());
}
fn apply_delete(&mut self, path: &Path, content: &str) {
if self.current_by_path.remove(path).is_none() && !self.baseline_by_path.contains_key(path)
fn apply_delete(&mut self, path: TrackedPath, content: &str) {
if self.current_by_path.remove(&path).is_none()
&& !self.baseline_by_path.contains_key(&path)
{
self.baseline_by_path
.insert(path.to_path_buf(), content.to_string());
.insert(path.clone(), content.to_string());
}
self.origin_by_current_path.remove(path);
self.origin_by_current_path.remove(&path);
}
fn apply_update(
&mut self,
source_path: &Path,
move_path: Option<&Path>,
source_path: TrackedPath,
move_path: Option<TrackedPath>,
old_content: &str,
overwritten_move_content: Option<&str>,
new_content: &str,
) {
if !self.current_by_path.contains_key(source_path)
&& !self.baseline_by_path.contains_key(source_path)
if !self.current_by_path.contains_key(&source_path)
&& !self.baseline_by_path.contains_key(&source_path)
{
self.baseline_by_path
.insert(source_path.to_path_buf(), old_content.to_string());
.insert(source_path.clone(), old_content.to_string());
}
match move_path {
Some(dest_path) => {
if !self.current_by_path.contains_key(dest_path)
&& !self.baseline_by_path.contains_key(dest_path)
if !self.current_by_path.contains_key(&dest_path)
&& !self.baseline_by_path.contains_key(&dest_path)
&& let Some(overwritten_move_content) = overwritten_move_content
{
self.baseline_by_path.insert(
dest_path.to_path_buf(),
overwritten_move_content.to_string(),
);
self.baseline_by_path
.insert(dest_path.clone(), overwritten_move_content.to_string());
}
let origin = self
.origin_by_current_path
.remove(source_path)
.unwrap_or_else(|| source_path.to_path_buf());
self.current_by_path.remove(source_path);
.remove(&source_path)
.unwrap_or_else(|| source_path.clone());
self.current_by_path.remove(&source_path);
self.current_by_path
.insert(dest_path.to_path_buf(), new_content.to_string());
self.origin_by_current_path.remove(dest_path);
if dest_path != origin.as_path() {
self.origin_by_current_path
.insert(dest_path.to_path_buf(), origin);
.insert(dest_path.clone(), new_content.to_string());
self.origin_by_current_path.remove(&dest_path);
if dest_path != origin {
self.origin_by_current_path.insert(dest_path, origin);
}
}
None => {
self.current_by_path
.insert(source_path.to_path_buf(), new_content.to_string());
.insert(source_path, new_content.to_string());
}
}
}
fn rename_pairs(&self) -> HashMap<PathBuf, PathBuf> {
fn rename_pairs(&self) -> HashMap<TrackedPath, TrackedPath> {
self.origin_by_current_path
.iter()
.filter_map(|(dest_path, origin_path)| {
@@ -215,7 +234,7 @@ impl TurnDiffTracker {
.collect()
}
fn render_path_diff(&self, path: &Path) -> Option<String> {
fn render_path_diff(&self, path: &TrackedPath) -> Option<String> {
self.render_diff(
path,
self.baseline_by_path.get(path).map(String::as_str),
@@ -224,7 +243,11 @@ impl TurnDiffTracker {
)
}
fn render_rename_diff(&self, source_path: &Path, dest_path: &Path) -> Option<String> {
fn render_rename_diff(
&self,
source_path: &TrackedPath,
dest_path: &TrackedPath,
) -> Option<String> {
self.render_diff(
source_path,
self.baseline_by_path.get(source_path).map(String::as_str),
@@ -235,9 +258,9 @@ impl TurnDiffTracker {
fn render_diff(
&self,
left_path: &Path,
left_path: &TrackedPath,
left_content: Option<&str>,
right_path: &Path,
right_path: &TrackedPath,
right_content: Option<&str>,
) -> Option<String> {
if left_content == right_content {
@@ -286,13 +309,18 @@ impl TurnDiffTracker {
Some(diff)
}
fn display_path(&self, path: &Path) -> String {
fn display_path(&self, path: &TrackedPath) -> String {
let display = self
.display_root
.as_deref()
.and_then(|root| path.strip_prefix(root).ok())
.unwrap_or(path);
display.display().to_string().replace('\\', "/")
.display_roots_by_environment
.get(&path.environment_id)
.and_then(|root| path.path.strip_prefix(root).ok())
.unwrap_or(path.path.as_path());
let display = display.display().to_string().replace('\\', "/");
if self.display_roots_by_environment.len() > 1 && !path.environment_id.is_empty() {
format!("{}/{display}", path.environment_id)
} else {
display
}
}
}
+63 -22
View File
@@ -41,24 +41,28 @@ async fn apply_verified_patch(root: &Path, patch: &str) -> AppliedPatchDelta {
.expect("patch should apply")
}
fn tracker_with_root(root: &Path) -> TurnDiffTracker {
TurnDiffTracker::with_environment_display_roots([("".to_string(), root.to_path_buf())])
}
#[tokio::test]
async fn accumulates_add_then_update_as_single_add() {
let dir = tempdir().expect("tempdir");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let add = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch",
)
.await;
tracker.track_delta(&add);
tracker.track_delta("", &add);
let update = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Update File: a.txt\n@@\n foo\n+bar\n*** End Patch",
)
.await;
tracker.track_delta(&update);
tracker.track_delta("", &update);
let right_oid = git_blob_sha1_hex("foo\nbar\n");
let expected = format!(
@@ -78,32 +82,69 @@ index {ZERO_OID}..{right_oid}
#[tokio::test]
async fn invalidated_tracker_suppresses_existing_diff() {
let dir = tempdir().expect("tempdir");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let add = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch",
)
.await;
tracker.track_delta(&add);
tracker.track_delta("", &add);
tracker.invalidate();
assert_eq!(tracker.get_unified_diff(), None);
}
#[tokio::test]
async fn tracks_same_absolute_path_across_multiple_environments() {
let dir = tempdir().expect("tempdir");
let add = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Add File: shared.txt\n+content\n*** End Patch",
)
.await;
let mut tracker = TurnDiffTracker::with_environment_display_roots([
("local".to_string(), dir.path().to_path_buf()),
("remote".to_string(), dir.path().to_path_buf()),
]);
tracker.track_delta("remote", &add);
tracker.track_delta("local", &add);
let right_oid = git_blob_sha1_hex("content\n");
let expected = format!(
r#"diff --git a/local/shared.txt b/local/shared.txt
new file mode {REGULAR_FILE_MODE}
index {ZERO_OID}..{right_oid}
--- {DEV_NULL}
+++ b/local/shared.txt
@@ -0,0 +1 @@
+content
diff --git a/remote/shared.txt b/remote/shared.txt
new file mode {REGULAR_FILE_MODE}
index {ZERO_OID}..{right_oid}
--- {DEV_NULL}
+++ b/remote/shared.txt
@@ -0,0 +1 @@
+content
"#,
);
assert_eq!(tracker.get_unified_diff(), Some(expected));
}
#[tokio::test]
async fn accumulates_delete() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("b.txt"), "x\n").expect("seed file");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let delete = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Delete File: b.txt\n*** End Patch",
)
.await;
tracker.track_delta(&delete);
tracker.track_delta("", &delete);
let left_oid = git_blob_sha1_hex("x\n");
let expected = format!(
@@ -124,13 +165,13 @@ async fn accumulates_move_and_update() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("src.txt"), "line\n").expect("seed file");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let update = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Update File: src.txt\n*** Move to: dst.txt\n@@\n-line\n+line2\n*** End Patch",
)
.await;
tracker.track_delta(&update);
tracker.track_delta("", &update);
let left_oid = git_blob_sha1_hex("line\n");
let right_oid = git_blob_sha1_hex("line2\n");
@@ -152,13 +193,13 @@ async fn pure_rename_yields_no_diff() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("old.txt"), "same\n").expect("seed file");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let rename = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n same\n*** End Patch",
)
.await;
tracker.track_delta(&rename);
tracker.track_delta("", &rename);
assert_eq!(tracker.get_unified_diff(), None);
}
@@ -168,13 +209,13 @@ async fn add_over_existing_file_becomes_update() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("dup.txt"), "before\n").expect("seed file");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let add = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Add File: dup.txt\n+after\n*** End Patch",
)
.await;
tracker.track_delta(&add);
tracker.track_delta("", &add);
let left_oid = git_blob_sha1_hex("before\n");
let right_oid = git_blob_sha1_hex("after\n");
@@ -196,20 +237,20 @@ async fn delete_then_readd_same_path_becomes_update() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("cycle.txt"), "before\n").expect("seed file");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let delete = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Delete File: cycle.txt\n*** End Patch",
)
.await;
tracker.track_delta(&delete);
tracker.track_delta("", &delete);
let add = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Add File: cycle.txt\n+after\n*** End Patch",
)
.await;
tracker.track_delta(&add);
tracker.track_delta("", &add);
let left_oid = git_blob_sha1_hex("before\n");
let right_oid = git_blob_sha1_hex("after\n");
@@ -232,13 +273,13 @@ async fn move_over_existing_destination_without_content_change_deletes_source_on
fs::write(dir.path().join("a.txt"), "same\n").expect("seed source");
fs::write(dir.path().join("b.txt"), "same\n").expect("seed destination");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let move_overwrite = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n same\n*** End Patch",
)
.await;
tracker.track_delta(&move_overwrite);
tracker.track_delta("", &move_overwrite);
let left_oid = git_blob_sha1_hex("same\n");
let expected = format!(
@@ -261,13 +302,13 @@ async fn move_over_existing_destination_with_content_change_deletes_source_and_u
fs::write(dir.path().join("a.txt"), "from\n").expect("seed source");
fs::write(dir.path().join("b.txt"), "existing\n").expect("seed destination");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let move_overwrite = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch",
)
.await;
tracker.track_delta(&move_overwrite);
tracker.track_delta("", &move_overwrite);
let left_oid_a = git_blob_sha1_hex("from\n");
let left_oid_b = git_blob_sha1_hex("existing\n");
@@ -298,13 +339,13 @@ async fn preserves_committed_change_order_with_delete_then_move_overwrite() {
fs::write(dir.path().join("a.txt"), "from\n").expect("seed source");
fs::write(dir.path().join("b.txt"), "existing\n").expect("seed destination");
let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf());
let mut tracker = tracker_with_root(dir.path());
let ordered_patch = apply_verified_patch(
dir.path(),
"*** Begin Patch\n*** Delete File: b.txt\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch",
)
.await;
tracker.track_delta(&ordered_patch);
tracker.track_delta("", &ordered_patch);
let left_oid_a = git_blob_sha1_hex("from\n");
let left_oid_b = git_blob_sha1_hex("existing\n");
@@ -8,11 +8,18 @@ use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc;
use core_test_support::responses::ev_shell_command_call;
use core_test_support::test_codex::ApplyPatchModelOutput;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::AtomicI32;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
use codex_exec_server::RemoveOptions;
use codex_features::Feature;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
@@ -25,11 +32,14 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::user_input::UserInput;
#[cfg(target_os = "linux")]
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use core_test_support::assert_regex_match;
use core_test_support::get_remote_test_env;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
@@ -37,11 +47,13 @@ use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_shell_command_call_with_args;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_remote;
use core_test_support::test_codex::TestCodexBuilder;
use core_test_support::test_codex::TestCodexHarness;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use serde_json::json;
@@ -1553,6 +1565,162 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(_remote_env) = get_remote_test_env() else {
return Ok(());
};
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_with_remote_and_local_env(&server).await?;
let file_name = "shared-turn-diff.txt";
let shared_cwd = PathBuf::from(format!(
"/tmp/codex-remote-turn-diff-{}",
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
))
.abs();
let _ = fs::remove_dir_all(shared_cwd.as_path());
test.fs()
.remove(
&shared_cwd,
RemoveOptions {
recursive: true,
force: true,
},
/*sandbox*/ None,
)
.await?;
fs::create_dir_all(shared_cwd.as_path())?;
test.fs()
.create_directory(
&shared_cwd,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
let local_patch = format!(
"*** Begin Patch\n*** Environment ID: {LOCAL_ENVIRONMENT_ID}\n*** Add File: {file_name}\n+local\n*** End Patch"
);
let remote_patch = format!(
"*** Begin Patch\n*** Environment ID: {REMOTE_ENVIRONMENT_ID}\n*** Add File: {file_name}\n+remote\n*** End Patch"
);
mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-local"),
ev_apply_patch_custom_tool_call("call-local", &local_patch),
ev_completed("resp-local"),
]),
sse(vec![
ev_response_created("resp-remote"),
ev_apply_patch_custom_tool_call("call-remote", &remote_patch),
ev_completed("resp-remote"),
]),
sse(vec![
ev_response_created("resp-done"),
ev_assistant_message("msg-done", "done"),
ev_completed("resp-done"),
]),
],
)
.await;
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "apply matching patches to local and remote environments".into(),
text_elements: Vec::new(),
}],
environments: Some(vec![
TurnEnvironmentSelection {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
cwd: shared_cwd.clone(),
},
TurnEnvironmentSelection {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd: shared_cwd.clone(),
},
]),
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
cwd: Some(test.config.cwd.clone()),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: test.session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
},
})
.await?;
let mut last_diff = None;
wait_for_event(&test.codex, |event| match event {
EventMsg::TurnDiff(ev) => {
last_diff = Some(ev.unified_diff.clone());
false
}
EventMsg::TurnComplete(_) => true,
_ => false,
})
.await;
assert_eq!(fs::read_to_string(shared_cwd.join(file_name))?, "local\n");
assert_eq!(
test.fs()
.read_file_text(&shared_cwd.join(file_name), /*sandbox*/ None)
.await?,
"remote\n"
);
let diff = last_diff.expect("expected TurnDiff event");
assert_eq!(
diff,
r#"diff --git a/local/shared-turn-diff.txt b/local/shared-turn-diff.txt
new file mode 100644
index 0000000000000000000000000000000000000000..40830374235df1c19661a2901b7ca73cc9499f3d
--- /dev/null
+++ b/local/shared-turn-diff.txt
@@ -0,0 +1 @@
+local
diff --git a/remote/shared-turn-diff.txt b/remote/shared-turn-diff.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9c998f7b995a7327177b38a90d1385170df2b94b
--- /dev/null
+++ b/remote/shared-turn-diff.txt
@@ -0,0 +1 @@
+remote
"#
);
let _ = fs::remove_dir_all(shared_cwd.as_path());
test.fs()
.remove(
&shared_cwd,
RemoveOptions {
recursive: true,
force: true,
},
/*sandbox*/ None,
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> {
skip_if_no_network!(Ok(()));