Stop emitting item/fileChange/outputDelta output delta notifications (#20471)

## Why

`item/fileChange/outputDelta` text output was only the tool's summary or
error text and not used by client surfaces.

We keep `item/fileChange/outputDelta` in the app-server protocol as a
deprecated compatibility entry, but the server no longer emits it.

## What changed

- stop the `apply_patch` runtime from emitting `ExecCommandOutputDelta`
events
- simplify `item_event_to_server_notification` so command output deltas
always map to `item/commandExecution/outputDelta`
- remove the app-server bookkeeping that tried to detect whether an
output delta belonged to a file change
- mark `item/fileChange/outputDelta` as a deprecated legacy protocol
entry in the v2 types, schema, and README
- simplify the file-change approval tests so they only wait for
completion instead of expecting output-delta notifications

## Testing

- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-thread-manager-sample`
- `cargo test -p codex-app-server-protocol
protocol::event_mapping::tests::exec_command_output_delta_maps_to_command_execution_output_delta
-- --exact`
- `cargo test -p codex-app-server
turn_start_file_change_approval_accept_for_session_persists_v2 --
--exact` *(failed before the test assertions because the wiremock
`/responses` mock received 0 requests in setup)*
This commit is contained in:
pakrym-oai
2026-04-30 11:42:07 -07:00
committed by GitHub
Unverified
parent f2bc2f26a9
commit b52083146c
13 changed files with 59 additions and 96 deletions
@@ -1032,6 +1032,7 @@
"type": "object"
},
"FileChangeOutputDeltaNotification": {
"description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.",
"properties": {
"delta": {
"type": "string"
@@ -5191,6 +5192,7 @@
"type": "object"
},
{
"description": "Deprecated legacy apply_patch output stream notification.",
"properties": {
"method": {
"enum": [
@@ -4289,6 +4289,7 @@
"type": "object"
},
{
"description": "Deprecated legacy apply_patch output stream notification.",
"properties": {
"method": {
"enum": [
@@ -8600,6 +8601,7 @@
},
"FileChangeOutputDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.",
"properties": {
"delta": {
"type": "string"
@@ -5099,6 +5099,7 @@
},
"FileChangeOutputDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.",
"properties": {
"delta": {
"type": "string"
@@ -11307,6 +11308,7 @@
"type": "object"
},
{
"description": "Deprecated legacy apply_patch output stream notification.",
"properties": {
"method": {
"enum": [
@@ -1,5 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.",
"properties": {
"delta": {
"type": "string"
@@ -2,4 +2,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Deprecated legacy notification for `apply_patch` textual output.
*
* The server no longer emits this notification.
*/
export type FileChangeOutputDeltaNotification = { threadId: string, turnId: string, itemId: string, delta: string, };
@@ -1397,6 +1397,7 @@ server_notification_definitions! {
CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification),
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
/// Deprecated legacy apply_patch output stream notification.
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
FileChangePatchUpdated => "item/fileChange/patchUpdated" (v2::FileChangePatchUpdatedNotification),
ServerRequestResolved => "serverRequest/resolved" (v2::ServerRequestResolvedNotification),
@@ -10,7 +10,6 @@ use crate::protocol::v2::CollabAgentToolCallStatus;
use crate::protocol::v2::CommandExecutionOutputDeltaNotification;
use crate::protocol::v2::DynamicToolCallOutputContentItem;
use crate::protocol::v2::DynamicToolCallStatus;
use crate::protocol::v2::FileChangeOutputDeltaNotification;
use crate::protocol::v2::FileChangePatchUpdatedNotification;
use crate::protocol::v2::ItemCompletedNotification;
use crate::protocol::v2::ItemStartedNotification;
@@ -37,7 +36,6 @@ pub fn item_event_to_server_notification(
msg: EventMsg,
thread_id: &str,
turn_id: &str,
is_file_change_output: bool,
) -> ServerNotification {
let thread_id = thread_id.to_string();
let turn_id = turn_id.to_string();
@@ -477,23 +475,14 @@ pub fn item_event_to_server_notification(
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
let item_id = exec_command_output_delta_event.call_id;
let delta = String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string();
if is_file_change_output {
ServerNotification::FileChangeOutputDelta(FileChangeOutputDeltaNotification {
ServerNotification::CommandExecutionOutputDelta(
CommandExecutionOutputDeltaNotification {
thread_id,
turn_id,
item_id,
delta,
})
} else {
ServerNotification::CommandExecutionOutputDelta(
CommandExecutionOutputDeltaNotification {
thread_id,
turn_id,
item_id,
delta,
},
)
}
},
)
}
EventMsg::TerminalInteraction(terminal_event) => {
ServerNotification::TerminalInteraction(TerminalInteractionNotification {
@@ -522,6 +511,8 @@ mod tests {
use codex_protocol::mcp::CallToolResult;
use codex_protocol::protocol::CollabResumeBeginEvent;
use codex_protocol::protocol::CollabResumeEndEvent;
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecOutputStream;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
@@ -549,6 +540,18 @@ mod tests {
}
}
fn assert_command_execution_output_delta_server_notification(
notification: ServerNotification,
expected: CommandExecutionOutputDeltaNotification,
) {
match notification {
ServerNotification::CommandExecutionOutputDelta(payload) => {
assert_eq!(payload, expected)
}
other => panic!("expected command execution output delta, got {other:?}"),
}
}
#[test]
fn collab_resume_begin_maps_to_item_started_resume_agent() {
let event = CollabResumeBeginEvent {
@@ -563,7 +566,6 @@ mod tests {
EventMsg::CollabResumeBegin(event.clone()),
"thread-1",
"turn-1",
/*is_file_change_output*/ false,
);
assert_item_started_server_notification(
notification,
@@ -601,7 +603,6 @@ mod tests {
EventMsg::CollabResumeEnd(event.clone()),
"thread-2",
"turn-2",
/*is_file_change_output*/ false,
);
assert_item_completed_server_notification(
notification,
@@ -644,7 +645,6 @@ mod tests {
EventMsg::McpToolCallBegin(begin_event.clone()),
"thread-1",
"turn_1",
/*is_file_change_output*/ false,
);
assert_item_started_server_notification(
notification,
@@ -682,7 +682,6 @@ mod tests {
EventMsg::McpToolCallBegin(begin_event.clone()),
"thread-2",
"turn_2",
/*is_file_change_output*/ false,
);
assert_item_started_server_notification(
notification,
@@ -735,7 +734,6 @@ mod tests {
EventMsg::McpToolCallEnd(end_event.clone()),
"thread-3",
"turn_3",
/*is_file_change_output*/ false,
);
assert_item_completed_server_notification(
notification,
@@ -781,7 +779,6 @@ mod tests {
EventMsg::McpToolCallEnd(end_event.clone()),
"thread-4",
"turn_4",
/*is_file_change_output*/ false,
);
assert_item_completed_server_notification(
notification,
@@ -804,4 +801,27 @@ mod tests {
},
);
}
#[test]
fn exec_command_output_delta_maps_to_command_execution_output_delta() {
let notification = item_event_to_server_notification(
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
call_id: "call-1".to_string(),
stream: ExecOutputStream::Stdout,
chunk: b"hello".to_vec(),
}),
"thread-1",
"turn-1",
);
assert_command_execution_output_delta_server_notification(
notification,
CommandExecutionOutputDeltaNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
delta: "hello".to_string(),
},
);
}
}
@@ -6992,6 +6992,9 @@ pub struct CommandExecOutputDeltaNotification {
pub cap_reached: bool,
}
/// Deprecated legacy notification for `apply_patch` textual output.
///
/// The server no longer emits this notification.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1 -1
View File
@@ -1183,7 +1183,7 @@ There are additional item-specific events:
#### fileChange
- `item/fileChange/patchUpdated` - when `features.apply_patch_streaming_events` is enabled, streams structured file-change snapshots parsed from the model-generated patch before it is executed.
- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call.
- `item/fileChange/outputDelta` - deprecated legacy protocol entry for `apply_patch` text output; retained for compatibility but no longer emitted by the server.
### Errors
@@ -887,7 +887,6 @@ pub(crate) async fn apply_bespoke_event_handling(
msg,
&conversation_id.to_string(),
&event_turn_id,
/*is_file_change_output*/ false,
);
outgoing.send_server_notification(notification).await;
}
@@ -905,7 +904,6 @@ pub(crate) async fn apply_bespoke_event_handling(
EventMsg::CollabCloseEnd(end_event),
&conversation_id.to_string(),
&event_turn_id,
/*is_file_change_output*/ false,
);
outgoing.send_server_notification(notification).await;
}
@@ -1041,7 +1039,6 @@ pub(crate) async fn apply_bespoke_event_handling(
msg,
&conversation_id.to_string(),
&event_turn_id,
/*is_file_change_output*/ false,
);
outgoing.send_server_notification(notification).await;
}
@@ -1124,7 +1121,6 @@ pub(crate) async fn apply_bespoke_event_handling(
EventMsg::PatchApplyBegin(patch_begin_event),
&conversation_id.to_string(),
&event_turn_id,
/*is_file_change_output*/ false,
);
outgoing.send_server_notification(notification).await;
}
@@ -1166,28 +1162,15 @@ pub(crate) async fn apply_bespoke_event_handling(
EventMsg::ExecCommandBegin(exec_command_begin_event),
&conversation_id.to_string(),
&event_turn_id,
/*is_file_change_output*/ false,
);
outgoing.send_server_notification(notification).await;
}
}
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
let item_id = exec_command_output_delta_event.call_id.clone();
// The underlying EventMsg::ExecCommandOutputDelta is used for shell, unified_exec,
// and apply_patch tool calls. We represent apply_patch with the FileChange item, and
// everything else with the CommandExecution item.
//
// We need to detect which item type it is so we can emit the right notification.
// We already have state tracking FileChange items on item/started, so let's use that.
let is_file_change = {
let state = thread_state.lock().await;
state.turn_summary.file_change_started.contains(&item_id)
};
let notification = item_event_to_server_notification(
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event),
&conversation_id.to_string(),
&event_turn_id,
is_file_change,
);
outgoing.send_server_notification(notification).await;
}
@@ -1213,7 +1196,6 @@ pub(crate) async fn apply_bespoke_event_handling(
EventMsg::ExecCommandEnd(exec_command_end_event),
&conversation_id.to_string(),
&event_turn_id,
/*is_file_change_output*/ false,
);
outgoing.send_server_notification(notification).await;
}
@@ -24,7 +24,6 @@ use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::FileChangeApprovalDecision;
use codex_app_server_protocol::FileChangeOutputDeltaNotification;
use codex_app_server_protocol::FileChangePatchUpdatedNotification;
use codex_app_server_protocol::FileChangeRequestApprovalResponse;
use codex_app_server_protocol::ItemCompletedNotification;
@@ -2198,9 +2197,8 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
)
.await?;
let mut saw_resolved = false;
let mut output_delta: Option<FileChangeOutputDeltaNotification> = None;
let mut completed_file_change: Option<ThreadItem> = None;
while !(output_delta.is_some() && completed_file_change.is_some()) {
while completed_file_change.is_none() {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
@@ -2217,16 +2215,6 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
assert_eq!(resolved.request_id, resolved_request_id);
saw_resolved = true;
}
"item/fileChange/outputDelta" => {
assert!(saw_resolved, "serverRequest/resolved should arrive first");
let notification: FileChangeOutputDeltaNotification = serde_json::from_value(
notification
.params
.clone()
.expect("item/fileChange/outputDelta params"),
)?;
output_delta = Some(notification);
}
"item/completed" => {
let completed: ItemCompletedNotification = serde_json::from_value(
notification.params.clone().expect("item/completed params"),
@@ -2239,16 +2227,6 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
_ => {}
}
}
let output_delta = output_delta.expect("file change output delta should be observed");
assert_eq!(output_delta.thread_id, thread.id);
assert_eq!(output_delta.turn_id, turn.id);
assert_eq!(output_delta.item_id, "patch-call");
assert!(
!output_delta.delta.is_empty(),
"expected delta to be non-empty, got: {}",
output_delta.delta
);
let completed_file_change =
completed_file_change.expect("file change completion should be observed");
let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else {
@@ -3000,11 +2978,6 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
)
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/fileChange/outputDelta"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/completed"),
@@ -3058,11 +3031,6 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
// If the server incorrectly emits FileChangeRequestApproval, the helper below will error
// (it bails on unexpected JSONRPCMessage::Request), causing the test to fail.
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/fileChange/outputDelta"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("item/completed"),
@@ -25,10 +25,6 @@ use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::exec_output::StreamOutput;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecOutputStream;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxType;
@@ -87,22 +83,6 @@ impl ApplyPatchRuntime {
use_legacy_landlock: attempt.use_legacy_landlock,
})
}
async fn emit_output_delta(ctx: &ToolCtx, stream: ExecOutputStream, chunk: &[u8]) {
if chunk.is_empty() {
return;
}
let event = Event {
id: ctx.turn.sub_id.clone(),
msg: EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
call_id: ctx.call_id.clone(),
stream,
chunk: chunk.to_vec(),
}),
};
let _ = ctx.session.get_tx_event().send(event).await;
}
}
impl Sandboxable for ApplyPatchRuntime {
@@ -230,8 +210,6 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
.await;
let stdout = String::from_utf8_lossy(&stdout).into_owned();
let stderr = String::from_utf8_lossy(&stderr).into_owned();
Self::emit_output_delta(ctx, ExecOutputStream::Stdout, stdout.as_bytes()).await;
Self::emit_output_delta(ctx, ExecOutputStream::Stderr, stderr.as_bytes()).await;
let exit_code = if result.is_ok() { 0 } else { 1 };
let output = ExecToolCallOutput {
exit_code,
@@ -320,7 +320,6 @@ async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyh
current_turn_id
.as_deref()
.context("mapped notification arrived before turn started")?,
/*is_file_change_output*/ false,
)),
_ => None,
};