@@ -23,6 +23,7 @@ use crate::protocol::v2::UserInput;
2323use crate :: protocol:: v2:: WebSearchAction ;
2424use codex_protocol:: items:: parse_hook_prompt_message;
2525use codex_protocol:: models:: MessagePhase ;
26+ use codex_protocol:: protocol:: AgentMessageContentDeltaEvent ;
2627use codex_protocol:: protocol:: AgentReasoningEvent ;
2728use codex_protocol:: protocol:: AgentReasoningRawContentEvent ;
2829use codex_protocol:: protocol:: AgentStatus ;
@@ -55,6 +56,7 @@ use codex_protocol::protocol::ViewImageToolCallEvent;
5556use codex_protocol:: protocol:: WebSearchBeginEvent ;
5657use codex_protocol:: protocol:: WebSearchEndEvent ;
5758use std:: collections:: HashMap ;
59+ use std:: collections:: HashSet ;
5860use tracing:: warn;
5961use uuid:: Uuid ;
6062
@@ -97,6 +99,7 @@ pub struct ThreadHistoryBuilder {
9799 next_item_index : i64 ,
98100 current_rollout_index : usize ,
99101 next_rollout_index : usize ,
102+ delta_agent_message_item_ids : HashSet < String > ,
100103}
101104
102105impl Default for ThreadHistoryBuilder {
@@ -113,6 +116,7 @@ impl ThreadHistoryBuilder {
113116 next_item_index : 1 ,
114117 current_rollout_index : 0 ,
115118 next_rollout_index : 0 ,
119+ delta_agent_message_item_ids : HashSet :: new ( ) ,
116120 }
117121 }
118122
@@ -171,6 +175,9 @@ impl ThreadHistoryBuilder {
171175 pub fn handle_event ( & mut self , event : & EventMsg ) {
172176 match event {
173177 EventMsg :: UserMessage ( payload) => self . handle_user_message ( payload) ,
178+ EventMsg :: AgentMessageContentDelta ( payload) => {
179+ self . handle_agent_message_content_delta ( payload)
180+ }
174181 EventMsg :: AgentMessage ( payload) => self . handle_agent_message (
175182 payload. message . clone ( ) ,
176183 payload. phase . clone ( ) ,
@@ -300,6 +307,28 @@ impl ThreadHistoryBuilder {
300307 return ;
301308 }
302309
310+ let delta_backed_last_id = self . ensure_turn ( ) . items . last ( ) . and_then ( |item| {
311+ if matches ! ( item, ThreadItem :: AgentMessage { .. } ) {
312+ Some ( item. id ( ) . to_string ( ) )
313+ } else {
314+ None
315+ }
316+ } ) ;
317+ if let Some ( id) = delta_backed_last_id
318+ && self . delta_agent_message_item_ids . remove ( & id)
319+ && let Some ( ThreadItem :: AgentMessage {
320+ text : existing_text,
321+ phase : existing_phase,
322+ memory_citation : existing_memory_citation,
323+ ..
324+ } ) = self . ensure_turn ( ) . items . last_mut ( )
325+ {
326+ * existing_text = text;
327+ * existing_phase = phase;
328+ * existing_memory_citation = memory_citation;
329+ return ;
330+ }
331+
303332 let id = self . next_item_id ( ) ;
304333 self . ensure_turn ( ) . items . push ( ThreadItem :: AgentMessage {
305334 id,
@@ -309,6 +338,52 @@ impl ThreadHistoryBuilder {
309338 } ) ;
310339 }
311340
341+ fn handle_agent_message_content_delta ( & mut self , payload : & AgentMessageContentDeltaEvent ) {
342+ if payload. delta . is_empty ( ) {
343+ return ;
344+ }
345+
346+ self . delta_agent_message_item_ids
347+ . insert ( payload. item_id . clone ( ) ) ;
348+
349+ let append_delta = |items : & mut Vec < ThreadItem > | {
350+ if let Some ( ThreadItem :: AgentMessage { text, .. } ) =
351+ items. iter_mut ( ) . find ( |item| item. id ( ) == payload. item_id )
352+ {
353+ text. push_str ( & payload. delta ) ;
354+ return ;
355+ }
356+
357+ items. push ( ThreadItem :: AgentMessage {
358+ id : payload. item_id . clone ( ) ,
359+ text : payload. delta . clone ( ) ,
360+ phase : None ,
361+ memory_citation : None ,
362+ } ) ;
363+ } ;
364+
365+ if let Some ( turn) = self . current_turn . as_mut ( )
366+ && turn. id == payload. turn_id
367+ {
368+ append_delta ( & mut turn. items ) ;
369+ return ;
370+ }
371+
372+ if let Some ( turn) = self
373+ . turns
374+ . iter_mut ( )
375+ . find ( |turn| turn. id == payload. turn_id )
376+ {
377+ append_delta ( & mut turn. items ) ;
378+ return ;
379+ }
380+
381+ warn ! (
382+ item_id = payload. item_id,
383+ "dropping agent message delta for unknown turn id `{}`" , payload. turn_id
384+ ) ;
385+ }
386+
312387 fn handle_agent_reasoning ( & mut self , payload : & AgentReasoningEvent ) {
313388 if payload. text . is_empty ( ) {
314389 return ;
@@ -1254,6 +1329,7 @@ mod tests {
12541329 use codex_protocol:: models:: MessagePhase as CoreMessagePhase ;
12551330 use codex_protocol:: models:: WebSearchAction as CoreWebSearchAction ;
12561331 use codex_protocol:: parse_command:: ParsedCommand ;
1332+ use codex_protocol:: protocol:: AgentMessageContentDeltaEvent ;
12571333 use codex_protocol:: protocol:: AgentMessageEvent ;
12581334 use codex_protocol:: protocol:: AgentReasoningEvent ;
12591335 use codex_protocol:: protocol:: AgentReasoningRawContentEvent ;
@@ -1401,7 +1477,7 @@ mod tests {
14011477 local_images: Vec :: new( ) ,
14021478 } ) ,
14031479 EventMsg :: ItemStarted ( ItemStartedEvent {
1404- thread_id,
1480+ thread_id: thread_id . clone ( ) ,
14051481 turn_id: turn_id. to_string( ) ,
14061482 item: CoreTurnItem :: UserMessage ( CoreUserMessageItem {
14071483 id: "user-item-id" . to_string( ) ,
@@ -1462,6 +1538,86 @@ mod tests {
14621538 ) ;
14631539 }
14641540
1541+ #[ test]
1542+ fn active_turn_snapshot_accumulates_agent_message_deltas ( ) {
1543+ let thread_id = ThreadId :: new ( ) . to_string ( ) ;
1544+ let mut builder = ThreadHistoryBuilder :: new ( ) ;
1545+
1546+ builder. handle_event ( & EventMsg :: TurnStarted ( TurnStartedEvent {
1547+ turn_id : "turn-1" . to_string ( ) ,
1548+ started_at : None ,
1549+ model_context_window : None ,
1550+ collaboration_mode_kind : Default :: default ( ) ,
1551+ } ) ) ;
1552+ builder. handle_event ( & EventMsg :: AgentMessageContentDelta (
1553+ AgentMessageContentDeltaEvent {
1554+ thread_id : thread_id. clone ( ) ,
1555+ turn_id : "turn-1" . to_string ( ) ,
1556+ item_id : "msg-1" . to_string ( ) ,
1557+ delta : "partial " . to_string ( ) ,
1558+ } ,
1559+ ) ) ;
1560+ builder. handle_event ( & EventMsg :: AgentMessageContentDelta (
1561+ AgentMessageContentDeltaEvent {
1562+ thread_id : thread_id. clone ( ) ,
1563+ turn_id : "turn-1" . to_string ( ) ,
1564+ item_id : "msg-1" . to_string ( ) ,
1565+ delta : "reply" . to_string ( ) ,
1566+ } ,
1567+ ) ) ;
1568+
1569+ let turn = builder
1570+ . active_turn_snapshot ( )
1571+ . expect ( "active turn snapshot" ) ;
1572+ assert_eq ! (
1573+ turn. items,
1574+ vec![ ThreadItem :: AgentMessage {
1575+ id: "msg-1" . to_string( ) ,
1576+ text: "partial reply" . to_string( ) ,
1577+ phase: None ,
1578+ memory_citation: None ,
1579+ } ]
1580+ ) ;
1581+ }
1582+
1583+ #[ test]
1584+ fn final_agent_message_replaces_delta_backed_message ( ) {
1585+ let thread_id = ThreadId :: new ( ) . to_string ( ) ;
1586+ let mut builder = ThreadHistoryBuilder :: new ( ) ;
1587+
1588+ builder. handle_event ( & EventMsg :: TurnStarted ( TurnStartedEvent {
1589+ turn_id : "turn-1" . to_string ( ) ,
1590+ started_at : None ,
1591+ model_context_window : None ,
1592+ collaboration_mode_kind : Default :: default ( ) ,
1593+ } ) ) ;
1594+ builder. handle_event ( & EventMsg :: AgentMessageContentDelta (
1595+ AgentMessageContentDeltaEvent {
1596+ thread_id : thread_id. clone ( ) ,
1597+ turn_id : "turn-1" . to_string ( ) ,
1598+ item_id : "msg-1" . to_string ( ) ,
1599+ delta : "streamed prefix" . to_string ( ) ,
1600+ } ,
1601+ ) ) ;
1602+ builder. handle_event ( & EventMsg :: AgentMessage ( AgentMessageEvent {
1603+ message : "Final reply" . to_string ( ) ,
1604+ phase : Some ( CoreMessagePhase :: FinalAnswer ) ,
1605+ memory_citation : None ,
1606+ } ) ) ;
1607+
1608+ let turns = builder. finish ( ) ;
1609+ assert_eq ! ( turns. len( ) , 1 ) ;
1610+ assert_eq ! (
1611+ turns[ 0 ] . items,
1612+ vec![ ThreadItem :: AgentMessage {
1613+ id: "msg-1" . to_string( ) ,
1614+ text: "Final reply" . to_string( ) ,
1615+ phase: Some ( MessagePhase :: FinalAnswer ) ,
1616+ memory_citation: None ,
1617+ } ]
1618+ ) ;
1619+ }
1620+
14651621 #[ test]
14661622 fn replays_image_generation_end_events_into_turn_history ( ) {
14671623 let items = vec ! [
0 commit comments