use vw_gateway_client::{
GatewayChatStreamEvent, GatewayChatUsage, GatewayTypedChatStreamEvent,
normalize_chat_stream_event,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum UiRuntimeTerminalEvent {
Done {
finish_reason: Option<String>,
usage: Option<GatewayChatUsage>,
message_id: Option<String>,
parent_message_id: Option<String>,
},
Cancelled {
reason: Option<String>,
usage: Option<GatewayChatUsage>,
message_id: Option<String>,
parent_message_id: Option<String>,
},
TimedOut {
message: String,
usage: Option<GatewayChatUsage>,
message_id: Option<String>,
parent_message_id: Option<String>,
},
Error(String),
}
impl UiRuntimeTerminalEvent {
pub(crate) fn from_error_message(error: String) -> Self {
let message = normalize_optional_string(Some(error))
.unwrap_or_else(|| "gateway stream failed".to_string());
match classify_terminal_marker(Some(&message)) {
TerminalMarker::Done => Self::Error(message),
TerminalMarker::Cancelled => Self::Cancelled {
reason: Some(message),
usage: None,
message_id: None,
parent_message_id: None,
},
TerminalMarker::TimedOut => {
Self::TimedOut { message, usage: None, message_id: None, parent_message_id: None }
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum UiRuntimeEvent {
Delta(String),
StepStart { step_index: u32, created_ms: u64, model: Option<String> },
StepFinish {
step_index: u32,
finished_ms: u64,
usage: GatewayChatUsage,
finish_reason: Option<String>,
model: Option<String>,
},
Terminal(UiRuntimeTerminalEvent),
TaskStateChanged { session_id: Option<String> },
SessionMetadataChanged { session_id: Option<String>, title: Option<String> },
UsageUpdated { session_id: Option<String>, usage: GatewayChatUsage },
Unknown { event_type: Option<String> },
}
pub(crate) fn adapt_gateway_stream_event(event: GatewayChatStreamEvent) -> UiRuntimeEvent {
match normalize_chat_stream_event(event) {
GatewayTypedChatStreamEvent::Delta(delta) => UiRuntimeEvent::Delta(delta),
GatewayTypedChatStreamEvent::StepStart(event) => UiRuntimeEvent::StepStart {
step_index: event.step_index,
created_ms: event.created_ms,
model: event.model,
},
GatewayTypedChatStreamEvent::StepFinish(event) => UiRuntimeEvent::StepFinish {
step_index: event.step_index,
finished_ms: event.finished_ms,
usage: event.usage,
finish_reason: event.finish_reason,
model: event.model,
},
GatewayTypedChatStreamEvent::PostToolRound(_) => {
UiRuntimeEvent::Unknown { event_type: Some("chat.post_tool_round".to_string()) }
}
GatewayTypedChatStreamEvent::Done {
finish_reason,
usage,
message_id,
parent_message_id,
} => UiRuntimeEvent::Terminal(terminal_from_done(
finish_reason,
usage,
message_id,
parent_message_id,
)),
GatewayTypedChatStreamEvent::Error(error) => {
UiRuntimeEvent::Terminal(UiRuntimeTerminalEvent::from_error_message(error))
}
GatewayTypedChatStreamEvent::TodoUpdated { session_id }
| GatewayTypedChatStreamEvent::QuestionRaised { session_id }
| GatewayTypedChatStreamEvent::QuestionResolved { session_id } => {
UiRuntimeEvent::TaskStateChanged { session_id }
}
GatewayTypedChatStreamEvent::TitleUpdated { session_id, title } => {
UiRuntimeEvent::SessionMetadataChanged {
session_id,
title: normalize_optional_string(Some(title)),
}
}
GatewayTypedChatStreamEvent::SessionUpdated { session_id, title } => {
UiRuntimeEvent::SessionMetadataChanged {
session_id,
title: normalize_optional_string(title),
}
}
GatewayTypedChatStreamEvent::UsageUpdated { session_id, usage } => {
UiRuntimeEvent::UsageUpdated { session_id, usage }
}
GatewayTypedChatStreamEvent::Unknown { event_type } => {
UiRuntimeEvent::Unknown { event_type }
}
}
}
fn terminal_from_done(
finish_reason: Option<String>,
usage: Option<GatewayChatUsage>,
message_id: Option<String>,
parent_message_id: Option<String>,
) -> UiRuntimeTerminalEvent {
let finish_reason = normalize_optional_string(finish_reason);
match classify_terminal_marker(finish_reason.as_deref()) {
TerminalMarker::Done => {
UiRuntimeTerminalEvent::Done { finish_reason, usage, message_id, parent_message_id }
}
TerminalMarker::Cancelled => UiRuntimeTerminalEvent::Cancelled {
reason: finish_reason,
usage,
message_id,
parent_message_id,
},
TerminalMarker::TimedOut => UiRuntimeTerminalEvent::TimedOut {
message: finish_reason
.clone()
.unwrap_or_else(|| "gateway stream timed out".to_string()),
usage,
message_id,
parent_message_id,
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalMarker {
Done,
Cancelled,
TimedOut,
}
fn classify_terminal_marker(value: Option<&str>) -> TerminalMarker {
let Some(value) = normalize_optional_str_ref(value) else {
return TerminalMarker::Done;
};
let normalized = value.to_ascii_lowercase();
if contains_terminal_keyword(&normalized, &["timeout", "timed out", "deadline exceeded"]) {
return TerminalMarker::TimedOut;
}
if contains_terminal_keyword(
&normalized,
&["cancelled", "canceled", "cancel", "interrupted", "aborted"],
) {
return TerminalMarker::Cancelled;
}
TerminalMarker::Done
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
}
fn normalize_optional_str_ref(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn contains_terminal_keyword(value: &str, keywords: &[&str]) -> bool {
keywords.iter().any(|keyword| value.contains(keyword))
}