use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessageInfo {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
pub role: String,
pub time: MessageTime,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<crate::types::project::ModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub tools: HashMap<String, bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<MessagePath>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens: Option<TokenUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structured: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish: Option<String>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MessagePath {
pub cwd: String,
pub root: String,
#[serde(flatten)]
pub extra: serde_json::Value,
}
pub enum MessageInfoKind<'a> {
User(&'a MessageInfo),
Assistant(&'a MessageInfo),
System(&'a MessageInfo),
Other(&'a MessageInfo),
}
impl MessageInfo {
pub fn kind(&self) -> MessageInfoKind<'_> {
match self.role.as_str() {
"user" => MessageInfoKind::User(self),
"assistant" => MessageInfoKind::Assistant(self),
"system" => MessageInfoKind::System(self),
_ => MessageInfoKind::Other(self),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageTime {
pub created: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Message {
pub info: MessageInfo,
pub parts: Vec<Part>,
}
impl Message {
pub fn id(&self) -> &str {
&self.info.id
}
pub fn session_id(&self) -> Option<&str> {
self.info.session_id.as_deref()
}
pub fn role(&self) -> &str {
&self.info.role
}
}
pub type MessageWithParts = Message;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum Part {
Text {
#[serde(default)]
id: Option<String>,
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
synthetic: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ignored: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
},
File {
#[serde(default)]
id: Option<String>,
mime: String,
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<FilePartSource>,
},
Tool {
#[serde(default)]
id: Option<String>,
#[serde(rename = "callID")]
call_id: String,
tool: String,
#[serde(default)]
input: serde_json::Value,
#[serde(default)]
state: Option<ToolState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
},
Reasoning {
#[serde(default)]
id: Option<String>,
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
},
#[serde(rename = "step-start")]
StepStart {
#[serde(default)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
snapshot: Option<String>,
},
#[serde(rename = "step-finish")]
StepFinish {
#[serde(default)]
id: Option<String>,
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
snapshot: Option<String>,
#[serde(default)]
cost: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
tokens: Option<TokenUsage>,
},
Snapshot {
#[serde(default)]
id: Option<String>,
snapshot: String,
},
Patch {
#[serde(default)]
id: Option<String>,
hash: String,
#[serde(default)]
files: Vec<String>,
},
Agent {
#[serde(default)]
id: Option<String>,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<AgentSource>,
},
Retry {
#[serde(default)]
id: Option<String>,
attempt: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<crate::types::error::APIError>,
},
Compaction {
#[serde(default)]
id: Option<String>,
#[serde(default)]
auto: bool,
},
Subtask {
#[serde(default)]
id: Option<String>,
prompt: String,
description: String,
agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentSource {
pub value: String,
pub start: i64,
pub end: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FilePartSourceText {
pub value: String,
pub start: i64,
pub end: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "type")]
pub enum FilePartSource {
#[serde(rename = "file")]
File {
text: FilePartSourceText,
path: String,
#[serde(flatten)]
extra: serde_json::Value,
},
#[serde(rename = "symbol")]
Symbol {
text: FilePartSourceText,
path: String,
range: serde_json::Value,
name: String,
kind: i64,
#[serde(flatten)]
extra: serde_json::Value,
},
#[serde(rename = "resource")]
Resource {
text: FilePartSourceText,
#[serde(rename = "clientName")]
client_name: String,
uri: String,
#[serde(flatten)]
extra: serde_json::Value,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolTimeStart {
pub start: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolTimeRange {
pub start: i64,
pub end: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compacted: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolStatePending {
pub status: String,
pub input: serde_json::Value,
pub raw: String,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolStateRunning {
pub status: String,
pub input: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
pub time: ToolTimeStart,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolStateCompleted {
pub status: String,
pub input: serde_json::Value,
pub output: String,
pub title: String,
pub metadata: serde_json::Value,
pub time: ToolTimeRange,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachments: Option<Vec<serde_json::Value>>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolStateError {
pub status: String,
pub input: serde_json::Value,
pub error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
pub time: ToolTimeRange,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(untagged)]
pub enum ToolState {
Completed(ToolStateCompleted),
Error(ToolStateError),
Running(ToolStateRunning),
Pending(ToolStatePending),
Unknown(serde_json::Value),
}
impl ToolState {
pub fn status(&self) -> &str {
match self {
Self::Pending(s) => &s.status,
Self::Running(s) => &s.status,
Self::Completed(s) => &s.status,
Self::Error(s) => &s.status,
Self::Unknown(_) => "unknown",
}
}
pub fn output(&self) -> Option<&str> {
match self {
Self::Completed(s) => Some(&s.output),
_ => None,
}
}
pub fn error(&self) -> Option<&str> {
match self {
Self::Error(s) => Some(&s.error),
_ => None,
}
}
pub fn is_pending(&self) -> bool {
matches!(self, Self::Pending(_))
}
pub fn is_running(&self) -> bool {
matches!(self, Self::Running(_))
}
pub fn is_completed(&self) -> bool {
matches!(self, Self::Completed(_))
}
pub fn is_error(&self) -> bool {
matches!(self, Self::Error(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenUsage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total: Option<u64>,
pub input: u64,
pub output: u64,
#[serde(default)]
pub reasoning: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache: Option<CacheUsage>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheUsage {
pub read: u64,
pub write: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptRequest {
pub parts: Vec<PromptPart>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<crate::types::project::ModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_reply: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum PromptPart {
Text {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
synthetic: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ignored: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
},
File {
mime: String,
url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
filename: Option<String>,
},
Agent {
name: String,
},
Subtask {
prompt: String,
description: String,
agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
command: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandRequest {
pub command: String,
pub arguments: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShellRequest {
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<crate::types::project::ModelRef>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_part_text_deserialize() {
let json = r#"{"type":"text","id":"p1","text":"hello"}"#;
let part: Part = serde_json::from_str(json).unwrap();
assert!(matches!(part, Part::Text { text, .. } if text == "hello"));
}
#[test]
fn test_part_tool_deserialize() {
let json = r#"{"type":"tool","callID":"c1","tool":"read_file","input":{}}"#;
let part: Part = serde_json::from_str(json).unwrap();
assert!(matches!(part, Part::Tool { tool, .. } if tool == "read_file"));
}
#[test]
fn test_part_step_start_deserialize() {
let json = r#"{"type":"step-start"}"#;
let part: Part = serde_json::from_str(json).unwrap();
assert!(matches!(part, Part::StepStart { .. }));
}
#[test]
fn test_part_step_finish_deserialize() {
let json = r#"{"type":"step-finish","reason":"done","cost":0.01}"#;
let part: Part = serde_json::from_str(json).unwrap();
assert!(matches!(part, Part::StepFinish { reason, .. } if reason == "done"));
}
#[test]
fn test_part_unknown_deserialize() {
let json = r#"{"type":"future-part-type","data":"whatever"}"#;
let part: Part = serde_json::from_str(json).unwrap();
assert!(matches!(part, Part::Unknown));
}
#[test]
fn test_tool_state_pending() {
let json = r#"{
"status": "pending",
"input": {"file": "test.rs"},
"raw": "read test.rs"
}"#;
let state: ToolState = serde_json::from_str(json).unwrap();
assert!(state.is_pending());
assert_eq!(state.status(), "pending");
assert!(state.output().is_none());
}
#[test]
fn test_tool_state_running() {
let json = r#"{
"status": "running",
"input": {"file": "test.rs"},
"title": "Reading file",
"time": {"start": 1234567890}
}"#;
let state: ToolState = serde_json::from_str(json).unwrap();
assert!(state.is_running());
assert_eq!(state.status(), "running");
}
#[test]
fn test_tool_state_completed() {
let json = r#"{
"status": "completed",
"input": {"file": "test.rs"},
"output": "file contents here",
"title": "Read test.rs",
"metadata": {},
"time": {"start": 1234567890, "end": 1234567900}
}"#;
let state: ToolState = serde_json::from_str(json).unwrap();
assert!(state.is_completed());
assert_eq!(state.status(), "completed");
assert_eq!(state.output(), Some("file contents here"));
}
#[test]
fn test_tool_state_error() {
let json = r#"{
"status": "error",
"input": {"file": "missing.rs"},
"error": "File not found",
"time": {"start": 1234567890, "end": 1234567900}
}"#;
let state: ToolState = serde_json::from_str(json).unwrap();
assert!(state.is_error());
assert_eq!(state.status(), "error");
assert_eq!(state.error(), Some("File not found"));
}
#[test]
fn test_tool_state_unknown() {
let json = r#"{
"status": "future-status",
"someField": "someValue"
}"#;
let state: ToolState = serde_json::from_str(json).unwrap();
assert!(matches!(state, ToolState::Unknown(_)));
assert_eq!(state.status(), "unknown");
}
#[test]
fn test_file_part_source_file() {
let json = r#"{
"type": "file",
"text": {"value": "content", "start": 0, "end": 100},
"path": "/src/main.rs"
}"#;
let source: FilePartSource = serde_json::from_str(json).unwrap();
assert!(matches!(source, FilePartSource::File { path, .. } if path == "/src/main.rs"));
}
#[test]
fn test_file_part_source_symbol() {
let json = r#"{
"type": "symbol",
"text": {"value": "fn main()", "start": 10, "end": 20},
"path": "/src/main.rs",
"range": {"start": {"line": 0, "character": 0}, "end": {"line": 5, "character": 1}},
"name": "main",
"kind": 12
}"#;
let source: FilePartSource = serde_json::from_str(json).unwrap();
assert!(
matches!(source, FilePartSource::Symbol { name, kind, .. } if name == "main" && kind == 12)
);
}
#[test]
fn test_file_part_source_resource() {
let json = r#"{
"type": "resource",
"text": {"value": "resource content", "start": 0, "end": 50},
"clientName": "my-mcp-server",
"uri": "resource://data/file.txt"
}"#;
let source: FilePartSource = serde_json::from_str(json).unwrap();
assert!(
matches!(source, FilePartSource::Resource { client_name, uri, .. }
if client_name == "my-mcp-server" && uri == "resource://data/file.txt")
);
}
#[test]
fn test_file_part_source_unknown() {
let json = r#"{
"type": "future-source",
"data": "whatever"
}"#;
let source: FilePartSource = serde_json::from_str(json).unwrap();
assert!(matches!(source, FilePartSource::Unknown));
}
#[test]
fn test_file_part_source_with_extra_fields() {
let json = r#"{
"type": "file",
"text": {"value": "content", "start": 0, "end": 100},
"path": "/src/main.rs",
"newField": "preserved"
}"#;
let source: FilePartSource = serde_json::from_str(json).unwrap();
if let FilePartSource::File { extra, .. } = source {
assert_eq!(extra.get("newField").unwrap(), "preserved");
} else {
panic!("Expected FilePartSource::File");
}
}
#[test]
fn test_message_info_minimal() {
let json = r#"{
"id": "msg-123",
"role": "user",
"time": {"created": 1234567890}
}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.id, "msg-123");
assert_eq!(info.role, "user");
assert!(info.tokens.is_none());
assert!(info.cost.is_none());
}
#[test]
fn test_message_info_with_new_fields() {
let json = r#"{
"id": "msg-123",
"sessionId": "sess-456",
"role": "assistant",
"time": {"created": 1234567890, "completed": 1234567900},
"format": "markdown",
"model": {"providerID": "anthropic", "modelID": "claude-3"},
"system": "You are a helpful assistant",
"tools": {"read_file": true, "write_file": false},
"parentId": "msg-100",
"modelId": "claude-3",
"providerId": "anthropic",
"cost": 0.0125,
"tokens": {"total": 1500, "input": 1000, "output": 500, "reasoning": 0},
"finish": "stop"
}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.id, "msg-123");
assert_eq!(info.session_id, Some("sess-456".to_string()));
assert_eq!(info.role, "assistant");
assert_eq!(info.format, Some("markdown".to_string()));
assert!(info.model.is_some());
assert_eq!(
info.model.as_ref().unwrap().provider_id,
Some("anthropic".to_string())
);
assert_eq!(info.system, Some("You are a helpful assistant".to_string()));
assert_eq!(info.tools.len(), 2);
assert_eq!(info.tools.get("read_file"), Some(&true));
assert_eq!(info.tools.get("write_file"), Some(&false));
assert_eq!(info.parent_id, Some("msg-100".to_string()));
assert_eq!(info.model_id, Some("claude-3".to_string()));
assert_eq!(info.provider_id, Some("anthropic".to_string()));
assert_eq!(info.cost, Some(0.0125));
assert!(info.tokens.is_some());
let tokens = info.tokens.unwrap();
assert_eq!(tokens.total, Some(1500));
assert_eq!(tokens.input, 1000);
assert_eq!(tokens.output, 500);
assert_eq!(info.finish, Some("stop".to_string()));
}
#[test]
fn test_message_info_with_path() {
let json = r#"{
"id": "msg-123",
"role": "user",
"time": {"created": 1234567890},
"path": {"cwd": "/home/user/project", "root": "/home/user"}
}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert!(info.path.is_some());
let path = info.path.unwrap();
assert_eq!(path.cwd, "/home/user/project");
assert_eq!(path.root, "/home/user");
}
#[test]
fn test_message_info_extra_fields_preserved() {
let json = r#"{
"id": "msg-123",
"role": "user",
"time": {"created": 1234567890},
"futureField": "preserved"
}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.extra.get("futureField").unwrap(), "preserved");
}
#[test]
fn test_message_info_kind_user() {
let json = r#"{"id": "m1", "role": "user", "time": {"created": 1}}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert!(matches!(info.kind(), MessageInfoKind::User(_)));
}
#[test]
fn test_message_info_kind_assistant() {
let json = r#"{"id": "m1", "role": "assistant", "time": {"created": 1}}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert!(matches!(info.kind(), MessageInfoKind::Assistant(_)));
}
#[test]
fn test_message_info_kind_system() {
let json = r#"{"id": "m1", "role": "system", "time": {"created": 1}}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert!(matches!(info.kind(), MessageInfoKind::System(_)));
}
#[test]
fn test_message_info_kind_other() {
let json = r#"{"id": "m1", "role": "tool", "time": {"created": 1}}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert!(matches!(info.kind(), MessageInfoKind::Other(_)));
}
#[test]
fn test_token_usage_with_total() {
let json = r#"{"total": 2000, "input": 1500, "output": 500, "reasoning": 0}"#;
let tokens: TokenUsage = serde_json::from_str(json).unwrap();
assert_eq!(tokens.total, Some(2000));
assert_eq!(tokens.input, 1500);
assert_eq!(tokens.output, 500);
assert_eq!(tokens.reasoning, 0);
}
#[test]
fn test_token_usage_without_total() {
let json = r#"{"input": 1500, "output": 500}"#;
let tokens: TokenUsage = serde_json::from_str(json).unwrap();
assert!(tokens.total.is_none());
assert_eq!(tokens.input, 1500);
assert_eq!(tokens.output, 500);
}
#[test]
fn test_message_path_deserialize() {
let json = r#"{"cwd": "/path/to/dir", "root": "/path"}"#;
let path: MessagePath = serde_json::from_str(json).unwrap();
assert_eq!(path.cwd, "/path/to/dir");
assert_eq!(path.root, "/path");
}
#[test]
fn test_message_info_tools_as_hashmap() {
let json = r#"{
"id": "msg-1",
"role": "user",
"time": {"created": 1234567890},
"tools": {"read_file": true, "write_file": false}
}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.tools.len(), 2);
assert_eq!(info.tools.get("read_file"), Some(&true));
assert_eq!(info.tools.get("write_file"), Some(&false));
}
#[test]
fn test_message_info_tools_empty_hashmap() {
let json = r#"{
"id": "msg-1",
"role": "user",
"time": {"created": 1234567890},
"tools": {}
}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert!(info.tools.is_empty());
}
#[test]
fn test_message_info_tools_missing_defaults_to_empty() {
let json = r#"{
"id": "msg-1",
"role": "user",
"time": {"created": 1234567890}
}"#;
let info: MessageInfo = serde_json::from_str(json).unwrap();
assert!(info.tools.is_empty());
}
}