use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub(super) enum CursorFrame {
Init(InitFrame),
User,
ThinkingDelta(String),
ThinkingCompleted,
Assistant(AssistantFrame),
ToolCall(ToolCallFrame),
Result(ResultFrame),
Unknown {
kind: String,
subtype: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub(super) struct InitFrame {
#[serde(default)]
pub(super) session_id: Option<String>,
#[serde(default)]
pub(super) cwd: Option<String>,
#[serde(default)]
pub(super) model: Option<String>,
#[serde(default, rename = "permissionMode")]
pub(super) permission_mode: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub(super) struct AssistantFrame {
pub(super) text: String,
pub(super) has_timestamp: bool,
pub(super) has_model_call_id: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ToolCallPhase {
Started,
Completed,
}
#[derive(Debug, Clone, PartialEq)]
pub(super) struct ToolCallFrame {
pub(super) phase: ToolCallPhase,
pub(super) call_id: String,
pub(super) tool_key: String,
pub(super) args: Option<Value>,
pub(super) result: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub(super) struct ResultFrame {
#[serde(default)]
pub(super) subtype: Option<String>,
#[serde(default)]
pub(super) is_error: Option<bool>,
#[serde(default)]
pub(super) duration_ms: Option<u64>,
#[serde(default)]
pub(super) result: Option<String>,
#[serde(default)]
pub(super) session_id: Option<String>,
#[serde(default)]
pub(super) request_id: Option<String>,
#[serde(default)]
pub(super) usage: Option<RawUsage>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawUsage {
#[serde(default)]
pub(super) input_tokens: Option<u64>,
#[serde(default)]
pub(super) output_tokens: Option<u64>,
#[serde(default)]
pub(super) cache_read_tokens: Option<u64>,
#[serde(default)]
pub(super) cache_write_tokens: Option<u64>,
}
impl RawUsage {
pub(super) fn to_model(&self) -> rho_sdk::model::ModelUsage {
rho_sdk::model::ModelUsage {
input_tokens: self.input_tokens,
output_tokens: self.output_tokens,
cache_read_tokens: self.cache_read_tokens,
cache_write_tokens: self.cache_write_tokens,
total_tokens: None,
context_window: None,
cost_usd_micros: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub(super) enum DecodeError {
#[error("not a JSON object")]
NotObject,
#[error("missing `type`")]
MissingType,
#[error("{kind}: {detail}")]
Shape { kind: String, detail: String },
}
pub(super) fn decode_frame(value: Value) -> Result<CursorFrame, DecodeError> {
let Value::Object(map) = value else {
return Err(DecodeError::NotObject);
};
let kind = map
.get("type")
.and_then(Value::as_str)
.ok_or(DecodeError::MissingType)?
.to_string();
let subtype = map
.get("subtype")
.and_then(Value::as_str)
.map(str::to_string);
let value = Value::Object(map);
let shape = |detail: &str| DecodeError::Shape {
kind: kind.clone(),
detail: detail.to_string(),
};
Ok(match (kind.as_str(), subtype.as_deref()) {
("system", Some("init")) => CursorFrame::Init(
serde_json::from_value(value).map_err(|error| shape(&error.to_string()))?,
),
("user", _) => CursorFrame::User,
("thinking", Some("delta")) => CursorFrame::ThinkingDelta(
value
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
),
("thinking", Some("completed")) => CursorFrame::ThinkingCompleted,
("assistant", _) => CursorFrame::Assistant(AssistantFrame {
text: content_text(value.get("message")),
has_timestamp: value.get("timestamp_ms").is_some(),
has_model_call_id: value.get("model_call_id").is_some(),
}),
("tool_call", Some(phase @ ("started" | "completed"))) => {
let phase = if phase == "started" {
ToolCallPhase::Started
} else {
ToolCallPhase::Completed
};
let call_id = value
.get("call_id")
.and_then(Value::as_str)
.ok_or_else(|| shape("missing call_id"))?
.to_string();
let body = value
.get("tool_call")
.and_then(Value::as_object)
.ok_or_else(|| shape("missing tool_call object"))?;
let (tool_key, inner) = body
.iter()
.find(|(key, _)| key.ends_with("ToolCall"))
.ok_or_else(|| shape("tool_call has no *ToolCall key"))?;
CursorFrame::ToolCall(ToolCallFrame {
phase,
call_id,
tool_key: tool_key.clone(),
args: inner.get("args").cloned(),
result: inner.get("result").cloned(),
})
}
("result", _) => CursorFrame::Result(
serde_json::from_value(value).map_err(|error| shape(&error.to_string()))?,
),
_ => CursorFrame::Unknown { kind, subtype },
})
}
fn content_text(message: Option<&Value>) -> String {
message
.and_then(|message| message.get("content"))
.and_then(Value::as_array)
.map(|blocks| {
blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect::<String>()
})
.unwrap_or_default()
}