Files
codex/codex-rs/core/tests/suite/realtime_misalignment.rs
Benjamin Carlsson b01c3986fd Add live WebRTC voice conversations to the TUI (#43581)
## What changed

- Add feature-gated `/voice`, `/voice mute`, and `/voice stop` commands with local WebRTC audio and app-server signaling.
- Show live transcripts, conversation status, and microphone and speaker levels. Preserve captions and undelivered answers across thread switches.
- Speak final answers from voice handoffs while keeping delegated reasoning and commentary hidden and typed answers unspoken.
- Retry eligible startup failures once and clean up voice sessions on thread switches and disconnects.
- Stop voice and block late handoffs after a misalignment policy violation. Remove realtime event payloads and spoken text from receipt and debug logs.

## Testing

Add coverage for voice command mapping, rejected starts, disconnect cleanup, delegated final-answer speech, late handoff rejection, and transcript and footer rendering.

GitOrigin-RevId: 11def1f86b0e023b21d0d92072d6636ccad89815
2026-09-07 20:25:30 +00:00

152 lines
5.5 KiB
Rust

//! A failed turn must not admit handoffs still arriving from its voice session.
use anyhow::Context;
use anyhow::Result;
use codex_config::config_toml::RealtimeWsVersion;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::RealtimeOutputModality;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event_match;
use futures::SinkExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
use wiremock::ResponseTemplate;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn misalignment_retires_late_voice_handoff_before_it_starts_a_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = responses::start_mock_server().await;
let first_response = responses::mount_response_once(
&api_server,
ResponseTemplate::new(403).set_body_json(json!({
"error": {
"message": "This request violated the misalignment policy.",
"type": "invalid_request_error",
"code": "misalignment_policy_violation"
}
})),
)
.await;
let listener = TcpListener::bind("127.0.0.1:0").await?;
let realtime_url = format!("ws://{}", listener.local_addr()?);
let (late_handoff_tx, mut late_handoff_rx) = oneshot::channel();
let (finish_tx, finish_rx) = oneshot::channel();
let sideband = tokio::spawn(async move {
let (stream, _) = listener.accept().await?;
let mut websocket = accept_async(stream).await?;
websocket
.send(Message::Text(
json!({
"type": "session.updated",
"session": {"id": "session_misalignment", "instructions": "backend prompt"}
})
.to_string()
.into(),
))
.await?;
for (handoff_id, text) in [
("initial_handoff", "first request"),
("late_handoff", "must not start a turn"),
] {
if handoff_id == "late_handoff" {
(&mut late_handoff_rx).await?;
}
websocket
.send(Message::Text(
json!({
"type": "conversation.handoff.requested",
"handoff_id": handoff_id,
"item_id": handoff_id,
"input_transcript": text
})
.to_string()
.into(),
))
.await?;
}
finish_rx.await?;
websocket.close(None).await?;
Ok::<_, anyhow::Error>(())
});
let mut builder = test_codex().with_config(move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_url);
config.realtime.version = RealtimeWsVersion::V1;
});
let test = builder.build_with_auto_env(&api_server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
delegation_ack_filler: None,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: None,
version: None,
voice: None,
}))
.await?;
wait_for_event_match(&test.codex, |event| match event {
EventMsg::Error(error)
if error.codex_error_info == Some(CodexErrorInfo::MisalignmentPolicyViolation) =>
{
Some(())
}
_ => None,
})
.await;
assert_eq!(first_response.requests().len(), 1);
late_handoff_tx.send(()).expect("sideband still open");
wait_for_event_match(&test.codex, |event| match event {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::HandoffRequested(handoff),
}) if handoff.handoff_id == "late_handoff" => Some(()),
_ => None,
})
.await;
finish_tx.send(()).expect("sideband still open");
sideband.await??;
// The fanout forwards a handoff only after deciding whether to route it.
// Give any spawned turn a chance to reach the mock API before checking.
tokio::time::sleep(Duration::from_millis(200)).await;
let requests = api_server
.received_requests()
.await
.context("API requests")?;
assert_eq!(
requests
.iter()
.filter(|request| request.url.path() == "/v1/responses")
.count(),
1,
);
Ok(())
}