use crate::action::ActionDisplay;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum ProviderContinuation {
Anthropic {
#[serde(default, skip_serializing_if = "String::is_empty")]
signature: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
compaction: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
native_tool_calls: Vec<String>,
},
MetaResponses { output: Vec<ResponseItem> },
OpenaiResponses {
model: String,
output: Vec<ResponseItem>,
},
Gemini {
model: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
signatures: Vec<GeminiSignature>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
native_calls: Vec<GeminiNativeCall>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GeminiNativeCall {
pub call_id: String,
pub name: String,
pub args: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeminiSignature {
pub call_id: String,
#[serde(with = "crate::utils::serde_base64::string")]
pub signature: String,
}
impl ProviderContinuation {
#[must_use]
pub const fn anthropic(signature: String) -> Self {
Self::Anthropic {
signature,
compaction: None,
native_tool_calls: Vec::new(),
}
}
#[must_use]
pub fn anthropic_signature(&self) -> Option<&str> {
match self {
Self::Anthropic { signature, .. } => Some(signature.as_str()).filter(|s| !s.is_empty()),
Self::MetaResponses { .. } | Self::OpenaiResponses { .. } | Self::Gemini { .. } => None,
}
}
#[must_use]
pub fn without_compaction(self) -> Option<Self> {
match self {
Self::Anthropic {
signature,
native_tool_calls,
..
} if signature.is_empty() && native_tool_calls.is_empty() => None,
Self::Anthropic {
signature,
native_tool_calls,
..
} => Some(Self::Anthropic {
signature,
compaction: None,
native_tool_calls,
}),
kept @ (Self::MetaResponses { .. } | Self::Gemini { .. }) => Some(kept),
Self::OpenaiResponses { model, mut output } => {
output.retain(|item| !item.is_compaction());
(!output.is_empty()).then_some(Self::OpenaiResponses { model, output })
},
}
}
#[must_use]
pub const fn anthropic_compaction(&self) -> Option<&serde_json::Value> {
match self {
Self::Anthropic { compaction, .. } => compaction.as_ref(),
Self::MetaResponses { .. } | Self::OpenaiResponses { .. } | Self::Gemini { .. } => None,
}
}
#[must_use]
pub fn carries_server_compaction(&self) -> bool {
match self {
Self::Anthropic { compaction, .. } => compaction.is_some(),
Self::MetaResponses { .. } | Self::Gemini { .. } => false,
Self::OpenaiResponses { output, .. } => output.iter().any(ResponseItem::is_compaction),
}
}
#[must_use]
pub fn is_anthropic_native_call(&self, id: &str) -> bool {
match self {
Self::Anthropic {
native_tool_calls, ..
} => native_tool_calls.iter().any(|call| call == id),
Self::MetaResponses { .. } | Self::OpenaiResponses { .. } | Self::Gemini { .. } => {
false
},
}
}
#[must_use]
pub fn meta_output(&self) -> Option<&[ResponseItem]> {
match self {
Self::MetaResponses { output } => Some(output),
Self::Anthropic { .. } | Self::OpenaiResponses { .. } | Self::Gemini { .. } => None,
}
}
#[must_use]
pub fn openai_output(&self, model: &str) -> Option<&[ResponseItem]> {
match self {
Self::OpenaiResponses {
model: wrote,
output,
} if wrote == model => Some(output),
Self::Anthropic { .. }
| Self::MetaResponses { .. }
| Self::OpenaiResponses { .. }
| Self::Gemini { .. } => None,
}
}
#[must_use]
pub fn gemini_signatures(&self, model: &str) -> Option<&[GeminiSignature]> {
match self {
Self::Gemini {
model: wrote,
signatures,
..
} if wrote == model => Some(signatures),
Self::Anthropic { .. }
| Self::MetaResponses { .. }
| Self::OpenaiResponses { .. }
| Self::Gemini { .. } => None,
}
}
#[must_use]
pub fn gemini_native_call(&self, model: &str, id: &str) -> Option<&GeminiNativeCall> {
match self {
Self::Gemini {
model: wrote,
native_calls,
..
} if wrote == model => native_calls.iter().find(|call| call.call_id == id),
Self::Anthropic { .. }
| Self::MetaResponses { .. }
| Self::OpenaiResponses { .. }
| Self::Gemini { .. } => None,
}
}
pub fn retain_tool_calls(&mut self, mut keep: impl FnMut(&str) -> bool) {
match self {
Self::MetaResponses { output } | Self::OpenaiResponses { output, .. } => {
output.retain(|item| item.call_id().is_none_or(&mut keep));
},
Self::Gemini {
signatures,
native_calls,
..
} => {
signatures.retain(|s| keep(&s.call_id));
native_calls.retain(|c| keep(&c.call_id));
},
Self::Anthropic { .. } => {},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResponseItem {
Reasoning {
item: serde_json::Value,
#[serde(with = "crate::utils::serde_base64::string")]
encrypted_content: String,
},
Compaction {
item: serde_json::Value,
#[serde(with = "crate::utils::serde_base64::string")]
encrypted_content: String,
},
Other {
item: serde_json::Value,
},
}
const CALL_ITEM_TYPES: [&str; 3] = ["function_call", "apply_patch_call", "computer_call"];
impl ResponseItem {
pub fn from_wire(mut item: serde_json::Value) -> Self {
let kind = item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
if matches!(kind.as_str(), "reasoning" | "compaction")
&& let Some(encrypted) = item
.as_object_mut()
.and_then(|object| object.remove("encrypted_content"))
.and_then(|value| value.as_str().map(str::to_string))
{
return if kind == "reasoning" {
Self::Reasoning {
item,
encrypted_content: encrypted,
}
} else {
Self::Compaction {
item,
encrypted_content: encrypted,
}
};
}
Self::Other { item }
}
#[must_use]
pub fn to_wire(&self) -> serde_json::Value {
match self {
Self::Reasoning {
item,
encrypted_content,
}
| Self::Compaction {
item,
encrypted_content,
} => {
let mut item = item.clone();
if let Some(object) = item.as_object_mut() {
object.insert(
"encrypted_content".to_string(),
serde_json::Value::String(encrypted_content.clone()),
);
}
item
},
Self::Other { item } => item.clone(),
}
}
fn item(&self) -> &serde_json::Value {
match self {
Self::Reasoning { item, .. } | Self::Compaction { item, .. } | Self::Other { item } => {
item
},
}
}
#[must_use]
pub fn kind(&self) -> &str {
self.item()
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
}
#[must_use]
pub fn call_id(&self) -> Option<&str> {
CALL_ITEM_TYPES
.contains(&self.kind())
.then(|| {
self.item()
.get("call_id")
.and_then(serde_json::Value::as_str)
})
.flatten()
}
#[must_use]
pub const fn is_compaction(&self) -> bool {
matches!(self, Self::Compaction { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: MessageRole,
pub content: String,
pub timestamp: chrono::DateTime<chrono::Local>,
#[serde(default)]
pub kind: ChatMessageKind,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
#[serde(default)]
pub actions: Vec<ActionDisplay>,
#[serde(default)]
pub thinking: Option<String>,
#[serde(default)]
pub images: Option<Vec<String>>,
#[serde(default)]
pub image_numbers: Option<Vec<u64>>,
#[serde(default)]
pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
#[serde(default)]
pub tool_call_id: Option<String>,
#[serde(default)]
pub tool_name: Option<String>,
#[serde(default)]
pub provider_continuation: Option<ProviderContinuation>,
}
impl ChatMessage {
#[must_use]
pub fn since_provider_compaction(history: &[Self]) -> &[Self] {
let start = history
.iter()
.rposition(|m| {
m.provider_continuation
.as_ref()
.is_some_and(ProviderContinuation::carries_server_compaction)
})
.unwrap_or(0);
&history[start..]
}
pub fn user(content: impl Into<String>) -> Self {
Self::new(MessageRole::User, content.into())
}
pub fn assistant(content: impl Into<String>) -> Self {
Self::new(MessageRole::Assistant, content.into())
}
pub fn system(content: impl Into<String>) -> Self {
Self::new(MessageRole::System, content.into())
}
pub fn run_summary(content: impl Into<String>) -> Self {
let mut m = Self::new(MessageRole::System, content.into());
m.kind = ChatMessageKind::RunSummary;
m
}
pub fn tool(
tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self {
role: MessageRole::Tool,
content: content.into(),
timestamp: chrono::Local::now(),
kind: ChatMessageKind::Normal,
metadata: None,
actions: Vec::new(),
thinking: None,
images: None,
image_numbers: None,
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
tool_name: Some(tool_name.into()),
provider_continuation: None,
}
}
fn new(role: MessageRole, content: String) -> Self {
Self {
role,
content,
timestamp: chrono::Local::now(),
kind: ChatMessageKind::Normal,
metadata: None,
actions: Vec::new(),
thinking: None,
images: None,
image_numbers: None,
tool_calls: None,
tool_call_id: None,
tool_name: None,
provider_continuation: None,
}
}
#[must_use]
pub fn with_images(mut self, images: Vec<String>) -> Self {
self.images = Some(images);
self
}
#[must_use]
pub fn with_image_numbers(mut self, numbers: Vec<u64>) -> Self {
self.image_numbers = Some(numbers);
self
}
#[must_use]
pub fn with_tool_calls(mut self, tool_calls: Vec<crate::models::tool_call::ToolCall>) -> Self {
self.tool_calls = if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
};
self
}
#[must_use]
pub fn with_provider_continuation(mut self, continuation: ProviderContinuation) -> Self {
self.provider_continuation = Some(continuation);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum MessageRole {
User,
Assistant,
System,
Tool,
}
impl<'de> Deserialize<'de> for MessageRole {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Ok(match raw.as_str() {
"User" => Self::User,
"Assistant" => Self::Assistant,
"System" => Self::System,
"Tool" => Self::Tool,
other => {
tracing::warn!(
role = %other,
"unknown message role in saved conversation; treating as System (version skew?)"
);
Self::System
},
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatMessageKind {
#[default]
Normal,
ContextCheckpoint,
RunSummary,
Continuation,
RecoveryNudge,
ContextMarker,
GoalCheck,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageAudience {
Conversation,
ModelDirected,
}
impl ChatMessageKind {
#[must_use]
pub fn audience(self) -> MessageAudience {
match self {
Self::RecoveryNudge | Self::ContextMarker | Self::GoalCheck => {
MessageAudience::ModelDirected
},
Self::Normal
| Self::ContextCheckpoint
| Self::RunSummary
| Self::Continuation
| Self::Unknown => MessageAudience::Conversation,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FinishReason {
Stop,
ToolUse,
Length,
ContentFilter,
Other(String),
}
#[derive(Debug, Clone)]
pub struct ModelResponse {
pub content: String,
pub usage: Option<TokenUsage>,
pub model_name: String,
pub thinking: Option<String>,
pub tool_calls: Option<Vec<crate::models::tool_call::ToolCall>>,
pub stop_reason: Option<FinishReason>,
pub provider_continuation: Option<ProviderContinuation>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenUsageSource {
#[default]
Provider,
Estimate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: usize,
pub completion_tokens: usize,
#[serde(default)]
pub cached_input_tokens: usize,
#[serde(default)]
pub cache_creation_input_tokens: usize,
#[serde(default)]
pub reasoning_output_tokens: usize,
#[serde(default)]
pub source: TokenUsageSource,
}
impl TokenUsage {
#[must_use]
pub fn provider(prompt_tokens: usize, completion_tokens: usize) -> Self {
Self {
prompt_tokens,
completion_tokens,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
reasoning_output_tokens: 0,
source: TokenUsageSource::Provider,
}
}
#[must_use]
pub fn estimate(prompt_tokens: usize) -> Self {
Self {
prompt_tokens,
completion_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
reasoning_output_tokens: 0,
source: TokenUsageSource::Estimate,
}
}
#[must_use]
pub fn with_cached_input(mut self, cached_input_tokens: usize) -> Self {
self.cached_input_tokens = cached_input_tokens;
self
}
#[must_use]
pub fn with_cache_creation(mut self, cache_creation_input_tokens: usize) -> Self {
self.cache_creation_input_tokens = cache_creation_input_tokens;
self
}
#[must_use]
pub fn with_reasoning_output(mut self, reasoning_output_tokens: usize) -> Self {
self.reasoning_output_tokens = reasoning_output_tokens;
self
}
#[must_use]
pub fn input_total_tokens(&self) -> usize {
self.prompt_tokens
.saturating_add(self.cached_input_tokens)
.saturating_add(self.cache_creation_input_tokens)
}
#[must_use]
pub fn output_total_tokens(&self) -> usize {
self.completion_tokens
.saturating_add(self.reasoning_output_tokens)
}
#[must_use]
pub fn total_tokens(&self) -> usize {
self.input_total_tokens()
.saturating_add(self.output_total_tokens())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gemini_signatures_persist_as_bytes_and_follow_kept_calls() {
let mut continuation = ProviderContinuation::Gemini {
model: "gemini-3-flash".to_string(),
signatures: vec![
GeminiSignature {
call_id: "call_0".to_string(),
signature: "c2lnLWE=".to_string(),
},
GeminiSignature {
call_id: "call_1".to_string(),
signature: "c2lnLWI=".to_string(),
},
],
native_calls: Vec::new(),
};
let saved = serde_json::to_string(&continuation).unwrap();
assert!(!saved.contains("c2lnLWE="), "stored as bytes: {saved}");
let loaded: ProviderContinuation = serde_json::from_str(&saved).unwrap();
assert_eq!(loaded, continuation);
continuation.retain_tool_calls(|id| id == "call_1");
let kept = continuation.gemini_signatures("gemini-3-flash").unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].call_id, "call_1");
assert_eq!(
continuation.clone().without_compaction(),
Some(continuation)
);
}
#[test]
fn test_message_role_equality() {
let user1 = MessageRole::User;
let user2 = MessageRole::User;
let assistant = MessageRole::Assistant;
assert_eq!(user1, user2, "User roles should be equal");
assert_ne!(user1, assistant, "Different roles should not be equal");
}
#[test]
fn test_chat_message_constructors() {
let user = ChatMessage::user("Hello!");
assert_eq!(user.role, MessageRole::User);
assert_eq!(user.content, "Hello!");
assert!(user.tool_calls.is_none());
let assistant = ChatMessage::assistant("Hi there");
assert_eq!(assistant.role, MessageRole::Assistant);
let system = ChatMessage::system("You are helpful");
assert_eq!(system.role, MessageRole::System);
let tool = ChatMessage::tool("call_1", "read_file", "file contents");
assert_eq!(tool.role, MessageRole::Tool);
assert_eq!(tool.tool_call_id, Some("call_1".to_string()));
assert_eq!(tool.tool_name, Some("read_file".to_string()));
}
#[test]
fn test_chat_message_builders() {
let msg = ChatMessage::user("test").with_images(vec!["base64data".to_string()]);
assert_eq!(msg.images, Some(vec!["base64data".to_string()]));
}
#[test]
fn test_token_usage_structure() {
let usage = TokenUsage::provider(100, 50)
.with_cached_input(25)
.with_cache_creation(5)
.with_reasoning_output(10);
assert_eq!(usage.prompt_tokens, 100);
assert_eq!(usage.completion_tokens, 50);
assert_eq!(usage.cached_input_tokens, 25);
assert_eq!(usage.cache_creation_input_tokens, 5);
assert_eq!(usage.reasoning_output_tokens, 10);
assert_eq!(usage.input_total_tokens(), 130);
assert_eq!(usage.output_total_tokens(), 60);
assert_eq!(usage.total_tokens(), 190);
assert_eq!(usage.source, TokenUsageSource::Provider);
}
#[test]
fn provider_continuation_round_trips_through_serde() {
let msg = ChatMessage::assistant("Step 3 lives.").with_provider_continuation(
ProviderContinuation::anthropic("sig_abc123_encrypted_blob".to_string()),
);
let json = serde_json::to_string(&msg).expect("serialize");
let back: ChatMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
back.provider_continuation
.as_ref()
.and_then(ProviderContinuation::anthropic_signature),
Some("sig_abc123_encrypted_blob")
);
assert_eq!(back.content, "Step 3 lives.");
}
#[test]
fn provider_continuation_defaults_to_none() {
let pre_step3_json = r#"{
"role": "Assistant",
"content": "hello",
"timestamp": "2026-04-16T12:00:00-04:00"
}"#;
let msg: ChatMessage = serde_json::from_str(pre_step3_json).expect("backward compat");
assert!(msg.provider_continuation.is_none());
}
#[test]
fn meta_encrypted_continuation_survives_persistence_redaction_byte_exact() {
let original = "eyJopaque.reasoning.payload";
let message = ChatMessage::assistant("done").with_provider_continuation(
ProviderContinuation::MetaResponses {
output: vec![ResponseItem::from_wire(serde_json::json!({
"type": "reasoning",
"id": "rs_1",
"summary": [],
"encrypted_content": original,
}))],
},
);
let mut persisted = serde_json::to_value(message).unwrap();
crate::utils::redact_json(&mut persisted);
let restored: ChatMessage = serde_json::from_value(persisted).unwrap();
let output = restored
.provider_continuation
.as_ref()
.and_then(ProviderContinuation::meta_output)
.unwrap();
assert_eq!(output[0].to_wire()["encrypted_content"], original);
}
#[test]
fn unknown_message_role_deserializes_to_system() {
let role: MessageRole = serde_json::from_str("\"Developer\"").expect("tolerant");
assert_eq!(role, MessageRole::System);
assert_eq!(
serde_json::from_str::<MessageRole>("\"Tool\"").unwrap(),
MessageRole::Tool
);
}
#[test]
fn unknown_message_kind_deserializes_to_unknown() {
let kind: ChatMessageKind = serde_json::from_str("\"some_future_kind\"").expect("tolerant");
assert_eq!(kind, ChatMessageKind::Unknown);
assert_ne!(kind, ChatMessageKind::Normal);
}
#[test]
fn continuation_kinds_round_trip_through_serde() {
for kind in [
ChatMessageKind::Continuation,
ChatMessageKind::RecoveryNudge,
] {
let json = serde_json::to_string(&kind).unwrap();
let back: ChatMessageKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, kind);
}
assert_eq!(
serde_json::to_string(&ChatMessageKind::Continuation).unwrap(),
"\"continuation\""
);
assert_eq!(
serde_json::to_string(&ChatMessageKind::RecoveryNudge).unwrap(),
"\"recovery_nudge\""
);
}
#[test]
fn chat_message_with_unknown_role_round_trips() {
let json = r#"{
"role": "Developer",
"content": "hi",
"timestamp": "2026-04-16T12:00:00-04:00"
}"#;
let msg: ChatMessage = serde_json::from_str(json).expect("tolerant");
assert_eq!(msg.role, MessageRole::System);
assert_eq!(msg.content, "hi");
}
#[test]
fn test_model_response_creation() {
let usage = TokenUsage::provider(100, 50);
let response = ModelResponse {
content: "Hello, world!".to_string(),
usage: Some(usage),
model_name: "ollama/tinyllama".to_string(),
thinking: None,
tool_calls: None,
stop_reason: None,
provider_continuation: None,
};
assert_eq!(response.content, "Hello, world!");
assert!(response.usage.is_some());
assert_eq!(response.model_name, "ollama/tinyllama");
assert_eq!(response.usage.unwrap().total_tokens(), 150);
assert!(response.tool_calls.is_none());
}
}