diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs
index 947e040796..16a583e918 100644
--- a/codex-rs/tui/src/app/tests.rs
+++ b/codex-rs/tui/src/app/tests.rs
@@ -44,6 +44,8 @@ mod plugin_catalog;
mod rate_limits;
#[path = "tests/realtime_handoff_e2e.rs"]
mod realtime_handoff_e2e;
+#[path = "tests/realtime_requests.rs"]
+mod realtime_requests;
#[path = "tests/realtime_start.rs"]
mod realtime_start;
#[path = "tests/recap_generation_tests.rs"]
diff --git a/codex-rs/tui/src/app/tests/buffered_replay.rs b/codex-rs/tui/src/app/tests/buffered_replay.rs
index 31930bb752..8ed490e569 100644
--- a/codex-rs/tui/src/app/tests/buffered_replay.rs
+++ b/codex-rs/tui/src/app/tests/buffered_replay.rs
@@ -71,6 +71,360 @@ fn buffered_replay_keeps_unfinished_and_unrelated_deltas_in_order() {
assert_eq!(store.snapshot().events.len(), 6);
}
+#[test]
+fn evicted_voice_delegation_marker_still_suppresses_private_replay() {
+ let mut store = ThreadEventStore::new(/*capacity*/ 2);
+ let voice_request = ThreadItem::UserMessage {
+ id: "user".into(),
+ client_id: None,
+ content: vec![codex_app_server_protocol::UserInput::Text {
+ text: "question".into(),
+ text_elements: Vec::new(),
+ }],
+ };
+ store.push_notification(ServerNotification::ItemStarted(
+ codex_app_server_protocol::ItemStartedNotification {
+ thread_id: "thread".into(),
+ turn_id: "voice-turn".into(),
+ started_at_ms: 0,
+ item: voice_request.clone(),
+ },
+ ));
+ store.push_notification(delta("thread", "voice-turn", "reasoning"));
+ let private = ServerNotification::ItemCompleted(ItemCompletedNotification {
+ thread_id: "thread".into(),
+ turn_id: "voice-turn".into(),
+ completed_at_ms: 0,
+ item: ThreadItem::AgentMessage {
+ id: "reasoning".into(),
+ text: "[ANALYSIS] private".into(),
+ phase: None,
+ memory_citation: None,
+ delivery: None,
+ questions: None,
+ },
+ });
+ store.push_notification(private.clone());
+ assert!(store.snapshot().events.is_empty());
+ store.push_notification(delta("thread", "voice-turn", "unfinished"));
+ assert!(store.snapshot().events.is_empty());
+ assert_eq!(store.snapshot().delegated_turns, vec!["voice-turn"]);
+ for notification in [
+ ServerNotification::ReasoningSummaryPartAdded(
+ codex_app_server_protocol::ReasoningSummaryPartAddedNotification {
+ thread_id: "thread".into(),
+ turn_id: "voice-turn".into(),
+ item_id: "reasoning".into(),
+ summary_index: 0,
+ },
+ ),
+ ServerNotification::ReasoningSummaryTextDelta(
+ codex_app_server_protocol::ReasoningSummaryTextDeltaNotification {
+ thread_id: "thread".into(),
+ turn_id: "voice-turn".into(),
+ item_id: "reasoning".into(),
+ delta: "private summary".into(),
+ summary_index: 0,
+ },
+ ),
+ ServerNotification::ReasoningTextDelta(
+ codex_app_server_protocol::ReasoningTextDeltaNotification {
+ thread_id: "thread".into(),
+ turn_id: "voice-turn".into(),
+ item_id: "reasoning".into(),
+ delta: "private raw reasoning".into(),
+ content_index: 0,
+ },
+ ),
+ ServerNotification::ItemCompleted(ItemCompletedNotification {
+ thread_id: "thread".into(),
+ turn_id: "voice-turn".into(),
+ completed_at_ms: 0,
+ item: ThreadItem::Reasoning {
+ id: "reasoning".into(),
+ summary: vec!["private summary".into()],
+ content: vec!["private raw reasoning".into()],
+ },
+ }),
+ ] {
+ store.push_notification(notification);
+ assert!(store.snapshot().events.is_empty());
+ }
+
+ let mut resumed = ThreadEventStore::new(/*capacity*/ 1);
+ resumed.set_turns(vec![Turn {
+ id: "voice-turn".into(),
+ items: vec![voice_request],
+ items_view: codex_app_server_protocol::TurnItemsView::Summary,
+ status: TurnStatus::InProgress,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ }]);
+ resumed.push_notification(delta("thread", "voice-turn", "unfinished"));
+ let mut newer = resumed.turns[0].clone();
+ newer.id = "newer-turn".into();
+ resumed.set_turns(vec![resumed.turns[0].clone(), newer]);
+ assert!(resumed.snapshot().events.is_empty());
+ resumed.push_notification(delta("thread", "newer-turn", "unfinished"));
+ assert!(resumed.snapshot().events.is_empty());
+ let mut ordinary = ThreadEventStore::new(/*capacity*/ 2);
+ let ServerNotification::ItemCompleted(mut normal) = private else {
+ unreachable!()
+ };
+ normal.turn_id = "normal-turn".into();
+ ordinary.push_notification(ServerNotification::ItemCompleted(normal));
+ assert_eq!(ordinary.snapshot().events.len(), 1);
+}
+
+#[test]
+fn saved_voice_turn_suppresses_reasoning_without_hiding_typed_reasoning() {
+ let reasoning = ThreadItem::Reasoning {
+ id: "reasoning".into(),
+ summary: vec!["private summary".into()],
+ content: vec!["private raw reasoning".into()],
+ };
+ let voice_request = ThreadItem::UserMessage {
+ id: "voice-request".into(),
+ client_id: None,
+ content: vec![codex_app_server_protocol::UserInput::Text {
+ text: "question".into(),
+ text_elements: Vec::new(),
+ }],
+ };
+ let turn = |id: &str, items| Turn {
+ id: id.into(),
+ items,
+ items_view: codex_app_server_protocol::TurnItemsView::Full,
+ status: TurnStatus::Completed,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ };
+ let mut store = ThreadEventStore::new(/*capacity*/ 4);
+ store.set_turns(vec![
+ turn("voice", vec![voice_request.clone(), reasoning.clone()]),
+ turn("typed", vec![reasoning.clone()]),
+ ]);
+ assert_eq!(
+ store.snapshot().turns,
+ vec![
+ turn("voice", vec![voice_request]),
+ turn("typed", vec![reasoning])
+ ]
+ );
+}
+
+#[test]
+fn snapshot_keeps_typed_output_before_voice_handoff_in_the_same_turn() {
+ let commentary = |id: &str, text: &str| ThreadItem::AgentMessage {
+ id: id.into(),
+ text: text.into(),
+ phase: Some(codex_protocol::models::MessagePhase::Commentary),
+ questions: None,
+ memory_citation: None,
+ delivery: None,
+ };
+ let marker = ThreadItem::UserMessage {
+ id: "voice".into(),
+ client_id: None,
+ content: vec![codex_app_server_protocol::UserInput::Text {
+ text: "question".into(),
+ text_elements: Vec::new(),
+ }],
+ };
+ let typed = commentary("typed", "Earlier typed commentary");
+ let private = commentary("private", "Later voice-private commentary");
+ let mut store = ThreadEventStore::new(/*capacity*/ 8);
+ store.set_turns(vec![Turn {
+ id: "shared".into(),
+ items: vec![typed.clone(), marker.clone(), private.clone()],
+ items_view: codex_app_server_protocol::TurnItemsView::Full,
+ status: TurnStatus::InProgress,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ }]);
+ for item in [typed.clone(), marker.clone(), private] {
+ store.push_notification(ServerNotification::ItemCompleted(
+ ItemCompletedNotification {
+ thread_id: "thread".into(),
+ turn_id: "shared".into(),
+ completed_at_ms: 0,
+ item,
+ },
+ ));
+ }
+ let snapshot = store.snapshot();
+ assert_eq!(snapshot.turns[0].items, vec![typed.clone(), marker]);
+ let completed = snapshot
+ .events
+ .into_iter()
+ .filter_map(|event| match event {
+ ThreadBufferedEvent::Notification(notification) => match *notification {
+ ServerNotification::ItemCompleted(item) => Some(item.item),
+ _ => None,
+ },
+ _ => None,
+ })
+ .collect::>();
+ assert_eq!(completed, vec![typed, snapshot.turns[0].items[1].clone()]);
+}
+
+#[test]
+fn snapshot_keeps_typed_item_completed_after_voice_handoff() {
+ let typed = ThreadItem::AgentMessage {
+ id: "typed".into(),
+ text: "Typed commentary completed late".into(),
+ phase: Some(codex_protocol::models::MessagePhase::Commentary),
+ questions: None,
+ memory_citation: None,
+ delivery: None,
+ };
+ let private = ThreadItem::AgentMessage {
+ id: "private".into(),
+ text: "Voice-private commentary".into(),
+ phase: Some(codex_protocol::models::MessagePhase::Commentary),
+ questions: None,
+ memory_citation: None,
+ delivery: None,
+ };
+ let marker = ThreadItem::UserMessage {
+ id: "voice".into(),
+ client_id: None,
+ content: vec![codex_app_server_protocol::UserInput::Text {
+ text: "question".into(),
+ text_elements: Vec::new(),
+ }],
+ };
+ let mut store = ThreadEventStore::new(/*capacity*/ 8);
+ for item in [typed.clone(), marker, private.clone()] {
+ store.push_notification(ServerNotification::ItemStarted(
+ codex_app_server_protocol::ItemStartedNotification {
+ thread_id: "thread".into(),
+ turn_id: "shared".into(),
+ started_at_ms: 0,
+ item,
+ },
+ ));
+ }
+ for item in [typed.clone(), private] {
+ store.push_notification(ServerNotification::ItemCompleted(
+ ItemCompletedNotification {
+ thread_id: "thread".into(),
+ turn_id: "shared".into(),
+ completed_at_ms: 0,
+ item,
+ },
+ ));
+ }
+ let completed = store
+ .snapshot()
+ .events
+ .into_iter()
+ .filter_map(|event| match event {
+ ThreadBufferedEvent::Notification(notification) => match *notification {
+ ServerNotification::ItemCompleted(n) => Some(n.item),
+ _ => None,
+ },
+ _ => None,
+ })
+ .collect::>();
+ assert_eq!(completed, vec![typed]);
+}
+
+#[tokio::test]
+async fn evicted_voice_marker_survives_widget_snapshot_for_late_reasoning() {
+ let thread_id = ThreadId::new();
+ let mut store = ThreadEventStore::new(/*capacity*/ 1);
+ store.push_notification(ServerNotification::ItemStarted(
+ codex_app_server_protocol::ItemStartedNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: "voice-turn".into(),
+ started_at_ms: 0,
+ item: ThreadItem::UserMessage {
+ id: "user".into(),
+ client_id: None,
+ content: vec![codex_app_server_protocol::UserInput::Text {
+ text: "question"
+ .into(),
+ text_elements: Vec::new(),
+ }],
+ },
+ },
+ ));
+ store.push_notification(delta(&thread_id.to_string(), "voice-turn", "private"));
+ let snapshot = store.snapshot();
+ assert!(snapshot.events.is_empty());
+ assert!(snapshot.turns.is_empty());
+ assert_eq!(snapshot.delegated_turns, vec!["voice-turn"]);
+
+ let mut app = make_test_app().await;
+ let (widget, _sender, mut events, _ops) =
+ crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(thread_id);
+ app.replace_chat_widget(widget);
+ app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
+ app.chat_widget.handle_server_notification(
+ ServerNotification::ReasoningSummaryTextDelta(
+ codex_app_server_protocol::ReasoningSummaryTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: "voice-turn".into(),
+ item_id: "late-reasoning".into(),
+ delta: "private after switch".into(),
+ summary_index: 0,
+ },
+ ),
+ /*replay_kind*/ None,
+ );
+ app.chat_widget.handle_server_notification(
+ ServerNotification::ItemCompleted(ItemCompletedNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: "voice-turn".into(),
+ completed_at_ms: 0,
+ item: ThreadItem::Reasoning {
+ id: "late-reasoning".into(),
+ summary: vec!["private after switch".into()],
+ content: Vec::new(),
+ },
+ }),
+ /*replay_kind*/ None,
+ );
+ app.chat_widget.handle_server_notification(
+ delta(&thread_id.to_string(), "voice-turn", "private-commentary"),
+ /*replay_kind*/ None,
+ );
+ app.chat_widget.handle_server_notification(
+ ServerNotification::ItemCompleted(ItemCompletedNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: "voice-turn".into(),
+ completed_at_ms: 0,
+ item: ThreadItem::AgentMessage {
+ id: "private-commentary".into(),
+ text: "[COMMENTARY] private after switch".into(),
+ phase: None,
+ memory_citation: None,
+ delivery: None,
+ questions: None,
+ },
+ }),
+ /*replay_kind*/ None,
+ );
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ let rendered = cell
+ .transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::();
+ assert!(!rendered.contains("private"), "{rendered}");
+ }
+ }
+}
+
#[tokio::test]
async fn buffered_replay_renders_completed_text_without_streaming_again() {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
diff --git a/codex-rs/tui/src/app/tests/realtime_requests.rs b/codex-rs/tui/src/app/tests/realtime_requests.rs
new file mode 100644
index 0000000000..2df11927c9
--- /dev/null
+++ b/codex-rs/tui/src/app/tests/realtime_requests.rs
@@ -0,0 +1,1468 @@
+//! App-to-RPC coverage for a delegated voice answer without audio hardware.
+
+use super::*;
+use crate::app::tests::session_lifecycle_requests::RealtimeRequestBehavior;
+use crate::app::tests::session_lifecycle_requests::recorded_params;
+use crate::app::tests::session_lifecycle_requests::start_recording_realtime_speech_app_server;
+use crate::app::tests::session_lifecycle_requests::start_recording_remote_app_server;
+use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
+use codex_app_server_protocol::ItemCompletedNotification;
+use codex_app_server_protocol::ItemStartedNotification;
+use codex_app_server_protocol::ServerNotification;
+use codex_app_server_protocol::ThreadItem;
+use codex_app_server_protocol::Turn;
+use codex_app_server_protocol::TurnCompletedNotification;
+use codex_app_server_protocol::TurnItemsView;
+use codex_app_server_protocol::TurnStatus;
+use codex_app_server_protocol::UserInput;
+use codex_protocol::models::MessagePhase;
+use pretty_assertions::assert_eq;
+use std::path::Path;
+
+fn normalize_voice_snapshot_directory(rendered: &str, cwd: &Path) -> String {
+ let cwd = cwd.display().to_string();
+ let placeholder = "/tmp/project";
+ let padded_placeholder = format!(
+ "{placeholder}{}",
+ " ".repeat(cwd.len().saturating_sub(placeholder.len()))
+ );
+ rendered.replace(&cwd, &padded_placeholder)
+}
+
+#[test]
+fn voice_snapshot_directory_keeps_header_width_for_windows_paths() {
+ let rendered = "│ directory: C:\\tmp\\project │";
+ assert_eq!(
+ normalize_voice_snapshot_directory(rendered, Path::new("C:\\tmp\\project")),
+ "│ directory: /tmp/project │"
+ );
+}
+
+fn test_agent_message(id: &str, text: &str) -> ThreadItem {
+ ThreadItem::AgentMessage {
+ id: id.into(),
+ text: text.into(),
+ phase: Some(MessagePhase::FinalAnswer),
+ questions: None,
+ memory_citation: None,
+ delivery: None,
+ }
+}
+
+fn test_user_message(id: &str, text: &str) -> ThreadItem {
+ ThreadItem::UserMessage {
+ id: id.into(),
+ client_id: None,
+ content: vec![UserInput::Text {
+ text: text.into(),
+ text_elements: Vec::new(),
+ }],
+ }
+}
+
+fn empty_thread_snapshot(app: &App, thread_id: ThreadId) -> ThreadEventSnapshot {
+ ThreadEventSnapshot {
+ delegated_turns: Vec::new(),
+ session: Some(test_thread_session(thread_id, app.config.cwd.to_path_buf())),
+ turns: Vec::new(),
+ events: Vec::new(),
+ input_state: None,
+ }
+}
+
+enum ItemEventKind {
+ Started,
+ Completed,
+}
+
+#[tokio::test]
+async fn realtime_start_routes_v3_offer_without_startup_context() -> Result<()> {
+ let (mut app, _events, _ops) = make_test_app_with_channels().await;
+ let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server(
+ &app.config,
+ RealtimeRequestBehavior::AcceptStart,
+ )
+ .await?;
+ let thread_id = ThreadId::new();
+ app.active_thread_id = Some(thread_id);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf()));
+ let mut tui = crate::tui::test_support::make_test_tui()?;
+ Box::pin(app.handle_event(
+ &mut tui,
+ &mut app_server,
+ AppEvent::CodexOp(Op::RealtimeConversationStart {
+ thread_id,
+ offer_sdp: String::from("v=0\r\n").into(),
+ }),
+ ))
+ .await?;
+
+ let starts = recorded_params(&requests, "thread/realtime/start");
+ assert_eq!(starts.len(), 1);
+ let start = &starts[0];
+ assert_eq!(
+ serde_json::json!({
+ "threadId": start["threadId"],
+ "clientManagedHandoffs": start["clientManagedHandoffs"],
+ "includeStartupContext": start["includeStartupContext"],
+ "outputModality": start["outputModality"],
+ "version": start["version"],
+ "transport": start["transport"],
+ }),
+ serde_json::json!({
+ "threadId": thread_id.to_string(),
+ "clientManagedHandoffs": true,
+ "includeStartupContext": false,
+ "outputModality": "audio",
+ "version": "v3",
+ "transport": {"type": "webrtc", "sdp": "v=0\r\n"},
+ })
+ );
+ app_server.shutdown().await?;
+ proxy.await??;
+ Ok(())
+}
+
+#[tokio::test]
+async fn shutdown_stops_backend_voice_once_through_app_server() -> Result<()> {
+ let (mut app, _events, _ops) = make_test_app_with_channels().await;
+ let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server(
+ &app.config,
+ RealtimeRequestBehavior::AcceptSpeech,
+ )
+ .await?;
+ let thread_id = ThreadId::new();
+ app.active_thread_id = Some(thread_id);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf()));
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id);
+
+ app.shutdown_current_thread(&mut app_server).await;
+ app.shutdown_current_thread(&mut app_server).await;
+
+ assert_eq!(
+ recorded_params(&requests, "thread/realtime/stop"),
+ vec![serde_json::json!({"threadId": thread_id.to_string()})]
+ );
+ app_server.shutdown().await?;
+ proxy.await??;
+ Ok(())
+}
+
+#[tokio::test]
+async fn stalled_voice_stop_leaves_time_for_shutdown_unsubscribe() -> Result<()> {
+ let (mut app, _events, _ops) = make_test_app_with_channels().await;
+ let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server(
+ &app.config,
+ RealtimeRequestBehavior::AcceptSpeechAndStallStop,
+ )
+ .await?;
+ let thread_id = ThreadId::new();
+ app.active_thread_id = Some(thread_id);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf()));
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id);
+
+ tokio::time::timeout(
+ crate::app::event_dispatch::SHUTDOWN_FIRST_EXIT_TIMEOUT,
+ app.shutdown_current_thread(&mut app_server),
+ )
+ .await?;
+
+ assert_eq!(
+ recorded_params(&requests, "thread/realtime/stop"),
+ vec![serde_json::json!({"threadId": thread_id.to_string()})]
+ );
+ assert_eq!(
+ recorded_params(&requests, "thread/unsubscribe"),
+ vec![serde_json::json!({"threadId": thread_id.to_string()})]
+ );
+ app_server.shutdown().await?;
+ proxy.await??;
+ Ok(())
+}
+
+#[tokio::test]
+async fn switching_agent_threads_stops_backend_voice_once_through_app_server() -> Result<()> {
+ let (mut app, _events, mut ops) = make_test_app_with_channels().await;
+ let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server(
+ &app.config,
+ RealtimeRequestBehavior::AcceptSpeech,
+ )
+ .await?;
+ let source = ThreadId::new();
+ app.active_thread_id = Some(source);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(source, app.config.cwd.to_path_buf()));
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, source);
+ while ops.try_recv().is_ok() {}
+ let turn_id = "pending-voice-turn";
+ send_item(
+ &mut app,
+ source,
+ turn_id,
+ test_user_message(
+ "spoken-input",
+ "question",
+ ),
+ ItemEventKind::Started,
+ );
+ let answer = test_agent_message("pending-answer", "Answer to preserve after switching.");
+ send_item(
+ &mut app,
+ source,
+ turn_id,
+ answer.clone(),
+ ItemEventKind::Started,
+ );
+ send_item(
+ &mut app,
+ source,
+ turn_id,
+ answer.clone(),
+ ItemEventKind::Completed,
+ );
+ app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
+ ServerNotification::TurnCompleted(TurnCompletedNotification {
+ thread_id: source.to_string(),
+ turn: Turn {
+ id: turn_id.into(),
+ items: vec![answer.clone()],
+ items_view: TurnItemsView::Summary,
+ status: TurnStatus::Completed,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ },
+ }),
+ )));
+ let speech = ops.try_recv()?;
+ assert!(
+ matches!(speech, Op::RealtimeConversationSpeech { .. }),
+ "{speech:?}"
+ );
+ let mut source_channel = ThreadEventChannel::new_with_session(
+ THREAD_EVENT_CHANNEL_CAPACITY,
+ test_thread_session(source, app.config.cwd.to_path_buf()),
+ Vec::new(),
+ );
+ source_channel.store.lock().await.active = true;
+ app.active_thread_rx = source_channel.receiver.take();
+ app.thread_event_channels.insert(source, source_channel);
+ // Routed to the active channel, but not yet delivered to the old widget.
+ app.enqueue_thread_notification(
+ source,
+ ServerNotification::ThreadRealtimeTranscriptDone(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification {
+ thread_id: source.to_string(),
+ role: "user".into(),
+ text: "queued before switch".into(),
+ },
+ ),
+ )
+ .await?;
+ let target = ThreadId::new();
+ app.thread_event_channels.insert(
+ target,
+ ThreadEventChannel::new_with_session(
+ THREAD_EVENT_CHANNEL_CAPACITY,
+ test_thread_session(target, app.config.cwd.to_path_buf()),
+ Vec::new(),
+ ),
+ );
+ let mut tui = crate::tui::test_support::make_test_tui()?;
+
+ Box::pin(app.select_agent_thread(&mut tui, &mut app_server, target)).await?;
+
+ assert_eq!(app.chat_widget.thread_id(), Some(target));
+ assert_eq!(
+ app.pending_realtime_speech_replay[&source],
+ vec![(turn_id.into(), answer)]
+ );
+ assert_eq!(app.pending_realtime_transcript_replay[&source].len(), 1);
+ assert_eq!(
+ app.pending_realtime_transcript_replay[&source][0].text,
+ "queued before switch"
+ );
+ assert_eq!(
+ recorded_params(&requests, "thread/realtime/stop"),
+ vec![serde_json::json!({"threadId": source.to_string()})]
+ );
+ app_server.shutdown().await?;
+ proxy.await??;
+ Ok(())
+}
+
+#[tokio::test]
+async fn switching_threads_keeps_the_source_voice_partial_only_on_reattach() {
+ let (mut app, _events, _ops) = make_test_app_with_channels().await;
+ let source = ThreadId::new();
+ app.active_thread_id = Some(source);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(source, app.config.cwd.to_path_buf()));
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, source);
+ app.chat_widget.handle_server_notification(
+ ServerNotification::ThreadRealtimeTranscriptDelta(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification {
+ thread_id: source.to_string(),
+ role: "user".into(),
+ delta: "spoken partial".into(),
+ },
+ ),
+ /*replay_kind*/ None,
+ );
+
+ let side = ThreadId::new();
+ let (side_widget, _, mut side_events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(side);
+ app.replace_chat_widget(side_widget);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(side, app.config.cwd.to_path_buf()));
+ assert_eq!(app.pending_realtime_transcript_replay[&source].len(), 1);
+ while let Ok(event) = side_events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ assert!(
+ cell.transcript_lines(/*width*/ 80)
+ .iter()
+ .all(|line| !line.to_string().contains("spoken partial"))
+ );
+ }
+ }
+
+ let (source_widget, _, mut source_events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(source);
+ app.replace_chat_widget(source_widget);
+ app.replay_thread_snapshot(
+ empty_thread_snapshot(&app, source),
+ /*resume_restored_queue*/ false,
+ );
+ assert!(!app.pending_realtime_transcript_replay.contains_key(&source));
+ app.chat_widget.handle_server_notification(
+ ServerNotification::ThreadRealtimeTranscriptDone(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification {
+ thread_id: source.to_string(),
+ role: "user".into(),
+ text: "spoken partial completed".into(),
+ },
+ ),
+ /*replay_kind*/ None,
+ );
+ let rendered = std::iter::from_fn(|| source_events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(rendered.matches("spoken partial completed").count(), 1);
+ insta::assert_snapshot!(
+ "voice_partial_completed_after_thread_switch",
+ normalize_voice_snapshot_directory(&rendered, &app.config.cwd)
+ );
+ while let Ok(event) = side_events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ assert!(
+ cell.transcript_lines(/*width*/ 80)
+ .iter()
+ .all(|line| !line.to_string().contains("spoken partial"))
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn queued_voice_caption_after_switch_returns_once_to_its_source_thread() {
+ let (mut app, _events, _ops) = make_test_app_with_channels().await;
+ let source = ThreadId::new();
+ app.active_thread_id = Some(source);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(source, app.config.cwd.to_path_buf()));
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, source);
+
+ // Exercise a completion that arrives only after two switches without a prior delta.
+ for _ in 0..2 {
+ let (other_widget, _, _, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(ThreadId::new());
+ app.replace_chat_widget(other_widget);
+ assert!(app.pending_realtime_transcript_replay.contains_key(&source));
+
+ let (source_widget, _, _, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(source);
+ app.replace_chat_widget(source_widget);
+ app.replay_thread_snapshot(
+ empty_thread_snapshot(&app, source),
+ /*resume_restored_queue*/ false,
+ );
+ }
+
+ let other = ThreadId::new();
+ let (other_widget, _, mut other_events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(other);
+ app.replace_chat_widget(other_widget);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(other, app.config.cwd.to_path_buf()));
+
+ app.enqueue_thread_notification(
+ source,
+ ServerNotification::ThreadRealtimeTranscriptDone(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification {
+ thread_id: source.to_string(),
+ role: "assistant".into(),
+ text: "arrived after two switches".into(),
+ },
+ ),
+ )
+ .await
+ .unwrap();
+
+ for notification in [
+ ServerNotification::ThreadRealtimeTranscriptDelta(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification {
+ thread_id: source.to_string(),
+ role: "user".into(),
+ delta: "last ".into(),
+ },
+ ),
+ ServerNotification::ThreadRealtimeTranscriptDelta(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification {
+ thread_id: source.to_string(),
+ role: "user".into(),
+ delta: "words".into(),
+ },
+ ),
+ ServerNotification::ThreadRealtimeTranscriptDone(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification {
+ thread_id: source.to_string(),
+ role: "user".into(),
+ text: "last words".into(),
+ },
+ ),
+ ] {
+ app.enqueue_thread_notification(source, notification)
+ .await
+ .unwrap();
+ }
+ app.enqueue_thread_notification(
+ source,
+ ServerNotification::ThreadRealtimeTranscriptDelta(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification {
+ thread_id: source.to_string(),
+ role: "assistant".into(),
+ delta: "discarded partial".into(),
+ },
+ ),
+ )
+ .await
+ .unwrap();
+ app.enqueue_thread_notification(
+ source,
+ ServerNotification::ThreadRealtimeTranscriptDone(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification {
+ thread_id: source.to_string(),
+ role: "assistant".into(),
+ text: String::new(),
+ },
+ ),
+ )
+ .await
+ .unwrap();
+ assert_eq!(app.pending_realtime_transcript_replay[&source].len(), 2);
+ while let Ok(event) = other_events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ assert!(
+ cell.transcript_lines(/*width*/ 80)
+ .iter()
+ .all(|line| !line.to_string().contains("last words"))
+ );
+ }
+ }
+
+ let (source_widget, _, mut source_events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(source);
+ app.replace_chat_widget(source_widget);
+ app.replay_thread_snapshot(
+ empty_thread_snapshot(&app, source),
+ /*resume_restored_queue*/ false,
+ );
+ let rendered = std::iter::from_fn(|| source_events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::(),
+ ),
+ _ => None,
+ })
+ .collect::();
+ assert_eq!(rendered.matches("last words").count(), 1);
+ assert_eq!(rendered.matches("arrived after two switches").count(), 1);
+ assert!(!rendered.contains("last last words"));
+}
+
+#[tokio::test]
+async fn replay_reconciles_only_matching_voice_captions_one_for_one() {
+ let (mut app, _initial_events, _ops) = make_test_app_with_channels().await;
+ let source = ThreadId::new();
+ let (widget, _, mut events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(source);
+ app.replace_chat_widget(widget);
+ let records = [
+ ("user", "show & tests"),
+ ("user", "show & tests"),
+ ("assistant", "spoken answer"),
+ ("user", "typed words"),
+ ("assistant", "different caption"),
+ ]
+ .into_iter()
+ .map(|(role, text)| crate::chatwidget::RealtimeTranscriptRecord {
+ role: role.to_string(),
+ text: text.to_string(),
+ complete: true,
+ })
+ .collect();
+ app.pending_realtime_transcript_replay
+ .insert(source, records);
+ let voice = Turn {
+ id: "voice-turn".into(),
+ items: vec![
+ test_user_message(
+ "voice-user",
+ "show <code> & tests",
+ ),
+ test_agent_message("voice-answer", "spoken answer"),
+ ],
+ items_view: TurnItemsView::Summary,
+ status: TurnStatus::Completed,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ };
+ let typed = Turn {
+ id: "typed-turn".into(),
+ items: vec![test_user_message("typed-user", "typed words")],
+ ..voice.clone()
+ };
+ app.replay_thread_snapshot(
+ ThreadEventSnapshot {
+ delegated_turns: vec![voice.id.clone()],
+ session: Some(test_thread_session(source, app.config.cwd.to_path_buf())),
+ turns: vec![voice, typed],
+ events: Vec::new(),
+ input_state: None,
+ },
+ /*resume_restored_queue*/ false,
+ );
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(rendered.matches("show & tests").count(), 2);
+ assert_eq!(rendered.matches("spoken answer").count(), 1);
+ assert_eq!(rendered.matches("typed words").count(), 2);
+ assert_eq!(rendered.matches("different caption").count(), 1);
+ insta::assert_snapshot!(
+ "voice_replay_reconciles_matching_captions",
+ normalize_voice_snapshot_directory(&rendered, &app.config.cwd)
+ );
+}
+
+#[tokio::test]
+async fn retained_caption_consumes_only_one_matching_answer_fallback_on_reattach() {
+ let (mut app, _initial_events, _ops) = make_test_app_with_channels().await;
+ let source = ThreadId::new();
+ let (widget, _, mut events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(source);
+ app.replace_chat_widget(widget);
+ app.pending_realtime_transcript_replay.insert(
+ source,
+ [crate::chatwidget::RealtimeTranscriptRecord {
+ role: "assistant".into(),
+ text: "Same answer".into(),
+ complete: true,
+ }]
+ .into(),
+ );
+ let answer = |id: &str, text: &str| ThreadItem::AgentMessage {
+ id: id.into(),
+ text: text.into(),
+ phase: Some(MessagePhase::FinalAnswer),
+ questions: None,
+ memory_citation: None,
+ delivery: None,
+ };
+ app.pending_realtime_speech_replay.insert(
+ source,
+ vec![
+ ("turn-1".into(), answer("answer-1", "[FINAL] Same answer")),
+ ("turn-2".into(), answer("answer-2", "Same answer")),
+ ("turn-3".into(), answer("answer-3", "Different answer")),
+ ],
+ );
+ app.replay_thread_snapshot(
+ empty_thread_snapshot(&app, source),
+ /*resume_restored_queue*/ false,
+ );
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(rendered.matches("Same answer").count(), 1);
+ assert_eq!(rendered.matches("Same answer").count(), 1);
+ assert_eq!(rendered.matches("Different answer").count(), 1);
+ assert!(!rendered.contains("[FINAL]"));
+}
+
+#[tokio::test]
+async fn inactive_voice_replay_is_bounded_and_discarded_with_its_thread() {
+ let (mut app, _events, _ops) = make_test_app_with_channels().await;
+ let ids = (0..17).map(|_| ThreadId::new()).collect::>();
+ for (index, thread_id) in ids.iter().enumerate() {
+ app.retain_inactive_realtime_transcript(
+ *thread_id,
+ &ServerNotification::ThreadRealtimeTranscriptDone(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification {
+ thread_id: thread_id.to_string(),
+ role: "assistant".into(),
+ text: format!("caption {index}"),
+ },
+ ),
+ );
+ }
+ assert_eq!(app.realtime_replay_order.len(), 16);
+ assert!(!app.pending_realtime_transcript_replay.contains_key(&ids[0]));
+ assert!(
+ app.pending_realtime_transcript_replay
+ .contains_key(&ids[16])
+ );
+
+ app.discard_thread_local_state(ids[16]).await;
+ assert!(
+ !app.pending_realtime_transcript_replay
+ .contains_key(&ids[16])
+ );
+ assert!(!app.realtime_replay_order.contains(&ids[16]));
+ app.reset_thread_event_state();
+ assert!(app.pending_realtime_transcript_replay.is_empty());
+ assert!(app.pending_realtime_speech_replay.is_empty());
+ assert!(app.realtime_replay_order.is_empty());
+}
+
+#[tokio::test]
+async fn buffered_voice_items_reconcile_captions_after_thread_switch() {
+ let (mut app, _initial_events, _ops) = make_test_app_with_channels().await;
+ let source = ThreadId::new();
+ let (widget, _, mut events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(source);
+ app.replace_chat_widget(widget);
+ app.pending_realtime_transcript_replay.insert(
+ source,
+ [
+ ("user", "buffered question"),
+ ("assistant", "buffered answer"),
+ ]
+ .into_iter()
+ .map(|(role, text)| crate::chatwidget::RealtimeTranscriptRecord {
+ role: role.to_string(),
+ text: text.to_string(),
+ complete: true,
+ })
+ .collect(),
+ );
+ let items = [
+ ThreadItem::UserMessage {
+ id: "user-item".into(),
+ client_id: None,
+ content: vec![UserInput::Text {
+ text: "buffered question"
+ .into(),
+ text_elements: Vec::new(),
+ }],
+ },
+ test_agent_message("answer-item", "buffered answer"),
+ ];
+ let events_to_replay = items
+ .into_iter()
+ .map(|item| {
+ ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted(
+ ItemCompletedNotification {
+ item,
+ thread_id: source.to_string(),
+ turn_id: "buffered-turn".into(),
+ completed_at_ms: 0,
+ },
+ )))
+ })
+ .collect();
+ app.replay_thread_snapshot(
+ ThreadEventSnapshot {
+ delegated_turns: vec!["buffered-turn".into()],
+ session: Some(test_thread_session(source, app.config.cwd.to_path_buf())),
+ turns: Vec::new(),
+ events: events_to_replay,
+ input_state: None,
+ },
+ /*resume_restored_queue*/ false,
+ );
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(rendered.matches("buffered question").count(), 1);
+ assert_eq!(rendered.matches("buffered answer").count(), 1);
+ insta::assert_snapshot!(
+ "voice_buffered_replay_reconciles_captions",
+ normalize_voice_snapshot_directory(&rendered, &app.config.cwd)
+ );
+}
+
+#[tokio::test]
+async fn unrendered_buffered_items_do_not_consume_retained_captions() {
+ let (mut app, _initial_events, _ops) = make_test_app_with_channels().await;
+ let source = ThreadId::new();
+ let (widget, _, mut events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(source);
+ app.replace_chat_widget(widget);
+ app.pending_realtime_transcript_replay.insert(
+ source,
+ [
+ ("user", "started only"),
+ ("user", "evicted prompt"),
+ ("assistant", "interrupted answer"),
+ ]
+ .into_iter()
+ .map(|(role, text)| crate::chatwidget::RealtimeTranscriptRecord {
+ role: role.to_string(),
+ text: text.to_string(),
+ complete: true,
+ })
+ .collect(),
+ );
+ let user_item = |id: &str, text: &str| ThreadItem::UserMessage {
+ id: id.into(),
+ client_id: None,
+ content: vec![UserInput::Text {
+ text: format!("{text}"),
+ text_elements: Vec::new(),
+ }],
+ };
+ let completion = |turn_id: &str, status: TurnStatus, items: Vec| {
+ ThreadBufferedEvent::Notification(Box::new(ServerNotification::TurnCompleted(
+ TurnCompletedNotification {
+ thread_id: source.to_string(),
+ turn: Turn {
+ id: turn_id.into(),
+ items,
+ items_view: TurnItemsView::Summary,
+ status,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ },
+ },
+ )))
+ };
+ app.replay_thread_snapshot(
+ ThreadEventSnapshot {
+ delegated_turns: vec!["started".into(), "evicted".into(), "interrupted".into()],
+ session: Some(test_thread_session(source, app.config.cwd.to_path_buf())),
+ turns: Vec::new(),
+ events: vec![
+ ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemStarted(
+ ItemStartedNotification {
+ item: user_item("started-user", "started only"),
+ thread_id: source.to_string(),
+ turn_id: "started".into(),
+ started_at_ms: 0,
+ },
+ ))),
+ completion(
+ "evicted",
+ TurnStatus::Completed,
+ vec![user_item("evicted-user", "evicted prompt")],
+ ),
+ completion(
+ "interrupted",
+ TurnStatus::Interrupted,
+ vec![test_agent_message(
+ "interrupted-agent",
+ "interrupted answer",
+ )],
+ ),
+ ],
+ input_state: None,
+ },
+ /*resume_restored_queue*/ false,
+ );
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ for text in ["started only", "evicted prompt", "interrupted answer"] {
+ assert_eq!(
+ rendered.matches(text).count(),
+ 1,
+ "{text} should survive replay"
+ );
+ }
+}
+
+#[tokio::test]
+async fn completed_voice_caption_survives_repeated_thread_replacement() {
+ let (mut app, mut initial_events, _ops) = make_test_app_with_channels().await;
+ let source = ThreadId::new();
+ app.active_thread_id = Some(source);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(source, app.config.cwd.to_path_buf()));
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, source);
+ app.chat_widget.handle_server_notification(
+ ServerNotification::ThreadRealtimeTranscriptDone(
+ codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification {
+ thread_id: source.to_string(),
+ role: "assistant".into(),
+ text: "spoken complete".into(),
+ },
+ ),
+ /*replay_kind*/ None,
+ );
+ let initial = std::iter::from_fn(|| initial_events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::(),
+ ),
+ _ => None,
+ })
+ .collect::();
+ assert_eq!(initial.matches("spoken complete").count(), 1);
+
+ for cycle in 0..2 {
+ let side = ThreadId::new();
+ let (side_widget, _, mut side_events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(side);
+ app.replace_chat_widget(side_widget);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(side, app.config.cwd.to_path_buf()));
+ assert_eq!(app.pending_realtime_transcript_replay[&source].len(), 1);
+ while let Ok(event) = side_events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ assert!(
+ cell.transcript_lines(/*width*/ 80)
+ .iter()
+ .all(|line| !line.to_string().contains("spoken complete"))
+ );
+ }
+ }
+
+ let (source_widget, _, mut source_events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(source);
+ app.replace_chat_widget(source_widget);
+ app.replay_thread_snapshot(
+ empty_thread_snapshot(&app, source),
+ /*resume_restored_queue*/ false,
+ );
+ assert!(!app.pending_realtime_transcript_replay.contains_key(&source));
+ let rendered = std::iter::from_fn(|| source_events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(rendered.matches("spoken complete").count(), 1);
+ if cycle == 0 {
+ insta::assert_snapshot!(
+ "voice_completed_after_thread_switch",
+ normalize_voice_snapshot_directory(&rendered, &app.config.cwd)
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn rejected_realtime_speech_restores_the_delegated_final_answer() -> Result<()> {
+ let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
+ // This proxy forwards appendSpeech to an embedded server with no voice
+ // session for the synthetic thread, which rejects the request.
+ let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?;
+ let thread_id = ThreadId::new();
+ app.active_thread_id = Some(thread_id);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf()));
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id);
+ while ops.try_recv().is_ok() {}
+ send_item(
+ &mut app,
+ thread_id,
+ "rejected-turn",
+ test_user_message(
+ "voice-input",
+ "hello",
+ ),
+ ItemEventKind::Started,
+ );
+ let answer = test_agent_message("rejected-final", "An answer to keep.");
+ send_item(
+ &mut app,
+ thread_id,
+ "rejected-turn",
+ answer.clone(),
+ ItemEventKind::Started,
+ );
+ send_item(
+ &mut app,
+ thread_id,
+ "rejected-turn",
+ answer.clone(),
+ ItemEventKind::Completed,
+ );
+ app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
+ ServerNotification::TurnCompleted(TurnCompletedNotification {
+ thread_id: thread_id.to_string(),
+ turn: Turn {
+ id: "rejected-turn".to_string(),
+ items: vec![answer],
+ items_view: TurnItemsView::Summary,
+ status: TurnStatus::Completed,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ },
+ }),
+ )));
+ let speech = ops.try_recv()?;
+ let delivery_id = match &speech {
+ AppCommand::RealtimeConversationSpeech { delivery_id, .. } => *delivery_id,
+ _ => unreachable!("voice completion queues speech"),
+ };
+ while events.try_recv().is_ok() {}
+ let mut tui = crate::tui::test_support::make_test_tui()?;
+ Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(speech))).await?;
+ assert_eq!(
+ recorded_params(&requests, "thread/realtime/appendSpeech").len(),
+ 1
+ );
+ assert!(!app.chat_widget.has_pending_realtime_speech(delivery_id));
+ let mut restored = 0;
+ let mut rendered = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ let lines = cell
+ .display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>();
+ if lines.iter().any(|line| line.contains("An answer to keep.")) {
+ restored += 1;
+ }
+ rendered.extend(lines);
+ }
+ }
+ assert_eq!(restored, 1);
+ assert!(
+ rendered
+ .iter()
+ .any(|line| line.contains("Voice conversation failed:"))
+ );
+ insta::assert_snapshot!(
+ "rejected_realtime_speech",
+ rendered
+ .join("\n")
+ .replace(&thread_id.to_string(), "")
+ );
+ app_server.shutdown().await?;
+ proxy.await??;
+ Ok(())
+}
+
+#[tokio::test]
+async fn switching_threads_retains_undelivered_voice_answer_after_replay_eviction() -> Result<()> {
+ let (mut app, _events, mut ops) = make_test_app_with_channels().await;
+ let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server(
+ &app.config,
+ RealtimeRequestBehavior::AcceptSpeech,
+ )
+ .await?;
+ let original = ThreadId::new();
+ app.active_thread_id = Some(original);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(original, app.config.cwd.to_path_buf()));
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, original);
+ while ops.try_recv().is_ok() {}
+ send_item(
+ &mut app,
+ original,
+ "switched-turn",
+ test_user_message(
+ "voice-input",
+ "hello",
+ ),
+ ItemEventKind::Started,
+ );
+ let answer = test_agent_message("switched-final", "Answer survives a thread switch.");
+ send_item(
+ &mut app,
+ original,
+ "switched-turn",
+ answer.clone(),
+ ItemEventKind::Started,
+ );
+ send_item(
+ &mut app,
+ original,
+ "switched-turn",
+ answer.clone(),
+ ItemEventKind::Completed,
+ );
+ app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
+ ServerNotification::TurnCompleted(TurnCompletedNotification {
+ thread_id: original.to_string(),
+ turn: Turn {
+ id: "switched-turn".to_string(),
+ items: vec![answer],
+ items_view: TurnItemsView::Summary,
+ status: TurnStatus::Completed,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ },
+ }),
+ )));
+ let speech = ops.try_recv()?;
+ let (side_widget, _, mut side_events, _) = make_chatwidget_manual_with_sender().await;
+ let side = ThreadId::new();
+ app.active_thread_id = Some(side);
+ app.replace_chat_widget(side_widget);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(side, app.config.cwd.to_path_buf()));
+ assert_eq!(app.pending_realtime_speech_replay[&original].len(), 1);
+ // Reconnect discards the old AppEvent receiver before installing a new widget.
+ let (replacement_tx, _replacement_rx) = tokio::sync::mpsc::unbounded_channel();
+ app.app_event_tx = crate::app_event_sender::AppEventSender::new(replacement_tx);
+
+ let mut tui = crate::tui::test_support::make_test_tui()?;
+ Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(speech))).await?;
+ assert!(recorded_params(&requests, "thread/realtime/appendSpeech").is_empty());
+ while let Ok(event) = side_events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ assert!(cell.display_lines(/*width*/ 80).iter().all(|line| {
+ !line
+ .to_string()
+ .contains("Answer survives a thread switch.")
+ }));
+ }
+ }
+
+ let (original_widget, _, mut original_events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(original);
+ app.replace_chat_widget(original_widget);
+ // The ordinary bounded event buffer has lost ItemCompleted and TurnCompleted.
+ app.replay_thread_snapshot(
+ empty_thread_snapshot(&app, original),
+ /*resume_restored_queue*/ false,
+ );
+ assert!(!app.pending_realtime_speech_replay.contains_key(&original));
+ let mut restored = 0;
+ while let Ok(event) = original_events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event
+ && cell.display_lines(/*width*/ 80).iter().any(|line| {
+ line.to_string()
+ .contains("Answer survives a thread switch.")
+ })
+ {
+ restored += 1;
+ }
+ }
+ assert_eq!(restored, 1);
+ let replayed_item = test_agent_message("replayed-final", "Already in the thread snapshot.");
+ let mut older_item = replayed_item.clone();
+ if let ThreadItem::AgentMessage { text, .. } = &mut older_item {
+ *text = "Older pending text.".to_string();
+ }
+ app.pending_realtime_speech_replay
+ .insert(original, vec![("replayed-turn".to_string(), older_item)]);
+ app.replay_thread_snapshot(
+ ThreadEventSnapshot {
+ delegated_turns: Vec::new(),
+ session: Some(test_thread_session(original, app.config.cwd.to_path_buf())),
+ turns: vec![Turn {
+ id: "replayed-turn".to_string(),
+ items: vec![replayed_item],
+ items_view: TurnItemsView::Summary,
+ status: TurnStatus::Completed,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ }],
+ events: Vec::new(),
+ input_state: None,
+ },
+ /*resume_restored_queue*/ false,
+ );
+ let mut snapshot_cells = 0;
+ while let Ok(event) = original_events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ for line in cell.display_lines(/*width*/ 80) {
+ assert!(!line.to_string().contains("Older pending text."));
+ snapshot_cells +=
+ usize::from(line.to_string().contains("Already in the thread snapshot."));
+ }
+ }
+ }
+ assert_eq!(snapshot_cells, 1);
+ assert!(!app.pending_realtime_speech_replay.contains_key(&original));
+
+ // Switch before TurnCompleted exists at all. A hidden ItemCompleted must
+ // survive without relying on a later turn event in the bounded buffer.
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, original);
+ send_item(
+ &mut app,
+ original,
+ "early-switch-turn",
+ test_user_message(
+ "early-voice-input",
+ "early",
+ ),
+ ItemEventKind::Started,
+ );
+ let early_answer =
+ test_agent_message("early-switch-final", "Hidden final before turn completion.");
+ send_item(
+ &mut app,
+ original,
+ "early-switch-turn",
+ early_answer.clone(),
+ ItemEventKind::Started,
+ );
+ send_item(
+ &mut app,
+ original,
+ "early-switch-turn",
+ early_answer,
+ ItemEventKind::Completed,
+ );
+ assert!(ops.try_recv().is_err());
+ let (side_widget, _, _, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(side);
+ app.replace_chat_widget(side_widget);
+ assert_eq!(app.pending_realtime_speech_replay[&original].len(), 1);
+ let (original_widget, _, mut early_events, _) = make_chatwidget_manual_with_sender().await;
+ app.active_thread_id = Some(original);
+ app.replace_chat_widget(original_widget);
+ app.replay_thread_snapshot(
+ empty_thread_snapshot(&app, original),
+ /*resume_restored_queue*/ false,
+ );
+ let mut early_restored = 0;
+ while let Ok(event) = early_events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event
+ && cell.display_lines(/*width*/ 80).iter().any(|line| {
+ line.to_string()
+ .contains("Hidden final before turn completion.")
+ })
+ {
+ early_restored += 1;
+ }
+ }
+ assert_eq!(early_restored, 1);
+ assert!(!app.pending_realtime_speech_replay.contains_key(&original));
+ // Primary resume/fork uses a different replay entry point than a thread
+ // switch; it must also drain the old widget's per-thread delivery stash.
+ let resumed_answer = test_agent_message("primary-resume-final", "Recovered on primary resume.");
+ app.pending_realtime_speech_replay.insert(
+ original,
+ vec![("primary-resume-turn".to_string(), resumed_answer)],
+ );
+ app.enqueue_primary_thread_session_with_presentation(
+ test_thread_session(original, app.config.cwd.to_path_buf()),
+ Vec::new(),
+ crate::app::session_lifecycle::ThreadAttachPresentation::SessionLineage,
+ )
+ .await?;
+ assert!(!app.pending_realtime_speech_replay.contains_key(&original));
+ let primary_restored = std::iter::from_fn(|| early_events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(cell.raw_lines()),
+ _ => None,
+ })
+ .flatten()
+ .filter(|line| line.to_string() == "Recovered on primary resume.")
+ .count();
+ assert_eq!(primary_restored, 1);
+ assert!(recorded_params(&requests, "thread/realtime/appendSpeech").is_empty());
+ app_server.shutdown().await?;
+ proxy.await??;
+ Ok(())
+}
+
+fn send_item(
+ app: &mut App,
+ thread_id: ThreadId,
+ turn_id: &str,
+ item: ThreadItem,
+ kind: ItemEventKind,
+) {
+ let notification = match kind {
+ ItemEventKind::Started => ServerNotification::ItemStarted(ItemStartedNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.to_string(),
+ item,
+ started_at_ms: 0,
+ }),
+ ItemEventKind::Completed => ServerNotification::ItemCompleted(ItemCompletedNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.to_string(),
+ item,
+ completed_at_ms: 0,
+ }),
+ };
+ app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(notification)));
+}
+
+#[tokio::test]
+async fn delegated_final_speech_reaches_app_server_once_and_stale_speech_is_rejected() -> Result<()>
+{
+ let (mut app, mut events, mut ops) = make_test_app_with_channels().await;
+ let (mut app_server, requests, proxy) = start_recording_realtime_speech_app_server(
+ &app.config,
+ RealtimeRequestBehavior::AcceptSpeech,
+ )
+ .await?;
+ let thread_id = ThreadId::new();
+ app.active_thread_id = Some(thread_id);
+ app.chat_widget
+ .handle_thread_session_quiet(test_thread_session(thread_id, app.config.cwd.to_path_buf()));
+ crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id);
+ while ops.try_recv().is_ok() {}
+ let turn_id = "delegated-voice-turn";
+ let delegation = test_user_message(
+ "voice-input",
+ "hello",
+ );
+ let answer = test_agent_message("voice-final", "Hello back.");
+ send_item(
+ &mut app,
+ thread_id,
+ turn_id,
+ delegation.clone(),
+ ItemEventKind::Started,
+ );
+ send_item(
+ &mut app,
+ thread_id,
+ turn_id,
+ delegation,
+ ItemEventKind::Completed,
+ );
+ send_item(
+ &mut app,
+ thread_id,
+ turn_id,
+ answer.clone(),
+ ItemEventKind::Started,
+ );
+ send_item(
+ &mut app,
+ thread_id,
+ turn_id,
+ answer.clone(),
+ ItemEventKind::Completed,
+ );
+ send_item(
+ &mut app,
+ thread_id,
+ turn_id,
+ answer.clone(),
+ ItemEventKind::Completed,
+ );
+ app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
+ ServerNotification::TurnCompleted(TurnCompletedNotification {
+ thread_id: thread_id.to_string(),
+ turn: Turn {
+ id: turn_id.to_string(),
+ items: vec![answer],
+ items_view: TurnItemsView::Summary,
+ status: TurnStatus::Completed,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ },
+ }),
+ )));
+
+ let speech = ops.try_recv()?;
+ let delivery_id = match &speech {
+ Op::RealtimeConversationSpeech { delivery_id, .. } => *delivery_id,
+ _ => unreachable!("voice completion queues speech"),
+ };
+ assert!(
+ matches!(&speech, Op::RealtimeConversationSpeech { text, .. } if text.as_str() == "Hello back."),
+ "unexpected command: {speech:?}"
+ );
+ assert!(ops.try_recv().is_err());
+ let mut tui = crate::tui::test_support::make_test_tui()?;
+ Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(speech.clone())))
+ .await?;
+ assert!(!app.chat_widget.has_pending_realtime_speech(delivery_id));
+ assert_eq!(
+ recorded_params(&requests, "thread/realtime/appendSpeech"),
+ vec![serde_json::json!({"threadId": thread_id.to_string(), "text": "Hello back."})]
+ );
+
+ let typed = test_user_message("typed-input", "type instead");
+ send_item(
+ &mut app,
+ thread_id,
+ "typed-turn",
+ typed,
+ ItemEventKind::Started,
+ );
+ Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(speech))).await?;
+ assert_eq!(
+ recorded_params(&requests, "thread/realtime/appendSpeech").len(),
+ 1
+ );
+ // A completed second turn may be queued when voice closes. The old command
+ // must not reach the app server or duplicate the restored final history.
+ let second_turn = "second-delegated-voice-turn";
+ let second_input = test_user_message(
+ "second-voice-input",
+ "again",
+ );
+ send_item(
+ &mut app,
+ thread_id,
+ second_turn,
+ second_input,
+ ItemEventKind::Started,
+ );
+ let second_answer = test_agent_message("second-final", "Second answer.");
+ send_item(
+ &mut app,
+ thread_id,
+ second_turn,
+ second_answer.clone(),
+ ItemEventKind::Started,
+ );
+ send_item(
+ &mut app,
+ thread_id,
+ second_turn,
+ second_answer.clone(),
+ ItemEventKind::Completed,
+ );
+ app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
+ ServerNotification::TurnCompleted(TurnCompletedNotification {
+ thread_id: thread_id.to_string(),
+ turn: Turn {
+ id: second_turn.to_string(),
+ items: vec![second_answer],
+ items_view: TurnItemsView::Summary,
+ status: TurnStatus::Completed,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ },
+ }),
+ )));
+ let queued = ops.try_recv()?;
+ let queued_delivery_id = match &queued {
+ Op::RealtimeConversationSpeech { delivery_id, .. } => *delivery_id,
+ _ => unreachable!("second voice completion queues speech"),
+ };
+ assert!(
+ app.chat_widget
+ .has_pending_realtime_speech(queued_delivery_id)
+ );
+ app.chat_widget.reset_realtime_conversation();
+ assert!(
+ !app.chat_widget
+ .has_pending_realtime_speech(queued_delivery_id)
+ );
+ Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::CodexOp(queued))).await?;
+ assert_eq!(
+ recorded_params(&requests, "thread/realtime/appendSpeech").len(),
+ 1
+ );
+ let restored = events
+ .try_recv()
+ .ok()
+ .into_iter()
+ .chain(std::iter::from_fn(|| events.try_recv().ok()))
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::(),
+ ),
+ _ => None,
+ })
+ .filter(|line| line.contains("Second answer."))
+ .count();
+ assert_eq!(restored, 1);
+ while events.try_recv().is_ok() {}
+
+ app_server.shutdown().await?;
+ proxy.await??;
+ Ok(())
+}
diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs
index 3333f430f9..69acc3e1d1 100644
--- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs
+++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs
@@ -197,11 +197,56 @@ pub(super) async fn start_recording_remote_app_server(
/// Proxies a real app server while optionally rejecting modern pagination like an older server.
pub(super) async fn start_recording_app_server_with_history(
+ config: &Config,
+ history_capabilities: HistoryCapabilities,
+ blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>,
+ failed_thread_name: Option<&'static str>,
+ thread_params_mode: crate::app_server_session::ThreadParamsMode,
+ loader_overrides: LoaderOverrides,
+) -> Result {
+ start_recording_app_server_with_realtime_speech(
+ config,
+ history_capabilities,
+ blocked_thread_list,
+ failed_thread_name,
+ thread_params_mode,
+ RealtimeRequestBehavior::Forward,
+ loader_overrides,
+ )
+ .await
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(super) enum RealtimeRequestBehavior {
+ Forward,
+ AcceptStart,
+ AcceptSpeech,
+ AcceptSpeechAndStallStop,
+}
+
+pub(super) async fn start_recording_realtime_speech_app_server(
+ config: &Config,
+ realtime_behavior: RealtimeRequestBehavior,
+) -> Result {
+ start_recording_app_server_with_realtime_speech(
+ config,
+ HistoryCapabilities::Current,
+ /*blocked_thread_list*/ None,
+ /*failed_thread_name*/ None,
+ crate::app_server_session::ThreadParamsMode::Embedded,
+ realtime_behavior,
+ LoaderOverrides::default(),
+ )
+ .await
+}
+
+async fn start_recording_app_server_with_realtime_speech(
config: &Config,
history_capabilities: HistoryCapabilities,
mut blocked_thread_list: Option<(ThreadId, oneshot::Sender<()>, oneshot::Receiver<()>)>,
failed_thread_name: Option<&'static str>,
thread_params_mode: crate::app_server_session::ThreadParamsMode,
+ realtime_behavior: RealtimeRequestBehavior,
loader_overrides: LoaderOverrides,
) -> Result {
let state_db =
@@ -256,6 +301,11 @@ pub(super) async fn start_recording_app_server_with_history(
.lock()
.expect("request recorder lock")
.push(request.clone());
+ if realtime_behavior == RealtimeRequestBehavior::AcceptSpeechAndStallStop
+ && request.method == "thread/realtime/stop"
+ {
+ continue;
+ }
let request_id = request.id.clone();
let params = request.params.as_ref();
let requires_pagination = match request.method.as_str() {
@@ -286,7 +336,20 @@ pub(super) async fn start_recording_app_server_with_history(
.is_some_and(|tools| {
tools.iter().any(|tool| tool["type"] == "namespace")
});
- let response = if let HistoryCapabilities::ConfigReadUnsupported(code) =
+ let fake_realtime_response = (realtime_behavior
+ == RealtimeRequestBehavior::AcceptStart
+ && request.method == "thread/realtime/start")
+ || (matches!(
+ realtime_behavior,
+ RealtimeRequestBehavior::AcceptSpeech
+ | RealtimeRequestBehavior::AcceptSpeechAndStallStop
+ ) && request.method == "thread/realtime/appendSpeech");
+ let response = if fake_realtime_response {
+ JSONRPCMessage::Response(JSONRPCResponse {
+ id: request_id,
+ result: serde_json::json!({}),
+ })
+ } else if let HistoryCapabilities::ConfigReadUnsupported(code) =
history_capabilities
&& request.method == "config/read"
{
diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__rejected_realtime_speech.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__rejected_realtime_speech.snap
new file mode 100644
index 0000000000..ad1abc1dcc
--- /dev/null
+++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__rejected_realtime_speech.snap
@@ -0,0 +1,7 @@
+---
+source: tui/src/app/tests/realtime_requests.rs
+assertion_line: 1083
+expression: "rendered.join(\"\\n\").replace(&thread_id.to_string(), \"\")"
+---
+• An answer to keep.
+■ Voice conversation failed: thread/realtime/appendSpeech failed in TUI: thread/realtime/appendSpeech failed: thread not found: (code -32600)
diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_buffered_replay_reconciles_captions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_buffered_replay_reconciles_captions.snap
new file mode 100644
index 0000000000..0b25023363
--- /dev/null
+++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_buffered_replay_reconciles_captions.snap
@@ -0,0 +1,23 @@
+---
+source: tui/src/app/tests/realtime_requests.rs
+assertion_line: 645
+expression: "normalize_voice_snapshot_directory(&rendered, &app.config.cwd)"
+---
+╭────────────────────────────────────────╮
+│ >_ OpenAI Codex (v0.0.0) │
+│ │
+│ model: gpt-test /model to change │
+│ directory: /tmp/project │
+╰────────────────────────────────────────╯
+
+ To get started, describe a task or try one of these commands:
+
+ /init - create an AGENTS.md file with instructions for Codex
+ /status - show current session configuration
+ /permissions - choose what Codex is allowed to do
+ /model - choose what model and reasoning effort to use
+ /review - review any changes and find issues
+
+› buffered question
+
+• buffered answer
diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_completed_after_thread_switch.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_completed_after_thread_switch.snap
new file mode 100644
index 0000000000..45a423ced3
--- /dev/null
+++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_completed_after_thread_switch.snap
@@ -0,0 +1,20 @@
+---
+source: tui/src/app/tests/realtime_requests.rs
+assertion_line: 182
+expression: "rendered.replace(&app.config.cwd.display().to_string(), \"/tmp/project\")"
+---
+╭────────────────────────────────────────╮
+│ >_ OpenAI Codex (v0.0.0) │
+│ │
+│ model: gpt-test /model to change │
+│ directory: /tmp/project │
+╰────────────────────────────────────────╯
+
+ To get started, describe a task or try one of these commands:
+
+ /init - create an AGENTS.md file with instructions for Codex
+ /status - show current session configuration
+ /permissions - choose what Codex is allowed to do
+ /model - choose what model and reasoning effort to use
+ /review - review any changes and find issues
+• spoken complete
diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap
new file mode 100644
index 0000000000..44a8b74e8f
--- /dev/null
+++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_partial_completed_after_thread_switch.snap
@@ -0,0 +1,21 @@
+---
+source: tui/src/app/tests/realtime_requests.rs
+assertion_line: 302
+expression: "normalize_voice_snapshot_directory(&rendered, &app.config.cwd)"
+---
+╭────────────────────────────────────────╮
+│ >_ OpenAI Codex (v0.0.0) │
+│ │
+│ model: gpt-test /model to change │
+│ directory: /tmp/project │
+╰────────────────────────────────────────╯
+
+ To get started, describe a task or try one of these commands:
+
+ /init - create an AGENTS.md file with instructions for Codex
+ /status - show current session configuration
+ /permissions - choose what Codex is allowed to do
+ /model - choose what model and reasoning effort to use
+ /review - review any changes and find issues
+
+› spoken partial completed
diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_replay_reconciles_matching_captions.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_replay_reconciles_matching_captions.snap
new file mode 100644
index 0000000000..76e81da3ea
--- /dev/null
+++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__realtime_requests__voice_replay_reconciles_matching_captions.snap
@@ -0,0 +1,33 @@
+---
+source: tui/src/app/tests/realtime_requests.rs
+assertion_line: 561
+expression: "normalize_voice_snapshot_directory(&rendered, &app.config.cwd)"
+---
+╭────────────────────────────────────────╮
+│ >_ OpenAI Codex (v0.0.0) │
+│ │
+│ model: gpt-test /model to change │
+│ directory: /tmp/project │
+╰────────────────────────────────────────╯
+
+ To get started, describe a task or try one of these commands:
+
+ /init - create an AGENTS.md file with instructions for Codex
+ /status - show current session configuration
+ /permissions - choose what Codex is allowed to do
+ /model - choose what model and reasoning effort to use
+ /review - review any changes and find issues
+
+› show & tests
+
+• spoken answer
+
+› typed words
+
+
+› show & tests
+
+
+› typed words
+
+• different caption
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests.rs b/codex-rs/tui/src/chatwidget/realtime_tests.rs
index 8c194c47a2..4df28cfe83 100644
--- a/codex-rs/tui/src/chatwidget/realtime_tests.rs
+++ b/codex-rs/tui/src/chatwidget/realtime_tests.rs
@@ -1,11 +1,37 @@
-//! Focused visual coverage and shared setup for voice behavior tests.
+//! Regression coverage for thread-owned voice sessions and transcript/handoff safety.
+//! Synthetic events preserve typed turns and reject stale session generations.
use super::RealtimeConversationPhase;
+use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::chatwidget::ChatWidget;
+use crate::chatwidget::ReplayKind;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::chatwidget::tests::render_bottom_popup;
+use codex_app_server_protocol::AgentMessageDeltaNotification;
+use codex_app_server_protocol::ItemCompletedNotification;
+use codex_app_server_protocol::ItemStartedNotification;
+use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
+use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
+use codex_app_server_protocol::ReasoningTextDeltaNotification;
+use codex_app_server_protocol::ServerNotification;
+use codex_app_server_protocol::ThreadItem;
+use codex_app_server_protocol::Turn;
+use codex_app_server_protocol::TurnCompletedNotification;
+use codex_app_server_protocol::TurnItemsView;
+use codex_app_server_protocol::TurnStatus;
+use codex_app_server_protocol::UserInput;
use codex_protocol::ThreadId;
+use codex_protocol::items::AsyncUserInputQuestion;
+use codex_protocol::models::MessagePhase;
+use futures::future::AbortHandle;
+use std::collections::VecDeque;
+
+fn activate_voice(chat: &mut ChatWidget) -> ThreadId {
+ let thread_id = ThreadId::new();
+ activate_voice_for_thread(chat, thread_id);
+ thread_id
+}
pub(crate) fn activate_voice_for_thread(chat: &mut ChatWidget, thread_id: ThreadId) {
chat.thread_id = Some(thread_id);
@@ -15,93 +41,99 @@ pub(crate) fn activate_voice_for_thread(chat: &mut ChatWidget, thread_id: Thread
chat.realtime_conversation.latest_input_was_voice = true;
}
-#[tokio::test]
-async fn enabling_voice_on_an_open_thread_snapshots_the_new_thread_notice() {
- let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
- chat.set_feature_enabled(
- codex_features::Feature::RealtimeConversation,
- /*enabled*/ true,
+fn user_item(text: &str) -> ThreadItem {
+ ThreadItem::UserMessage {
+ id: format!("user-{text}"),
+ client_id: None,
+ content: vec![UserInput::Text {
+ text: text.to_string(),
+ text_elements: Vec::new(),
+ }],
+ }
+}
+
+fn agent_item(item_id: &str, text: &str, phase: Option) -> ThreadItem {
+ ThreadItem::AgentMessage {
+ id: item_id.to_string(),
+ text: text.to_string(),
+ phase,
+ questions: None,
+ memory_citation: None,
+ delivery: None,
+ }
+}
+
+fn start_item(chat: &mut ChatWidget, thread_id: ThreadId, turn_id: &str, item: ThreadItem) {
+ chat.handle_server_notification(
+ ServerNotification::ItemStarted(ItemStartedNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.to_string(),
+ item,
+ started_at_ms: 0,
+ }),
+ /*replay_kind*/ None,
);
- let rendered = std::iter::from_fn(|| events.try_recv().ok())
- .filter_map(|event| match event {
- AppEvent::InsertHistoryCell(cell) => Some(
- cell.display_lines(/*width*/ 80)
- .into_iter()
- .map(|line| line.to_string())
- .collect::>()
- .join("\n"),
- ),
- _ => None,
+}
+
+fn complete_item(chat: &mut ChatWidget, thread_id: ThreadId, turn_id: &str, item: ThreadItem) {
+ chat.handle_server_notification(
+ ServerNotification::ItemCompleted(ItemCompletedNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.to_string(),
+ item,
+ completed_at_ms: 0,
+ }),
+ /*replay_kind*/ None,
+ );
+}
+
+fn finish_turn(
+ chat: &mut ChatWidget,
+ thread_id: ThreadId,
+ turn_id: &str,
+ items: Vec,
+ status: TurnStatus,
+) {
+ chat.handle_server_notification(
+ ServerNotification::TurnCompleted(TurnCompletedNotification {
+ thread_id: thread_id.to_string(),
+ turn: Turn {
+ id: turn_id.to_string(),
+ items,
+ items_view: TurnItemsView::Summary,
+ status,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ },
+ }),
+ /*replay_kind*/ None,
+ );
+}
+
+fn without_completion_metadata(rendered: &str) -> String {
+ rendered
+ .lines()
+ .filter_map(|line| {
+ let stable = line
+ .split_once(" done ")
+ .map_or(line, |(before, _)| before);
+ (!stable.is_empty()).then_some(stable)
})
.collect::>()
- .join("\n");
- insta::assert_snapshot!(rendered, @"• Voice conversations will be available in new threads.");
+ .join("\n")
}
-#[tokio::test]
-async fn voice_footer_renders_the_main_conversation_states() {
- let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
- let mut states = Vec::new();
-
- for (label, phase, muted, level, speaker_level, role, transcript) in [
- (
- "connecting",
- RealtimeConversationPhase::Starting,
- false,
- 0,
- 0,
- None,
- "",
- ),
- (
- "listening",
- RealtimeConversationPhase::Active,
- false,
- 0,
- 0,
- None,
- "",
- ),
- (
- "speaking",
- RealtimeConversationPhase::Active,
- false,
- 4,
- 5,
- None,
- "",
- ),
- (
- "muted",
- RealtimeConversationPhase::Active,
- true,
- 4,
- 4,
- None,
- "",
- ),
- (
- "transcript",
- RealtimeConversationPhase::Active,
- false,
- 2,
- 2,
- Some("assistant"),
- "Hello there",
- ),
- ] {
- chat.realtime_conversation.phase = phase;
- chat.realtime_conversation.microphone_muted = muted;
- chat.realtime_conversation.microphone_level = level;
- chat.realtime_conversation.speaker_level = speaker_level;
- chat.realtime_conversation.transcript_role = role.map(str::to_string);
- chat.realtime_conversation.transcript = transcript.to_string();
- chat.update_realtime_footer();
- states.push(format!(
- "{label}:\n{}",
- render_bottom_popup(&chat, /*width*/ 80)
- ));
- }
-
- insta::assert_snapshot!(states.join("\n\n"));
-}
+#[path = "realtime_tests/caption_replay.rs"]
+mod caption_replay;
+#[path = "realtime_tests/handoff_privacy.rs"]
+mod handoff_privacy;
+#[path = "realtime_tests/handoffs.rs"]
+mod handoffs;
+#[path = "realtime_tests/lifecycle.rs"]
+mod lifecycle;
+#[path = "realtime_tests/speech_recovery.rs"]
+mod speech_recovery;
+#[path = "realtime_tests/transcripts.rs"]
+mod transcripts;
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs b/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs
new file mode 100644
index 0000000000..6aaa9d1130
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs
@@ -0,0 +1,392 @@
+//! Replay and captions reconcile once across thread and session changes.
+
+use super::*;
+use pretty_assertions::assert_eq;
+
+#[tokio::test]
+async fn replay_preserves_typed_updates_before_voice_steers_the_turn() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.thread_id = Some(ThreadId::new());
+ chat.replay_thread_turns(
+ vec![Turn {
+ id: "typed-then-voice".into(),
+ items: vec![
+ user_item("Typed question"),
+ agent_item(
+ "typed-update",
+ "Checking the typed request",
+ Some(MessagePhase::Commentary),
+ ),
+ ThreadItem::Reasoning {
+ id: "typed-reasoning".into(),
+ summary: vec!["Typed reasoning summary".into()],
+ content: Vec::new(),
+ },
+ user_item(
+ "spoken correction",
+ ),
+ agent_item(
+ "private-update",
+ "Private voice commentary",
+ Some(MessagePhase::Commentary),
+ ),
+ ThreadItem::Reasoning {
+ id: "private-reasoning".into(),
+ summary: vec!["Private voice reasoning".into()],
+ content: Vec::new(),
+ },
+ ],
+ items_view: TurnItemsView::Full,
+ status: TurnStatus::Completed,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ }],
+ ReplayKind::ThreadSnapshot,
+ );
+ chat.flush_answer_stream_with_separator();
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert!(
+ rendered.contains("Checking the typed request"),
+ "{rendered}"
+ );
+ assert!(rendered.contains("Typed reasoning summary"), "{rendered}");
+ assert!(!rendered.contains("Private voice"), "{rendered}");
+ insta::assert_snapshot!("typed_turn_replay_before_voice_steering", rendered);
+}
+
+#[tokio::test]
+async fn in_progress_voice_replay_restores_the_late_reasoning_guard() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = ThreadId::new();
+ chat.thread_id = Some(thread_id);
+ let voice_request =
+ user_item("question");
+ chat.replay_thread_turns(
+ vec![Turn {
+ id: "saved-voice-turn".into(),
+ items: vec![voice_request.clone()],
+ items_view: TurnItemsView::Full,
+ status: TurnStatus::InProgress,
+ error: None,
+ started_at: None,
+ completed_at: None,
+ duration_ms: None,
+ }],
+ ReplayKind::ThreadSnapshot,
+ );
+ chat.handle_server_notification(
+ ServerNotification::ItemStarted(ItemStartedNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: "buffered-voice-turn".into(),
+ item: voice_request,
+ started_at_ms: 0,
+ }),
+ Some(ReplayKind::ThreadSnapshot),
+ );
+ while events.try_recv().is_ok() {}
+ for turn_id in ["saved-voice-turn", "buffered-voice-turn"] {
+ chat.handle_server_notification(
+ ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.into(),
+ item_id: "late-reasoning".into(),
+ delta: "private after switch".into(),
+ summary_index: 0,
+ }),
+ /*replay_kind*/ None,
+ );
+ complete_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ ThreadItem::Reasoning {
+ id: "late-reasoning".into(),
+ summary: vec!["private after switch".into()],
+ content: Vec::new(),
+ },
+ );
+ assert!(chat.is_realtime_delegated_reasoning_turn(turn_id));
+ }
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ let rendered = cell
+ .transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::();
+ assert!(!rendered.contains("private"), "{rendered}");
+ }
+ }
+}
+
+#[tokio::test]
+async fn accepted_voice_answer_with_only_an_old_caption_returns_to_history_on_close() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "accepted-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ let answer = agent_item("answer", "Accepted answer", Some(MessagePhase::FinalAnswer));
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+ let AppCommand::RealtimeConversationSpeech { delivery_id, .. } = ops.try_recv().unwrap() else {
+ panic!("completed voice turn should queue speech");
+ };
+ while events.try_recv().is_ok() {}
+ chat.accept_realtime_speech(delivery_id);
+ assert!(!chat.has_pending_realtime_speech(delivery_id));
+ chat.realtime_conversation.assistant_transcript_generation =
+ Some(chat.realtime_conversation.input_generation.wrapping_sub(1));
+ chat.on_realtime_transcript_done("assistant".into(), "Earlier speech".into());
+ assert_eq!(chat.realtime_conversation.pending_speech.len(), 1);
+ chat.on_realtime_conversation_closed(Some("transport_closed".into()));
+ chat.restore_undelivered_realtime_speech(delivery_id);
+ let mut answers = 0;
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ let rendered = cell
+ .display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::();
+ answers += usize::from(rendered.contains("Accepted answer"));
+ }
+ }
+ assert_eq!(answers, 1);
+}
+
+#[tokio::test]
+async fn unrelated_caption_started_before_speech_queue_keeps_answer_fallback() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "answer-after-old-caption";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ chat.on_realtime_transcript_delta("assistant".into(), "Unrelated ".into());
+ let answer = agent_item("answer", "New answer", Some(MessagePhase::FinalAnswer));
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+ let AppCommand::RealtimeConversationSpeech { delivery_id, .. } = ops.try_recv().unwrap() else {
+ panic!("completed voice turn should queue speech");
+ };
+ while events.try_recv().is_ok() {}
+ chat.accept_realtime_speech(delivery_id);
+ chat.on_realtime_transcript_done("assistant".into(), "Unrelated caption".into());
+ assert_eq!(chat.realtime_conversation.pending_speech.len(), 1);
+ chat.on_realtime_conversation_closed(Some("transport_closed".into()));
+ let restored = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .filter(|text| text.contains("New answer"))
+ .count();
+ assert_eq!(restored, 1);
+}
+
+#[tokio::test]
+async fn done_only_caption_cannot_retire_a_newer_voice_answer() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "done-only-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ let answer = agent_item("answer", "New answer", Some(MessagePhase::FinalAnswer));
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+ let AppCommand::RealtimeConversationSpeech { delivery_id, .. } = ops.try_recv().unwrap() else {
+ panic!("completed voice turn should queue speech");
+ };
+ while events.try_recv().is_ok() {}
+ chat.accept_realtime_speech(delivery_id);
+ assert!(
+ chat.realtime_conversation
+ .assistant_transcript_generation
+ .is_none()
+ );
+ chat.on_realtime_transcript_done("assistant".into(), "Old done-only caption".into());
+ assert_eq!(chat.realtime_conversation.pending_speech.len(), 1);
+ chat.on_realtime_conversation_closed(Some("transport_closed".into()));
+ let restored = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .filter(|text| text.contains("New answer"))
+ .count();
+ assert_eq!(restored, 1);
+}
+
+#[tokio::test]
+async fn captioned_voice_answer_does_not_duplicate_on_close() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "captioned-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ let answer = agent_item(
+ "answer",
+ "Captioned answer",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+ let AppCommand::RealtimeConversationSpeech { delivery_id, .. } = ops.try_recv().unwrap() else {
+ panic!("completed voice turn should queue speech");
+ };
+ while events.try_recv().is_ok() {}
+ // A delta establishes which input generation owns the completed caption.
+ chat.on_realtime_transcript_delta("assistant".into(), "Captioned ".into());
+ chat.on_realtime_transcript_done("assistant".into(), "Captioned answer".into());
+ chat.accept_realtime_speech(delivery_id);
+ chat.reset_realtime_conversation();
+ chat.restore_undelivered_realtime_speech(delivery_id);
+ let mut rendered = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ rendered.extend(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string()),
+ );
+ }
+ }
+ insta::assert_snapshot!("captioned_voice_answer_on_close", rendered.join("\n"));
+}
+
+#[tokio::test]
+async fn restored_partial_caption_accepts_late_completion_without_duplicate_history() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.restore_realtime_transcript_cells(VecDeque::from([
+ super::super::RealtimeTranscriptRecord {
+ role: "user".into(),
+ text: "last ".into(),
+ complete: false,
+ },
+ ]));
+ let live = chat
+ .realtime_conversation
+ .live_transcript_cell
+ .as_ref()
+ .unwrap();
+ insta::assert_snapshot!(
+ "restored_voice_partial_live",
+ live.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n")
+ );
+ chat.on_realtime_transcript_delta("user".into(), "words".into());
+ let retained = chat.take_realtime_transcript_cells_for_replay();
+ assert_eq!(retained.len(), 1);
+ assert_eq!(retained[0].text, "last words");
+ chat.restore_realtime_transcript_cells(retained);
+ chat.on_realtime_transcript_done("user".into(), "last words".into());
+ assert!(chat.realtime_conversation.live_transcript_cell.is_none());
+ assert_eq!(chat.realtime_conversation.accepted_transcripts.len(), 1);
+ assert_eq!(
+ chat.realtime_conversation.accepted_transcripts[0].text,
+ "last words"
+ );
+ assert!(chat.realtime_conversation.accepted_transcripts[0].complete);
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::(),
+ ),
+ _ => None,
+ })
+ .collect::();
+ assert_eq!(rendered.matches("last words").count(), 1);
+}
+
+#[tokio::test]
+async fn empty_late_completion_discards_the_restored_partial() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.restore_realtime_transcript_cells(VecDeque::from([
+ super::super::RealtimeTranscriptRecord {
+ role: "user".into(),
+ text: "unfinished".into(),
+ complete: false,
+ },
+ ]));
+ chat.on_realtime_transcript_done("user".into(), String::new());
+
+ assert!(chat.realtime_conversation.live_transcript_cell.is_none());
+ assert!(chat.realtime_conversation.accepted_transcripts.is_empty());
+ assert!(events.try_recv().is_err());
+}
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs b/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs
new file mode 100644
index 0000000000..4a9c0ebb53
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/handoff_privacy.rs
@@ -0,0 +1,481 @@
+//! Typed and spoken steering preserve ownership and private reasoning boundaries.
+
+use super::*;
+use pretty_assertions::assert_eq;
+
+fn complete_stale_handoff(chat: &mut ChatWidget, thread_id: ThreadId, question: &str) {
+ let turn_id = "stale-turn";
+ start_item(
+ chat,
+ thread_id,
+ turn_id,
+ user_item(&format!(
+ "{question}"
+ )),
+ );
+ let answer = agent_item(
+ "stale-answer",
+ "stale answer",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(chat, thread_id, turn_id, answer.clone());
+ complete_item(chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+}
+
+#[tokio::test]
+async fn typed_turn_remains_a_normal_text_response_while_voice_is_active() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "typed-turn";
+ chat.turn_lifecycle.last_turn_id = Some(turn_id.to_string());
+ let typed = user_item("pick a random number");
+ start_item(&mut chat, thread_id, turn_id, typed.clone());
+ complete_item(&mut chat, thread_id, turn_id, typed);
+ let answer = agent_item("typed-answer", "73", Some(MessagePhase::FinalAnswer));
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(matches!(
+ chat.pending_notification.as_ref(),
+ Some(crate::chatwidget::Notification::AgentTurnComplete { response }) if response == "73"
+ ));
+ let mut rendered = String::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ for line in cell.transcript_lines(/*width*/ 80) {
+ rendered.push_str(&line.to_string());
+ }
+ }
+ }
+ assert!(rendered.contains("pick a random number"));
+ assert!(rendered.contains("73"));
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn typed_steering_restores_normal_output_for_an_existing_voice_item() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "shared-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("earlier voice"),
+ );
+ let answer = agent_item(
+ "shared-answer",
+ "typed answer",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ let private = agent_item(
+ "private-commentary",
+ "private voice commentary",
+ Some(MessagePhase::Commentary),
+ );
+ start_item(&mut chat, thread_id, turn_id, private.clone());
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("new typed question"),
+ );
+ complete_item(&mut chat, thread_id, turn_id, private);
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ let mut rendered = String::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ for line in cell.display_lines(/*width*/ 80) {
+ rendered.push_str(&line.to_string());
+ }
+ }
+ }
+ assert!(rendered.contains("typed answer"), "{rendered}");
+ assert!(!rendered.contains("private voice commentary"), "{rendered}");
+ insta::assert_snapshot!(
+ "typed_steering_keeps_voice_commentary_private",
+ without_completion_metadata(&rendered)
+ );
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn newer_voice_steering_an_existing_typed_turn_is_spoken_once() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "shared-typed-turn";
+ start_item(&mut chat, thread_id, turn_id, user_item("typed task"));
+ let earlier_typed = agent_item("typed-item", "existing typed work", /*phase*/ None);
+ start_item(&mut chat, thread_id, turn_id, earlier_typed);
+
+ chat.on_realtime_transcript_delta("user".to_string(), "spoken follow-up".to_string());
+ chat.on_realtime_transcript_done("user".to_string(), "spoken follow-up".to_string());
+ let delegation =
+ user_item("spoken follow-up");
+ start_item(&mut chat, thread_id, turn_id, delegation.clone());
+ complete_item(&mut chat, thread_id, turn_id, delegation);
+ let answer = agent_item(
+ "voice-item",
+ "answer to the spoken follow-up",
+ /*phase*/ None,
+ );
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(matches!(
+ ops.try_recv(),
+ Ok(AppCommand::RealtimeConversationSpeech { text, .. })
+ if text.as_str() == "answer to the spoken follow-up"
+ ));
+ assert!(ops.try_recv().is_err());
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ assert!(
+ cell.display_lines(/*width*/ 80)
+ .iter()
+ .all(|line| !line.to_string().contains("realtime_delegation"))
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn voice_handoff_preserves_started_typed_reasoning_but_hides_new_reasoning() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "mixed-reasoning-turn";
+ start_item(&mut chat, thread_id, turn_id, user_item("typed task"));
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ ThreadItem::Reasoning {
+ id: "typed-reasoning".into(),
+ summary: Vec::new(),
+ content: Vec::new(),
+ },
+ );
+ chat.handle_server_notification(
+ ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.into(),
+ item_id: "typed-reasoning".into(),
+ delta: "Typed beginning ".into(),
+ summary_index: 0,
+ }),
+ /*replay_kind*/ None,
+ );
+
+ let delegation =
+ user_item("spoken follow-up");
+ start_item(&mut chat, thread_id, turn_id, delegation);
+ chat.handle_server_notification(
+ ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.into(),
+ item_id: "typed-reasoning".into(),
+ delta: "and typed tail".into(),
+ summary_index: 0,
+ }),
+ /*replay_kind*/ None,
+ );
+ complete_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ ThreadItem::Reasoning {
+ id: "typed-reasoning".into(),
+ summary: vec!["Typed beginning and typed tail".into()],
+ content: Vec::new(),
+ },
+ );
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ ThreadItem::Reasoning {
+ id: "private-reasoning".into(),
+ summary: Vec::new(),
+ content: Vec::new(),
+ },
+ );
+ chat.handle_server_notification(
+ ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.into(),
+ item_id: "private-reasoning".into(),
+ delta: "private after handoff".into(),
+ summary_index: 0,
+ }),
+ /*replay_kind*/ None,
+ );
+ complete_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ ThreadItem::Reasoning {
+ id: "private-reasoning".into(),
+ summary: vec!["private after handoff".into()],
+ content: Vec::new(),
+ },
+ );
+
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert!(
+ rendered.contains("Typed beginning and typed tail"),
+ "{rendered}"
+ );
+ assert!(!rendered.contains("private after handoff"), "{rendered}");
+ insta::assert_snapshot!("typed_reasoning_through_voice_handoff", rendered);
+}
+
+#[tokio::test]
+async fn voice_delegation_without_transcript_can_steer_an_existing_typed_turn() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "shared-turn-without-transcript";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("earlier typed task"),
+ );
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("new spoken request"),
+ );
+ let answer = agent_item(
+ "voice-answer",
+ "new spoken answer",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(matches!(
+ ops.try_recv(),
+ Ok(AppCommand::RealtimeConversationSpeech { text, .. }) if text.as_str() == "new spoken answer"
+ ));
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn typed_agent_item_completes_visibly_after_voice_handoff() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "typed-then-voice";
+ start_item(&mut chat, thread_id, turn_id, user_item("typed request"));
+ let typed = agent_item(
+ "typed-commentary",
+ "Visible typed commentary",
+ Some(MessagePhase::Commentary),
+ );
+ start_item(&mut chat, thread_id, turn_id, typed.clone());
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("voice request"),
+ );
+ complete_item(&mut chat, thread_id, turn_id, typed);
+ let private = agent_item(
+ "voice-commentary",
+ "Hidden voice commentary",
+ Some(MessagePhase::Commentary),
+ );
+ start_item(&mut chat, thread_id, turn_id, private.clone());
+ complete_item(&mut chat, thread_id, turn_id, private);
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert!(rendered.contains("Visible typed commentary"), "{rendered}");
+ assert!(!rendered.contains("Hidden voice commentary"), "{rendered}");
+}
+
+#[tokio::test]
+async fn stale_voice_handoff_after_newer_typed_input_is_never_spoken() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.on_realtime_transcript_done("user".to_string(), "How are you doing".to_string());
+ chat.note_realtime_typed_input("what's the best pizza in new york?");
+ complete_stale_handoff(&mut chat, thread_id, "How are you doing");
+
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn typed_submission_suppresses_old_speaker_audio() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.note_realtime_typed_input("typed correction");
+ let generation = chat.realtime_conversation.input_generation;
+ assert_eq!(
+ chat.realtime_conversation.speaker_suppression_generation,
+ Some(generation)
+ );
+ start_item(
+ &mut chat,
+ thread_id,
+ "typed-turn",
+ user_item("typed correction"),
+ );
+ assert_eq!(
+ chat.realtime_conversation.speaker_suppression_generation,
+ Some(chat.realtime_conversation.input_generation)
+ );
+}
+
+#[tokio::test]
+async fn late_voice_transcript_after_typed_input_cannot_revive_a_stale_handoff() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta("user".to_string(), "How are ".to_string());
+ chat.note_realtime_typed_input("what's the best pizza in new york?");
+ chat.on_realtime_transcript_delta("user".to_string(), "you doing".to_string());
+ chat.on_realtime_transcript_done("user".to_string(), "How are you doing".to_string());
+
+ complete_stale_handoff(&mut chat, thread_id, "How are you doing");
+
+ assert!(!chat.realtime_conversation.latest_input_was_voice);
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn duplicate_delta_less_transcript_after_typed_input_cannot_revive_old_voice() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.on_realtime_transcript_done("user".to_string(), "old question".to_string());
+ while events.try_recv().is_ok() {}
+ chat.note_realtime_typed_input("newer typed question");
+ let typed_generation = chat.realtime_conversation.input_generation;
+
+ chat.on_realtime_transcript_done("user".to_string(), "old question".to_string());
+
+ assert_eq!(
+ chat.realtime_conversation.input_generation,
+ typed_generation
+ );
+ assert!(!chat.realtime_conversation.latest_input_was_voice);
+ assert!(events.try_recv().is_err());
+ complete_stale_handoff(&mut chat, thread_id, "old question");
+
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn stale_voice_handoff_longer_than_the_display_limit_cannot_be_revived() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let old_question = format!("{} final words", "é".repeat(600));
+ chat.on_realtime_transcript_done("user".to_string(), old_question.clone());
+ while events.try_recv().is_ok() {}
+ chat.note_realtime_typed_input("a newer typed question");
+ let typed_generation = chat.realtime_conversation.input_generation;
+
+ chat.on_realtime_transcript_done("user".to_string(), old_question.clone());
+
+ assert_eq!(
+ chat.realtime_conversation.input_generation,
+ typed_generation
+ );
+ assert!(!chat.realtime_conversation.latest_input_was_voice);
+ assert!(events.try_recv().is_err());
+ complete_stale_handoff(&mut chat, thread_id, &old_question);
+
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn new_voice_handoff_without_transcript_still_speaks_after_a_typed_turn() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.note_realtime_typed_input("a previous typed question");
+ let turn_id = "new-voice-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("fresh spoken request"),
+ );
+ let answer = agent_item(
+ "answer",
+ "The voice answer",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(matches!(
+ ops.try_recv(),
+ Ok(AppCommand::RealtimeConversationSpeech { text, .. }) if text.as_str() == "The voice answer"
+ ));
+ assert!(ops.try_recv().is_err());
+}
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs b/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs
new file mode 100644
index 0000000000..adb10ee428
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/handoffs.rs
@@ -0,0 +1,424 @@
+//! Voice delegation routes completed answers without exposing private protocol text.
+
+use super::*;
+use pretty_assertions::assert_eq;
+
+#[tokio::test]
+async fn closing_voice_before_turn_completion_restores_the_delegated_answer_once() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "voice-turn-closing";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("the answer?"),
+ );
+ let answer = agent_item(
+ "final-answer",
+ "The answer is 42.",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ assert!(events.try_recv().is_err());
+
+ chat.on_realtime_conversation_closed(/*reason*/ None);
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ let mut visible_answers = 0;
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ visible_answers += cell
+ .display_lines(/*width*/ 80)
+ .iter()
+ .any(|line| line.to_string().contains("The answer is 42."))
+ as usize;
+ }
+ }
+ assert_eq!(visible_answers, 1);
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn delegated_voice_work_speaks_only_the_completed_final_answer_once() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "voice-turn";
+ chat.turn_lifecycle.last_turn_id = Some(turn_id.to_string());
+ let delegation =
+ user_item("best pizza?");
+ start_item(&mut chat, thread_id, turn_id, delegation.clone());
+ complete_item(&mut chat, thread_id, turn_id, delegation);
+
+ let commentary = agent_item(
+ "commentary",
+ "[COMMENTARY] checking current guides",
+ Some(MessagePhase::Commentary),
+ );
+ start_item(&mut chat, thread_id, turn_id, commentary.clone());
+ complete_item(&mut chat, thread_id, turn_id, commentary);
+ let first_final = agent_item("first-final", "Earlier guess", /*phase*/ None);
+ start_item(&mut chat, thread_id, turn_id, first_final.clone());
+ complete_item(&mut chat, thread_id, turn_id, first_final);
+ let final_answer = agent_item(
+ "last-final",
+ "[FINAL] Lucali in Brooklyn",
+ /*phase*/ None,
+ );
+ start_item(&mut chat, thread_id, turn_id, final_answer.clone());
+ chat.handle_server_notification(
+ ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.to_string(),
+ item_id: "last-final".to_string(),
+ delta: "Lucali".to_string(),
+ }),
+ /*replay_kind*/ None,
+ );
+ complete_item(&mut chat, thread_id, turn_id, final_answer.clone());
+ assert!(ops.try_recv().is_err());
+ assert!(events.try_recv().is_err());
+
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![final_answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(
+ chat.pending_notification.is_none(),
+ "voice-delegated turns should not trigger desktop completion notifications"
+ );
+ assert!(matches!(
+ ops.try_recv(),
+ Ok(AppCommand::RealtimeConversationSpeech { thread_id: spoken_thread_id, text, .. })
+ if spoken_thread_id == thread_id && text.as_str() == "Lucali in Brooklyn"
+ ));
+ assert!(ops.try_recv().is_err());
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ assert!(
+ cell.display_lines(/*width*/ 80)
+ .iter()
+ .all(|line| !line.to_string().contains("Earlier guess"))
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn delegation_started_before_peer_connection_keeps_its_voice_origin() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.backend_started = true;
+ chat.realtime_conversation.webrtc_connected = false;
+ let turn_id = "startup-delegation";
+ chat.turn_lifecycle.agent_turn_running = true;
+ chat.turn_lifecycle.last_turn_id = Some(turn_id.to_string());
+ let delegation =
+ user_item("spoken question");
+ start_item(&mut chat, thread_id, turn_id, delegation.clone());
+ complete_item(&mut chat, thread_id, turn_id, delegation);
+
+ chat.on_realtime_webrtc_connected(chat.realtime_conversation.attempt_id, Ok(()));
+ let final_answer = agent_item(
+ "startup-final",
+ "[FINAL] Spoken answer",
+ /*phase*/ None,
+ );
+ start_item(&mut chat, thread_id, turn_id, final_answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, final_answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![final_answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(matches!(
+ ops.try_recv(),
+ Ok(AppCommand::RealtimeConversationSpeech { text, .. }) if text.as_str() == "Spoken answer"
+ ));
+ assert!(ops.try_recv().is_err());
+ let mut rendered_history = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ let rendered = cell
+ .transcript_lines(/*width*/ 80)
+ .iter()
+ .map(ToString::to_string)
+ .collect::();
+ assert!(!rendered.contains("Spoken answer"));
+ assert!(!rendered.contains(""));
+ rendered_history.push(rendered);
+ }
+ }
+ insta::assert_snapshot!(
+ "voice_delegation_during_connection",
+ without_completion_metadata(&rendered_history.join("\n"))
+ );
+}
+
+#[tokio::test]
+async fn delegated_answer_with_async_question_opens_the_editor_instead_of_speech() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "question-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("choose"),
+ );
+ let answer = ThreadItem::AgentMessage {
+ id: "question-answer".into(),
+ text: "Which option?".into(),
+ phase: Some(MessagePhase::FinalAnswer),
+ questions: Some(vec![AsyncUserInputQuestion {
+ title: "Which option?".into(),
+ options: None,
+ }]),
+ memory_citation: None,
+ delivery: None,
+ };
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ assert_eq!(
+ chat.bottom_pane
+ .questions
+ .as_ref()
+ .map(|editor| editor.unanswered_count()),
+ Some(1)
+ );
+ assert!(
+ ops.try_recv().is_err(),
+ "question-bearing answer stays in the TUI"
+ );
+}
+
+#[tokio::test]
+async fn typed_turn_running_during_peer_connection_stays_typed() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.backend_started = true;
+ chat.realtime_conversation.webrtc_connected = false;
+ chat.turn_lifecycle.agent_turn_running = true;
+ chat.turn_lifecycle.last_turn_id = Some("typed-startup".to_string());
+
+ chat.on_realtime_webrtc_connected(chat.realtime_conversation.attempt_id, Ok(()));
+
+ assert!(!chat.realtime_conversation.latest_input_was_voice);
+ assert!(matches!(
+ chat.realtime_conversation.turn_origins.get("typed-startup"),
+ Some(super::super::RealtimeTurnOrigin::Typed { .. })
+ ));
+ assert_eq!(chat.thread_id(), Some(thread_id));
+}
+
+#[tokio::test]
+async fn delegated_item_that_becomes_final_at_turn_completion_is_recoverable() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "changed-final-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ let analysis = agent_item(
+ "same-id",
+ "[ANALYSIS] secret",
+ Some(MessagePhase::Commentary),
+ );
+ start_item(&mut chat, thread_id, turn_id, analysis.clone());
+ complete_item(&mut chat, thread_id, turn_id, analysis);
+ let final_answer = agent_item("same-id", "Public answer", Some(MessagePhase::FinalAnswer));
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![final_answer],
+ TurnStatus::Completed,
+ );
+ let AppCommand::RealtimeConversationSpeech {
+ delivery_id, text, ..
+ } = ops.try_recv().unwrap()
+ else {
+ panic!("changed final should be delivered");
+ };
+ assert_eq!(text.as_str(), "Public answer");
+ chat.restore_undelivered_realtime_speech(delivery_id);
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ for line in cell.display_lines(/*width*/ 80) {
+ assert!(!line.to_string().contains("secret"));
+ }
+ }
+ }
+}
+
+#[tokio::test]
+async fn explicit_final_answer_can_explain_private_channel_markers() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "marker-final-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("explain markers"),
+ );
+ let answer = agent_item(
+ "marker-final",
+ "[COMMENTARY] is a label in the documentation.",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+ let AppCommand::RealtimeConversationSpeech {
+ delivery_id, text, ..
+ } = ops.try_recv().expect("final answer should be delivered")
+ else {
+ panic!("expected speech delivery");
+ };
+ assert_eq!(
+ text.as_str(),
+ "[COMMENTARY] is a label in the documentation."
+ );
+ assert!(ops.try_recv().is_err());
+ chat.restore_undelivered_realtime_speech(delivery_id);
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ insta::assert_snapshot!(
+ "explicit_final_answer_with_channel_marker",
+ without_completion_metadata(&rendered)
+ );
+}
+
+#[tokio::test]
+async fn newer_spoken_input_prevents_an_older_voice_turn_from_speaking() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta("user".to_string(), "first question".to_string());
+ chat.on_realtime_transcript_done("user".to_string(), "first question".to_string());
+
+ let old_turn_id = "old-voice-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ old_turn_id,
+ user_item("first question"),
+ );
+ let old_answer = agent_item(
+ "old-answer",
+ "answer to the earlier question",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, old_turn_id, old_answer.clone());
+ complete_item(&mut chat, thread_id, old_turn_id, old_answer.clone());
+
+ chat.on_realtime_transcript_delta("user".to_string(), "newer question".to_string());
+ chat.on_realtime_transcript_done("user".to_string(), "newer question".to_string());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ old_turn_id,
+ vec![old_answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn newer_voice_delegation_on_the_same_turn_speaks_only_its_own_answer() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.on_realtime_transcript_done("user".to_string(), "first".to_string());
+ let turn_id = "shared-voice-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("first question"),
+ );
+ let old_answer = agent_item(
+ "old-answer",
+ "answer to the earlier question",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, old_answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, old_answer);
+
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("newer question"),
+ );
+ let latest_answer = agent_item(
+ "latest-answer",
+ "answer to the newer question",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, latest_answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, latest_answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![latest_answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(matches!(
+ ops.try_recv(),
+ Ok(AppCommand::RealtimeConversationSpeech { text, .. })
+ if text.as_str() == "answer to the newer question"
+ ));
+ let owner = chat.realtime_conversation.speaker_suppression_generation;
+ assert_eq!(owner, Some(chat.realtime_conversation.input_generation));
+ chat.on_realtime_transcript_delta("assistant".to_string(), "answer".to_string());
+ let owner = chat.realtime_conversation.speaker_suppression_generation;
+ assert!(owner.is_none());
+ assert!(ops.try_recv().is_err());
+}
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs b/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs
new file mode 100644
index 0000000000..0b3ef3a0d4
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs
@@ -0,0 +1,581 @@
+//! Voice startup, shutdown, and retry maintain one active owned session.
+
+use super::*;
+use pretty_assertions::assert_eq;
+
+#[tokio::test]
+async fn enabling_voice_on_an_open_thread_snapshots_the_new_thread_notice() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.set_feature_enabled(
+ codex_features::Feature::RealtimeConversation,
+ /*enabled*/ true,
+ );
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ insta::assert_snapshot!(rendered, @"• Voice conversations will be available in new threads.");
+}
+
+#[tokio::test]
+async fn voice_cannot_start_in_a_side_conversation() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ chat.set_side_conversation_active(/*active*/ true);
+
+ chat.toggle_realtime_conversation();
+
+ let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else {
+ panic!("voice should report that side conversations are unsupported");
+ };
+ assert!(
+ cell.display_lines(/*width*/ 80)
+ .iter()
+ .any(|line| line.to_string().contains("side conversations"))
+ );
+ assert!(ops.try_recv().is_err());
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Inactive
+ );
+}
+
+#[tokio::test]
+async fn mute_during_startup_is_saved_before_the_offer_handle_exists() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.thread_id = Some(ThreadId::new());
+
+ chat.toggle_realtime_microphone();
+ assert!(chat.realtime_conversation.microphone_muted);
+ assert!(events.try_recv().is_err());
+
+ chat.toggle_realtime_microphone();
+ assert!(!chat.realtime_conversation.microphone_muted);
+ assert!(events.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn voice_cannot_start_on_a_parent_owned_thread() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.blocks_direct_input = true;
+
+ chat.toggle_realtime_conversation();
+
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Inactive
+ );
+ assert!(matches!(
+ events.try_recv(),
+ Ok(AppEvent::InsertHistoryCell(_))
+ ));
+}
+
+#[tokio::test]
+async fn voice_footer_renders_the_main_conversation_states() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ let mut states = Vec::new();
+
+ for (label, phase, muted, level, speaker_level, role, transcript) in [
+ (
+ "connecting",
+ RealtimeConversationPhase::Starting,
+ false,
+ 0,
+ 0,
+ None,
+ "",
+ ),
+ (
+ "listening",
+ RealtimeConversationPhase::Active,
+ false,
+ 0,
+ 0,
+ None,
+ "",
+ ),
+ (
+ "speaking",
+ RealtimeConversationPhase::Active,
+ false,
+ 4,
+ 5,
+ None,
+ "",
+ ),
+ (
+ "muted",
+ RealtimeConversationPhase::Active,
+ true,
+ 4,
+ 4,
+ None,
+ "",
+ ),
+ (
+ "transcript",
+ RealtimeConversationPhase::Active,
+ false,
+ 2,
+ 2,
+ Some("assistant"),
+ "Hello there",
+ ),
+ ] {
+ chat.realtime_conversation.phase = phase;
+ chat.realtime_conversation.microphone_muted = muted;
+ chat.realtime_conversation.microphone_level = level;
+ chat.realtime_conversation.speaker_level = speaker_level;
+ chat.realtime_conversation.transcript_role = role.map(str::to_string);
+ chat.realtime_conversation.transcript = transcript.to_string();
+ chat.update_realtime_footer();
+ states.push(format!(
+ "{label}:\n{}",
+ render_bottom_popup(&chat, /*width*/ 80)
+ ));
+ }
+
+ insta::assert_snapshot!(states.join("\n\n"));
+}
+
+#[tokio::test]
+async fn narrow_voice_footer_keeps_the_stop_control_before_meters() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.realtime_conversation.speaker_active_until =
+ Some(std::time::Instant::now() + super::super::SPEAKER_ACTIVITY_HOLD);
+ chat.update_realtime_footer();
+
+ let footer = render_bottom_popup(&chat, /*width*/ 45);
+ assert!(footer.contains("voice ● speaking"));
+ assert!(footer.contains("/voice stop"));
+ chat.realtime_conversation.speaker_active_until = None;
+ chat.realtime_conversation.speaker_level = 1;
+ chat.on_realtime_transcript_delta("user".to_string(), "stop".to_string());
+ assert_eq!(chat.realtime_conversation.speaker_level, 0);
+ assert_eq!(chat.realtime_conversation.speaker_active_until, None);
+ chat.realtime_conversation.speaker_active_until =
+ Some(std::time::Instant::now() - super::super::SPEAKER_ACTIVITY_HOLD);
+ chat.update_realtime_footer();
+ assert!(render_bottom_popup(&chat, /*width*/ 45).contains("voice ● listening"));
+ for (peak, expected) in [
+ (0, 0),
+ (1, 0),
+ (511, 0),
+ (512, 0),
+ (513, 1),
+ (4096, 3),
+ (6144, 4),
+ (6656, 4),
+ (6657, 5),
+ (8192, 5),
+ (32768, 5),
+ (u16::MAX, 5),
+ ] {
+ assert_eq!(super::super::audio_meter_level(peak), expected);
+ }
+ assert_eq!(super::super::audio_meter_bar(/*level*/ 0), "░░░░░");
+ assert_eq!(super::super::audio_meter_bar(/*level*/ 5), "█████");
+}
+
+#[tokio::test]
+async fn stopping_while_the_offer_is_pending_resets_the_session() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let (abort, _registration) = AbortHandle::new_pair();
+ let observed_abort = abort.clone();
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.startup_abort = Some(abort);
+
+ chat.stop_realtime_conversation();
+
+ assert_eq!(
+ (
+ chat.realtime_conversation.phase,
+ ops.try_recv().ok(),
+ observed_abort.is_aborted(),
+ ),
+ (RealtimeConversationPhase::Inactive, None, true)
+ );
+}
+
+#[tokio::test]
+async fn resetting_pending_voice_aborts_without_reporting_a_backend_session() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ let (abort, _registration) = AbortHandle::new_pair();
+ let observed_abort = abort.clone();
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.thread_id = Some(ThreadId::new());
+ chat.realtime_conversation.startup_abort = Some(abort);
+
+ assert_eq!(
+ (
+ chat.reset_realtime_conversation(),
+ chat.realtime_conversation.phase,
+ observed_abort.is_aborted(),
+ ),
+ (None, RealtimeConversationPhase::Inactive, true)
+ );
+}
+
+#[tokio::test]
+async fn audio_failure_cancels_pending_voice_and_reports_the_device_error() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ let (abort, _registration) = AbortHandle::new_pair();
+ let observed_abort = abort.clone();
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.startup_abort = Some(abort);
+
+ chat.on_realtime_error("speaker stream failed: device disconnected".to_string());
+
+ let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else {
+ panic!("voice should report the speaker failure");
+ };
+ assert!(
+ cell.display_lines(/*width*/ 80)
+ .iter()
+ .any(|line| line.to_string().contains("device disconnected"))
+ );
+ assert_eq!(
+ (
+ chat.realtime_conversation.phase,
+ observed_abort.is_aborted()
+ ),
+ (RealtimeConversationPhase::Inactive, true)
+ );
+}
+
+#[tokio::test]
+async fn voice_waits_for_the_previous_session_to_close_before_restarting() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
+
+ chat.toggle_realtime_conversation();
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Stopping
+ );
+
+ chat.on_realtime_conversation_closed(/*reason*/ Some("requested".into()));
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Inactive
+ );
+}
+
+#[tokio::test]
+async fn canceled_offer_is_ignored_after_a_new_start_attempt() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.attempt_id = 2;
+
+ chat.on_realtime_webrtc_offer_created(
+ ThreadId::new(),
+ /*attempt_id*/ 1,
+ Err("canceled offer failed".to_string()),
+ );
+
+ assert_eq!(
+ (
+ chat.realtime_conversation.phase,
+ chat.realtime_conversation.attempt_id,
+ events.try_recv().is_err()
+ ),
+ (RealtimeConversationPhase::Starting, 2, true)
+ );
+}
+
+#[tokio::test]
+async fn voice_becomes_active_only_after_backend_and_current_peer_are_ready() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.attempt_id = 2;
+
+ chat.on_realtime_webrtc_connected(/*attempt_id*/ 1, Ok(()));
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Starting
+ );
+
+ chat.on_realtime_webrtc_connected(/*attempt_id*/ 2, Ok(()));
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Starting
+ );
+
+ chat.on_realtime_conversation_started();
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Active
+ );
+ let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else {
+ panic!("voice start should insert its history banner");
+ };
+ insta::assert_snapshot!(
+ "voice_start_banner",
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n")
+ );
+}
+
+#[tokio::test]
+async fn normal_voice_close_renders_the_ended_message() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ while events.try_recv().is_ok() {}
+
+ chat.on_realtime_conversation_closed(Some("transport_closed".into()));
+
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ insta::assert_snapshot!("voice_ended_message", rendered);
+}
+
+#[tokio::test]
+async fn voice_waits_for_peer_when_backend_starts_first() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.attempt_id = 2;
+
+ chat.on_realtime_conversation_started();
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Starting
+ );
+
+ chat.on_realtime_webrtc_connected(/*attempt_id*/ 2, Ok(()));
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Active
+ );
+}
+
+#[tokio::test]
+async fn startup_retry_waits_for_closed_then_uses_a_fresh_attempt_and_preserves_mute() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.attempt_id = 0;
+ chat.realtime_conversation.microphone_muted = true;
+ chat.on_realtime_webrtc_connected(
+ /*attempt_id*/ 0,
+ Err(codex_realtime_webrtc::ConnectionError::NegotiationTimedOut),
+ );
+ assert!(
+ matches!(ops.try_recv().unwrap(), AppCommand::RealtimeConversationStop { thread_id: id } if id == thread_id)
+ );
+ assert_eq!(
+ (
+ chat.realtime_conversation.phase,
+ chat.realtime_conversation.startup_retry
+ ),
+ (
+ RealtimeConversationPhase::Stopping,
+ super::super::StartupRetry::WaitingForStop
+ )
+ );
+ assert!(ops.try_recv().is_err());
+ let mut rendered = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ rendered.extend(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string()),
+ );
+ }
+ }
+ insta::assert_snapshot!("voice_startup_retry", rendered.join("\n"));
+ chat.on_realtime_conversation_closed(Some("transport_closed".into()));
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Stopping
+ );
+ assert_eq!(chat.realtime_conversation.attempt_id, 0);
+ chat.on_realtime_conversation_closed(Some("requested".into()));
+ let new_attempt = chat.realtime_conversation.attempt_id;
+ assert_ne!(new_attempt, 0);
+ assert_eq!(
+ (
+ chat.realtime_conversation.phase,
+ chat.realtime_conversation.startup_retry,
+ chat.realtime_conversation.microphone_muted
+ ),
+ (
+ RealtimeConversationPhase::Starting,
+ super::super::StartupRetry::Used,
+ true
+ )
+ );
+ // A completion from the first attempt cannot finish or fail the retry.
+ chat.on_realtime_webrtc_connected(
+ /*attempt_id*/ 0,
+ Err(codex_realtime_webrtc::ConnectionError::NegotiationTimedOut),
+ );
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Starting
+ );
+ chat.on_realtime_webrtc_connected(new_attempt, Ok(()));
+ chat.on_realtime_conversation_started();
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Active
+ );
+ chat.reset_realtime_conversation();
+}
+
+#[tokio::test]
+async fn startup_retry_is_cancelled_while_waiting_for_backend_close() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.on_realtime_webrtc_connected(
+ /*attempt_id*/ 0,
+ Err(codex_realtime_webrtc::ConnectionError::NegotiationTimedOut),
+ );
+ chat.toggle_realtime_conversation();
+ chat.on_realtime_conversation_closed(Some("transport_closed".into()));
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Stopping
+ );
+ chat.on_realtime_conversation_closed(Some("requested".into()));
+ assert_eq!(
+ (
+ chat.realtime_conversation.phase,
+ chat.realtime_conversation.attempt_id
+ ),
+ (RealtimeConversationPhase::Inactive, 0)
+ );
+}
+
+#[tokio::test]
+async fn startup_transport_close_before_peer_timeout_retries_once_and_ignores_old_result() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.attempt_id = 0;
+ // The close can also overtake the backend's started notification.
+ chat.realtime_conversation.backend_started = false;
+ chat.realtime_conversation.microphone_muted = true;
+
+ chat.on_realtime_conversation_closed(Some("transport_closed".into()));
+ let retry_attempt = chat.realtime_conversation.attempt_id;
+ assert_ne!(retry_attempt, 0);
+ assert_eq!(
+ (
+ chat.realtime_conversation.phase,
+ chat.realtime_conversation.startup_retry,
+ chat.realtime_conversation.microphone_muted,
+ ),
+ (
+ RealtimeConversationPhase::Starting,
+ super::super::StartupRetry::Used,
+ true,
+ )
+ );
+ assert!(
+ ops.try_recv().is_err(),
+ "closed backend needs no extra stop"
+ );
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ insta::assert_snapshot!("voice_startup_transport_close_retry", rendered);
+
+ chat.on_realtime_webrtc_connected(
+ /*attempt_id*/ 0,
+ Err(codex_realtime_webrtc::ConnectionError::NegotiationTimedOut),
+ );
+ assert_eq!(
+ (
+ chat.realtime_conversation.phase,
+ chat.realtime_conversation.attempt_id
+ ),
+ (RealtimeConversationPhase::Starting, retry_attempt)
+ );
+ chat.on_realtime_conversation_started();
+ chat.on_realtime_webrtc_connected(retry_attempt, Ok(()));
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Active
+ );
+ chat.on_realtime_conversation_closed(Some("transport_closed".into()));
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Inactive
+ );
+ assert!(ops.try_recv().is_err(), "retry budget must be used");
+}
+
+#[tokio::test]
+async fn startup_retry_never_retries_twice_or_retries_other_errors_or_active_sessions() {
+ for (phase, budget, error) in [
+ (
+ RealtimeConversationPhase::Starting,
+ super::super::StartupRetry::Used,
+ codex_realtime_webrtc::ConnectionError::NegotiationTimedOut,
+ ),
+ (
+ RealtimeConversationPhase::Starting,
+ super::super::StartupRetry::Available,
+ codex_realtime_webrtc::ConnectionError::Failed,
+ ),
+ (
+ RealtimeConversationPhase::Active,
+ super::super::StartupRetry::Available,
+ codex_realtime_webrtc::ConnectionError::NegotiationTimedOut,
+ ),
+ ] {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.realtime_conversation.phase = phase;
+ chat.realtime_conversation.startup_retry = budget;
+ chat.on_realtime_webrtc_connected(/*attempt_id*/ 0, Err(error));
+ assert_ne!(
+ chat.realtime_conversation.startup_retry,
+ super::super::StartupRetry::WaitingForStop
+ );
+ if phase == RealtimeConversationPhase::Starting {
+ assert!(matches!(
+ ops.try_recv(),
+ Ok(AppCommand::RealtimeConversationStop { thread_id: stopped }) if stopped == thread_id
+ ));
+ }
+ assert!(ops.try_recv().is_err(), "no retry may be queued");
+ }
+}
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__caption_replay__captioned_voice_answer_on_close.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__caption_replay__captioned_voice_answer_on_close.snap
new file mode 100644
index 0000000000..b83523abd7
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__caption_replay__captioned_voice_answer_on_close.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/realtime_tests/caption_replay.rs
+assertion_line: 1675
+expression: "rendered.join(\"\\n\")"
+---
+• Captioned answer
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__caption_replay__restored_voice_partial_live.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__caption_replay__restored_voice_partial_live.snap
new file mode 100644
index 0000000000..ad1c58f515
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__caption_replay__restored_voice_partial_live.snap
@@ -0,0 +1,7 @@
+---
+source: tui/src/chatwidget/realtime_tests/caption_replay.rs
+assertion_line: 2528
+expression: "live.display_lines(80).into_iter().map(|line|\nline.to_string()).collect::>().join(\"\\n\")"
+---
+
+› last
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__caption_replay__typed_turn_replay_before_voice_steering.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__caption_replay__typed_turn_replay_before_voice_steering.snap
new file mode 100644
index 0000000000..8dc537c30b
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__caption_replay__typed_turn_replay_before_voice_steering.snap
@@ -0,0 +1,12 @@
+---
+source: tui/src/chatwidget/realtime_tests/caption_replay.rs
+assertion_line: 1586
+expression: rendered
+---
+
+› Typed question
+
+• Checking the typed request
+• Typed reasoning summary
+
+› spoken correction
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoff_privacy__typed_reasoning_through_voice_handoff.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoff_privacy__typed_reasoning_through_voice_handoff.snap
new file mode 100644
index 0000000000..35e9a7e527
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoff_privacy__typed_reasoning_through_voice_handoff.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/realtime_tests/handoff_privacy.rs
+assertion_line: 1188
+expression: rendered
+---
+• Typed beginning and typed tail
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoff_privacy__typed_steering_keeps_voice_commentary_private.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoff_privacy__typed_steering_keeps_voice_commentary_private.snap
new file mode 100644
index 0000000000..087bc88892
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoff_privacy__typed_steering_keeps_voice_commentary_private.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/realtime_tests/handoff_privacy.rs
+assertion_line: 1046
+expression: rendered
+---
+• typed answer
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoffs__explicit_final_answer_with_channel_marker.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoffs__explicit_final_answer_with_channel_marker.snap
new file mode 100644
index 0000000000..bbd770c6f0
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoffs__explicit_final_answer_with_channel_marker.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/realtime_tests/handoffs.rs
+assertion_line: 918
+expression: rendered
+---
+• [COMMENTARY] is a label in the documentation.
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoffs__voice_delegation_during_connection.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoffs__voice_delegation_during_connection.snap
new file mode 100644
index 0000000000..d95a3ed22e
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__handoffs__voice_delegation_during_connection.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/realtime_tests/handoffs.rs
+assertion_line: 514
+expression: "rendered_history.join(\"\\n\")"
+---
+• Voice conversation started. Use /voice mute to mute or /voice to stop.
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_ended_message.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_ended_message.snap
new file mode 100644
index 0000000000..ecdde9b81e
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_ended_message.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/realtime_tests/lifecycle.rs
+assertion_line: 2510
+expression: rendered
+---
+• Voice conversation ended: transport_closed
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime__tests__voice_footer_renders_the_main_conversation_states.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_footer_renders_the_main_conversation_states.snap
similarity index 92%
rename from codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime__tests__voice_footer_renders_the_main_conversation_states.snap
rename to codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_footer_renders_the_main_conversation_states.snap
index cb205e97df..6960dba6f1 100644
--- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__realtime__tests__voice_footer_renders_the_main_conversation_states.snap
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_footer_renders_the_main_conversation_states.snap
@@ -1,5 +1,5 @@
---
-source: tui/src/chatwidget/realtime_tests.rs
+source: tui/src/chatwidget/realtime_tests/lifecycle.rs
assertion_line: 176
expression: "states.join(\"\\n\\n\")"
---
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_start_banner.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_start_banner.snap
new file mode 100644
index 0000000000..56b6e3bb44
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_start_banner.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/realtime_tests/lifecycle.rs
+assertion_line: 1372
+expression: "cell.display_lines(80).into_iter().map(|line|\nline.to_string()).collect::>().join(\"\\n\")"
+---
+• Voice conversation started. Use /voice mute to mute or /voice to stop.
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_startup_retry.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_startup_retry.snap
new file mode 100644
index 0000000000..d03abbf81f
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_startup_retry.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/realtime_tests/lifecycle.rs
+assertion_line: 1425
+expression: "rendered.join(\"\\n\")"
+---
+• Voice connection timed out. Retrying once after cleanup.
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_startup_transport_close_retry.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_startup_transport_close_retry.snap
new file mode 100644
index 0000000000..65e1041a43
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__lifecycle__voice_startup_transport_close_retry.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/realtime_tests/lifecycle.rs
+assertion_line: 2670
+expression: rendered
+---
+• Voice connection closed during startup. Retrying once.
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__completed_transcript_is_added_to_history_and_clears_the_caption.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__completed_transcript_is_added_to_history_and_clears_the_caption.snap
new file mode 100644
index 0000000000..625ec0d777
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__completed_transcript_is_added_to_history_and_clears_the_caption.snap
@@ -0,0 +1,8 @@
+---
+source: tui/src/chatwidget/realtime_tests/transcripts.rs
+assertion_line: 218
+expression: "rendered.join(\"\\n\")"
+---
+• Hello there
+
+› Can you hear me?
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__interleaved_partials_on_close.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__interleaved_partials_on_close.snap
new file mode 100644
index 0000000000..a019373c73
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__interleaved_partials_on_close.snap
@@ -0,0 +1,9 @@
+---
+source: tui/src/chatwidget/realtime_tests/transcripts.rs
+assertion_line: 397
+expression: rendered
+---
+
+› Correction
+
+• First answer
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__interleaved_partials_on_stop.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__interleaved_partials_on_stop.snap
new file mode 100644
index 0000000000..efb3837a2d
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__interleaved_partials_on_stop.snap
@@ -0,0 +1,9 @@
+---
+source: tui/src/chatwidget/realtime_tests/transcripts.rs
+assertion_line: 446
+expression: rendered
+---
+
+› Correction
+
+• First answer
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__voice_stop_late_transcript.snap b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__voice_stop_late_transcript.snap
new file mode 100644
index 0000000000..fe4a67761d
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/snapshots/codex_tui__chatwidget__realtime__tests__transcripts__voice_stop_late_transcript.snap
@@ -0,0 +1,8 @@
+---
+source: tui/src/chatwidget/realtime_tests/transcripts.rs
+assertion_line: 500
+expression: rendered
+---
+• First
+• First and last
+• Voice conversation ended: requested
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs b/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs
new file mode 100644
index 0000000000..4710b8feb7
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/speech_recovery.rs
@@ -0,0 +1,604 @@
+//! Undelivered speech returns to visible history without leaks or clipping.
+
+use super::*;
+use pretty_assertions::assert_eq;
+
+#[tokio::test]
+async fn queued_voice_answers_return_to_history_once_if_voice_closes_before_delivery() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let mut deliveries = Vec::new();
+ for (turn_id, item_id, text) in [
+ ("first-turn", "first-answer", "First answer"),
+ ("second-turn", "second-answer", "Second answer"),
+ ] {
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ let answer = agent_item(item_id, text, Some(MessagePhase::FinalAnswer));
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+ let AppCommand::RealtimeConversationSpeech { delivery_id, .. } = ops.try_recv().unwrap()
+ else {
+ panic!("completed voice turn should queue speech");
+ };
+ deliveries.push(delivery_id);
+ }
+ assert_eq!(chat.realtime_conversation.pending_speech.len(), 2);
+ while events.try_recv().is_ok() {}
+
+ chat.stop_realtime_conversation();
+ chat.reset_realtime_conversation();
+ for delivery_id in deliveries {
+ chat.restore_undelivered_realtime_speech(delivery_id);
+ }
+ let mut rendered = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ rendered.push(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::(),
+ );
+ }
+ }
+ assert_eq!(
+ rendered
+ .iter()
+ .filter(|line| line.contains("First answer"))
+ .count(),
+ 1
+ );
+ assert_eq!(
+ rendered
+ .iter()
+ .filter(|line| line.contains("Second answer"))
+ .count(),
+ 1
+ );
+}
+
+#[tokio::test]
+async fn hidden_and_queued_voice_answers_share_a_lossless_sixteen_item_cap() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let mut oldest_delivery_id = None;
+ for index in 0..=super::super::MAX_PENDING_SPEECH_DELIVERIES {
+ let turn_id = format!("turn-{index}");
+ start_item(
+ &mut chat,
+ thread_id,
+ &turn_id,
+ user_item("question"),
+ );
+ let answer = agent_item(
+ &format!("answer-{index}"),
+ &format!("Answer number {index}"),
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, &turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, &turn_id, answer.clone());
+ if index == 0 {
+ finish_turn(
+ &mut chat,
+ thread_id,
+ &turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+ let AppCommand::RealtimeConversationSpeech { delivery_id, .. } =
+ ops.try_recv().unwrap()
+ else {
+ panic!("first voice answer should queue speech");
+ };
+ oldest_delivery_id = Some(delivery_id);
+ }
+ }
+ assert_eq!(
+ chat.realtime_conversation.pending_speech.len(),
+ super::super::MAX_PENDING_SPEECH_DELIVERIES
+ );
+ assert!(!chat.has_pending_realtime_speech(oldest_delivery_id.unwrap()));
+ let mut restored = 0;
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event
+ && cell
+ .display_lines(/*width*/ 80)
+ .iter()
+ .any(|line| line.to_string().contains("Answer number 0"))
+ {
+ restored += 1;
+ }
+ }
+ assert_eq!(restored, 1);
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn delegated_analysis_never_enters_history_on_completion_overflow_or_stop() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "private-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ let private_items = [
+ agent_item(
+ "analysis",
+ "[ANALYSIS] private thought",
+ /*phase*/ None,
+ ),
+ agent_item(
+ "commentary",
+ "private commentary",
+ Some(MessagePhase::Commentary),
+ ),
+ agent_item(
+ "oversized-analysis",
+ &format!(
+ "[ANALYSIS] {}",
+ "secret".repeat(super::super::MAX_PENDING_SPEECH_ITEM_BYTES)
+ ),
+ /*phase*/ None,
+ ),
+ ];
+ for item in &private_items {
+ start_item(&mut chat, thread_id, turn_id, item.clone());
+ complete_item(&mut chat, thread_id, turn_id, item.clone());
+ }
+ assert!(chat.realtime_conversation.pending_speech.is_empty());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ private_items.to_vec(),
+ TurnStatus::Completed,
+ );
+ for index in 0..=super::super::MAX_PENDING_SPEECH_DELIVERIES {
+ let turn_id = format!("answer-turn-{index}");
+ start_item(
+ &mut chat,
+ thread_id,
+ &turn_id,
+ user_item("question"),
+ );
+ let answer = agent_item(
+ &format!("answer-{index}"),
+ &format!("Public answer {index}"),
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, &turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, &turn_id, answer);
+ }
+ chat.stop_realtime_conversation();
+ chat.reset_realtime_conversation();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ let lines = cell.display_lines(/*width*/ 80);
+ assert!(lines.iter().all(|line| {
+ !line.to_string().contains("private") && !line.to_string().contains("secret")
+ }));
+ }
+ }
+ assert!(matches!(
+ ops.try_recv(),
+ Ok(AppCommand::RealtimeConversationStop { .. })
+ ));
+ assert!(ops.try_recv().is_err(), "no private speech may be queued");
+}
+
+#[tokio::test]
+async fn delegated_reasoning_never_enters_live_history() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "private-reasoning-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ chat.config.show_raw_agent_reasoning = true;
+ for notification in [
+ ServerNotification::ReasoningSummaryPartAdded(ReasoningSummaryPartAddedNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.into(),
+ item_id: "reasoning".into(),
+ summary_index: 0,
+ }),
+ ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.into(),
+ item_id: "reasoning".into(),
+ delta: "private summary".into(),
+ summary_index: 0,
+ }),
+ ServerNotification::ReasoningTextDelta(ReasoningTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.into(),
+ item_id: "reasoning".into(),
+ delta: "private raw reasoning".into(),
+ content_index: 0,
+ }),
+ ] {
+ chat.handle_server_notification(notification, /*replay_kind*/ None);
+ }
+ complete_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ ThreadItem::Reasoning {
+ id: "reasoning".into(),
+ summary: vec!["private summary".into()],
+ content: vec!["private raw reasoning".into()],
+ },
+ );
+ chat.reset_realtime_conversation();
+ start_item(
+ &mut chat,
+ thread_id,
+ "late-start-turn",
+ user_item("delayed input"),
+ );
+ assert!(chat.is_realtime_delegated_reasoning_turn("late-start-turn"));
+ chat.handle_server_notification(
+ ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.into(),
+ item_id: "late-reasoning".into(),
+ delta: "private late summary".into(),
+ summary_index: 0,
+ }),
+ /*replay_kind*/ None,
+ );
+ complete_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ ThreadItem::Reasoning {
+ id: "late-reasoning".into(),
+ summary: vec!["private late summary".into()],
+ content: Vec::new(),
+ },
+ );
+ chat.handle_server_notification(
+ ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: "late-start-turn".into(),
+ item_id: "late-start-reasoning".into(),
+ delta: "private delayed start".into(),
+ summary_index: 0,
+ }),
+ /*replay_kind*/ None,
+ );
+ complete_item(
+ &mut chat,
+ thread_id,
+ "late-start-turn",
+ ThreadItem::Reasoning {
+ id: "late-start-reasoning".into(),
+ summary: vec!["private delayed start".into()],
+ content: Vec::new(),
+ },
+ );
+ chat.handle_server_notification(
+ ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: turn_id.into(),
+ item_id: "late-commentary".into(),
+ delta: "private commentary stream".into(),
+ }),
+ /*replay_kind*/ None,
+ );
+ complete_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ agent_item(
+ "late-commentary",
+ "private commentary item",
+ Some(MessagePhase::Commentary),
+ ),
+ );
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![agent_item(
+ "private-none",
+ "[ANALYSIS] private fallback",
+ /*phase*/ None,
+ )],
+ TurnStatus::Completed,
+ );
+ chat.flush_answer_stream_with_separator();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ let rendered = cell
+ .transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n");
+ assert!(!rendered.contains("private"), "{rendered}");
+ }
+ }
+ assert!(!chat.is_realtime_delegated_reasoning_turn(turn_id));
+ chat.handle_server_notification(
+ ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
+ thread_id: thread_id.to_string(),
+ turn_id: "typed-turn".into(),
+ item_id: "typed-reasoning".into(),
+ delta: "ordinary summary".into(),
+ summary_index: 0,
+ }),
+ /*replay_kind*/ None,
+ );
+ complete_item(
+ &mut chat,
+ thread_id,
+ "typed-turn",
+ ThreadItem::Reasoning {
+ id: "typed-reasoning".into(),
+ summary: vec!["ordinary summary".into()],
+ content: Vec::new(),
+ },
+ );
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::();
+ assert!(rendered.contains("ordinary summary"), "{rendered}");
+}
+
+#[tokio::test]
+async fn answer_exceeding_speech_budget_is_shown_in_full_instead() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "text-only-final-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ while events.try_recv().is_ok() {}
+ let text = format!("Start. {} End.", "word ".repeat(/*n*/ 800));
+ let answer = agent_item("text-only-answer", &text, Some(MessagePhase::FinalAnswer));
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(
+ ops.try_recv().is_err(),
+ "an over-budget answer must not be clipped for speech"
+ );
+ assert!(chat.realtime_conversation.pending_speech.is_empty());
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(
+ without_completion_metadata(&rendered)
+ .split_whitespace()
+ .collect::>()
+ .join(" "),
+ format!("• {text}")
+ .split_whitespace()
+ .collect::>()
+ .join(" ")
+ );
+}
+
+#[tokio::test]
+async fn oversized_delegated_answer_remains_in_normal_history() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "large-final-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ let answer = agent_item(
+ "large-answer",
+ &"word ".repeat(super::super::MAX_PENDING_SPEECH_ITEM_BYTES / 5),
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ assert!(chat.realtime_conversation.pending_speech.is_empty());
+ assert!(ops.try_recv().is_err());
+ assert!(events.try_recv().is_ok());
+ assert_eq!(
+ chat.transcript.last_completed_agent_message,
+ Some((turn_id.to_string(), "large-answer".to_string()))
+ );
+}
+
+#[tokio::test]
+async fn final_item_that_grows_after_completion_is_not_queued_without_recovery() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "expanded-final-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("question"),
+ );
+ let short = agent_item("expanded-answer", "short", Some(MessagePhase::FinalAnswer));
+ start_item(&mut chat, thread_id, turn_id, short.clone());
+ complete_item(&mut chat, thread_id, turn_id, short);
+ let expanded_text = "word ".repeat(super::super::MAX_PENDING_SPEECH_ITEM_BYTES / 5);
+ let expanded = agent_item(
+ "expanded-answer",
+ &expanded_text,
+ Some(MessagePhase::FinalAnswer),
+ );
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![expanded],
+ TurnStatus::Completed,
+ );
+
+ assert!(chat.realtime_conversation.pending_speech.is_empty());
+ assert!(ops.try_recv().is_err());
+ let recovered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(cell.raw_lines()),
+ _ => None,
+ })
+ .flatten()
+ .map(|line| line.to_string())
+ .filter(|text| text == "short" || text.starts_with("word "))
+ .collect::>();
+ assert_eq!(recovered, vec![expanded_text.trim_end().to_string()]);
+}
+
+#[tokio::test]
+async fn interrupted_voice_delegation_never_speaks_a_completed_item() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let turn_id = "interrupted-turn";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("stop me"),
+ );
+ let answer = agent_item("answer", "Never say this", Some(MessagePhase::FinalAnswer));
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Interrupted,
+ );
+
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn replay_projects_the_delegated_request_without_internal_xml() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ chat.replay_thread_item(
+ user_item(
+ "show <code> & tests",
+ ),
+ "old-turn".to_string(),
+ ReplayKind::ResumeInitialMessages,
+ );
+
+ let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else {
+ panic!("a resumed voice delegation should preserve the original user request");
+ };
+ let rendered = cell
+ .display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n");
+ assert!(rendered.contains("show & tests"));
+ assert!(!rendered.contains("realtime_delegation"));
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn locally_typed_delegation_shaped_text_keeps_its_normal_answer() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ let typed_text = "explain this xml";
+ chat.note_realtime_typed_input(typed_text);
+ let turn_id = "typed-xml-turn";
+ let prompt = user_item(typed_text);
+ start_item(&mut chat, thread_id, turn_id, prompt.clone());
+ complete_item(&mut chat, thread_id, turn_id, prompt);
+ let answer = agent_item("xml-answer", "It is an XML element.", /*phase*/ None);
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+
+ let mut saw_answer = false;
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ saw_answer |= cell
+ .display_lines(/*width*/ 80)
+ .iter()
+ .any(|line| line.to_string().contains("It is an XML element."));
+ }
+ }
+ assert!(saw_answer);
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn new_user_input_invalidates_already_queued_voice_speech() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.realtime_conversation.attempt_id = 4;
+ let generation = chat.realtime_conversation.input_generation;
+
+ assert!(chat.is_current_realtime_attempt(thread_id, /*attempt_id*/ 4, generation));
+ chat.note_realtime_typed_input("newer typed message");
+ assert!(!chat.is_current_realtime_attempt(thread_id, /*attempt_id*/ 4, generation));
+
+ chat.on_realtime_transcript_done("user".to_string(), "newer spoken message".to_string());
+ assert!(!chat.is_current_realtime_attempt(thread_id, /*attempt_id*/ 4, generation));
+}
diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/transcripts.rs b/codex-rs/tui/src/chatwidget/realtime_tests/transcripts.rs
new file mode 100644
index 0000000000..b49d31e4aa
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/realtime_tests/transcripts.rs
@@ -0,0 +1,696 @@
+//! Partial and completed captions stay ordered and bounded through voice shutdown.
+
+use super::*;
+use pretty_assertions::assert_eq;
+
+fn seed_interleaved_partials(chat: &mut ChatWidget) {
+ chat.on_realtime_transcript_delta("assistant".into(), "First ".into());
+ chat.on_realtime_transcript_delta("user".into(), "Correction".into());
+ chat.on_realtime_transcript_delta("assistant".into(), "answer".into());
+}
+
+#[tokio::test]
+async fn interleaved_transcripts_keep_interrupted_speech_suppressed() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta("assistant".into(), "The long answer".into());
+ chat.on_realtime_transcript_delta("user".into(), "Actually, ".into());
+ let generation = chat.realtime_conversation.input_generation;
+
+ chat.on_realtime_transcript_delta("assistant".into(), " keeps going".into());
+ assert_eq!(
+ chat.realtime_conversation.speaker_suppression_generation,
+ Some(generation)
+ );
+ chat.on_realtime_transcript_done("user".into(), "Actually, make it short".into());
+ chat.on_realtime_transcript_delta("assistant".into(), " and going".into());
+ chat.on_realtime_transcript_done("assistant".into(), "The long answer keeps going".into());
+ assert_eq!(
+ chat.realtime_conversation.speaker_suppression_generation,
+ Some(generation)
+ );
+
+ chat.on_realtime_transcript_delta("assistant".into(), "Short answer".into());
+ assert!(
+ chat.realtime_conversation
+ .speaker_suppression_generation
+ .is_none()
+ );
+}
+
+#[tokio::test]
+async fn delayed_user_transcript_suppresses_old_audio_after_microphone_mute() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta("assistant".into(), "Old answer".into());
+ chat.realtime_conversation.microphone_muted = true;
+
+ chat.on_realtime_transcript_delta("user".into(), "Actually".into());
+
+ assert_eq!(
+ (
+ chat.realtime_conversation.microphone_muted,
+ chat.realtime_conversation.speaker_suppression_generation,
+ ),
+ (true, Some(chat.realtime_conversation.input_generation))
+ );
+}
+
+#[tokio::test]
+async fn interleaved_transcripts_do_not_discard_the_interrupting_request() {
+ let (mut chat, _sender, _events, mut ops) = make_chatwidget_manual_with_sender().await;
+ let thread_id = activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta("assistant".into(), "The long answer".into());
+ chat.on_realtime_transcript_delta("user".into(), "Actually, ".into());
+ let generation = chat.realtime_conversation.input_generation;
+ let turn_id = "interruption";
+ start_item(
+ &mut chat,
+ thread_id,
+ turn_id,
+ user_item("make it short"),
+ );
+ chat.on_realtime_transcript_delta("assistant".into(), " keeps going".into());
+ chat.on_realtime_transcript_delta("user".into(), "make it short".into());
+ chat.on_realtime_transcript_done("assistant".into(), "The long answer keeps going".into());
+ chat.on_realtime_transcript_done("user".into(), "Actually, make it short".into());
+
+ let answer = agent_item(
+ "short-answer",
+ "Short answer",
+ Some(MessagePhase::FinalAnswer),
+ );
+ start_item(&mut chat, thread_id, turn_id, answer.clone());
+ complete_item(&mut chat, thread_id, turn_id, answer.clone());
+ finish_turn(
+ &mut chat,
+ thread_id,
+ turn_id,
+ vec![answer],
+ TurnStatus::Completed,
+ );
+ assert!(matches!(
+ ops.try_recv(),
+ Ok(AppCommand::RealtimeConversationSpeech { input_generation, text, .. })
+ if input_generation == generation && text.as_str() == "Short answer"
+ ));
+ assert!(ops.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn completed_user_transcript_keeps_the_old_assistant_turn_suppressed() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta("assistant".into(), "Old answer".into());
+ chat.on_realtime_transcript_done("user".into(), "Stop, I have a correction".into());
+ let generation = chat.realtime_conversation.input_generation;
+ chat.on_realtime_transcript_done("assistant".into(), "Old answer".into());
+ assert_eq!(
+ chat.realtime_conversation.speaker_suppression_generation,
+ Some(generation)
+ );
+ chat.on_realtime_transcript_delta("assistant".into(), "Go".into());
+ chat.on_realtime_transcript_done("assistant".into(), "Go ahead".into());
+ assert!(
+ chat.realtime_conversation
+ .speaker_suppression_generation
+ .is_none()
+ );
+}
+
+#[tokio::test]
+async fn final_only_assistant_transcript_cannot_release_an_interruption() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta("user".into(), "Stop".into());
+ let generation = chat.realtime_conversation.input_generation;
+ chat.on_realtime_transcript_done("user".into(), "Stop".into());
+
+ chat.on_realtime_transcript_done("assistant".into(), "Late old answer".into());
+
+ assert_eq!(
+ chat.realtime_conversation.speaker_suppression_generation,
+ Some(generation)
+ );
+}
+
+#[tokio::test]
+async fn transcript_deltas_track_the_current_speaker() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
+ let owner = |chat: &ChatWidget| chat.realtime_conversation.speaker_suppression_generation;
+
+ chat.on_realtime_transcript_delta("user".to_string(), "Hello ".to_string());
+ chat.on_realtime_transcript_delta("user".to_string(), "there".to_string());
+
+ assert_eq!(
+ (
+ chat.realtime_conversation.transcript_role.as_deref(),
+ chat.realtime_conversation.transcript.as_str()
+ ),
+ (Some("user"), "Hello there")
+ );
+ chat.on_realtime_transcript_done("assistant".to_string(), "Old".to_string());
+ assert_eq!(
+ chat.realtime_conversation.speaker_suppression_generation,
+ Some(chat.realtime_conversation.input_generation)
+ );
+
+ chat.on_realtime_transcript_done("user".to_string(), "Hello there".to_string());
+ chat.on_realtime_transcript_delta("assistant".to_string(), "Hi".to_string());
+ assert!(owner(&chat).is_none());
+ chat.on_realtime_transcript_done("assistant".to_string(), "Hi".to_string());
+
+ chat.on_realtime_transcript_delta("user".to_string(), "Again".to_string());
+ chat.on_realtime_transcript_done("user".to_string(), "Again".to_string());
+ assert!(owner(&chat).is_some());
+ chat.on_realtime_transcript_delta("assistant".to_string(), "".to_string());
+ assert!(owner(&chat).is_some());
+ chat.on_realtime_transcript_delta("assistant".to_string(), "Hi".to_string());
+ assert!(owner(&chat).is_none());
+
+ assert_eq!(
+ (
+ chat.realtime_conversation.transcript_role.as_deref(),
+ chat.realtime_conversation.transcript.as_str()
+ ),
+ (Some("assistant"), "Hi")
+ );
+
+ chat.on_realtime_transcript_done("user".to_string(), "Again".to_string());
+ let generation = chat.realtime_conversation.input_generation;
+ assert_eq!(owner(&chat), Some(generation));
+ chat.on_realtime_transcript_done("user".to_string(), " ".to_string());
+ assert_eq!(owner(&chat), Some(generation));
+ chat.on_realtime_transcript_done("assistant".to_string(), "".to_string());
+ assert_eq!(owner(&chat), Some(generation));
+ chat.on_realtime_transcript_done("assistant".to_string(), "Hi".to_string());
+ assert_eq!(owner(&chat), Some(generation));
+ chat.on_realtime_transcript_delta("user".to_string(), "noise".to_string());
+ chat.on_realtime_transcript_done("user".to_string(), "".to_string());
+ assert!(owner(&chat).is_none());
+}
+
+#[tokio::test]
+async fn voice_transcripts_stream_in_the_conversation_instead_of_the_footer() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.config.animations = false;
+ activate_voice(&mut chat);
+ chat.update_realtime_footer();
+
+ chat.on_realtime_transcript_delta("user".to_string(), "pick a ".to_string());
+ chat.on_realtime_transcript_delta("user".to_string(), "number".to_string());
+
+ let live = chat
+ .active_cell_transcript_lines(/*width*/ 80)
+ .unwrap_or_default()
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n");
+ assert!(live.contains("pick a number"));
+ let footer = render_bottom_popup(&chat, /*width*/ 80);
+ assert!(footer.contains("voice ● listening"));
+ let status_line = footer
+ .lines()
+ .find(|line| line.contains("voice ● listening"))
+ .unwrap_or_default();
+ assert!(!status_line.contains("pick a number"));
+ assert!(events.try_recv().is_err());
+
+ chat.on_realtime_transcript_done("user".to_string(), "pick a number".to_string());
+ assert!(chat.active_cell_transcript_lines(/*width*/ 80).is_none());
+ let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else {
+ panic!("the completed voice transcript should become one normal user turn");
+ };
+ assert!(
+ cell.display_lines(/*width*/ 80)
+ .iter()
+ .any(|line| line.to_string().contains("pick a number"))
+ );
+ assert!(events.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn unexpected_voice_close_preserves_partial_transcripts_once() {
+ for (role, text) in [
+ ("user", "Part of my request"),
+ ("assistant", "Part of the answer"),
+ ] {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta(role.to_string(), text.to_string());
+
+ chat.on_realtime_conversation_closed(/*reason*/ None);
+ chat.on_realtime_transcript_done(role.to_string(), text.to_string());
+
+ let mut appearances = 0;
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ appearances +=
+ cell.display_lines(/*width*/ 80)
+ .iter()
+ .any(|line| line.to_string().contains(text)) as usize;
+ }
+ }
+ assert_eq!(appearances, 1, "{role} transcript should be preserved once");
+ assert!(chat.active_cell_transcript_lines(/*width*/ 80).is_none());
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Inactive
+ );
+ }
+}
+
+#[tokio::test]
+async fn interleaved_partial_transcripts_survive_voice_close() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ seed_interleaved_partials(&mut chat);
+
+ chat.on_realtime_conversation_closed(/*reason*/ None);
+ chat.on_realtime_transcript_done("user".into(), "Correction".into());
+ chat.on_realtime_transcript_done("assistant".into(), "First answer".into());
+
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(rendered.matches("Correction").count(), 1);
+ assert_eq!(rendered.matches("First answer").count(), 1);
+ insta::assert_snapshot!("interleaved_partials_on_close", rendered);
+}
+
+#[tokio::test]
+async fn interleaved_partials_transfer_once_on_thread_switch() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ seed_interleaved_partials(&mut chat);
+
+ let cells = chat.take_realtime_transcript_cells_for_replay();
+ let rendered = cells
+ .iter()
+ .map(|record| record.text.as_str())
+ .collect::();
+ assert_eq!(cells.len(), 2);
+ assert_eq!(rendered.matches("Correction").count(), 1);
+ assert_eq!(rendered.matches("First answer").count(), 1);
+ assert!(chat.take_realtime_transcript_cells_for_replay().is_empty());
+}
+
+#[tokio::test]
+async fn intentional_stop_preserves_both_interleaved_partials_once() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ seed_interleaved_partials(&mut chat);
+
+ chat.stop_realtime_conversation();
+ chat.on_realtime_conversation_closed(/*reason*/ None);
+
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(rendered.matches("Correction").count(), 1);
+ assert_eq!(rendered.matches("First answer").count(), 1);
+ insta::assert_snapshot!("interleaved_partials_on_stop", rendered);
+}
+
+#[tokio::test]
+async fn stopping_voice_keeps_the_complete_late_final_caption() {
+ let (mut chat, _sender, mut events, mut ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta("assistant".into(), "First ".into());
+ chat.finish_realtime_partial_transcripts();
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
+ chat.on_realtime_transcript_delta("assistant".into(), "and last".into());
+ chat.on_realtime_transcript_done("assistant".into(), "First and last".into());
+ chat.on_realtime_conversation_closed(Some("requested".into()));
+
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>()
+ .join("\n");
+ assert_eq!(rendered.matches("First").count(), 2);
+ assert_eq!(rendered.matches("and last").count(), 1);
+ assert!(ops.try_recv().is_err());
+ insta::assert_snapshot!("voice_stop_late_transcript", rendered);
+}
+
+#[tokio::test]
+async fn separate_late_finals_with_a_shared_prefix_keep_both_full_captions() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
+ chat.on_realtime_transcript_done("assistant".into(), "Hello".into());
+ chat.on_realtime_transcript_done("assistant".into(), "Hello again".into());
+
+ let captions = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::>()
+ .join("\n"),
+ ),
+ _ => None,
+ })
+ .collect::>();
+ assert_eq!(captions, vec!["• Hello", "• Hello again"]);
+}
+
+#[tokio::test]
+async fn direct_reset_preserves_both_partial_speakers_for_replay() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_realtime_transcript_delta("assistant".into(), "First ".into());
+ chat.on_realtime_transcript_delta("user".into(), "Correction".into());
+ chat.on_realtime_transcript_delta("assistant".into(), "answer".into());
+
+ chat.reset_realtime_conversation();
+
+ let records = chat.take_realtime_transcript_cells_for_replay();
+ assert_eq!(
+ records
+ .into_iter()
+ .map(|record| (record.role, record.text))
+ .collect::>(),
+ vec![
+ ("user".to_string(), "Correction".to_string()),
+ ("assistant".to_string(), "First answer".to_string())
+ ]
+ );
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::(),
+ ),
+ _ => None,
+ })
+ .collect::();
+ assert_eq!(rendered.matches("Correction").count(), 1);
+ assert_eq!(rendered.matches("First answer").count(), 1);
+}
+
+#[tokio::test]
+async fn stopping_voice_preserves_the_live_transcript_once() {
+ for command in ["/voice", "/voice stop"] {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.config.animations = true;
+ activate_voice(&mut chat);
+ chat.on_realtime_transcript_done("user".to_string(), "Earlier question".to_string());
+ chat.on_realtime_transcript_done("assistant".to_string(), "Earlier answer".to_string());
+ chat.on_realtime_transcript_delta("assistant".to_string(), "Answer in ".to_string());
+ chat.on_realtime_transcript_delta("assistant".to_string(), "progress".to_string());
+
+ if command == "/voice" {
+ chat.handle_slash_command_dispatch(crate::slash_command::SlashCommand::Voice);
+ } else {
+ chat.handle_slash_command_with_args_dispatch(
+ crate::slash_command::SlashCommand::Voice,
+ "stop".to_string(),
+ Vec::new(),
+ );
+ }
+ chat.on_realtime_transcript_done("assistant".to_string(), "Answer in progress".to_string());
+ chat.on_realtime_conversation_closed(/*reason*/ None);
+ chat.stop_realtime_conversation();
+
+ assert_eq!(
+ chat.realtime_conversation.phase,
+ RealtimeConversationPhase::Inactive
+ );
+ assert!(chat.active_cell_transcript_key().is_none());
+ let mut rendered = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ assert!(cell.transcript_animation_tick().is_none());
+ rendered.extend(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string()),
+ );
+ }
+ }
+ insta::allow_duplicates! {
+ insta::assert_snapshot!(rendered.join("\n"), @r"
+
+ › Earlier question
+
+ • Earlier answer
+ • Answer in progress
+ ");
+ }
+ }
+}
+
+#[tokio::test]
+async fn transcript_completion_waits_for_normal_agent_stream_consolidation() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_agent_message_delta("normal typed output".to_string());
+
+ chat.on_realtime_transcript_done("assistant".to_string(), "voice output".to_string());
+ chat.on_realtime_transcript_delta(
+ "assistant".to_string(),
+ "unfinished voice output".to_string(),
+ );
+ chat.stop_realtime_conversation();
+
+ assert_eq!(chat.realtime_conversation.pending_history_cells.len(), 2);
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ assert!(
+ cell.display_lines(/*width*/ 80)
+ .iter()
+ .all(|line| !line.to_string().contains("voice output"))
+ );
+ }
+ }
+
+ chat.finalize_completed_assistant_message(Some("normal typed output"));
+ chat.note_stream_consolidation_completed();
+ chat.flush_realtime_transcript_history();
+
+ let mut rendered = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ rendered.extend(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string()),
+ );
+ }
+ }
+ assert_eq!(
+ rendered,
+ [
+ "• normal typed output",
+ "• voice output",
+ "• unfinished voice output"
+ ]
+ );
+}
+
+#[tokio::test]
+async fn transcript_handoff_moves_deferred_repeats_and_partial_once() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_agent_message_delta("ordinary stream".to_string());
+ for _ in 0..super::super::MAX_PENDING_TRANSCRIPT_CELLS {
+ chat.on_realtime_transcript_done("assistant".to_string(), "same words".to_string());
+ }
+ chat.on_realtime_transcript_delta("assistant".to_string(), "partial words".to_string());
+ assert_eq!(
+ chat.realtime_conversation.pending_history_cells.len(),
+ super::super::MAX_PENDING_TRANSCRIPT_CELLS
+ );
+
+ let cells = chat.take_realtime_transcript_cells_for_replay();
+ assert_eq!(cells.len(), super::super::MAX_PENDING_TRANSCRIPT_CELLS + 1);
+ assert!(chat.take_realtime_transcript_cells_for_replay().is_empty());
+ chat.restore_realtime_transcript_cells(cells);
+ chat.finalize_completed_assistant_message(Some("ordinary stream"));
+ chat.note_stream_consolidation_completed();
+ chat.flush_realtime_transcript_history();
+
+ let rendered = std::iter::from_fn(|| events.try_recv().ok())
+ .filter_map(|event| match event {
+ AppEvent::InsertHistoryCell(cell) => Some(
+ cell.transcript_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string())
+ .collect::(),
+ ),
+ _ => None,
+ })
+ .collect::();
+ assert_eq!(
+ rendered.matches("same words").count(),
+ super::super::MAX_PENDING_TRANSCRIPT_CELLS
+ );
+ assert_eq!(rendered.matches("partial words").count(), 0);
+ let live = chat
+ .realtime_conversation
+ .live_transcript_cell
+ .as_ref()
+ .unwrap();
+ assert!(
+ live.display_lines(/*width*/ 80)
+ .iter()
+ .any(|line| line.to_string().contains("partial words"))
+ );
+}
+
+#[tokio::test]
+async fn dropped_deferred_captions_do_not_return_on_thread_replacement() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ activate_voice(&mut chat);
+ chat.on_agent_message_delta("ordinary stream".to_string());
+ for index in 0..(super::super::MAX_PENDING_TRANSCRIPT_CELLS + 2) {
+ chat.on_realtime_transcript_done("assistant".into(), format!("caption {index}"));
+ }
+
+ assert_eq!(
+ chat.realtime_conversation.pending_history_cells.len(),
+ super::super::MAX_PENDING_TRANSCRIPT_CELLS
+ );
+ let records = chat.take_realtime_transcript_cells_for_replay();
+ assert_eq!(records.len(), super::super::MAX_PENDING_TRANSCRIPT_CELLS);
+ assert_eq!(
+ records.front().map(|record| record.text.as_str()),
+ Some("caption 2")
+ );
+ assert_eq!(
+ records.back().map(|record| record.text.as_str()),
+ Some("caption 33")
+ );
+}
+
+#[tokio::test]
+async fn transcripts_are_preserved_while_the_peer_is_connecting() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
+ chat.realtime_conversation.backend_started = true;
+
+ chat.on_realtime_transcript_delta("assistant".to_string(), "Hello".to_string());
+
+ assert_eq!(
+ (
+ chat.realtime_conversation.transcript_role.as_deref(),
+ chat.realtime_conversation.transcript.as_str(),
+ ),
+ (Some("assistant"), "Hello")
+ );
+
+ chat.on_realtime_transcript_done("assistant".to_string(), "Hello there".to_string());
+
+ let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else {
+ panic!("voice should preserve transcripts received before the peer connects");
+ };
+ assert!(
+ cell.display_lines(/*width*/ 80)
+ .iter()
+ .any(|line| line.to_string().contains("Hello there"))
+ );
+ assert_eq!(
+ (
+ chat.realtime_conversation.phase,
+ chat.realtime_conversation.transcript_role.as_deref(),
+ chat.realtime_conversation.transcript.as_str(),
+ ),
+ (RealtimeConversationPhase::Starting, None, "")
+ );
+}
+
+#[tokio::test]
+async fn transcript_deltas_keep_a_bounded_utf8_suffix() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
+
+ chat.on_realtime_transcript_delta("user".to_string(), "é".repeat(600));
+ chat.on_realtime_transcript_delta("user".to_string(), "done".to_string());
+
+ assert_eq!(
+ chat.realtime_conversation.transcript,
+ format!("{}done", "é".repeat(510))
+ );
+}
+
+#[tokio::test]
+async fn completed_transcript_is_added_to_history_and_clears_the_caption() {
+ let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
+ chat.on_realtime_transcript_delta("assistant".to_string(), "Hello".to_string());
+
+ chat.on_realtime_transcript_done("assistant".to_string(), "Hello there".to_string());
+ chat.on_realtime_transcript_done("user".to_string(), "Can you hear me?".to_string());
+
+ let mut rendered = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let AppEvent::InsertHistoryCell(cell) = event {
+ rendered.extend(
+ cell.display_lines(/*width*/ 80)
+ .into_iter()
+ .map(|line| line.to_string()),
+ );
+ }
+ }
+
+ assert_eq!(
+ (
+ chat.realtime_conversation.transcript_role.as_deref(),
+ chat.realtime_conversation.transcript.as_str(),
+ ),
+ (None, "")
+ );
+ insta::assert_snapshot!(rendered.join("\n"));
+}
+
+#[tokio::test]
+async fn completed_transcript_preserves_the_other_speakers_caption() {
+ let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
+ chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
+ chat.on_realtime_transcript_delta("assistant".to_string(), "Still speaking".to_string());
+
+ chat.on_realtime_transcript_done("user".to_string(), "Interruption".to_string());
+
+ assert_eq!(
+ (
+ chat.realtime_conversation.transcript_role.as_deref(),
+ chat.realtime_conversation.transcript.as_str(),
+ ),
+ (Some("assistant"), "Still speaking")
+ );
+}