use std::{
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::Instant,
};
use crate::{
streaming::{emit_frame, emit_text, emit_thinking, MurmurFrame, StreamSink, StreamTask},
tool_call_progress::{SettledCall, ToolCallProgress},
};
#[derive(Clone)]
pub(crate) struct StreamEventsTarget {
pub(crate) sse: StreamSink,
pub(crate) task: Option<StreamTask>,
pub(crate) chunks_emitted: Arc<AtomicBool>,
pub(crate) tool_calls: Option<Arc<Mutex<ToolCallProgress>>>,
}
impl StreamEventsTarget {
fn task(&self) -> Option<&StreamTask> {
self.sse.as_ref()?;
self.task.as_ref()
}
}
pub(crate) fn emit_chunk(target: &StreamEventsTarget, chunk: &str) {
target.chunks_emitted.store(true, Ordering::Relaxed);
if let Some(task) = target.task() {
emit_text(&target.sse, task, chunk, false);
}
}
pub(crate) fn emit_thinking_chunk(target: &StreamEventsTarget, chunk: &str) {
if let Some(task) = target.task() {
emit_thinking(&target.sse, task, chunk);
}
}
pub(crate) fn tool_call_started(target: &StreamEventsTarget, id: &str, name: &str) {
let (Some(task), Some(tool_calls)) = (target.task(), target.tool_calls.as_ref()) else {
return;
};
if !lock(tool_calls).start(id, name) {
return;
}
emit_frame(
&target.sse,
task,
MurmurFrame::ToolCallStarted {
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(task), Some(tool_calls)) = (target.task(), target.tool_calls.as_ref()) else {
return;
};
let Some(size) = lock(tool_calls).report(id, bytes, now) else {
return;
};
write_progress(&target.sse, task, id, size);
}
pub(crate) fn settle_started_calls(
sse: &StreamSink,
task: Option<&StreamTask>,
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(task), Some(size)) = (task, held) {
write_progress(sse, task, &id, size);
}
(id, name)
})
.collect()
}
fn write_progress(sse: &StreamSink, task: &StreamTask, tool_call_id: &str, input_bytes: u64) {
emit_frame(
sse,
task,
MurmurFrame::ToolCallProgress {
tool_call_id: tool_call_id.to_string(),
input_bytes,
},
);
}
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::{buffered, event_kind, frame_of, SseEventBuffer};
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: Some(StreamTask::new("tsk_test", "ctx_test")),
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)> {
buffered(frames)
.iter()
.map(|result| {
crate::a2a::assert_bare_status(
&result["statusUpdate"]["status"],
"TASK_STATE_WORKING",
);
assert_eq!(result["statusUpdate"]["taskId"], "tsk_test");
(event_kind(result), frame_of(result))
})
.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!({
"frame": "tool-call-started",
"toolCallId": "c1",
"toolName": "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.as_ref(),
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["inputBytes"].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: 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!({"frame": "text", "text": "hello", "final": false})
),
(
"thinking".to_string(),
serde_json::json!({"frame": "thinking", "text": "hmm", "final": false})
),
]
);
assert!(target.chunks_emitted.load(Ordering::Relaxed));
}
}