# PR #1661: [client] Add item ids for deltas and messages - URL: https://github.com/openai/codex/pull/1661 - Author: dylan-hurd-oai - Created: 2025-07-23 20:25:15 UTC - Updated: 2025-08-19 22:05:25 UTC - Changes: +61/-27, Files changed: 10, Commits: 1 ## Description ## Summary Includes `id` in Message and Reasoning from `response.output_item.done` streaming events, and `item_id` in .delta events, so mcp clients can match deltas to their eventual final event. Open Question: I think we re-use the same `ResponseItem::Message` struct for API input and output, which leads to some unnecessary `id: Option` fields. Should we separate these? @bolinfest ## Test Plan - [x] Tested locally with mcp client ## Full Diff ```diff diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 35045c8e1b..54f9841c3f 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -41,7 +41,12 @@ pub(crate) async fn stream_chat_completions( for item in &prompt.input { match item { - ResponseItem::Message { role, content } => { + ResponseItem::Message { + // id will always be None for input items + id: _, + role, + content, + } => { let mut text = String::new(); for c in content { match c { @@ -255,6 +260,7 @@ async fn process_chat_sse( .and_then(|c| c.as_str()) { let item = ResponseItem::Message { + id: None, role: "assistant".to_string(), content: vec![ContentItem::OutputText { text: content.to_string(), @@ -402,6 +408,7 @@ where }))) => { if !this.cumulative.is_empty() { let aggregated_item = crate::models::ResponseItem::Message { + id: None, role: "assistant".to_string(), content: vec![crate::models::ContentItem::OutputText { text: std::mem::take(&mut this.cumulative), @@ -430,8 +437,8 @@ where // will never appear in a Chat Completions stream. continue; } - Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(_)))) - | Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => { + Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta { .. }))) + | Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta { .. }))) => { // Deltas are ignored here since aggregation waits for the // final OutputItemDone. continue; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index beeaa453ad..d2c968e827 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -230,6 +230,7 @@ struct SseEvent { response: Option, item: Option, delta: Option, + item_id: Option, } #[derive(Debug, Deserialize)] @@ -360,21 +361,22 @@ async fn process_sse( }; let event = ResponseEvent::OutputItemDone(item); + trace!(?event, "output_item.done"); if tx_event.send(Ok(event)).await.is_err() { return; } } "response.output_text.delta" => { - if let Some(delta) = event.delta { - let event = ResponseEvent::OutputTextDelta(delta); + if let (Some(delta), Some(item_id)) = (event.delta, event.item_id) { + let event = ResponseEvent::OutputTextDelta { delta, item_id }; if tx_event.send(Ok(event)).await.is_err() { return; } } } "response.reasoning_summary_text.delta" => { - if let Some(delta) = event.delta { - let event = ResponseEvent::ReasoningSummaryDelta(delta); + if let (Some(delta), Some(item_id)) = (event.delta, event.item_id) { + let event = ResponseEvent::ReasoningSummaryDelta { delta, item_id }; if tx_event.send(Ok(event)).await.is_err() { return; } diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 94d09e7fd4..d16ceb3dd6 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -56,6 +56,8 @@ impl Prompt { } } +/// Events emitted by the response stream. +/// https://platform.openai.com/docs/api-reference/responses-streaming/response #[derive(Debug)] pub enum ResponseEvent { Created, @@ -64,8 +66,14 @@ pub enum ResponseEvent { response_id: String, token_usage: Option, }, - OutputTextDelta(String), - ReasoningSummaryDelta(String), + OutputTextDelta { + delta: String, + item_id: String, + }, + ReasoningSummaryDelta { + delta: String, + item_id: String, + }, } #[derive(Debug, Serialize)] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6eb1715fbf..eb3b0852db 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1248,17 +1248,17 @@ async fn try_run_turn( state.previous_response_id = Some(response_id); return Ok(output); } - ResponseEvent::OutputTextDelta(delta) => { + ResponseEvent::OutputTextDelta { delta, item_id } => { let event = Event { id: sub_id.to_string(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta, item_id }), }; sess.tx_event.send(event).await.ok(); } - ResponseEvent::ReasoningSummaryDelta(delta) => { + ResponseEvent::ReasoningSummaryDelta { delta, item_id } => { let event = Event { id: sub_id.to_string(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta, item_id }), }; sess.tx_event.send(event).await.ok(); } @@ -1273,26 +1273,32 @@ async fn handle_response_item( ) -> CodexResult> { debug!(?item, "Output item"); let output = match item { - ResponseItem::Message { content, .. } => { + ResponseItem::Message { content, id, .. } => { for item in content { if let ContentItem::OutputText { text } = item { let event = Event { id: sub_id.to_string(), - msg: EventMsg::AgentMessage(AgentMessageEvent { message: text }), + msg: EventMsg::AgentMessage(AgentMessageEvent { + id: id.clone(), + message: text, + }), }; sess.tx_event.send(event).await.ok(); } } None } - ResponseItem::Reasoning { id: _, summary } => { + ResponseItem::Reasoning { id, summary } => { for item in summary { let text = match item { ReasoningItemReasoningSummary::SummaryText { text } => text, }; let event = Event { id: sub_id.to_string(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { text }), + msg: EventMsg::AgentReasoning(AgentReasoningEvent { + id: id.clone(), + text, + }), }; sess.tx_event.send(event).await.ok(); } @@ -2092,7 +2098,7 @@ fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> Strin fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option { responses.iter().rev().find_map(|item| { - if let ResponseItem::Message { role, content } = item { + if let ResponseItem::Message { role, content, .. } = item { if role == "assistant" { content.iter().rev().find_map(|ci| { if let ContentItem::OutputText { text } = ci { diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs index 6b392fb19d..706a0ae1c3 100644 --- a/codex-rs/core/src/models.rs +++ b/codex-rs/core/src/models.rs @@ -37,6 +37,7 @@ pub enum ContentItem { #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { + id: Option, role: String, content: Vec, }, @@ -78,7 +79,11 @@ pub enum ResponseItem { impl From for ResponseItem { fn from(item: ResponseInputItem) -> Self { match item { - ResponseInputItem::Message { role, content } => Self::Message { role, content }, + ResponseInputItem::Message { role, content } => Self::Message { + id: None, + role, + content, + }, ResponseInputItem::FunctionCallOutput { call_id, output } => { Self::FunctionCallOutput { call_id, output } } diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 9f6e004b67..294488b29f 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -351,22 +351,26 @@ pub struct TokenUsage { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentMessageEvent { + pub id: Option, pub message: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentMessageDeltaEvent { pub delta: String, + pub item_id: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentReasoningEvent { + pub id: String, pub text: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentReasoningDeltaEvent { pub delta: String, + pub item_id: String, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs index 0be6110571..b9b0563641 100644 --- a/codex-rs/core/tests/live_agent.rs +++ b/codex-rs/core/tests/live_agent.rs @@ -120,7 +120,7 @@ async fn live_streaming_and_prev_id_reset() { .expect("agent closed"); match &ev.msg { - EventMsg::AgentMessage(AgentMessageEvent { message }) + EventMsg::AgentMessage(AgentMessageEvent { id: _, message }) if message.contains("second turn succeeded") => { got_expected = true; diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 7b39071116..7721d295f5 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -174,7 +174,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => { ts_println!(self, "tokens used: {total_tokens}"); } - EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { + EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta, item_id: _ }) => { if !self.answer_started { ts_println!(self, "{}\n", "codex".style(self.italic).style(self.magenta)); self.answer_started = true; @@ -183,7 +183,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { #[allow(clippy::expect_used)] std::io::stdout().flush().expect("could not flush stdout"); } - EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { + EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta, item_id: _ }) => { if !self.show_agent_reasoning { return; } @@ -199,7 +199,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { #[allow(clippy::expect_used)] std::io::stdout().flush().expect("could not flush stdout"); } - EventMsg::AgentMessage(AgentMessageEvent { message }) => { + EventMsg::AgentMessage(AgentMessageEvent { id: _, message }) => { // if answer_started is false, this means we haven't received any // delta. Thus, we need to print the message as a new answer. if !self.answer_started { diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index a1eea65f25..08f6a9dc51 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -16,6 +16,7 @@ use serde::Serialize; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; +use tracing::trace; use tracing::warn; pub(crate) struct OutgoingMessageSender { @@ -79,6 +80,7 @@ impl OutgoingMessageSender { } pub(crate) async fn send_event_as_notification(&self, event: &Event) { + trace!(?event, "sending event as notification"); #[expect(clippy::expect_used)] let params = Some(serde_json::to_value(event).expect("Event must serialize")); let outgoing_message = OutgoingMessage::Notification(OutgoingNotification { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c70c6f6d72..7182ed7942 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -246,7 +246,7 @@ impl ChatWidget<'_> { self.request_redraw(); } - EventMsg::AgentMessage(AgentMessageEvent { message }) => { + EventMsg::AgentMessage(AgentMessageEvent { id: _, message }) => { // if the answer buffer is empty, this means we haven't received any // delta. Thus, we need to print the message as a new answer. if self.answer_buffer.is_empty() { @@ -259,7 +259,7 @@ impl ChatWidget<'_> { self.answer_buffer.clear(); self.request_redraw(); } - EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { + EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta, .. }) => { if self.answer_buffer.is_empty() { self.conversation_history .add_agent_message(&self.config, "".to_string()); @@ -269,7 +269,7 @@ impl ChatWidget<'_> { .replace_prev_agent_message(&self.config, self.answer_buffer.clone()); self.request_redraw(); } - EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { + EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta, .. }) => { if self.reasoning_buffer.is_empty() { self.conversation_history .add_agent_reasoning(&self.config, "".to_string()); @@ -279,7 +279,7 @@ impl ChatWidget<'_> { .replace_prev_agent_reasoning(&self.config, self.reasoning_buffer.clone()); self.request_redraw(); } - EventMsg::AgentReasoning(AgentReasoningEvent { text }) => { + EventMsg::AgentReasoning(AgentReasoningEvent { id: _, text }) => { // if the reasoning buffer is empty, this means we haven't received any // delta. Thus, we need to print the message as a new reasoning. if self.reasoning_buffer.is_empty() { ``` ## Review Comments ### codex-rs/core/src/codex.rs - Created: 2025-07-23 21:06:21 UTC | Link: https://github.com/openai/codex/pull/1661#discussion_r2226689096 ```diff @@ -1273,26 +1273,32 @@ async fn handle_response_item( ) -> CodexResult> { debug!(?item, "Output item"); let output = match item { - ResponseItem::Message { content, .. } => { + ResponseItem::Message { content, id, .. } => { for item in content { if let ContentItem::OutputText { text } = item { let event = Event { id: sub_id.to_string(), - msg: EventMsg::AgentMessage(AgentMessageEvent { message: text }), + msg: EventMsg::AgentMessage(AgentMessageEvent { + id: id.clone(), + message: text, + }), }; sess.tx_event.send(event).await.ok(); } } None } - ResponseItem::Reasoning { id: _, summary } => { + ResponseItem::Reasoning { id, summary } => { for item in summary { let text = match item { ReasoningItemReasoningSummary::SummaryText { text } => text, }; let event = Event { id: sub_id.to_string(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { text }), + msg: EventMsg::AgentReasoning(AgentReasoningEvent { ``` > Why can't you use the `id` on `Event`?