mirror of
https://github.com/openai/codex.git
synced 2026-09-13 11:47:17 +00:00
## 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
152 lines
5.5 KiB
Rust
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(())
|
|
}
|