use async_trait::async_trait;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TranscriptRole {
User,
Assistant,
Tool,
System,
}
#[derive(Debug, Clone)]
pub struct TranscriptEntry {
pub role: TranscriptRole,
pub content: String,
pub source_plugin: String,
pub sender_id: Option<String>,
pub message_id: Option<Uuid>,
}
#[async_trait]
pub trait TranscriptAppender: Send + Sync + std::fmt::Debug {
async fn append(
&self,
agent_id: &str,
session_id: Uuid,
entry: TranscriptEntry,
) -> anyhow::Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[derive(Debug, Default, Clone)]
pub struct RecordingTranscriptAppender {
pub captured: Arc<Mutex<Vec<(String, Uuid, TranscriptEntry)>>>,
pub fail_with: Option<String>,
}
impl RecordingTranscriptAppender {
fn new_failing(msg: impl Into<String>) -> Self {
Self {
captured: Arc::new(Mutex::new(Vec::new())),
fail_with: Some(msg.into()),
}
}
fn calls(&self) -> Vec<(String, Uuid, TranscriptEntry)> {
self.captured.lock().unwrap().clone()
}
}
#[async_trait]
impl TranscriptAppender for RecordingTranscriptAppender {
async fn append(
&self,
agent_id: &str,
session_id: Uuid,
entry: TranscriptEntry,
) -> anyhow::Result<()> {
if let Some(msg) = &self.fail_with {
return Err(anyhow::anyhow!(msg.clone()));
}
self.captured
.lock()
.unwrap()
.push((agent_id.into(), session_id, entry));
Ok(())
}
}
#[test]
fn trait_is_object_safe() {
fn _accepts(_: &dyn TranscriptAppender) {}
}
#[tokio::test]
async fn recording_appender_captures_entries() {
let app = RecordingTranscriptAppender::default();
let session = Uuid::new_v4();
let entry = TranscriptEntry {
role: TranscriptRole::Assistant,
content: "hola".into(),
source_plugin: "intervention:whatsapp".into(),
sender_id: Some("operator:abc".into()),
message_id: None,
};
app.append("ana", session, entry.clone()).await.unwrap();
let calls = app.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "ana");
assert_eq!(calls[0].1, session);
assert_eq!(calls[0].2.content, "hola");
assert_eq!(calls[0].2.source_plugin, "intervention:whatsapp");
assert!(matches!(calls[0].2.role, TranscriptRole::Assistant));
}
#[tokio::test]
async fn failing_appender_returns_error_without_recording() {
let app = RecordingTranscriptAppender::new_failing("disk full");
let entry = TranscriptEntry {
role: TranscriptRole::System,
content: "x".into(),
source_plugin: "intervention:summary".into(),
sender_id: None,
message_id: None,
};
let r = app.append("ana", Uuid::new_v4(), entry).await;
assert!(r.is_err());
assert_eq!(r.unwrap_err().to_string(), "disk full");
assert!(app.calls().is_empty());
}
#[test]
fn role_enum_is_copy() {
let r: TranscriptRole = TranscriptRole::User;
let _r2 = r;
let _r3 = r;
}
}