Move apply-patch file changes into turn items (#20540)

## Why

Apply-patch file changes are now part of the core turn item stream, so
v2 clients can consume the same first-class item lifecycle path used by
other turn items instead of relying on app-server-specific remapping
from legacy patch events.

## What changed

- Added a core `TurnItem::FileChange` carrying apply-patch changes and
completion metadata.
- Updated the apply-patch tool emitter to send `ItemStarted` /
`ItemCompleted` with the new `FileChange` item while preserving legacy
`PatchApplyBegin` / `PatchApplyEnd` fan-out.
- Updated app-server v2 conversion to render the new core item directly
and stopped `event_mapping` from remapping old patch begin/end events
into item notifications.
- Kept thread history reconstruction based on the existing old
apply-patch events for rollout compatibility.

## Verification

- `cargo test -p codex-protocol -p codex-app-server-protocol`
- `cargo test -p codex-core --test all
apply_patch_tool_executes_and_emits_patch_events`
- `cargo test -p codex-app-server bespoke_event_handling`
This commit is contained in:
pakrym-oai
2026-05-01 08:47:18 -07:00
committed by GitHub
Unverified
parent 0b04d1b3cc
commit f476338f93
10 changed files with 240 additions and 165 deletions
@@ -1,7 +1,6 @@
use crate::protocol::common::ServerNotification;
use crate::protocol::item_builders::build_command_execution_begin_item;
use crate::protocol::item_builders::build_command_execution_end_item;
use crate::protocol::item_builders::build_file_change_begin_item;
use crate::protocol::item_builders::convert_patch_changes;
use crate::protocol::v2::AgentMessageDeltaNotification;
use crate::protocol::v2::CollabAgentState;
@@ -450,13 +449,6 @@ pub fn item_event_to_server_notification(
item: item_completed_event.item.into(),
})
}
EventMsg::PatchApplyBegin(patch_begin_event) => {
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id,
turn_id,
item: build_file_change_begin_item(&patch_begin_event),
})
}
EventMsg::PatchApplyUpdated(event) => {
ServerNotification::FileChangePatchUpdated(FileChangePatchUpdatedNotification {
thread_id,
@@ -1,9 +1,8 @@
//! Shared builders for synthetic [`ThreadItem`] values emitted by the app-server layer.
//! Shared builders for app-server [`ThreadItem`] values derived from compatibility events.
//!
//! These items do not come from first-class core `ItemStarted` / `ItemCompleted` events.
//! Instead, the app-server synthesizes them so clients can render a coherent lifecycle for
//! approvals and other pre-execution flows before the underlying tool has started or when the
//! tool never starts at all.
//! Most live tool items now come from first-class core `ItemStarted` / `ItemCompleted` events.
//! These builders remain for approval flows, rebuilt legacy history, and other pre-execution
//! paths where the underlying tool has not started or never starts at all.
//!
//! Keeping these builders in one place is useful for two reasons:
//! - Live notifications and rebuilt `thread/read` history both need to construct the same
@@ -357,6 +357,7 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::FileChange(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
@@ -378,6 +379,7 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::FileChange(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
@@ -5,6 +5,7 @@ use std::path::PathBuf;
use crate::RequestId;
use crate::protocol::common::AuthMode;
use crate::protocol::item_builders::convert_patch_changes;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::account::PlanType;
use codex_protocol::account::ProviderAccount;
@@ -6469,6 +6470,15 @@ impl From<CoreTurnItem> for ThreadItem {
result: image.result,
saved_path: image.saved_path,
},
CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange {
id: file_change.id,
changes: convert_patch_changes(&file_change.changes),
status: file_change
.status
.as_ref()
.map(PatchApplyStatus::from)
.unwrap_or(PatchApplyStatus::InProgress),
},
CoreTurnItem::ContextCompaction(compaction) => {
ThreadItem::ContextCompaction { id: compaction.id }
}
@@ -8078,6 +8088,7 @@ mod tests {
use super::*;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::FileChangeItem;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
@@ -10358,6 +10369,35 @@ mod tests {
}),
}
);
let file_change_item = TurnItem::FileChange(FileChangeItem {
id: "patch-1".to_string(),
changes: [(
PathBuf::from("README.md"),
codex_protocol::protocol::FileChange::Add {
content: "hello\n".to_string(),
},
)]
.into_iter()
.collect(),
status: Some(codex_protocol::protocol::PatchApplyStatus::Completed),
auto_approved: None,
stdout: Some("Done!".to_string()),
stderr: Some(String::new()),
});
assert_eq!(
ThreadItem::from(file_change_item),
ThreadItem::FileChange {
id: "patch-1".to_string(),
changes: vec![FileUpdateChange {
path: "README.md".to_string(),
kind: PatchChangeKind::Add,
diff: "hello\n".to_string(),
}],
status: PatchApplyStatus::Completed,
}
);
}
#[test]