use std::collections::HashMap;
use std::sync::Mutex;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq)]
pub struct Turn {
pub turn_id: String,
pub role: String,
pub content: String,
pub tool_calls: Option<Vec<serde_json::Value>>,
pub tool_call_id: Option<String>,
pub ts_ms: u64,
pub voice_analysis: Option<serde_json::Value>,
pub audio: Option<clawft_types::AudioRef>,
}
impl Turn {
pub fn plain(role: impl Into<String>, content: impl Into<String>, ts_ms: u64) -> Self {
Self {
turn_id: String::new(),
role: role.into(),
content: content.into(),
tool_calls: None,
tool_call_id: None,
ts_ms,
voice_analysis: None,
audio: None,
}
}
pub fn with_audio(mut self, audio: clawft_types::AudioRef) -> Self {
self.audio = Some(audio);
self
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
pub trait ConversationSink: Send + Sync + 'static {
async fn lock_conversation(&self, conv_id: &str);
async fn append_turn(&self, conv_id: &str, turn: Turn) -> Result<(), String>;
async fn publish_status(
&self,
conv_id: &str,
status: &str,
payload: serde_json::Value,
) -> Result<(), String>;
async fn load_history(&self, conv_id: &str) -> Result<Vec<Turn>, String>;
async fn history(&self, _conv_id: &str, _window: usize) -> Vec<Turn> {
Vec::new()
}
async fn meta(&self, _conv_id: &str) -> serde_json::Value {
serde_json::Value::Null
}
async fn set_meta(&self, _conv_id: &str, _meta: serde_json::Value) {}
}
#[derive(Debug, Default)]
pub struct InMemorySink {
turns: Mutex<HashMap<String, Vec<Turn>>>,
meta: Mutex<HashMap<String, serde_json::Value>>,
}
impl InMemorySink {
pub fn new() -> Self {
Self::default()
}
pub fn turn_count(&self, conv_id: &str) -> usize {
self.turns
.lock()
.map(|g| g.get(conv_id).map(|v| v.len()).unwrap_or(0))
.unwrap_or(0)
}
}
#[cfg_attr(not(feature = "browser"), async_trait)]
#[cfg_attr(feature = "browser", async_trait(?Send))]
impl ConversationSink for InMemorySink {
async fn lock_conversation(&self, _conv_id: &str) {
}
async fn append_turn(&self, conv_id: &str, turn: Turn) -> Result<(), String> {
let mut guard = self
.turns
.lock()
.map_err(|_| "InMemorySink mutex poisoned".to_string())?;
guard.entry(conv_id.to_string()).or_default().push(turn);
Ok(())
}
async fn publish_status(
&self,
_conv_id: &str,
_status: &str,
_payload: serde_json::Value,
) -> Result<(), String> {
Ok(())
}
async fn load_history(&self, conv_id: &str) -> Result<Vec<Turn>, String> {
let guard = self
.turns
.lock()
.map_err(|_| "InMemorySink mutex poisoned".to_string())?;
Ok(guard.get(conv_id).cloned().unwrap_or_default())
}
async fn history(&self, conv_id: &str, window: usize) -> Vec<Turn> {
let guard = match self.turns.lock() {
Ok(g) => g,
Err(_) => return Vec::new(), };
let all = guard.get(conv_id).cloned().unwrap_or_default();
if window == 0 || all.len() <= window {
all
} else {
all[all.len() - window..].to_vec()
}
}
async fn meta(&self, conv_id: &str) -> serde_json::Value {
self.meta
.lock()
.map(|g| g.get(conv_id).cloned().unwrap_or(serde_json::Value::Null))
.unwrap_or(serde_json::Value::Null)
}
async fn set_meta(&self, conv_id: &str, meta: serde_json::Value) {
if let Ok(mut g) = self.meta.lock() {
g.insert(conv_id.to_string(), meta);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[allow(dead_code)]
fn _coerce(_sink: &dyn ConversationSink) {}
fn make_turn(role: &str, content: &str) -> Turn {
Turn {
turn_id: format!("test-{role}"),
role: role.into(),
content: content.into(),
tool_calls: None,
tool_call_id: None,
ts_ms: 1_700_000_000_000,
voice_analysis: None,
audio: None,
}
}
#[tokio::test]
async fn round_trip_one_turn() {
let sink = InMemorySink::new();
sink.lock_conversation("c1").await;
sink.append_turn("c1", make_turn("user", "hello"))
.await
.unwrap();
let history = sink.load_history("c1").await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].role, "user");
assert_eq!(history[0].content, "hello");
}
#[tokio::test]
async fn empty_history_for_unknown_conv() {
let sink = InMemorySink::new();
let history = sink.load_history("nope").await.unwrap();
assert!(history.is_empty());
}
#[tokio::test]
async fn turns_are_isolated_per_conversation() {
let sink = InMemorySink::new();
sink.append_turn("a", make_turn("user", "from a"))
.await
.unwrap();
sink.append_turn("b", make_turn("user", "from b"))
.await
.unwrap();
let a = sink.load_history("a").await.unwrap();
let b = sink.load_history("b").await.unwrap();
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 1);
assert_eq!(a[0].content, "from a");
assert_eq!(b[0].content, "from b");
}
#[tokio::test]
async fn append_preserves_order() {
let sink = InMemorySink::new();
sink.append_turn("c", make_turn("user", "1")).await.unwrap();
sink.append_turn("c", make_turn("assistant", "2"))
.await
.unwrap();
sink.append_turn("c", make_turn("tool", "3")).await.unwrap();
let history = sink.load_history("c").await.unwrap();
assert_eq!(history.len(), 3);
assert_eq!(history[0].content, "1");
assert_eq!(history[1].content, "2");
assert_eq!(history[2].content, "3");
}
#[tokio::test]
async fn publish_status_is_a_noop() {
let sink = InMemorySink::new();
let r = sink
.publish_status("c1", "thinking", json!({"step": 1}))
.await;
assert!(r.is_ok());
}
#[tokio::test]
async fn turn_count_helper_reports_length() {
let sink = InMemorySink::new();
assert_eq!(sink.turn_count("x"), 0);
sink.append_turn("x", make_turn("user", "hi"))
.await
.unwrap();
assert_eq!(sink.turn_count("x"), 1);
}
#[tokio::test]
async fn history_windows_to_the_most_recent_turns() {
let sink = InMemorySink::new();
for i in 0..5 {
sink.append_turn("c", make_turn("user", &i.to_string()))
.await
.unwrap();
}
let all = sink.history("c", 0).await;
assert_eq!(all.len(), 5);
assert_eq!(all[0].content, "0");
assert_eq!(all[4].content, "4");
let tail = sink.history("c", 2).await;
assert_eq!(tail.len(), 2);
assert_eq!(tail[0].content, "3");
assert_eq!(tail[1].content, "4");
let over = sink.history("c", 100).await;
assert_eq!(over.len(), 5);
}
#[tokio::test]
async fn history_preserves_the_superset_intermediates() {
let sink = InMemorySink::new();
sink.append_turn("c", make_turn("user", "q")).await.unwrap();
let tool_call = Turn {
tool_calls: Some(vec![json!({"name": "search"})]),
..make_turn("assistant", "calling")
};
sink.append_turn("c", tool_call).await.unwrap();
let tool_result = Turn {
tool_call_id: Some("call-1".into()),
..make_turn("tool", "result")
};
sink.append_turn("c", tool_result).await.unwrap();
sink.append_turn("c", make_turn("assistant", "final"))
.await
.unwrap();
let turns = sink.history("c", 0).await;
assert_eq!(turns.len(), 4, "history must keep all superset turns");
assert!(turns[1].tool_calls.is_some());
assert_eq!(turns[2].tool_call_id.as_deref(), Some("call-1"));
}
#[tokio::test]
async fn meta_round_trips_and_defaults_to_null() {
let sink = InMemorySink::new();
assert_eq!(sink.meta("c").await, serde_json::Value::Null);
sink.set_meta("c", json!({"hallucination_score": 0.42}))
.await;
assert_eq!(sink.meta("c").await["hallucination_score"], 0.42);
sink.set_meta("c", json!({"hallucination_score": 0.99}))
.await;
assert_eq!(sink.meta("c").await["hallucination_score"], 0.99);
assert_eq!(sink.meta("other").await, serde_json::Value::Null);
}
#[tokio::test]
async fn default_trait_impls_degrade_safely() {
struct BareSink;
#[async_trait]
impl ConversationSink for BareSink {
async fn lock_conversation(&self, _c: &str) {}
async fn append_turn(&self, _c: &str, _t: Turn) -> Result<(), String> {
Ok(())
}
async fn publish_status(
&self,
_c: &str,
_s: &str,
_p: serde_json::Value,
) -> Result<(), String> {
Ok(())
}
async fn load_history(&self, _c: &str) -> Result<Vec<Turn>, String> {
Ok(vec![])
}
}
let sink = BareSink;
assert!(sink.history("c", 10).await.is_empty());
assert_eq!(sink.meta("c").await, serde_json::Value::Null);
sink.set_meta("c", json!({"x": 1})).await; }
#[tokio::test]
async fn turn_with_tool_calls_round_trips() {
let sink = InMemorySink::new();
let turn = Turn {
turn_id: "t1".into(),
role: "assistant".into(),
content: "calling".into(),
tool_calls: Some(vec![json!({"name": "echo"})]),
tool_call_id: None,
ts_ms: 1_700_000_000_000,
voice_analysis: None,
audio: None,
};
sink.append_turn("c1", turn.clone()).await.unwrap();
let history = sink.load_history("c1").await.unwrap();
assert_eq!(history[0], turn);
}
}