llm_stream_accumulator/
lib.rs1#[derive(Debug, Clone)]
20pub enum Chunk {
21 Text(String),
22 ToolUse { id: String, name: String, input_fragment: String },
23 InputTokens(u64),
24 OutputTokens(u64),
25 Stop(String),
26}
27
28#[derive(Debug, Clone)]
30pub struct ToolCall {
31 pub id: String,
32 pub name: String,
33 pub input_json: String,
34}
35
36#[derive(Debug, Default)]
38pub struct StreamAccumulator {
39 text_parts: Vec<String>,
40 tool_calls: Vec<ToolCall>,
41 current_tool_id: Option<String>,
43 input_tokens: u64,
44 output_tokens: u64,
45 stop_reason: Option<String>,
46 complete: bool,
47}
48
49impl StreamAccumulator {
50 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub fn push_text(&mut self, delta: &str) {
56 self.text_parts.push(delta.to_string());
57 }
58
59 pub fn push_tool_use(&mut self, id: &str, name: &str, input_fragment: &str) {
61 if self.current_tool_id.as_deref() != Some(id) {
62 self.tool_calls.push(ToolCall { id: id.to_string(), name: name.to_string(), input_json: String::new() });
63 self.current_tool_id = Some(id.to_string());
64 }
65 if let Some(tc) = self.tool_calls.iter_mut().find(|t| t.id == id) {
66 tc.input_json.push_str(input_fragment);
67 }
68 }
69
70 pub fn push(&mut self, chunk: Chunk) {
72 match chunk {
73 Chunk::Text(s) => self.push_text(&s),
74 Chunk::ToolUse { id, name, input_fragment } => self.push_tool_use(&id, &name, &input_fragment),
75 Chunk::InputTokens(n) => self.input_tokens += n,
76 Chunk::OutputTokens(n) => self.output_tokens += n,
77 Chunk::Stop(r) => { self.stop_reason = Some(r); self.complete = true; }
78 }
79 }
80
81 pub fn finish(&mut self) {
83 self.complete = true;
84 }
85
86 pub fn text(&self) -> String {
88 self.text_parts.join("")
89 }
90
91 pub fn tool_calls(&self) -> &[ToolCall] {
93 &self.tool_calls
94 }
95
96 pub fn input_tokens(&self) -> u64 { self.input_tokens }
97 pub fn output_tokens(&self) -> u64 { self.output_tokens }
98 pub fn stop_reason(&self) -> Option<&str> { self.stop_reason.as_deref() }
99 pub fn is_complete(&self) -> bool { self.complete }
100
101 pub fn has_text(&self) -> bool { !self.text_parts.is_empty() }
103
104 pub fn has_tool_calls(&self) -> bool { !self.tool_calls.is_empty() }
106
107 pub fn reset(&mut self) {
109 *self = Self::default();
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn accumulates_text() {
119 let mut acc = StreamAccumulator::new();
120 acc.push_text("Hello");
121 acc.push_text(", world");
122 assert_eq!(acc.text(), "Hello, world");
123 }
124
125 #[test]
126 fn finish_marks_complete() {
127 let mut acc = StreamAccumulator::new();
128 assert!(!acc.is_complete());
129 acc.finish();
130 assert!(acc.is_complete());
131 }
132
133 #[test]
134 fn stop_chunk_marks_complete() {
135 let mut acc = StreamAccumulator::new();
136 acc.push(Chunk::Stop("end_turn".to_string()));
137 assert!(acc.is_complete());
138 assert_eq!(acc.stop_reason(), Some("end_turn"));
139 }
140
141 #[test]
142 fn token_counts_accumulate() {
143 let mut acc = StreamAccumulator::new();
144 acc.push(Chunk::InputTokens(100));
145 acc.push(Chunk::InputTokens(50));
146 acc.push(Chunk::OutputTokens(200));
147 assert_eq!(acc.input_tokens(), 150);
148 assert_eq!(acc.output_tokens(), 200);
149 }
150
151 #[test]
152 fn tool_use_accumulates_fragments() {
153 let mut acc = StreamAccumulator::new();
154 acc.push_tool_use("t1", "search", r#"{"q": "#);
155 acc.push_tool_use("t1", "search", r#""rust"}"#);
156 assert_eq!(acc.tool_calls().len(), 1);
157 assert_eq!(acc.tool_calls()[0].input_json, r#"{"q": "rust"}"#);
158 }
159
160 #[test]
161 fn multiple_tool_calls() {
162 let mut acc = StreamAccumulator::new();
163 acc.push_tool_use("t1", "search", "{}");
164 acc.push_tool_use("t2", "fetch", "{}");
165 assert_eq!(acc.tool_calls().len(), 2);
166 }
167
168 #[test]
169 fn has_text_and_has_tool_calls() {
170 let mut acc = StreamAccumulator::new();
171 assert!(!acc.has_text());
172 assert!(!acc.has_tool_calls());
173 acc.push_text("hi");
174 assert!(acc.has_text());
175 acc.push_tool_use("t1", "fn", "{}");
176 assert!(acc.has_tool_calls());
177 }
178
179 #[test]
180 fn reset_clears_state() {
181 let mut acc = StreamAccumulator::new();
182 acc.push_text("hello");
183 acc.push(Chunk::InputTokens(100));
184 acc.reset();
185 assert!(acc.text().is_empty());
186 assert_eq!(acc.input_tokens(), 0);
187 }
188
189 #[test]
190 fn empty_text_by_default() {
191 let acc = StreamAccumulator::new();
192 assert_eq!(acc.text(), "");
193 }
194
195 #[test]
196 fn push_text_chunk() {
197 let mut acc = StreamAccumulator::new();
198 acc.push(Chunk::Text("chunk".to_string()));
199 assert_eq!(acc.text(), "chunk");
200 }
201
202 #[test]
203 fn stop_reason_none_before_stop() {
204 let acc = StreamAccumulator::new();
205 assert_eq!(acc.stop_reason(), None);
206 }
207
208 #[test]
209 fn tool_call_name_preserved() {
210 let mut acc = StreamAccumulator::new();
211 acc.push_tool_use("id1", "my_tool", "{}");
212 assert_eq!(acc.tool_calls()[0].name, "my_tool");
213 }
214
215 #[test]
216 fn interleaved_text_and_tool() {
217 let mut acc = StreamAccumulator::new();
218 acc.push_text("I'll search for ");
219 acc.push_tool_use("t1", "search", r#"{"q": "foo"}"#);
220 acc.push_text("Done.");
221 assert_eq!(acc.text(), "I'll search for Done.");
222 assert_eq!(acc.tool_calls().len(), 1);
223 }
224}