use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::Instant,
};
use crate::{
streaming::{
emit_chunk_sse, emit_frame, emit_thinking_chunk_sse, SseBroadcast, SseEventBuffer,
StreamFrame, TaskToolCallProgressEvent, TaskToolCallStartedEvent,
},
tool_call_progress::{SettledCall, ToolCallProgress},
};
#[derive(Clone)]
pub(crate) struct StreamEventsTarget {
pub(crate) sse: Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
pub(crate) task_id: Option<String>,
pub(crate) chunks_emitted: Arc<AtomicBool>,
pub(crate) tool_calls: Option<Arc<Mutex<ToolCallProgress>>>,
}
struct Wire<'a> {
tx: &'a SseBroadcast,
buf: &'a Arc<Mutex<SseEventBuffer>>,
task_id: &'a str,
}
impl StreamEventsTarget {
fn wire(&self) -> Option<Wire<'_>> {
let (tx, buf) = self.sse.as_ref()?;
Some(Wire {
tx,
buf,
task_id: self.task_id.as_deref()?,
})
}
}
pub(crate) fn emit_chunk(target: &StreamEventsTarget, chunk: &str) {
target.chunks_emitted.store(true, Ordering::Relaxed);
if let Some(wire) = target.wire() {
emit_chunk_sse(wire.tx, wire.buf, wire.task_id, chunk);
}
}
pub(crate) fn emit_thinking_chunk(target: &StreamEventsTarget, chunk: &str) {
if let Some(wire) = target.wire() {
emit_thinking_chunk_sse(wire.tx, wire.buf, wire.task_id, chunk);
}
}
pub(crate) fn tool_call_started(target: &StreamEventsTarget, id: &str, name: &str) {
let (Some(wire), Some(tool_calls)) = (target.wire(), target.tool_calls.as_ref()) else {
return;
};
if !lock(tool_calls).start(id, name) {
return;
}
write(
wire.tx,
wire.buf,
StreamFrame::ToolCallStarted,
&TaskToolCallStartedEvent {
id: wire.task_id.to_string(),
tool_call_id: id.to_string(),
tool_name: name.to_string(),
},
);
}
pub(crate) fn tool_call_input_bytes(
target: &StreamEventsTarget,
id: &str,
bytes: u64,
now: Instant,
) {
let (Some(wire), Some(tool_calls)) = (target.wire(), target.tool_calls.as_ref()) else {
return;
};
let Some(size) = lock(tool_calls).report(id, bytes, now) else {
return;
};
write_progress(wire.tx, wire.buf, wire.task_id, id, size);
}
pub(crate) fn settle_started_calls(
sse: &Option<(SseBroadcast, Arc<Mutex<SseEventBuffer>>)>,
task_id: Option<&str>,
tool_calls: &Mutex<ToolCallProgress>,
now: Instant,
) -> Vec<(String, String)> {
let settled = lock(tool_calls).complete_all(now);
settled
.into_iter()
.map(|(id, SettledCall { name, held })| {
if let (Some((tx, buf)), Some(task_id), Some(size)) = (sse, task_id, held) {
write_progress(tx, buf, task_id, &id, size);
}
(id, name)
})
.collect()
}
fn write_progress(
tx: &SseBroadcast,
buf: &Arc<Mutex<SseEventBuffer>>,
task_id: &str,
tool_call_id: &str,
input_bytes: u64,
) {
write(
tx,
buf,
StreamFrame::ToolCallProgress,
&TaskToolCallProgressEvent {
id: task_id.to_string(),
tool_call_id: tool_call_id.to_string(),
input_bytes,
},
);
}
fn write(
tx: &SseBroadcast,
buf: &Arc<Mutex<SseEventBuffer>>,
frame: StreamFrame,
data: &impl serde::Serialize,
) {
if let Ok(data) = serde_json::to_string(data) {
emit_frame(tx, buf, frame, &data);
}
}
fn lock(tool_calls: &Mutex<ToolCallProgress>) -> std::sync::MutexGuard<'_, ToolCallProgress> {
tool_calls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use serde_json::Value;
use super::*;
use crate::streaming::ReplayResult;
fn target(tracked: bool) -> (StreamEventsTarget, Arc<Mutex<SseEventBuffer>>) {
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let frames = Arc::new(Mutex::new(SseEventBuffer::new(64)));
let target = StreamEventsTarget {
sse: Some((tx, Arc::clone(&frames))),
task_id: Some("tsk_test".to_string()),
chunks_emitted: Arc::new(AtomicBool::new(false)),
tool_calls: tracked.then(|| Arc::new(Mutex::new(ToolCallProgress::default()))),
};
(target, frames)
}
fn frames_in(frames: &Arc<Mutex<SseEventBuffer>>) -> Vec<(String, Value)> {
let ReplayResult::Complete(frames) = frames.lock().unwrap().replay_from(0) else {
panic!("a gap in a test buffer");
};
frames
.iter()
.map(|frame| {
let event = frame
.lines()
.find_map(|line| line.strip_prefix("event: "))
.unwrap();
let data = frame
.lines()
.find_map(|line| line.strip_prefix("data: "))
.unwrap();
(event.to_string(), serde_json::from_str(data).unwrap())
})
.collect()
}
fn kinds(frames: &Arc<Mutex<SseEventBuffer>>) -> Vec<String> {
frames_in(frames)
.into_iter()
.map(|(kind, _)| kind)
.collect()
}
#[test]
fn stream_events_without_a_tracker_a_tool_call_writes_no_frame() {
let (target, frames) = target(false);
tool_call_started(&target, "c1", "echo-tool");
tool_call_input_bytes(&target, "c1", 12, Instant::now());
assert!(kinds(&frames).is_empty());
assert!(!target.chunks_emitted.load(Ordering::Relaxed));
}
#[test]
fn stream_events_with_a_tracker_a_start_writes_one_tool_call_started_frame() {
let (target, frames) = target(true);
tool_call_started(&target, "c1", "echo-tool");
tool_call_started(&target, "c1", "echo-tool");
tool_call_started(&target, "", "echo-tool");
assert_eq!(
frames_in(&frames),
[(
"tool-call-started".to_string(),
serde_json::json!({
"id": "tsk_test",
"tool_call_id": "c1",
"tool_name": "echo-tool",
})
)]
);
assert!(!target.chunks_emitted.load(Ordering::Relaxed));
}
#[test]
fn stream_events_progress_is_coalesced_and_the_rest_is_flushed_on_return() {
let (target, frames) = target(true);
let now = Instant::now();
tool_call_input_bytes(&target, "ghost", 5, now);
tool_call_started(&target, "c1", "echo-tool");
tool_call_input_bytes(&target, "c1", 15, now);
tool_call_input_bytes(&target, "c1", 30, now + Duration::from_millis(1));
tool_call_input_bytes(&target, "c1", 10, now + Duration::from_millis(2));
let settled = settle_started_calls(
&target.sse,
target.task_id.as_deref(),
target.tool_calls.as_ref().unwrap(),
now + Duration::from_millis(3),
);
assert_eq!(settled, [("c1".to_string(), "echo-tool".to_string())]);
let progress: Vec<Value> = frames_in(&frames)
.into_iter()
.filter(|(kind, _)| kind == "tool-call-progress")
.map(|(_, data)| data["input_bytes"].clone())
.collect();
assert_eq!(progress, [15, 30]);
tool_call_input_bytes(&target, "c1", 90, now + Duration::from_secs(1));
assert_eq!(
kinds(&frames),
[
"tool-call-started",
"tool-call-progress",
"tool-call-progress"
]
);
}
#[test]
fn stream_events_without_sse_nothing_is_written_or_tracked() {
let tool_calls = Arc::new(Mutex::new(ToolCallProgress::default()));
let target = StreamEventsTarget {
sse: None,
task_id: None,
chunks_emitted: Arc::new(AtomicBool::new(false)),
tool_calls: Some(Arc::clone(&tool_calls)),
};
emit_chunk(&target, "hello");
emit_thinking_chunk(&target, "hmm");
tool_call_started(&target, "c1", "echo-tool");
tool_call_input_bytes(&target, "c1", 12, Instant::now());
assert!(settle_started_calls(&None, None, &tool_calls, Instant::now()).is_empty());
assert!(tool_calls.lock().unwrap().start("c1", "echo-tool"));
}
#[test]
fn stream_events_emit_chunk_without_a_tracker_still_writes_text() {
let (target, frames) = target(false);
emit_chunk(&target, "hello");
emit_thinking_chunk(&target, "hmm");
assert_eq!(
frames_in(&frames),
[
(
"text".to_string(),
serde_json::json!({"id": "tsk_test", "text": "hello", "final": false})
),
(
"thinking".to_string(),
serde_json::json!({"id": "tsk_test", "text": "hmm", "final": false})
),
]
);
assert!(target.chunks_emitted.load(Ordering::Relaxed));
}
}