From 5b0322599ef51c5cf6dd1358329f8a4732ef80fc Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Fri, 27 Feb 2026 12:14:18 -0800 Subject: [PATCH] tui: ignore mirrored live agent messages --- codex-rs/tui/src/chatwidget.rs | 7 +-- codex-rs/tui/src/chatwidget/tests.rs | 74 ++++++++++++++-------------- 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7f07d93df7..db1c84c469 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -85,6 +85,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::Settings; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::TurnItem; use codex_protocol::models::MessagePhase; @@ -2358,6 +2359,8 @@ impl ChatWidget { if self.stream_controller.is_some() { self.flush_answer_stream_with_separator(); self.handle_stream_finished(); + } else if let [AgentMessageContent::Text { text }] = item.content.as_slice() { + self.on_agent_message(text.clone()); } self.pending_status_indicator_restore = match item.phase { // Models that don't support preambles only output AgentMessageItems on turn completion. @@ -4417,9 +4420,7 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), - EventMsg::AgentMessage(AgentMessageEvent { message, .. }) - if from_replay || self.stream_controller.is_none() => - { + EventMsg::AgentMessage(AgentMessageEvent { message, .. }) if from_replay => { self.on_agent_message(message) } EventMsg::AgentMessage(AgentMessageEvent { .. }) => {} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3aabaff4b4..9a266d74f4 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3644,6 +3644,31 @@ async fn raw_response_item_does_not_duplicate_locally_rendered_user_message() { assert!(drain_insert_history(&mut rx).is_empty()); } +#[tokio::test] +async fn live_legacy_agent_message_after_item_completed_does_not_duplicate_assistant_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + complete_assistant_message( + &mut chat, + "msg-live", + "hello", + Some(MessagePhase::FinalAnswer), + ); + let inserted = drain_insert_history(&mut rx); + assert_eq!(inserted.len(), 1); + assert!(lines_to_single_string(&inserted[0]).contains("hello")); + + chat.handle_codex_event(Event { + id: "legacy-live".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "hello".into(), + phase: Some(MessagePhase::FinalAnswer), + }), + }); + + assert!(drain_insert_history(&mut rx).is_empty()); +} + #[tokio::test] async fn raw_response_item_only_pops_front_pending_nudge() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -4210,13 +4235,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() { begin_unified_exec_startup(&mut chat, "call-wait", "proc-1", "cargo test -p codex-core"); terminal_interaction(&mut chat, "call-wait-stdin", "proc-1", ""); - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Final response.".into(), - phase: None, - }), - }); + complete_assistant_message(&mut chat, "msg-1", "Final response.", None); chat.handle_codex_event(Event { id: "turn-1".into(), msg: EventMsg::TurnComplete(TurnCompleteEvent { @@ -5016,7 +5035,7 @@ async fn slash_copy_state_clears_on_thread_rollback() { async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_turn_complete() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { + chat.handle_codex_event_replay(Event { id: "turn-1".into(), msg: EventMsg::AgentMessage(AgentMessageEvent { message: "Legacy final message".into(), @@ -8551,22 +8570,10 @@ async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { }); // First finalized assistant message - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "First message".into(), - phase: None, - }), - }); + complete_assistant_message(&mut chat, "msg-first", "First message", None); // Second finalized assistant message in the same turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Second message".into(), - phase: None, - }), - }); + complete_assistant_message(&mut chat, "msg-second", "Second message", None); // End turn chat.handle_codex_event(Event { @@ -8606,13 +8613,7 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { text: "I will first analyze the request.".into(), }), }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Here is the result.".into(), - phase: None, - }), - }); + complete_assistant_message(&mut chat, "msg-result", "Here is the result.", None); // Drain history and snapshot the combined visible content. let cells = drain_insert_history(&mut rx); @@ -8629,15 +8630,12 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() { #[tokio::test] async fn chatwidget_exec_and_status_layout_vt100_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: - "I’m going to search the repo for where “Change Approved” is rendered to update that view." - .into(), - phase: None, - }), - }); + complete_assistant_message( + &mut chat, + "msg-search", + "I’m going to search the repo for where “Change Approved” is rendered to update that view.", + None, + ); let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()]; let parsed_cmd = vec![