use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::typed_id::{ImageId, MessageId, ModelId};
#[cfg(feature = "openapi")]
use utoipa::ToSchema;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub enum ExecutionPhase {
Commentary,
FinalAnswer,
}
impl ExecutionPhase {
pub fn from_has_tool_calls(has_tool_calls: bool) -> Self {
if has_tool_calls {
Self::Commentary
} else {
Self::FinalAnswer
}
}
pub fn from_provider_str(s: &str) -> Option<Self> {
match s {
"commentary" | "in_progress" => Some(Self::Commentary),
"final_answer" | "completed" => Some(Self::FinalAnswer),
_ => None,
}
}
pub fn as_provider_str(&self) -> &'static str {
match self {
Self::Commentary => "commentary",
Self::FinalAnswer => "final_answer",
}
}
}
impl std::fmt::Display for ExecutionPhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_provider_str())
}
}
impl Serialize for ExecutionPhase {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_provider_str())
}
}
impl<'de> Deserialize<'de> for ExecutionPhase {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
match s.as_str() {
"commentary" | "in_progress" => Ok(Self::Commentary),
"final_answer" | "completed" => Ok(Self::FinalAnswer),
other => Err(serde::de::Error::unknown_variant(
other,
&["commentary", "final_answer", "in_progress", "completed"],
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum MessageRole {
System,
User,
Agent,
ToolResult,
}
impl std::fmt::Display for MessageRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MessageRole::System => write!(f, "system"),
MessageRole::User => write!(f, "user"),
MessageRole::Agent => write!(f, "agent"),
MessageRole::ToolResult => write!(f, "tool_result"),
}
}
}
impl From<&str> for MessageRole {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"system" => MessageRole::System,
"user" => MessageRole::User,
"agent" | "assistant" => MessageRole::Agent,
"tool_result" => MessageRole::ToolResult,
_ => MessageRole::User,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ExternalActor {
pub actor_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor_name: Option<String>,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<std::collections::HashMap<String, String>>,
}
impl ExternalActor {
pub fn display_label(&self) -> &str {
self.actor_name.as_deref().unwrap_or(&self.actor_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ReasoningConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct Controls {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
pub model_id: Option<ModelId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<ReasoningConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
pub hints: Option<std::collections::HashMap<String, serde_json::Value>>,
}
impl Controls {
pub fn resolve_hints(
session_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
message_hints: Option<&std::collections::HashMap<String, serde_json::Value>>,
) -> std::collections::HashMap<String, serde_json::Value> {
match (session_hints, message_hints) {
(None, None) => std::collections::HashMap::new(),
(Some(s), None) => s.clone(),
(None, Some(m)) => m.clone(),
(Some(s), Some(m)) => {
let mut merged = s.clone();
merged.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
merged
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct Message {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "message_01933b5a00007000800000000000001"))]
pub id: MessageId,
pub role: MessageRole,
pub content: Vec<ContentPart>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<ExecutionPhase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thinking_signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub controls: Option<Controls>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_actor: Option<ExternalActor>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ContentType {
Text,
Image,
ImageFile,
ToolCall,
ToolResult,
}
impl std::fmt::Display for ContentType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ContentType::Text => write!(f, "text"),
ContentType::Image => write!(f, "image"),
ContentType::ImageFile => write!(f, "image_file"),
ContentType::ToolCall => write!(f, "tool_call"),
ContentType::ToolResult => write!(f, "tool_result"),
}
}
}
impl From<&str> for ContentType {
fn from(s: &str) -> Self {
match s {
"image" => ContentType::Image,
"image_file" => ContentType::ImageFile,
"tool_call" => ContentType::ToolCall,
"tool_result" => ContentType::ToolResult,
_ => ContentType::Text,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct TextContentPart {
pub text: String,
}
impl TextContentPart {
pub fn new(text: impl Into<String>) -> Self {
Self { text: text.into() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ImageContentPart {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base64: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
}
impl ImageContentPart {
pub fn from_url(url: impl Into<String>) -> Self {
Self {
url: Some(url.into()),
base64: None,
media_type: None,
}
}
pub fn from_base64(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
Self {
url: None,
base64: Some(base64.into()),
media_type: Some(media_type.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ImageFileContentPart {
#[cfg_attr(feature = "openapi", schema(value_type = String, example = "img_01933b5a00007000800000000000001"))]
pub image_id: ImageId,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
impl ImageFileContentPart {
pub fn new(image_id: ImageId) -> Self {
Self {
image_id,
filename: None,
}
}
pub fn with_filename(image_id: ImageId, filename: impl Into<String>) -> Self {
Self {
image_id,
filename: Some(filename.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolCallContentPart {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
impl ToolCallContentPart {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
Self {
id: id.into(),
name: name.into(),
arguments,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
pub struct ToolResultContentPart {
pub tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl ToolResultContentPart {
pub fn new(
tool_call_id: impl Into<String>,
result: Option<serde_json::Value>,
error: Option<String>,
) -> Self {
Self {
tool_call_id: tool_call_id.into(),
result,
error,
}
}
pub fn success(tool_call_id: impl Into<String>, result: serde_json::Value) -> Self {
Self {
tool_call_id: tool_call_id.into(),
result: Some(result),
error: None,
}
}
pub fn error(tool_call_id: impl Into<String>, error: impl Into<String>) -> Self {
Self {
tool_call_id: tool_call_id.into(),
result: None,
error: Some(error.into()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
Text(TextContentPart),
Image(ImageContentPart),
ImageFile(ImageFileContentPart),
ToolCall(ToolCallContentPart),
ToolResult(ToolResultContentPart),
}
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
ContentPart::Text(TextContentPart::new(text))
}
pub fn image_url(url: impl Into<String>) -> Self {
ContentPart::Image(ImageContentPart::from_url(url))
}
pub fn image_file(image_id: ImageId) -> Self {
ContentPart::ImageFile(ImageFileContentPart::new(image_id))
}
pub fn tool_call(
id: impl Into<String>,
name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
ContentPart::ToolCall(ToolCallContentPart::new(id, name, arguments))
}
pub fn tool_result(
tool_call_id: impl Into<String>,
result: Option<serde_json::Value>,
error: Option<String>,
) -> Self {
ContentPart::ToolResult(ToolResultContentPart::new(tool_call_id, result, error))
}
pub fn as_text(&self) -> Option<&str> {
match self {
ContentPart::Text(t) => Some(&t.text),
_ => None,
}
}
pub fn is_image_file(&self) -> bool {
matches!(self, ContentPart::ImageFile(_))
}
pub fn content_type(&self) -> ContentType {
match self {
ContentPart::Text(_) => ContentType::Text,
ContentPart::Image(_) => ContentType::Image,
ContentPart::ImageFile(_) => ContentType::ImageFile,
ContentPart::ToolCall(_) => ContentType::ToolCall,
ContentPart::ToolResult(_) => ContentType::ToolResult,
}
}
pub fn to_openai_format(&self) -> Option<serde_json::Value> {
match self {
ContentPart::Text(t) => Some(serde_json::json!({
"type": "text",
"text": t.text
})),
ContentPart::Image(img) => {
if let Some(url) = &img.url {
Some(serde_json::json!({
"type": "image_url",
"image_url": { "url": url }
}))
} else if let Some(b64) = &img.base64 {
let media_type = img.media_type.as_deref().unwrap_or("image/png");
Some(serde_json::json!({
"type": "image_url",
"image_url": { "url": format!("data:{};base64,{}", media_type, b64) }
}))
} else {
None
}
}
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(ToSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InputContentPart {
Text(TextContentPart),
Image(ImageContentPart),
ImageFile(ImageFileContentPart),
}
impl From<InputContentPart> for ContentPart {
fn from(input: InputContentPart) -> Self {
match input {
InputContentPart::Text(t) => ContentPart::Text(t),
InputContentPart::Image(i) => ContentPart::Image(i),
InputContentPart::ImageFile(f) => ContentPart::ImageFile(f),
}
}
}
impl InputContentPart {
pub fn text(text: impl Into<String>) -> Self {
InputContentPart::Text(TextContentPart::new(text))
}
pub fn image_url(url: impl Into<String>) -> Self {
InputContentPart::Image(ImageContentPart::from_url(url))
}
pub fn image_file(image_id: ImageId) -> Self {
InputContentPart::ImageFile(ImageFileContentPart::new(image_id))
}
pub fn as_text(&self) -> Option<&str> {
match self {
InputContentPart::Text(t) => Some(&t.text),
_ => None,
}
}
pub fn content_type(&self) -> ContentType {
match self {
InputContentPart::Text(_) => ContentType::Text,
InputContentPart::Image(_) => ContentType::Image,
InputContentPart::ImageFile(_) => ContentType::ImageFile,
}
}
}
impl Message {
pub fn user(content: impl Into<String>) -> Self {
Self {
id: MessageId::new(),
role: MessageRole::User,
content: vec![ContentPart::text(content)],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
id: MessageId::new(),
role: MessageRole::Agent,
content: vec![ContentPart::text(content)],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn assistant_with_tools(
content: impl Into<String>,
tool_calls: Vec<crate::tool_types::ToolCall>,
) -> Self {
let text_content = content.into();
let mut parts = Vec::new();
if !text_content.is_empty() {
parts.push(ContentPart::text(text_content));
}
for tc in tool_calls {
parts.push(ContentPart::ToolCall(ToolCallContentPart {
id: tc.id,
name: tc.name,
arguments: tc.arguments,
}));
}
Self {
id: MessageId::new(),
role: MessageRole::Agent,
content: parts,
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn system(content: impl Into<String>) -> Self {
Self {
id: MessageId::new(),
role: MessageRole::System,
content: vec![ContentPart::text(content)],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn tool_result(
tool_call_id: impl Into<String>,
result: Option<serde_json::Value>,
error: Option<String>,
) -> Self {
let tool_call_id = tool_call_id.into();
Self {
id: MessageId::new(),
role: MessageRole::ToolResult,
content: vec![ContentPart::ToolResult(ToolResultContentPart::new(
tool_call_id,
result,
error,
))],
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn tool_result_with_images(
tool_call_id: impl Into<String>,
result: Option<serde_json::Value>,
images: Vec<crate::tools::ToolResultImage>,
) -> Self {
let tool_call_id = tool_call_id.into();
let mut content = vec![ContentPart::ToolResult(ToolResultContentPart::new(
tool_call_id,
result,
None,
))];
for img in images {
content.push(ContentPart::Image(ImageContentPart::from_base64(
img.base64,
img.media_type,
)));
}
Self {
id: MessageId::new(),
role: MessageRole::ToolResult,
content,
phase: None,
thinking: None,
thinking_signature: None,
controls: None,
metadata: None,
external_actor: None,
created_at: Utc::now(),
}
}
pub fn with_phase(mut self, phase: ExecutionPhase) -> Self {
self.phase = Some(phase);
self
}
pub fn tool_call_id(&self) -> Option<&str> {
self.content.iter().find_map(|p| match p {
ContentPart::ToolResult(tr) => Some(tr.tool_call_id.as_str()),
_ => None,
})
}
pub fn text(&self) -> Option<&str> {
self.content.iter().find_map(|p| p.as_text())
}
pub fn tool_calls(&self) -> Vec<&ToolCallContentPart> {
self.content
.iter()
.filter_map(|p| match p {
ContentPart::ToolCall(tc) => Some(tc),
_ => None,
})
.collect()
}
pub fn has_tool_calls(&self) -> bool {
self.content
.iter()
.any(|p| matches!(p, ContentPart::ToolCall(_)))
}
pub fn tool_result_content(&self) -> Option<&ToolResultContentPart> {
self.content.iter().find_map(|p| match p {
ContentPart::ToolResult(tr) => Some(tr),
_ => None,
})
}
pub fn content_to_llm_string(&self) -> String {
self.content
.iter()
.map(|part| match part {
ContentPart::Text(t) => t.text.clone(),
ContentPart::Image(_) => "[Image]".to_string(),
ContentPart::ImageFile(_) => "[Image File]".to_string(),
ContentPart::ToolCall(tc) => {
format!(
"Tool call: {} with arguments: {}",
tc.name,
serde_json::to_string(&tc.arguments).unwrap_or_default()
)
}
ContentPart::ToolResult(tr) => {
if let Some(err) = &tr.error {
format!("Tool error: {}", err)
} else if let Some(res) = &tr.result {
serde_json::to_string(res).unwrap_or_else(|_| "{}".to_string())
} else {
"{}".to_string()
}
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn to_openai_format(&self) -> serde_json::Value {
let role = match self.role {
MessageRole::System => "system",
MessageRole::User => "user",
MessageRole::Agent => "assistant",
MessageRole::ToolResult => "tool",
};
if self.role == MessageRole::ToolResult {
let tool_call_id = self.tool_call_id().unwrap_or("");
let content = self
.content
.iter()
.find_map(|p| match p {
ContentPart::ToolResult(tr) => {
if let Some(error) = &tr.error {
Some(format!("Error: {}", error))
} else if let Some(result) = &tr.result {
Some(serde_json::to_string(result).unwrap_or_else(|_| "{}".to_string()))
} else {
Some("{}".to_string())
}
}
_ => None,
})
.unwrap_or_else(|| "{}".to_string());
return serde_json::json!({
"role": role,
"content": content,
"tool_call_id": tool_call_id
});
}
if self.role == MessageRole::Agent {
let tool_calls: Vec<serde_json::Value> = self
.content
.iter()
.filter_map(|p| match p {
ContentPart::ToolCall(tc) => Some(serde_json::json!({
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": serde_json::to_string(&tc.arguments).unwrap_or_else(|_| "{}".to_string())
}
})),
_ => None,
})
.collect();
let text_content: String = self
.content
.iter()
.filter_map(|p| match p {
ContentPart::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if tool_calls.is_empty() {
return serde_json::json!({
"role": role,
"content": text_content
});
} else {
let mut result = serde_json::json!({
"role": role,
"tool_calls": tool_calls
});
if !text_content.is_empty() {
result["content"] = serde_json::json!(text_content);
}
return result;
}
}
let content = self.content_to_openai_format();
serde_json::json!({
"role": role,
"content": content
})
}
fn content_to_openai_format(&self) -> serde_json::Value {
if self.content.len() == 1
&& let ContentPart::Text(t) = &self.content[0]
{
return serde_json::json!(t.text);
}
let parts: Vec<serde_json::Value> = self
.content
.iter()
.filter_map(|part| part.to_openai_format())
.collect();
if parts.is_empty() {
return serde_json::json!("");
}
if parts.len() == 1
&& let Some(text) = parts[0].get("text")
{
return text.clone();
}
serde_json::json!(parts)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool_types::ToolCall;
#[test]
fn test_user_message() {
let msg = Message::user("Hello");
assert_eq!(msg.role, MessageRole::User);
assert_eq!(msg.text(), Some("Hello"));
}
#[test]
fn test_assistant_message() {
let msg = Message::assistant("Hi there!");
assert_eq!(msg.role, MessageRole::Agent);
assert_eq!(msg.text(), Some("Hi there!"));
}
#[test]
fn test_tool_result_message() {
let msg = Message::tool_result(
"call_123",
Some(serde_json::json!({"result": "success"})),
None,
);
assert_eq!(msg.role, MessageRole::ToolResult);
assert_eq!(msg.tool_call_id(), Some("call_123"));
}
#[test]
fn test_assistant_with_tools_and_text() {
let tool_call = ToolCall {
id: "call_123".to_string(),
name: "get_weather".to_string(),
arguments: serde_json::json!({"location": "Tokyo"}),
};
let msg = Message::assistant_with_tools("Let me check the weather.", vec![tool_call]);
assert_eq!(msg.role, MessageRole::Agent);
assert_eq!(msg.text(), Some("Let me check the weather."));
assert_eq!(msg.tool_calls().len(), 1);
assert_eq!(msg.tool_calls()[0].name, "get_weather");
}
#[test]
fn test_assistant_with_tools_empty_text() {
let tool_call = ToolCall {
id: "call_123".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "rust"}),
};
let msg = Message::assistant_with_tools("", vec![tool_call]);
assert_eq!(msg.role, MessageRole::Agent);
assert_eq!(msg.text(), None);
assert_eq!(msg.tool_calls().len(), 1);
assert_eq!(msg.tool_calls()[0].name, "search");
assert_eq!(msg.content.len(), 1);
assert!(matches!(msg.content[0], ContentPart::ToolCall(_)));
}
#[test]
fn test_assistant_with_tools_whitespace_text() {
let tool_call = ToolCall {
id: "call_456".to_string(),
name: "fetch".to_string(),
arguments: serde_json::json!({}),
};
let msg = Message::assistant_with_tools(" ", vec![tool_call]);
assert_eq!(msg.text(), Some(" "));
assert_eq!(msg.content.len(), 2); }
#[test]
fn test_assistant_with_multiple_tool_calls() {
let tool_calls = vec![
ToolCall {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "a"}),
},
ToolCall {
id: "call_2".to_string(),
name: "fetch".to_string(),
arguments: serde_json::json!({"url": "http://example.com"}),
},
];
let msg = Message::assistant_with_tools("", tool_calls);
assert_eq!(msg.tool_calls().len(), 2);
assert_eq!(msg.content.len(), 2);
}
#[test]
fn test_to_openai_format_user_message() {
let msg = Message::user("Hello, world!");
let converted = msg.to_openai_format();
assert_eq!(converted["role"], "user");
assert_eq!(converted["content"], "Hello, world!");
}
#[test]
fn test_to_openai_format_system_message() {
let msg = Message::system("You are a helpful assistant.");
let converted = msg.to_openai_format();
assert_eq!(converted["role"], "system");
assert_eq!(converted["content"], "You are a helpful assistant.");
}
#[test]
fn test_to_openai_format_assistant_role_mapping() {
let msg = Message::assistant("Hi there!");
let converted = msg.to_openai_format();
assert_eq!(converted["role"], "assistant");
assert_eq!(converted["content"], "Hi there!");
}
#[test]
fn test_to_openai_format_assistant_with_tool_calls() {
let tool_call = ToolCall {
id: "call_123".to_string(),
name: "get_weather".to_string(),
arguments: serde_json::json!({"location": "Tokyo"}),
};
let msg = Message::assistant_with_tools("Let me check.", vec![tool_call]);
let converted = msg.to_openai_format();
assert_eq!(converted["role"], "assistant");
assert_eq!(converted["content"], "Let me check.");
let tool_calls = converted["tool_calls"].as_array().unwrap();
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0]["id"], "call_123");
assert_eq!(tool_calls[0]["type"], "function");
assert_eq!(tool_calls[0]["function"]["name"], "get_weather");
assert_eq!(
tool_calls[0]["function"]["arguments"],
r#"{"location":"Tokyo"}"#
);
}
#[test]
fn test_to_openai_format_assistant_tool_calls_only() {
let tool_call = ToolCall {
id: "call_abc".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "rust"}),
};
let msg = Message::assistant_with_tools("", vec![tool_call]);
let converted = msg.to_openai_format();
assert_eq!(converted["role"], "assistant");
assert!(converted.get("content").is_none());
assert!(converted["tool_calls"].is_array());
}
#[test]
fn test_to_openai_format_tool_result_role_mapping() {
let msg = Message::tool_result(
"call_123",
Some(serde_json::json!({"temperature": 72})),
None,
);
let converted = msg.to_openai_format();
assert_eq!(converted["role"], "tool");
assert_eq!(converted["tool_call_id"], "call_123");
assert_eq!(converted["content"], r#"{"temperature":72}"#);
}
#[test]
fn test_to_openai_format_tool_result_error() {
let msg = Message::tool_result("call_456", None, Some("API timeout".to_string()));
let converted = msg.to_openai_format();
assert_eq!(converted["role"], "tool");
assert_eq!(converted["tool_call_id"], "call_456");
assert_eq!(converted["content"], "Error: API timeout");
}
#[test]
fn test_to_openai_format_full_conversation() {
let tool_call = ToolCall {
id: "call_abc".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "rust"}),
};
let messages = [
Message::user("Search for rust"),
Message::assistant_with_tools("", vec![tool_call]),
Message::tool_result(
"call_abc",
Some(serde_json::json!({"results": ["rust-lang.org"]})),
None,
),
Message::assistant("Here are the search results."),
];
let converted: Vec<_> = messages.iter().map(|m| m.to_openai_format()).collect();
assert_eq!(converted.len(), 4);
assert_eq!(converted[0]["role"], "user");
assert_eq!(converted[1]["role"], "assistant");
assert!(converted[1]["tool_calls"].is_array());
assert_eq!(converted[2]["role"], "tool");
assert_eq!(converted[2]["tool_call_id"], "call_abc");
assert_eq!(converted[3]["role"], "assistant");
}
#[test]
fn test_content_part_to_openai_format_text() {
let part = ContentPart::text("Hello");
let converted = part.to_openai_format().unwrap();
assert_eq!(converted["type"], "text");
assert_eq!(converted["text"], "Hello");
}
#[test]
fn test_content_part_to_openai_format_image_url() {
let part = ContentPart::image_url("https://example.com/img.png");
let converted = part.to_openai_format().unwrap();
assert_eq!(converted["type"], "image_url");
assert_eq!(converted["image_url"]["url"], "https://example.com/img.png");
}
#[test]
fn test_content_part_to_openai_format_image_base64() {
let part = ContentPart::Image(ImageContentPart::from_base64("abc123", "image/jpeg"));
let converted = part.to_openai_format().unwrap();
assert_eq!(converted["type"], "image_url");
assert_eq!(
converted["image_url"]["url"],
"data:image/jpeg;base64,abc123"
);
}
#[test]
fn test_content_part_to_openai_format_tool_call_returns_none() {
let part = ContentPart::tool_call("call_1", "search", serde_json::json!({}));
assert!(part.to_openai_format().is_none());
}
#[test]
fn test_content_part_to_openai_format_tool_result_returns_none() {
let part = ContentPart::tool_result("call_1", Some(serde_json::json!({})), None);
assert!(part.to_openai_format().is_none());
}
#[test]
fn test_execution_phase_from_has_tool_calls() {
assert_eq!(
ExecutionPhase::from_has_tool_calls(true),
ExecutionPhase::Commentary
);
assert_eq!(
ExecutionPhase::from_has_tool_calls(false),
ExecutionPhase::FinalAnswer
);
}
#[test]
fn test_execution_phase_from_provider_str() {
assert_eq!(
ExecutionPhase::from_provider_str("commentary"),
Some(ExecutionPhase::Commentary)
);
assert_eq!(
ExecutionPhase::from_provider_str("final_answer"),
Some(ExecutionPhase::FinalAnswer)
);
assert_eq!(
ExecutionPhase::from_provider_str("in_progress"),
Some(ExecutionPhase::Commentary)
);
assert_eq!(
ExecutionPhase::from_provider_str("completed"),
Some(ExecutionPhase::FinalAnswer)
);
assert_eq!(ExecutionPhase::from_provider_str("unknown"), None);
}
#[test]
fn test_execution_phase_serde_roundtrip() {
let commentary = ExecutionPhase::Commentary;
let json = serde_json::to_string(&commentary).unwrap();
assert_eq!(json, "\"commentary\"");
let deserialized: ExecutionPhase = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, ExecutionPhase::Commentary);
let final_answer = ExecutionPhase::FinalAnswer;
let json = serde_json::to_string(&final_answer).unwrap();
assert_eq!(json, "\"final_answer\"");
let deserialized: ExecutionPhase = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, ExecutionPhase::FinalAnswer);
}
#[test]
fn test_execution_phase_deserialize_legacy() {
let legacy_in_progress: ExecutionPhase = serde_json::from_str("\"in_progress\"").unwrap();
assert_eq!(legacy_in_progress, ExecutionPhase::Commentary);
let legacy_completed: ExecutionPhase = serde_json::from_str("\"completed\"").unwrap();
assert_eq!(legacy_completed, ExecutionPhase::FinalAnswer);
}
#[test]
fn test_execution_phase_deserialize_unknown_fails() {
let result = serde_json::from_str::<ExecutionPhase>("\"bogus\"");
assert!(result.is_err());
}
#[test]
fn test_message_with_phase() {
let msg = Message::assistant("Hello").with_phase(ExecutionPhase::Commentary);
assert_eq!(msg.phase, Some(ExecutionPhase::Commentary));
}
#[test]
fn test_message_phase_skipped_when_none() {
let msg = Message::assistant("Hello");
let json = serde_json::to_value(&msg).unwrap();
assert!(json.get("phase").is_none());
}
#[test]
fn test_message_phase_included_when_set() {
let msg = Message::assistant("Hello").with_phase(ExecutionPhase::FinalAnswer);
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json.get("phase").unwrap(), "final_answer");
}
#[test]
fn test_resolve_hints_both_none() {
let result = Controls::resolve_hints(None, None);
assert!(result.is_empty());
}
#[test]
fn test_resolve_hints_session_only() {
let mut session = std::collections::HashMap::new();
session.insert("key1".into(), serde_json::json!("val1"));
session.insert("key2".into(), serde_json::json!(42));
let result = Controls::resolve_hints(Some(&session), None);
assert_eq!(result.len(), 2);
assert_eq!(result["key1"], serde_json::json!("val1"));
assert_eq!(result["key2"], serde_json::json!(42));
}
#[test]
fn test_resolve_hints_message_only() {
let mut message = std::collections::HashMap::new();
message.insert("key1".into(), serde_json::json!(true));
let result = Controls::resolve_hints(None, Some(&message));
assert_eq!(result.len(), 1);
assert_eq!(result["key1"], serde_json::json!(true));
}
#[test]
fn test_resolve_hints_message_overrides_session() {
let mut session = std::collections::HashMap::new();
session.insert("shared".into(), serde_json::json!("session_val"));
session.insert("session_only".into(), serde_json::json!(1));
let mut message = std::collections::HashMap::new();
message.insert("shared".into(), serde_json::json!("message_val"));
message.insert("message_only".into(), serde_json::json!(2));
let result = Controls::resolve_hints(Some(&session), Some(&message));
assert_eq!(result.len(), 3);
assert_eq!(result["shared"], serde_json::json!("message_val"));
assert_eq!(result["session_only"], serde_json::json!(1));
assert_eq!(result["message_only"], serde_json::json!(2));
}
#[test]
fn test_controls_hints_serde_roundtrip() {
let mut hints = std::collections::HashMap::new();
hints.insert("setup_connection".into(), serde_json::json!(true));
hints.insert("theme".into(), serde_json::json!("dark"));
let controls = Controls {
hints: Some(hints),
..Default::default()
};
let json = serde_json::to_value(&controls).unwrap();
let deserialized: Controls = serde_json::from_value(json).unwrap();
let h = deserialized.hints.unwrap();
assert_eq!(h["setup_connection"], serde_json::json!(true));
assert_eq!(h["theme"], serde_json::json!("dark"));
}
}