use std::{
collections::HashSet,
time::{Duration, Instant},
};
pub(crate) const TOOL_CALL_PROGRESS_INTERVAL: Duration = Duration::from_millis(250);
struct StartedCall {
name: String,
reported: u64,
sent: Option<(u64, Instant)>,
}
impl StartedCall {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
reported: 0,
sent: None,
}
}
fn report(&mut self, input_bytes: u64, now: Instant) -> Option<u64> {
if input_bytes <= self.reported {
return None;
}
self.reported = input_bytes;
let due = self
.sent
.is_none_or(|(_, at)| now.saturating_duration_since(at) >= TOOL_CALL_PROGRESS_INTERVAL);
if !due {
return None;
}
self.sent = Some((input_bytes, now));
Some(input_bytes)
}
fn flush(&mut self, now: Instant) -> Option<u64> {
let written = self.sent.map_or(0, |(size, _)| size);
if self.reported <= written {
return None;
}
self.sent = Some((self.reported, now));
Some(self.reported)
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct SettledCall {
pub(crate) name: String,
pub(crate) held: Option<u64>,
}
#[derive(Default)]
pub(crate) struct ToolCallProgress {
writing: Vec<(String, StartedCall)>,
settled: HashSet<String>,
}
impl ToolCallProgress {
pub(crate) fn start(&mut self, id: &str, name: &str) -> bool {
if id.is_empty() || self.writing(id).is_some() || self.settled.contains(id) {
return false;
}
self.writing.push((id.to_string(), StartedCall::new(name)));
true
}
pub(crate) fn report(&mut self, id: &str, input_bytes: u64, now: Instant) -> Option<u64> {
self.writing(id)?.report(input_bytes, now)
}
pub(crate) fn complete(&mut self, id: &str, now: Instant) -> Option<SettledCall> {
self.settled.insert(id.to_string());
let index = self.writing.iter().position(|(started, _)| started == id)?;
let (_, mut call) = self.writing.remove(index);
Some(SettledCall {
held: call.flush(now),
name: call.name,
})
}
pub(crate) fn complete_all(&mut self, now: Instant) -> Vec<(String, SettledCall)> {
std::mem::take(&mut self.writing)
.into_iter()
.map(|(id, mut call)| {
self.settled.insert(id.clone());
let settled = SettledCall {
held: call.flush(now),
name: call.name,
};
(id, settled)
})
.collect()
}
pub(crate) fn settle(&mut self, id: &str) {
self.writing.retain(|(started, _)| started != id);
self.settled.insert(id.to_string());
}
pub(crate) fn clear_writing(&mut self) {
self.writing.clear();
}
pub(crate) fn reset(&mut self) {
self.writing.clear();
self.settled.clear();
}
fn writing(&mut self, id: &str) -> Option<&mut StartedCall> {
self.writing
.iter_mut()
.find_map(|(started, call)| (started == id).then_some(call))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(base: Instant, ms: u64) -> Instant {
base + Duration::from_millis(ms)
}
#[test]
fn tool_call_progress_the_first_report_is_written_at_once() {
let now = Instant::now();
let mut call = StartedCall::new("write_file");
assert_eq!(call.report(12, now), Some(12));
}
#[test]
fn tool_call_progress_a_report_within_the_interval_is_held() {
let now = Instant::now();
let mut call = StartedCall::new("write_file");
call.report(12, now);
assert_eq!(call.report(40, at(now, 100)), None);
assert_eq!(call.report(60, at(now, 249)), None);
assert_eq!(call.reported, 60);
}
#[test]
fn tool_call_progress_a_report_at_the_interval_writes_the_largest_size() {
let now = Instant::now();
let mut call = StartedCall::new("write_file");
call.report(12, now);
call.report(40, at(now, 100));
assert_eq!(call.report(60, at(now, 250)), Some(60));
assert_eq!(call.report(80, at(now, 400)), None);
assert_eq!(call.report(90, at(now, 500)), Some(90));
}
#[test]
fn tool_call_progress_an_equal_or_lower_size_is_never_written() {
let now = Instant::now();
let mut call = StartedCall::new("write_file");
assert_eq!(call.report(0, now), None);
call.report(40, now);
assert_eq!(call.report(40, at(now, 1_000)), None);
assert_eq!(call.report(12, at(now, 2_000)), None);
assert_eq!(call.flush(at(now, 3_000)), None);
}
#[test]
fn tool_call_progress_the_complete_call_flushes_the_held_size_once_whatever_the_interval() {
let now = Instant::now();
let mut call = StartedCall::new("write_file");
call.report(12, now);
call.report(40, at(now, 1));
assert_eq!(call.flush(at(now, 2)), Some(40));
assert_eq!(call.flush(at(now, 3)), None);
}
#[test]
fn tool_call_progress_the_complete_call_flushes_nothing_when_nothing_is_held() {
let now = Instant::now();
let mut call = StartedCall::new("write_file");
assert_eq!(call.flush(now), None);
call.report(12, now);
assert_eq!(call.flush(at(now, 1)), None);
}
#[test]
fn tool_call_progress_a_start_with_an_empty_id_is_not_tracked() {
let mut calls = ToolCallProgress::default();
assert!(!calls.start("", "write_file"));
assert_eq!(calls.report("", 12, Instant::now()), None);
}
#[test]
fn tool_call_progress_a_second_start_for_a_call_being_written_writes_nothing() {
let mut calls = ToolCallProgress::default();
assert!(calls.start("c1", "write_file"));
assert!(!calls.start("c1", "write_file"));
assert!(!calls.start("c1", "read_file"));
}
#[test]
fn tool_call_progress_a_start_for_a_settled_call_writes_nothing() {
let now = Instant::now();
let mut calls = ToolCallProgress::default();
assert_eq!(calls.complete("c1", now), None);
assert!(!calls.start("c1", "write_file"));
calls.settle("c2");
assert!(!calls.start("c2", "write_file"));
}
#[test]
fn tool_call_progress_a_report_for_a_call_not_being_written_writes_nothing() {
let now = Instant::now();
let mut calls = ToolCallProgress::default();
assert_eq!(calls.report("ghost", 5, now), None);
calls.start("c1", "write_file");
calls.complete("c1", now);
assert_eq!(calls.report("c1", 40, at(now, 1_000)), None);
}
#[test]
fn tool_call_progress_reports_are_coalesced_per_call() {
let now = Instant::now();
let mut calls = ToolCallProgress::default();
calls.start("c1", "write_file");
calls.start("c2", "read_file");
assert_eq!(calls.report("c1", 12, now), Some(12));
assert_eq!(calls.report("c2", 3, at(now, 1)), Some(3));
assert_eq!(calls.report("c1", 30, at(now, 2)), None);
assert_eq!(calls.report("c1", 20, at(now, 300)), None);
assert_eq!(calls.report("c1", 31, at(now, 300)), Some(31));
}
#[test]
fn tool_call_progress_the_complete_call_returns_the_start_s_name_and_held_size() {
let now = Instant::now();
let mut calls = ToolCallProgress::default();
calls.start("c1", "write_file");
calls.report("c1", 12, now);
calls.report("c1", 40, at(now, 1));
assert_eq!(
calls.complete("c1", at(now, 2)),
Some(SettledCall {
name: "write_file".to_string(),
held: Some(40),
})
);
assert_eq!(calls.complete("c1", at(now, 3)), None);
}
#[test]
fn tool_call_progress_clearing_the_in_flight_set_keeps_settled_ids_settled() {
let now = Instant::now();
let mut calls = ToolCallProgress::default();
calls.start("c1", "write_file");
calls.complete("c1", now);
calls.start("c2", "write_file");
calls.clear_writing();
assert!(!calls.start("c1", "write_file"));
assert_eq!(calls.report("c2", 12, now), None);
assert!(calls.start("c2", "write_file"));
}
#[test]
fn tool_call_progress_the_flush_all_on_dispatch_return_flushes_every_in_flight_call_once() {
let now = Instant::now();
let mut calls = ToolCallProgress::default();
calls.start("c1", "write_file");
calls.start("c2", "read_file");
calls.start("c3", "list_dir");
calls.report("c1", 12, now);
calls.report("c1", 40, at(now, 1));
calls.report("c2", 7, now);
let settled = calls.complete_all(at(now, 2));
assert_eq!(
settled,
[
(
"c1".to_string(),
SettledCall {
name: "write_file".to_string(),
held: Some(40),
}
),
(
"c2".to_string(),
SettledCall {
name: "read_file".to_string(),
held: None,
}
),
(
"c3".to_string(),
SettledCall {
name: "list_dir".to_string(),
held: None,
}
),
]
);
assert!(calls.complete_all(at(now, 3)).is_empty());
assert!(!calls.start("c1", "write_file"));
assert_eq!(calls.report("c2", 90, at(now, 1_000)), None);
}
#[test]
fn tool_call_progress_a_reset_forgets_settled_calls() {
let mut calls = ToolCallProgress::default();
calls.settle("c1");
calls.reset();
assert!(calls.start("c1", "write_file"));
}
#[test]
fn tool_call_progress_interval_is_the_documented_one() {
let page = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/content/reference/streaming-protocol.md"
))
.expect("the streaming protocol reference page");
let section = page
.split("\n## ")
.find(|section| section.contains("{ #event-tool-call-progress }"))
.expect("a tool-call-progress section");
let stated = format!("{} ms", TOOL_CALL_PROGRESS_INTERVAL.as_millis());
assert!(
section.contains(&stated),
"the tool-call-progress section does not state {stated}"
);
}
}