use serde::{Deserialize, Serialize};
#[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>,
}
#[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)]
#[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)]
#[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)]
#[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 {
ToolState::Pending(s) => &s.status,
ToolState::Running(s) => &s.status,
ToolState::Completed(s) => &s.status,
ToolState::Error(s) => &s.status,
ToolState::Unknown(_) => "unknown",
}
}
pub fn output(&self) -> Option<&str> {
match self {
ToolState::Completed(s) => Some(&s.output),
_ => None,
}
}
pub fn error(&self) -> Option<&str> {
match self {
ToolState::Error(s) => Some(&s.error),
_ => None,
}
}
pub fn is_pending(&self) -> bool {
matches!(self, ToolState::Pending(_))
}
pub fn is_running(&self) -> bool {
matches!(self, ToolState::Running(_))
}
pub fn is_completed(&self) -> bool {
matches!(self, ToolState::Completed(_))
}
pub fn is_error(&self) -> bool {
matches!(self, ToolState::Error(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenUsage {
pub input: u64,
pub output: u64,
#[serde(default)]
pub reasoning: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache: Option<CacheUsage>,
}
#[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>,
}
impl PromptRequest {
pub fn text(text: impl Into<String>) -> Self {
Self {
parts: vec![PromptPart::Text {
text: text.into(),
synthetic: None,
ignored: None,
metadata: None,
}],
message_id: None,
model: None,
agent: None,
no_reply: None,
system: None,
variant: None,
}
}
pub fn with_model(
mut self,
provider_id: impl Into<String>,
model_id: impl Into<String>,
) -> Self {
self.model = Some(crate::types::project::ModelRef {
provider_id: provider_id.into(),
model_id: model_id.into(),
});
self
}
pub fn with_system(mut self, system: impl Into<String>) -> Self {
self.system = Some(system.into());
self
}
pub fn with_agent(mut self, agent: impl Into<String>) -> Self {
self.agent = Some(agent.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<serde_json::Value>,
}
#[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_prompt_request_text_builder() {
let req = PromptRequest::text("hello");
assert!(matches!(req.parts.as_slice(), [PromptPart::Text { text, .. }] if text == "hello"));
assert!(req.model.is_none());
assert!(req.system.is_none());
assert!(req.agent.is_none());
}
#[test]
fn test_prompt_request_chain_builders() {
let req = PromptRequest::text("hello")
.with_model("opencode", "kimi-k2.5-free")
.with_system("Be concise")
.with_agent("coder");
assert_eq!(
req.model.as_ref().map(|m| m.provider_id.as_str()),
Some("opencode")
);
assert_eq!(
req.model.as_ref().map(|m| m.model_id.as_str()),
Some("kimi-k2.5-free")
);
assert_eq!(req.system.as_deref(), Some("Be concise"));
assert_eq!(req.agent.as_deref(), Some("coder"));
}
}