use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use crate::request::{CliRunId, CliSessionId};
use crate::vendor::CliVendorKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FinishReason {
Completed,
BudgetExhausted,
Cancelled,
ProcessError,
StreamError,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDescriptorInit {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerInit {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CodingCliEvent {
RunStarted {
run_id: CliRunId,
vendor: CliVendorKind,
model: Option<String>,
session_id: Option<CliSessionId>,
},
SystemInit {
tools: Vec<ToolDescriptorInit>,
mcp_servers: Vec<McpServerInit>,
#[serde(default)]
plugins: Vec<String>,
},
AssistantTextDelta { text: String },
ToolCallStarted {
tool_call_id: String,
name: String,
input: serde_json::Value,
},
ToolCallFinished {
tool_call_id: String,
output: Option<serde_json::Value>,
error: Option<String>,
},
ApiRetry {
attempt: u32,
delay_ms: u64,
reason: String,
},
Usage {
input_tokens: u64,
output_tokens: u64,
cost_usd: Option<f64>,
},
RunFinished {
reason: FinishReason,
result_text: Option<String>,
},
RawVendorEvent {
vendor: CliVendorKind,
payload: serde_json::Value,
},
Note {
message: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
fields: BTreeMap<String, serde_json::Value>,
},
}
pub struct CodingCliEventStream {
rx: broadcast::Receiver<CodingCliEvent>,
}
impl CodingCliEventStream {
pub fn new(rx: broadcast::Receiver<CodingCliEvent>) -> Self {
Self { rx }
}
pub async fn recv(&mut self) -> Option<CodingCliEvent> {
loop {
match self.rx.recv().await {
Ok(ev) => return Some(ev),
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => return None,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_round_trips_json() {
let ev = CodingCliEvent::AssistantTextDelta {
text: "Hello".into(),
};
let j = serde_json::to_string(&ev).unwrap();
assert!(j.contains("\"kind\":\"assistant_text_delta\""));
let back: CodingCliEvent = serde_json::from_str(&j).unwrap();
if let CodingCliEvent::AssistantTextDelta { text } = back {
assert_eq!(text, "Hello");
} else {
panic!("wrong variant");
}
}
#[test]
fn finish_reason_serializes_snake_case() {
let j = serde_json::to_string(&FinishReason::BudgetExhausted).unwrap();
assert_eq!(j, "\"budget_exhausted\"");
}
}