use crate::tool_types::ToolCall;
use serde_json::{Value, json};
#[derive(Debug, Clone)]
struct PartialToolCall {
key: String,
id: String,
name: String,
arguments: String,
}
#[derive(Debug, Default)]
pub struct StreamToolCallAccumulator {
calls: Vec<PartialToolCall>,
}
impl StreamToolCallAccumulator {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.calls.is_empty()
}
fn slot(&mut self, key: &str) -> &mut PartialToolCall {
if let Some(pos) = self.calls.iter().position(|c| c.key == key) {
return &mut self.calls[pos];
}
self.calls.push(PartialToolCall {
key: key.to_string(),
id: String::new(),
name: String::new(),
arguments: String::new(),
});
self.calls.last_mut().expect("just pushed")
}
pub fn apply_indexed_delta(
&mut self,
index: u32,
id: Option<&str>,
name: Option<&str>,
arguments: Option<&str>,
) {
let slot = self.slot(&index.to_string());
if let Some(id) = id {
slot.id = id.to_string();
}
if let Some(name) = name {
slot.name = name.to_string();
}
if let Some(args) = arguments {
slot.arguments.push_str(args);
}
}
pub fn append_arguments(&mut self, item_id: &str, delta: &str) {
self.slot(item_id).arguments.push_str(delta);
}
pub fn set_item(&mut self, item_id: &str, call_id: &str, name: &str) {
let slot = self.slot(item_id);
slot.id = call_id.to_string();
slot.name = name.to_string();
}
pub fn push_complete(&mut self, id: String, name: String, arguments: Value) {
self.calls.push(PartialToolCall {
key: String::new(),
id,
name,
arguments: arguments.to_string(),
});
}
pub fn take_finalized(&mut self) -> Vec<ToolCall> {
std::mem::take(&mut self.calls)
.into_iter()
.map(|c| ToolCall {
id: c.id,
name: c.name,
arguments: parse_arguments(&c.arguments).unwrap_or_else(|| json!({})),
})
.collect()
}
pub fn take_pending_strict(&mut self) -> Vec<ToolCall> {
std::mem::take(&mut self.calls)
.into_iter()
.filter_map(|c| {
Some(ToolCall {
id: c.id,
name: c.name,
arguments: parse_arguments(&c.arguments)?,
})
})
.collect()
}
pub fn take_named(&mut self) -> Vec<ToolCall> {
std::mem::take(&mut self.calls)
.into_iter()
.filter(|c| !c.name.is_empty())
.map(|c| ToolCall {
id: c.id,
name: c.name,
arguments: parse_arguments(&c.arguments).unwrap_or_else(|| json!({})),
})
.collect()
}
}
fn parse_arguments(buffer: &str) -> Option<Value> {
if buffer.is_empty() {
return Some(json!({}));
}
serde_json::from_str(buffer).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn indexed_delta_reassembles_fragmented_arguments() {
let mut acc = StreamToolCallAccumulator::new();
acc.apply_indexed_delta(0, Some("call_a"), Some("get_weather"), Some(""));
acc.apply_indexed_delta(0, None, None, Some("{\"city\":"));
acc.apply_indexed_delta(0, None, None, Some("\"Paris\"}"));
let calls = acc.take_finalized();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_a");
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].arguments, json!({"city": "Paris"}));
assert!(acc.is_empty());
}
#[test]
fn indexed_delta_grows_to_index_and_preserves_order() {
let mut acc = StreamToolCallAccumulator::new();
acc.apply_indexed_delta(0, Some("c0"), Some("first"), Some("{}"));
acc.apply_indexed_delta(1, Some("c1"), Some("second"), Some("{}"));
let calls = acc.take_finalized();
assert_eq!(calls[0].name, "first");
assert_eq!(calls[1].name, "second");
}
#[test]
fn empty_arguments_finalize_to_empty_object() {
let mut acc = StreamToolCallAccumulator::new();
acc.apply_indexed_delta(0, Some("c"), Some("noop"), None);
let calls = acc.take_finalized();
assert_eq!(calls[0].arguments, json!({}));
}
#[test]
fn take_finalized_degrades_malformed_json_to_empty_object() {
let mut acc = StreamToolCallAccumulator::new();
acc.apply_indexed_delta(0, Some("c"), Some("bad"), Some("{not json"));
let calls = acc.take_finalized();
assert_eq!(calls[0].arguments, json!({}));
}
#[test]
fn take_pending_strict_drops_malformed_json() {
let mut acc = StreamToolCallAccumulator::new();
acc.apply_indexed_delta(0, Some("c"), Some("bad"), Some("{not json"));
acc.apply_indexed_delta(1, Some("d"), Some("good"), Some("{\"ok\":true}"));
let calls = acc.take_pending_strict();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "good");
}
#[test]
fn item_keyed_flow_matches_open_responses() {
let mut acc = StreamToolCallAccumulator::new();
acc.set_item("fc_1", "call_1", "get_weather");
acc.append_arguments("fc_1", "{\"city\":");
acc.append_arguments("fc_1", "\"Paris\"}");
let calls = acc.take_named();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "get_weather");
assert_eq!(calls[0].arguments, json!({"city": "Paris"}));
}
#[test]
fn take_named_skips_unnamed_items() {
let mut acc = StreamToolCallAccumulator::new();
acc.append_arguments("fc_orphan", "{}");
acc.set_item("fc_1", "call_1", "named");
acc.append_arguments("fc_1", "{}");
let calls = acc.take_named();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].name, "named");
}
#[test]
fn push_complete_serializes_and_reparses_identically() {
let mut acc = StreamToolCallAccumulator::new();
acc.push_complete("call_0".into(), "f".into(), json!({"a": 1, "b": "x"}));
let calls = acc.take_finalized();
assert_eq!(calls[0].arguments, json!({"a": 1, "b": "x"}));
}
}