use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
pub type Envelope = Value;
pub type State = HashMap<String, Value>;
pub type MessageHandler =
Arc<dyn Fn(Envelope) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
pub type OutboundDispatcher =
Arc<dyn Fn(Envelope) -> Pin<Box<dyn Future<Output = bool> + Send>> + Send + Sync>;
#[async_trait]
pub trait OutboundChannelRouter: Send + Sync {
async fn dispatch(&self, message: Envelope) -> bool;
async fn quit(&self, session_id: &str);
}
#[derive(Debug, Clone)]
pub struct TurnResult {
pub session_id: String,
pub prompt: PromptValue,
pub model_output: String,
pub outbounds: Vec<Envelope>,
}
#[derive(Debug, Clone)]
pub enum PromptValue {
Text(String),
Parts(Vec<Value>),
}
impl PromptValue {
pub fn as_text(&self) -> String {
match self {
PromptValue::Text(s) => s.clone(),
PromptValue::Parts(parts) => {
let mut texts = Vec::new();
for part in parts {
if let Some(text) = part.as_str() {
texts.push(text.to_string());
} else if let Some(obj) = part.as_object()
&& let Some(text) = obj.get("text").and_then(|v| v.as_str())
{
texts.push(text.to_string());
}
}
texts.join("\n")
}
}
}
pub fn is_empty(&self) -> bool {
match self {
PromptValue::Text(s) => s.is_empty(),
PromptValue::Parts(parts) => parts.is_empty(),
}
}
pub fn is_blank(&self) -> bool {
match self {
PromptValue::Text(s) => s.trim().is_empty(),
PromptValue::Parts(parts) => parts.is_empty(),
}
}
pub fn strict_text(&self) -> String {
match self {
PromptValue::Text(s) => s.clone(),
PromptValue::Parts(parts) => parts
.iter()
.filter_map(|p| {
if p.get("type").and_then(|v| v.as_str()) == Some("text") {
p.get("text").and_then(|v| v.as_str()).map(|s| s.to_owned())
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_turn_result_creation() {
let result = TurnResult {
session_id: "cli:default".into(),
prompt: PromptValue::Text("hello".into()),
model_output: "world".into(),
outbounds: vec![json!({"content": "world"})],
};
assert_eq!(result.session_id, "cli:default");
assert_eq!(result.model_output, "world");
assert_eq!(result.outbounds.len(), 1);
}
#[test]
fn test_turn_result_clone() {
let result = TurnResult {
session_id: "s1".into(),
prompt: PromptValue::Text("p".into()),
model_output: "o".into(),
outbounds: vec![],
};
let cloned = result.clone();
assert_eq!(cloned.session_id, "s1");
assert_eq!(cloned.model_output, "o");
}
#[test]
fn test_prompt_value_text_as_text() {
let pv = PromptValue::Text("hello world".into());
assert_eq!(pv.as_text(), "hello world");
}
#[test]
fn test_prompt_value_parts_as_text_with_strings() {
let pv = PromptValue::Parts(vec![json!("line1"), json!("line2")]);
assert_eq!(pv.as_text(), "line1\nline2");
}
#[test]
fn test_prompt_value_parts_as_text_with_text_objects() {
let pv = PromptValue::Parts(vec![json!({"text": "part1"}), json!({"text": "part2"})]);
assert_eq!(pv.as_text(), "part1\npart2");
}
#[test]
fn test_prompt_value_parts_as_text_mixed() {
let pv = PromptValue::Parts(vec![
json!("plain"),
json!({"text": "object"}),
json!({"image": "data"}), ]);
assert_eq!(pv.as_text(), "plain\nobject");
}
#[test]
fn test_prompt_value_is_empty_text() {
assert!(PromptValue::Text("".into()).is_empty());
assert!(!PromptValue::Text("hello".into()).is_empty());
}
#[test]
fn test_prompt_value_is_empty_parts() {
assert!(PromptValue::Parts(vec![]).is_empty());
assert!(!PromptValue::Parts(vec![json!("a")]).is_empty());
}
#[test]
fn test_prompt_value_is_blank_text() {
assert!(PromptValue::Text("".into()).is_blank());
assert!(PromptValue::Text(" ".into()).is_blank());
assert!(PromptValue::Text(" \n\t ".into()).is_blank());
assert!(!PromptValue::Text("hello".into()).is_blank());
assert!(!PromptValue::Text(" hello ".into()).is_blank());
}
#[test]
fn test_prompt_value_is_blank_parts() {
assert!(PromptValue::Parts(vec![]).is_blank());
assert!(!PromptValue::Parts(vec![json!("a")]).is_blank());
}
#[test]
fn test_prompt_value_strict_text_from_text() {
let pv = PromptValue::Text("hello world".into());
assert_eq!(pv.strict_text(), "hello world");
}
#[test]
fn test_prompt_value_strict_text_from_parts() {
let pv = PromptValue::Parts(vec![
json!({"type": "text", "text": "line1"}),
json!({"type": "image", "url": "http://example.com"}),
json!({"type": "text", "text": "line2"}),
]);
assert_eq!(pv.strict_text(), "line1\nline2");
}
#[test]
fn test_prompt_value_strict_text_empty_parts() {
let pv = PromptValue::Parts(vec![]);
assert_eq!(pv.strict_text(), "");
}
#[test]
fn test_prompt_value_strict_text_no_text_type() {
let pv = PromptValue::Parts(vec![
json!({"text": "bare"}), json!("plain string"), json!({"type": "image", "url": "x"}),
]);
assert_eq!(pv.strict_text(), "");
}
#[test]
fn test_prompt_value_as_text_with_image_base64_blocks() {
let pv = PromptValue::Parts(vec![
json!({"type": "text", "text": "describe this"}),
json!({"type": "image_base64", "mime_type": "image/png", "data": "ABC"}),
]);
assert_eq!(pv.as_text(), "describe this");
}
#[test]
fn test_prompt_value_strict_text_filters_image_base64() {
let pv = PromptValue::Parts(vec![
json!({"type": "text", "text": "line1"}),
json!({"type": "image_base64", "mime_type": "image/jpeg", "data": "XYZ"}),
json!({"type": "text", "text": "line2"}),
]);
assert_eq!(pv.strict_text(), "line1\nline2");
}
#[test]
fn test_prompt_value_image_only_parts_as_text_empty() {
let pv = PromptValue::Parts(vec![
json!({"type": "image_base64", "mime_type": "image/png", "data": "IMG"}),
]);
assert_eq!(pv.as_text(), "");
assert_eq!(pv.strict_text(), "");
}
#[test]
fn test_prompt_value_parts_is_not_empty_with_image() {
let pv = PromptValue::Parts(vec![
json!({"type": "image_base64", "mime_type": "image/png", "data": "X"}),
]);
assert!(!pv.is_empty());
assert!(!pv.is_blank());
}
#[test]
fn test_prompt_value_multimodal_multiple_images() {
let pv = PromptValue::Parts(vec![
json!({"type": "text", "text": "compare"}),
json!({"type": "image_base64", "mime_type": "image/png", "data": "A"}),
json!({"type": "image_base64", "mime_type": "image/jpeg", "data": "B"}),
]);
assert_eq!(pv.strict_text(), "compare");
assert_eq!(pv.as_text(), "compare");
}
}