use lellm_core::{ToolError, ToolErrorKind, ToolResult};
use lellm_graph::{StreamChunk, StreamSink, ToolPhase};
use lellm_provider::ProviderEvent;
use tokio::sync::mpsc;
use super::event::AgentEvent;
#[derive(Clone)]
pub struct AgentEventSink {
tx: mpsc::Sender<AgentEvent>,
}
impl AgentEventSink {
pub fn new(tx: mpsc::Sender<AgentEvent>) -> Self {
Self { tx }
}
fn emit_event(&self, event: AgentEvent) -> bool {
self.tx.try_send(event).is_ok()
}
}
impl StreamSink for AgentEventSink {
fn emit(&self, chunk: StreamChunk) {
match chunk {
StreamChunk::TextDelta(token) => {
self.emit_event(AgentEvent::Provider(ProviderEvent::Token { token }));
}
StreamChunk::ThinkingDelta { text, redacted } => {
self.emit_event(AgentEvent::Provider(ProviderEvent::ThinkingDelta {
thinking: text,
redacted,
}));
}
StreamChunk::ToolLifecycle {
phase,
call_id,
tool_name,
} => match phase {
ToolPhase::Queued => {
}
ToolPhase::Started => {
self.emit_event(AgentEvent::ToolStart {
tool_call_id: call_id,
name: tool_name,
});
}
ToolPhase::Finished => {
}
},
StreamChunk::ToolOutput {
call_id,
tool_name: _tool_name,
content,
is_error,
duration,
} => {
let result: ToolResult = if is_error {
Err(ToolError {
kind: ToolErrorKind::Internal,
message: content,
})
} else {
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(val) => Ok(val),
Err(_) => Ok(serde_json::json!(content)),
}
};
self.emit_event(AgentEvent::ToolEnd {
tool_call_id: call_id,
result,
duration,
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_delta_mapping() {
let (tx, mut rx) = mpsc::channel(32);
let sink = AgentEventSink::new(tx);
sink.emit(StreamChunk::TextDelta("hello".into()));
let event = rx.blocking_recv();
assert!(matches!(
event,
Some(AgentEvent::Provider(ProviderEvent::Token { token }))
if token == "hello"
));
}
#[test]
fn test_thinking_delta_mapping() {
let (tx, mut rx) = mpsc::channel(32);
let sink = AgentEventSink::new(tx);
sink.emit(StreamChunk::ThinkingDelta {
text: "thinking...".into(),
redacted: None,
});
let event = rx.blocking_recv();
assert!(matches!(
event,
Some(AgentEvent::Provider(ProviderEvent::ThinkingDelta { thinking, .. }))
if thinking == "thinking..."
));
}
#[test]
fn test_tool_started_mapping() {
let (tx, mut rx) = mpsc::channel(32);
let sink = AgentEventSink::new(tx);
sink.emit(StreamChunk::ToolLifecycle {
phase: ToolPhase::Started,
call_id: "call_1".into(),
tool_name: "search".into(),
});
let event = rx.blocking_recv();
assert!(matches!(
event,
Some(AgentEvent::ToolStart { tool_call_id, name })
if tool_call_id == "call_1" && name == "search"
));
}
#[test]
fn test_tool_output_success_mapping() {
let (tx, mut rx) = mpsc::channel(32);
let sink = AgentEventSink::new(tx);
sink.emit(StreamChunk::ToolOutput {
call_id: "call_1".into(),
tool_name: "search".into(),
content: r#"{"results": ["a", "b"]}"#.into(),
is_error: false,
duration: std::time::Duration::from_millis(100),
});
let event = rx.blocking_recv();
match event {
Some(AgentEvent::ToolEnd {
tool_call_id,
result,
duration,
}) => {
assert_eq!(tool_call_id, "call_1");
assert!(result.is_ok());
assert_eq!(duration, std::time::Duration::from_millis(100));
}
other => panic!("expected ToolEnd, got {:?}", other),
}
}
#[test]
fn test_tool_output_error_mapping() {
let (tx, mut rx) = mpsc::channel(32);
let sink = AgentEventSink::new(tx);
sink.emit(StreamChunk::ToolOutput {
call_id: "call_2".into(),
tool_name: "search".into(),
content: "not found".into(),
is_error: true,
duration: std::time::Duration::from_millis(50),
});
let event = rx.blocking_recv();
match event {
Some(AgentEvent::ToolEnd {
tool_call_id,
result,
duration,
}) => {
assert_eq!(tool_call_id, "call_2");
assert!(result.is_err());
assert_eq!(duration, std::time::Duration::from_millis(50));
}
other => panic!("expected ToolEnd, got {:?}", other),
}
}
#[test]
fn test_tool_queued_ignored() {
let (tx, mut rx) = mpsc::channel(32);
let sink = AgentEventSink::new(tx);
sink.emit(StreamChunk::ToolLifecycle {
phase: ToolPhase::Queued,
call_id: "call_1".into(),
tool_name: "search".into(),
});
drop(sink);
assert!(rx.blocking_recv().is_none());
}
#[test]
fn test_tool_finished_ignored() {
let (tx, mut rx) = mpsc::channel(32);
let sink = AgentEventSink::new(tx);
sink.emit(StreamChunk::ToolLifecycle {
phase: ToolPhase::Finished,
call_id: "call_1".into(),
tool_name: "search".into(),
});
drop(sink);
assert!(rx.blocking_recv().is_none());
}
}