mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Classify voice session failures and record terminal outcomes (#52756)
## Why Voice failure metrics lacked cause and lifecycle phase, and session durations were recorded before the final outcome was known. A failed control send could also overwrite a more specific WebRTC error. ## What changed - Tag `codex.voice.session.failure` with bounded `cause` and `phase` values, recording at most one failure per session. - Capture duration when stopping and emit `codex.voice.session.duration` at cleanup with a `success` or `failure` outcome. - Carry typed WebRTC errors into the TUI, preserve existing errors when control sends fail, and avoid duplicate errors after startup failure. - Record app-server disconnect failures for background voice sessions as well as the visible session. ## Testing Add coverage for terminal outcome and failure metric attributes, preservation of specific startup and audio-session errors, and startup failures without duplicate control-channel errors. GitOrigin-RevId: 5aeda6e766093e3fce7206560aa83b6f7770dddb
This commit is contained in:
committed by
copyberry
parent
1652992148
commit
322bbf4d84
@@ -1124,6 +1124,17 @@ impl App {
|
||||
self.active_thread_id = visible_thread;
|
||||
}
|
||||
if let Err(err) = result {
|
||||
if is_realtime_stop {
|
||||
let chat_widget = match self.background_voice.as_deref_mut() {
|
||||
Some(owner) if parked_voice => owner,
|
||||
_ => &mut self.chat_widget,
|
||||
};
|
||||
if chat_widget.thread_id() == realtime_stop_thread_id {
|
||||
chat_widget.record_realtime_failure(
|
||||
crate::chatwidget::RealtimeFailureCause::AppServerRequest,
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.recover_transport_error(&err) {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
@@ -1150,12 +1161,14 @@ impl App {
|
||||
let message = format!("Voice conversation failed: {err:#}");
|
||||
if is_realtime_stop {
|
||||
if chat_widget.thread_id() == realtime_stop_thread_id {
|
||||
chat_widget.record_realtime_failure();
|
||||
chat_widget.reset_realtime_conversation();
|
||||
chat_widget.add_realtime_error(message);
|
||||
}
|
||||
} else {
|
||||
chat_widget.on_realtime_error(message);
|
||||
chat_widget.on_realtime_error(
|
||||
message,
|
||||
crate::chatwidget::RealtimeFailureCause::AppServerRequest,
|
||||
);
|
||||
}
|
||||
tracing::error!(error = ?err, "realtime conversation request failed");
|
||||
} else if handled {
|
||||
|
||||
@@ -191,6 +191,7 @@ impl App {
|
||||
self.chat_widget.restore_user_message_to_composer(message);
|
||||
}
|
||||
if let Some(owner) = self.background_voice.as_mut() {
|
||||
owner.record_realtime_disconnect_failure();
|
||||
owner.reset_realtime_conversation();
|
||||
}
|
||||
self.retire_background_voice();
|
||||
|
||||
@@ -486,7 +486,8 @@ impl App {
|
||||
| AppCommand::RealtimeConversationStop { thread_id } = &op
|
||||
&& self.chat_widget.thread_id() == Some(*thread_id)
|
||||
{
|
||||
self.chat_widget.record_realtime_failure();
|
||||
self.chat_widget
|
||||
.record_realtime_failure(crate::chatwidget::RealtimeFailureCause::LocalRequest);
|
||||
self.chat_widget.reset_realtime_conversation();
|
||||
}
|
||||
self.chat_widget
|
||||
@@ -512,7 +513,8 @@ impl App {
|
||||
| AppCommand::RealtimeConversationStop { thread_id } = &op
|
||||
&& self.chat_widget.thread_id() == Some(*thread_id)
|
||||
{
|
||||
self.chat_widget.record_realtime_failure();
|
||||
self.chat_widget
|
||||
.record_realtime_failure(crate::chatwidget::RealtimeFailureCause::LocalRequest);
|
||||
self.chat_widget.reset_realtime_conversation();
|
||||
}
|
||||
self.chat_widget.add_error_message(
|
||||
|
||||
@@ -69,6 +69,12 @@ use codex_realtime_webrtc::StartedRealtimeWebrtcSession;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RealtimeWebrtcStartupFailure {
|
||||
pub message: String,
|
||||
pub cause: codex_realtime_webrtc::ConnectionError,
|
||||
}
|
||||
|
||||
/// Global voice controls always apply to the one call's owner.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum VoiceControl {
|
||||
@@ -1233,7 +1239,7 @@ pub(crate) enum AppEvent {
|
||||
RealtimeWebrtcOfferCreated {
|
||||
thread_id: ThreadId,
|
||||
attempt_id: u64,
|
||||
result: Result<StartedRealtimeWebrtcSession, String>,
|
||||
result: Result<StartedRealtimeWebrtcSession, RealtimeWebrtcStartupFailure>,
|
||||
},
|
||||
|
||||
/// Result of establishing the WebRTC connection for an active voice attempt.
|
||||
|
||||
@@ -357,6 +357,7 @@ mod realtime_settings;
|
||||
mod realtime_split_flap;
|
||||
pub(crate) use realtime::MAX_REPLAY_TRANSCRIPT_CELLS;
|
||||
pub(crate) use realtime::MAX_TRANSCRIPT_BYTES;
|
||||
pub(crate) use realtime::RealtimeFailureCause;
|
||||
pub(crate) use realtime::RealtimeTranscriptRecord;
|
||||
pub(crate) use realtime::is_private_realtime_agent_item;
|
||||
pub(crate) use realtime::realtime_delegation_display_text;
|
||||
|
||||
@@ -345,7 +345,7 @@ impl ChatWidget {
|
||||
}
|
||||
ServerNotification::ThreadRealtimeError(notification) => {
|
||||
if !from_replay {
|
||||
self.on_realtime_error(notification.message);
|
||||
self.on_realtime_error(notification.message, RealtimeFailureCause::Backend);
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadRealtimeClosed(notification) => {
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
//! Interleaved speakers retain separate displays so settled caption text never reanimates.
|
||||
//! Speech recovery suppresses stale queued answers while preserving unspoken text fallbacks.
|
||||
|
||||
mod failure_metrics;
|
||||
mod recording_controls;
|
||||
mod transcript_replay;
|
||||
|
||||
pub(crate) use failure_metrics::RealtimeFailureCause;
|
||||
|
||||
use super::ChatWidget;
|
||||
use super::HistoryCell;
|
||||
use super::PARENT_OWNED_INPUT_MESSAGE;
|
||||
@@ -13,6 +16,7 @@ use super::realtime_split_flap::SplitFlapTranscriptCell;
|
||||
use super::realtime_split_flap::VoiceAmplitudeHistory;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::RealtimeWebrtcStartupFailure;
|
||||
use crate::bottom_pane::VoiceStripPhase;
|
||||
use crate::bottom_pane::VoiceStripState;
|
||||
use crate::history_cell;
|
||||
@@ -144,6 +148,7 @@ pub(super) struct RealtimeConversationUiState {
|
||||
backend_started: bool,
|
||||
webrtc_connected: bool,
|
||||
active_since: Option<Instant>,
|
||||
finished_duration: Option<Duration>,
|
||||
failure_recorded: bool,
|
||||
microphone_muted: bool,
|
||||
microphone_level: usize,
|
||||
@@ -333,8 +338,16 @@ impl ChatWidget {
|
||||
.map(|channels| channels.as_slice().to_vec()),
|
||||
};
|
||||
std::thread::spawn(move || {
|
||||
let result = RealtimeWebrtcSession::start(abort_registration, selection)
|
||||
.map_err(|error| error.to_string());
|
||||
let result =
|
||||
RealtimeWebrtcSession::start(abort_registration, selection).map_err(|error| {
|
||||
RealtimeWebrtcStartupFailure {
|
||||
cause: error
|
||||
.downcast_ref::<codex_realtime_webrtc::ConnectionError>()
|
||||
.copied()
|
||||
.unwrap_or(codex_realtime_webrtc::ConnectionError::Failed),
|
||||
message: error.to_string(),
|
||||
}
|
||||
});
|
||||
app_event_tx.send(AppEvent::RealtimeWebrtcOfferCreated {
|
||||
thread_id,
|
||||
attempt_id,
|
||||
@@ -352,7 +365,7 @@ impl ChatWidget {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.finish_realtime_session_metrics();
|
||||
self.capture_realtime_session_duration();
|
||||
self.restore_all_undelivered_realtime_speech();
|
||||
|
||||
// Stopping may prevent final transcript events from arriving.
|
||||
@@ -368,7 +381,7 @@ impl ChatWidget {
|
||||
return;
|
||||
};
|
||||
if !self.submit_op(AppCommand::RealtimeConversationStop { thread_id }) {
|
||||
self.record_realtime_failure();
|
||||
self.record_realtime_failure(RealtimeFailureCause::LocalRequest);
|
||||
self.reset_realtime_conversation();
|
||||
}
|
||||
} else {
|
||||
@@ -383,7 +396,7 @@ impl ChatWidget {
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
attempt_id: u64,
|
||||
result: Result<StartedRealtimeWebrtcSession, String>,
|
||||
result: Result<StartedRealtimeWebrtcSession, RealtimeWebrtcStartupFailure>,
|
||||
) {
|
||||
if self.realtime_conversation.phase != RealtimeConversationPhase::Starting
|
||||
|| self.realtime_conversation.attempt_id != attempt_id
|
||||
@@ -397,7 +410,10 @@ impl ChatWidget {
|
||||
let offer = match result {
|
||||
Ok(offer) => offer,
|
||||
Err(error) => {
|
||||
self.on_realtime_error(format!("Failed to start voice mode: {error}"));
|
||||
self.on_realtime_error(
|
||||
format!("Failed to start voice mode: {}", error.message),
|
||||
error.cause.into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -405,8 +421,18 @@ impl ChatWidget {
|
||||
if self.realtime_conversation.microphone_muted
|
||||
&& let Err(error) = offer.handle.set_microphone_muted(/*muted*/ true)
|
||||
{
|
||||
let failure = offer.handle.take_error();
|
||||
offer.handle.close();
|
||||
self.on_realtime_error(format!("Failed to restore microphone mute: {error}"));
|
||||
let (message, cause) = match failure {
|
||||
Some((cause, error)) => {
|
||||
(format!("Voice conversation failed: {error}"), cause.into())
|
||||
}
|
||||
None => (
|
||||
format!("Failed to restore microphone mute: {error}"),
|
||||
RealtimeFailureCause::AudioControls,
|
||||
),
|
||||
};
|
||||
self.on_realtime_error(message, cause);
|
||||
return;
|
||||
}
|
||||
self.realtime_conversation.handle = Some(offer.handle);
|
||||
@@ -418,7 +444,7 @@ impl ChatWidget {
|
||||
thread_id,
|
||||
offer_sdp: offer.offer_sdp.into(),
|
||||
}) {
|
||||
self.record_realtime_failure();
|
||||
self.record_realtime_failure(RealtimeFailureCause::LocalRequest);
|
||||
self.reset_realtime_conversation();
|
||||
}
|
||||
}
|
||||
@@ -475,7 +501,7 @@ impl ChatWidget {
|
||||
self.realtime_conversation.startup_retry = StartupRetry::WaitingForStop;
|
||||
self.refresh_terminal_title();
|
||||
if !self.submit_op(AppCommand::RealtimeConversationStop { thread_id }) {
|
||||
self.record_realtime_failure();
|
||||
self.record_realtime_failure(RealtimeFailureCause::LocalRequest);
|
||||
self.reset_realtime_conversation();
|
||||
} else {
|
||||
self.add_info_message(
|
||||
@@ -485,7 +511,22 @@ impl ChatWidget {
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.on_realtime_error(format!("Failed to connect voice mode: {error}"));
|
||||
let stored_error = self
|
||||
.realtime_conversation
|
||||
.handle
|
||||
.as_ref()
|
||||
.and_then(RealtimeWebrtcSessionHandle::take_error);
|
||||
let (message, cause) = match stored_error {
|
||||
Some((cause, message)) => (
|
||||
format!("Voice conversation failed: {message}"),
|
||||
cause.into(),
|
||||
),
|
||||
None => (
|
||||
format!("Failed to connect voice mode: {error}"),
|
||||
error.into(),
|
||||
),
|
||||
};
|
||||
self.on_realtime_error(message, cause);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1005,7 +1046,10 @@ impl ChatWidget {
|
||||
text: text.into(),
|
||||
}) {
|
||||
self.restore_undelivered_realtime_speech(delivery_id);
|
||||
self.on_realtime_error("Failed to deliver the voice response.".to_string());
|
||||
self.on_realtime_error(
|
||||
"Failed to deliver the voice response.".to_string(),
|
||||
RealtimeFailureCause::SpeechDelivery,
|
||||
);
|
||||
} else {
|
||||
self.realtime_conversation.pending_speech.retain(|pending| {
|
||||
pending.turn_id != turn_id || pending.state != PendingSpeechState::AwaitingTurn
|
||||
@@ -1656,29 +1700,18 @@ impl ChatWidget {
|
||||
cells
|
||||
}
|
||||
|
||||
pub(crate) fn record_realtime_failure(&mut self) {
|
||||
if self.realtime_conversation.phase == RealtimeConversationPhase::Inactive
|
||||
|| self.realtime_conversation.failure_recorded
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.realtime_conversation.failure_recorded = true;
|
||||
self.session_telemetry
|
||||
.counter("codex.voice.session.failure", /*inc*/ 1, &[]);
|
||||
}
|
||||
|
||||
pub(super) fn realtime_retry_cleanup_pending(&self) -> bool {
|
||||
self.realtime_conversation.startup_retry == StartupRetry::WaitingForStop
|
||||
}
|
||||
|
||||
pub(crate) fn on_realtime_error(&mut self, message: String) {
|
||||
pub(crate) fn on_realtime_error(&mut self, message: String, cause: RealtimeFailureCause) {
|
||||
if self.realtime_conversation.phase == RealtimeConversationPhase::Inactive {
|
||||
return;
|
||||
}
|
||||
if self.realtime_conversation.phase != RealtimeConversationPhase::Stopping
|
||||
|| self.realtime_retry_cleanup_pending()
|
||||
{
|
||||
self.record_realtime_failure();
|
||||
self.record_realtime_failure(cause);
|
||||
}
|
||||
self.stop_realtime_conversation();
|
||||
self.add_realtime_error(message);
|
||||
@@ -1717,7 +1750,13 @@ impl ChatWidget {
|
||||
if retry_thread_id.is_none()
|
||||
&& (reason.is_none() || matches!(reason.as_deref(), Some("transport_closed" | "error")))
|
||||
{
|
||||
self.record_realtime_failure();
|
||||
let cause = match reason.as_deref() {
|
||||
Some("transport_closed") => RealtimeFailureCause::TransportClosed,
|
||||
Some("error") => RealtimeFailureCause::Backend,
|
||||
None => RealtimeFailureCause::UnknownClose,
|
||||
Some(_) => RealtimeFailureCause::Backend,
|
||||
};
|
||||
self.record_realtime_failure(cause);
|
||||
}
|
||||
let failed = self.realtime_conversation.failure_recorded;
|
||||
self.reset_realtime_conversation();
|
||||
@@ -1748,14 +1787,28 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn capture_realtime_session_duration(&mut self) {
|
||||
if self.realtime_conversation.finished_duration.is_none()
|
||||
&& let Some(active_since) = self.realtime_conversation.active_since.take()
|
||||
{
|
||||
self.realtime_conversation.finished_duration = Some(active_since.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_realtime_session_metrics(&mut self) {
|
||||
if let Some(active_since) = self.realtime_conversation.active_since.take() {
|
||||
self.capture_realtime_session_duration();
|
||||
if let Some(duration) = self.realtime_conversation.finished_duration.take() {
|
||||
let outcome = if self.realtime_conversation.failure_recorded {
|
||||
"failure"
|
||||
} else {
|
||||
"success"
|
||||
};
|
||||
self.session_telemetry
|
||||
.counter("codex.voice.session.ended", /*inc*/ 1, &[]);
|
||||
self.session_telemetry.record_duration(
|
||||
"codex.voice.session.duration",
|
||||
active_since.elapsed(),
|
||||
&[],
|
||||
duration,
|
||||
&[("outcome", outcome)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Bounded failure classification for locally owned voice sessions.
|
||||
//! Each session records at most one cause, while lifecycle phase remains an independent fact.
|
||||
|
||||
use super::RealtimeConversationPhase;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RealtimeFailureCause {
|
||||
AppServerDisconnect,
|
||||
AppServerRequest,
|
||||
AudioControls,
|
||||
AudioDevices,
|
||||
AudioSession,
|
||||
Backend,
|
||||
ConnectionUnknown,
|
||||
HelperStartup,
|
||||
NegotiationTimedOut,
|
||||
LocalRequest,
|
||||
RuntimeInitialization,
|
||||
Shutdown,
|
||||
SpeechDelivery,
|
||||
Transport,
|
||||
TransportClosed,
|
||||
UnknownClose,
|
||||
}
|
||||
|
||||
impl RealtimeFailureCause {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AppServerDisconnect => "app_server_disconnect",
|
||||
Self::AppServerRequest => "app_server_request",
|
||||
Self::AudioControls => "audio_controls",
|
||||
Self::AudioDevices => "audio_devices",
|
||||
Self::AudioSession => "audio_session",
|
||||
Self::Backend => "backend",
|
||||
Self::ConnectionUnknown => "connection_unknown",
|
||||
Self::HelperStartup => "helper_startup",
|
||||
Self::NegotiationTimedOut => "negotiation_timed_out",
|
||||
Self::LocalRequest => "local_request",
|
||||
Self::RuntimeInitialization => "runtime_initialization",
|
||||
Self::Shutdown => "shutdown",
|
||||
Self::SpeechDelivery => "speech_delivery",
|
||||
Self::Transport => "transport",
|
||||
Self::TransportClosed => "transport_closed",
|
||||
Self::UnknownClose => "unknown_close",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<codex_realtime_webrtc::ConnectionError> for RealtimeFailureCause {
|
||||
fn from(error: codex_realtime_webrtc::ConnectionError) -> Self {
|
||||
match error {
|
||||
codex_realtime_webrtc::ConnectionError::NegotiationTimedOut => {
|
||||
Self::NegotiationTimedOut
|
||||
}
|
||||
codex_realtime_webrtc::ConnectionError::Failed => Self::ConnectionUnknown,
|
||||
codex_realtime_webrtc::ConnectionError::HelperStartup => Self::HelperStartup,
|
||||
codex_realtime_webrtc::ConnectionError::RuntimeInitialization => {
|
||||
Self::RuntimeInitialization
|
||||
}
|
||||
codex_realtime_webrtc::ConnectionError::Transport => Self::Transport,
|
||||
codex_realtime_webrtc::ConnectionError::AudioDevices => Self::AudioDevices,
|
||||
codex_realtime_webrtc::ConnectionError::AudioControls => Self::AudioControls,
|
||||
codex_realtime_webrtc::ConnectionError::AudioSession => Self::AudioSession,
|
||||
codex_realtime_webrtc::ConnectionError::Shutdown => Self::Shutdown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn record_realtime_failure(&mut self, cause: RealtimeFailureCause) {
|
||||
if self.realtime_conversation.phase == RealtimeConversationPhase::Inactive
|
||||
|| self.realtime_conversation.failure_recorded
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.realtime_conversation.failure_recorded = true;
|
||||
let phase = match self.realtime_conversation.phase {
|
||||
RealtimeConversationPhase::Inactive => "inactive",
|
||||
RealtimeConversationPhase::Starting => "starting",
|
||||
RealtimeConversationPhase::Active => "active",
|
||||
RealtimeConversationPhase::Stopping => "stopping",
|
||||
};
|
||||
self.session_telemetry.counter(
|
||||
"codex.voice.session.failure",
|
||||
/*inc*/ 1,
|
||||
&[("cause", cause.as_str()), ("phase", phase)],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,16 @@ impl ChatWidget {
|
||||
let muted = !self.realtime_conversation.microphone_muted;
|
||||
if let Some(handle) = self.realtime_conversation.handle.as_ref() {
|
||||
if let Err(error) = handle.set_microphone_muted(muted) {
|
||||
self.on_realtime_error(format!("Failed to update microphone: {error}"));
|
||||
let (message, cause) = match handle.take_error() {
|
||||
Some((cause, error)) => {
|
||||
(format!("Voice conversation failed: {error}"), cause.into())
|
||||
}
|
||||
None => (
|
||||
format!("Failed to update microphone: {error}"),
|
||||
RealtimeFailureCause::AudioControls,
|
||||
),
|
||||
};
|
||||
self.on_realtime_error(message, cause);
|
||||
return;
|
||||
}
|
||||
} else if self.realtime_conversation.phase != RealtimeConversationPhase::Starting {
|
||||
@@ -136,8 +145,8 @@ impl ChatWidget {
|
||||
let Some(handle) = self.realtime_conversation.handle.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if let Some(error) = handle.take_error() {
|
||||
self.on_realtime_error(format!("Voice conversation failed: {error}"));
|
||||
if let Some((cause, error)) = handle.take_error() {
|
||||
self.on_realtime_error(format!("Voice conversation failed: {error}"), cause.into());
|
||||
return;
|
||||
}
|
||||
if self.realtime_conversation.phase == RealtimeConversationPhase::Starting {
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
use super::super::StartupRetry;
|
||||
use super::*;
|
||||
use codex_otel::MetricsClient;
|
||||
use codex_otel::MetricsConfig;
|
||||
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
|
||||
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
|
||||
use opentelemetry_sdk::metrics::data::MetricData;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Instant;
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_error_during_retry_cleanup_records_one_failure() {
|
||||
@@ -15,16 +22,114 @@ async fn terminal_error_during_retry_cleanup_records_one_failure() {
|
||||
chat.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
|
||||
chat.realtime_conversation.startup_retry = retry;
|
||||
|
||||
chat.on_realtime_error("connection lost".into());
|
||||
chat.on_realtime_error(
|
||||
"connection lost".into(),
|
||||
RealtimeFailureCause::ConnectionUnknown,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
chat.realtime_conversation.failure_recorded,
|
||||
expected_failure
|
||||
);
|
||||
chat.on_realtime_error("duplicate error".into());
|
||||
chat.on_realtime_error(
|
||||
"duplicate error".into(),
|
||||
RealtimeFailureCause::ConnectionUnknown,
|
||||
);
|
||||
assert_eq!(
|
||||
chat.realtime_conversation.failure_recorded,
|
||||
expected_failure
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_voice_duration_uses_terminal_success_or_failure_outcome() {
|
||||
for (failed, expected_outcome) in [(false, "success"), (true, "failure")] {
|
||||
let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
let metrics = MetricsClient::new(
|
||||
MetricsConfig::in_memory(
|
||||
"test",
|
||||
"codex-cli",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
InMemoryMetricExporter::default(),
|
||||
)
|
||||
.with_runtime_reader(),
|
||||
)
|
||||
.expect("in-memory metrics should initialize");
|
||||
chat.session_telemetry = chat
|
||||
.session_telemetry
|
||||
.clone()
|
||||
.with_metrics_without_metadata_tags(metrics);
|
||||
activate_voice(&mut chat);
|
||||
chat.realtime_conversation.active_since = Some(Instant::now());
|
||||
chat.capture_realtime_session_duration();
|
||||
if failed {
|
||||
chat.record_realtime_disconnect_failure();
|
||||
}
|
||||
|
||||
chat.reset_realtime_conversation();
|
||||
|
||||
let metrics = chat
|
||||
.session_telemetry
|
||||
.snapshot_metrics()
|
||||
.expect("runtime metrics snapshot should be available");
|
||||
let duration = metrics
|
||||
.scope_metrics()
|
||||
.flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics)
|
||||
.find(|metric| metric.name() == "codex.voice.session.duration")
|
||||
.expect("voice duration metric should be recorded");
|
||||
let duration_attributes = match duration.data() {
|
||||
AggregatedMetrics::F64(MetricData::Histogram(histogram)) => histogram
|
||||
.data_points()
|
||||
.next()
|
||||
.expect("voice duration should have one datapoint")
|
||||
.attributes()
|
||||
.map(|attribute| {
|
||||
(
|
||||
attribute.key.as_str().to_string(),
|
||||
attribute.value.as_str().to_string(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
_ => panic!("voice duration should be an f64 histogram"),
|
||||
};
|
||||
assert_eq!(
|
||||
duration_attributes,
|
||||
BTreeMap::from([("outcome".to_string(), expected_outcome.to_string())])
|
||||
);
|
||||
|
||||
let failure = metrics
|
||||
.scope_metrics()
|
||||
.flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics)
|
||||
.find(|metric| metric.name() == "codex.voice.session.failure");
|
||||
if failed {
|
||||
let failure_attributes = match failure
|
||||
.expect("failed voice session should record a failure")
|
||||
.data()
|
||||
{
|
||||
AggregatedMetrics::U64(MetricData::Sum(sum)) => sum
|
||||
.data_points()
|
||||
.next()
|
||||
.expect("voice failure should have one datapoint")
|
||||
.attributes()
|
||||
.map(|attribute| {
|
||||
(
|
||||
attribute.key.as_str().to_string(),
|
||||
attribute.value.as_str().to_string(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
_ => panic!("voice failure should be a u64 sum"),
|
||||
};
|
||||
assert_eq!(
|
||||
failure_attributes,
|
||||
BTreeMap::from([
|
||||
("cause".to_string(), "app_server_disconnect".to_string()),
|
||||
("phase".to_string(), "active".to_string()),
|
||||
])
|
||||
);
|
||||
} else {
|
||||
assert!(failure.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ mod recording_controls_tests;
|
||||
mod session_metrics_tests;
|
||||
|
||||
use super::RealtimeConversationPhase;
|
||||
use super::RealtimeFailureCause;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Voice startup, shutdown, and retry maintain one active owned session.
|
||||
|
||||
use super::*;
|
||||
use crate::app_event::RealtimeWebrtcStartupFailure;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -148,7 +149,10 @@ async fn audio_failure_cancels_pending_voice_and_reports_the_device_error() {
|
||||
chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
|
||||
chat.realtime_conversation.startup_abort = Some(abort);
|
||||
|
||||
chat.on_realtime_error("speaker stream failed: device disconnected".to_string());
|
||||
chat.on_realtime_error(
|
||||
"speaker stream failed: device disconnected".to_string(),
|
||||
RealtimeFailureCause::AudioSession,
|
||||
);
|
||||
|
||||
commit_realtime_history_events(&mut chat, &mut events);
|
||||
let cell = std::iter::from_fn(|| events.try_recv().ok())
|
||||
@@ -198,7 +202,10 @@ async fn canceled_offer_is_ignored_after_a_new_start_attempt() {
|
||||
chat.on_realtime_webrtc_offer_created(
|
||||
ThreadId::new(),
|
||||
/*attempt_id*/ 1,
|
||||
Err("canceled offer failed".to_string()),
|
||||
Err(RealtimeWebrtcStartupFailure {
|
||||
message: "superseded failure".into(),
|
||||
cause: codex_realtime_webrtc::ConnectionError::Failed,
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -211,6 +218,38 @@ async fn canceled_offer_is_ignored_after_a_new_start_attempt() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn voice_startup_failure_preserves_the_specific_error() {
|
||||
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.realtime_conversation.phase = RealtimeConversationPhase::Starting;
|
||||
chat.realtime_conversation.attempt_id = 1;
|
||||
|
||||
chat.on_realtime_webrtc_offer_created(
|
||||
ThreadId::new(),
|
||||
/*attempt_id*/ 1,
|
||||
Err(RealtimeWebrtcStartupFailure {
|
||||
message: "voice package unavailable".into(),
|
||||
cause: codex_realtime_webrtc::ConnectionError::Failed,
|
||||
}),
|
||||
);
|
||||
|
||||
commit_realtime_history_events(&mut chat, &mut events);
|
||||
let cell = std::iter::from_fn(|| events.try_recv().ok())
|
||||
.find_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||||
_ => None,
|
||||
})
|
||||
.expect("voice should report the startup failure");
|
||||
insta::assert_snapshot!(
|
||||
cell.display_lines(/*width*/ 80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
@"■ Failed to start voice mode: voice package unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -507,10 +546,13 @@ async fn failure_cleanup_does_not_attribute_stop_to_the_user() {
|
||||
// A local failure initiates backend cleanup; its acknowledgement is still "requested".
|
||||
chat.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
|
||||
chat.realtime_conversation.failure_recorded = true;
|
||||
chat.on_realtime_error(format!(
|
||||
"Failed to connect voice mode: {}",
|
||||
codex_realtime_webrtc::ConnectionError::AudioDevices
|
||||
));
|
||||
chat.on_realtime_error(
|
||||
format!(
|
||||
"Failed to connect voice mode: {}",
|
||||
codex_realtime_webrtc::ConnectionError::AudioDevices
|
||||
),
|
||||
RealtimeFailureCause::AudioDevices,
|
||||
);
|
||||
chat.on_realtime_conversation_closed(Some("requested".into()));
|
||||
assert_eq!(
|
||||
chat.realtime_conversation.phase,
|
||||
|
||||
@@ -6,6 +6,16 @@ use super::*;
|
||||
use crate::bottom_pane::RestrictedInputMode;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn record_realtime_disconnect_failure(&mut self) {
|
||||
if matches!(
|
||||
self.realtime_conversation.phase,
|
||||
RealtimeConversationPhase::Starting | RealtimeConversationPhase::Active
|
||||
) || self.realtime_retry_cleanup_pending()
|
||||
{
|
||||
self.record_realtime_failure(RealtimeFailureCause::AppServerDisconnect);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pause_for_disconnect(&mut self) {
|
||||
self.permission_discovery = None;
|
||||
self.invalidate_permission_discovery();
|
||||
@@ -13,13 +23,7 @@ impl ChatWidget {
|
||||
self.cancel_image_submission();
|
||||
// The app-server transport can fail while the separate WebRTC helper
|
||||
// still sends microphone audio. Retire local media before showing offline UI.
|
||||
if matches!(
|
||||
self.realtime_conversation.phase,
|
||||
RealtimeConversationPhase::Starting | RealtimeConversationPhase::Active
|
||||
) || self.realtime_retry_cleanup_pending()
|
||||
{
|
||||
self.record_realtime_failure();
|
||||
}
|
||||
self.record_realtime_disconnect_failure();
|
||||
let _ = self.reset_realtime_conversation();
|
||||
if let Some(questions) = &mut self.bottom_pane.questions {
|
||||
questions.delivery_enabled = false;
|
||||
|
||||
Reference in New Issue
Block a user