use serde_json::Value;
pub type AgentRecord = Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamingBehavior {
#[allow(
dead_code,
reason = "the protocol names both spellings; steering has its own command"
)]
Steer,
FollowUp,
}
impl StreamingBehavior {
pub fn as_wire(self) -> &'static str {
match self {
StreamingBehavior::Steer => "steer",
StreamingBehavior::FollowUp => "followUp",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AgentImage {
pub r#type: String,
pub data: String,
pub mime_type: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DialogRequest {
pub id: String,
pub method: DialogMethod,
pub title: String,
pub message: Option<String>,
pub options: Option<Vec<String>>,
pub placeholder: Option<String>,
pub prefill: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogMethod {
Select,
Confirm,
Input,
Editor,
}
impl DialogMethod {
pub fn as_str(self) -> &'static str {
match self {
DialogMethod::Select => "select",
DialogMethod::Confirm => "confirm",
DialogMethod::Input => "input",
DialogMethod::Editor => "editor",
}
}
}
pub fn is_dialog_method(method: Option<&str>) -> bool {
matches!(method, Some("select" | "confirm" | "input" | "editor"))
}
pub fn is_fire_and_forget(method: Option<&str>) -> bool {
matches!(
method,
Some("notify" | "setStatus" | "setWidget" | "setTitle" | "set_editor_text")
)
}
fn text_of(record: &Value, key: &str) -> Option<String> {
record.get(key).and_then(Value::as_str).map(str::to_owned)
}
pub fn as_dialog_request(record: &Value) -> Option<DialogRequest> {
if record.get("type").and_then(Value::as_str) != Some("extension_ui_request") {
return None;
}
let id = text_of(record, "id")?;
let method = record.get("method").and_then(Value::as_str);
if !is_dialog_method(method) {
return None;
}
let options = record
.get("options")
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect::<Vec<_>>()
});
let method = match method {
Some("select") => DialogMethod::Select,
Some("confirm") => DialogMethod::Confirm,
Some("editor") => DialogMethod::Editor,
_ => DialogMethod::Input,
};
Some(DialogRequest {
id,
method,
title: text_of(record, "title").unwrap_or_default(),
message: text_of(record, "message"),
options,
placeholder: text_of(record, "placeholder"),
prefill: text_of(record, "prefill"),
})
}
pub fn tool_target(args: Option<&Value>) -> Option<String> {
let record = args?.as_object()?;
for key in [
"command",
"path",
"file_path",
"filePath",
"pattern",
"query",
"url",
] {
if let Some(Value::String(value)) = record.get(key)
&& !value.trim().is_empty()
{
return Some(value.trim().to_owned());
}
}
None
}
pub fn message_text(message: Option<&Value>) -> String {
let Some(content) = message
.and_then(|message| message.get("content"))
.and_then(Value::as_array)
else {
return String::new();
};
let text: String = content
.iter()
.filter(|part| {
part.get("type").and_then(Value::as_str) == Some("text")
&& part.get("text").is_some_and(Value::is_string)
})
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect();
text
}
pub fn starts_thinking(record: &Value) -> bool {
record
.get("assistantMessageEvent")
.and_then(|event| event.get("type"))
.and_then(Value::as_str)
== Some("thinking_start")
}
pub fn thinking_ended(record: &Value) -> Option<String> {
let event = record.get("assistantMessageEvent")?;
if event.get("type").and_then(Value::as_str) != Some("thinking_end") {
return None;
}
event
.get("content")
.and_then(Value::as_str)
.map(str::to_owned)
}
pub fn message_role(message: Option<&Value>) -> Option<String> {
message?
.get("role")
.and_then(Value::as_str)
.map(str::to_owned)
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Usage {
pub input: f64,
pub output: f64,
pub cache_read: f64,
pub cache_write: f64,
pub total_tokens: f64,
pub cost: f64,
pub model: Option<String>,
}
fn number(value: Option<&Value>) -> f64 {
value.and_then(Value::as_f64).unwrap_or(0.0)
}
pub fn usage_of(record: &Value) -> Option<Usage> {
let message = record.get("message").unwrap_or(record);
let raw = message.get("usage")?;
let held = raw.as_object()?;
let mut usage = Usage {
input: number(held.get("input")),
output: number(held.get("output")),
cache_read: number(held.get("cacheRead")),
cache_write: number(held.get("cacheWrite")),
total_tokens: number(held.get("totalTokens")),
cost: held
.get("cost")
.and_then(|cost| cost.get("total"))
.and_then(Value::as_f64)
.unwrap_or(0.0),
model: None,
};
if let Some(model) = message.get("model").and_then(Value::as_str) {
usage.model = Some(model.to_owned());
}
Some(usage)
}
#[cfg(test)]
mod tests;