1use serde_json::Value;
30use std::collections::{HashSet, VecDeque};
31
32#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum WindowError {
36 NotAnObject,
37 MaxTurnsMustBePositive,
38}
39
40impl std::fmt::Display for WindowError {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 WindowError::NotAnObject => write!(f, "message must be a JSON object"),
44 WindowError::MaxTurnsMustBePositive => write!(f, "max_turns must be >= 1"),
45 }
46 }
47}
48
49impl std::error::Error for WindowError {}
50
51fn tool_use_ids(message: &Value) -> HashSet<String> {
55 let mut out = HashSet::new();
56 if let Some(content) = message.get("content") {
57 if let Some(blocks) = content.as_array() {
58 for block in blocks {
59 if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
60 if let Some(id) = block.get("id").and_then(|i| i.as_str()) {
61 out.insert(id.to_owned());
62 }
63 }
64 }
65 }
66 }
67 out
68}
69
70fn tool_result_refs(message: &Value) -> HashSet<String> {
72 let mut out = HashSet::new();
73 if let Some(content) = message.get("content") {
74 if let Some(blocks) = content.as_array() {
75 for block in blocks {
76 if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
77 if let Some(id) = block.get("tool_use_id").and_then(|i| i.as_str()) {
78 out.insert(id.to_owned());
79 }
80 }
81 }
82 }
83 }
84 out
85}
86
87pub struct MessageWindow {
96 max_turns: usize,
97 paired_protect: bool,
98 buf: VecDeque<Value>,
99 evicted_count: usize,
100}
101
102impl MessageWindow {
103 pub fn new(max_turns: usize, paired_protect: bool) -> Self {
107 assert!(max_turns >= 1, "max_turns must be >= 1");
108 Self {
109 max_turns,
110 paired_protect,
111 buf: VecDeque::new(),
112 evicted_count: 0,
113 }
114 }
115
116 pub fn try_new(max_turns: usize, paired_protect: bool) -> Result<Self, WindowError> {
118 if max_turns < 1 {
119 return Err(WindowError::MaxTurnsMustBePositive);
120 }
121 Ok(Self::new(max_turns, paired_protect))
122 }
123
124 pub fn max_turns(&self) -> usize {
125 self.max_turns
126 }
127
128 pub fn len(&self) -> usize {
129 self.buf.len()
130 }
131
132 pub fn is_empty(&self) -> bool {
133 self.buf.is_empty()
134 }
135
136 pub fn evicted_count(&self) -> usize {
137 self.evicted_count
138 }
139
140 pub fn messages(&self) -> Vec<&Value> {
142 self.buf.iter().collect()
143 }
144
145 pub fn messages_owned(&self) -> Vec<Value> {
147 self.buf.iter().cloned().collect()
148 }
149
150 pub fn clear(&mut self) {
151 self.buf.clear();
152 self.evicted_count = 0;
153 }
154
155 pub fn add(&mut self, message: Value) -> Result<(), WindowError> {
157 if !message.is_object() {
158 return Err(WindowError::NotAnObject);
159 }
160 self.buf.push_back(message);
161 self.evict_to_fit();
162 Ok(())
163 }
164
165 pub fn extend(&mut self, messages: impl IntoIterator<Item = Value>) -> Result<(), WindowError> {
167 for m in messages {
168 self.add(m)?;
169 }
170 Ok(())
171 }
172
173 fn evict_to_fit(&mut self) {
176 while self.buf.len() > self.max_turns {
177 let oldest = self.buf.pop_front().expect("buf non-empty");
178 self.evicted_count += 1;
179 if self.paired_protect {
180 let tu_ids = tool_use_ids(&oldest);
181 if !tu_ids.is_empty() {
182 self.drop_orphan_tool_results(&tu_ids);
183 }
184 }
185 }
186 }
187
188 fn drop_orphan_tool_results(&mut self, tu_ids: &HashSet<String>) {
190 let mut kept = VecDeque::with_capacity(self.buf.len());
191 for msg in self.buf.drain(..) {
192 let refs = tool_result_refs(&msg);
193 if refs.iter().any(|id| tu_ids.contains(id)) {
194 self.evicted_count += 1;
195 } else {
196 kept.push_back(msg);
197 }
198 }
199 self.buf = kept;
200 }
201}
202
203#[cfg(test)]
206mod tests {
207 use super::*;
208 use serde_json::json;
209
210 fn user(text: &str) -> Value {
211 json!({"role": "user", "content": text})
212 }
213
214 fn assistant(text: &str) -> Value {
215 json!({"role": "assistant", "content": text})
216 }
217
218 fn tool_use_msg(id: &str, name: &str) -> Value {
219 json!({
220 "role": "assistant",
221 "content": [{"type": "tool_use", "id": id, "name": name, "input": {}}]
222 })
223 }
224
225 fn tool_result_msg(tool_use_id: &str) -> Value {
226 json!({
227 "role": "user",
228 "content": [{"type": "tool_result", "tool_use_id": tool_use_id, "content": "ok"}]
229 })
230 }
231
232 #[test]
233 fn basic_sliding_window() {
234 let mut win = MessageWindow::new(3, false);
235 win.add(user("a")).unwrap();
236 win.add(user("b")).unwrap();
237 win.add(user("c")).unwrap();
238 win.add(user("d")).unwrap();
239 assert_eq!(win.len(), 3);
240 assert_eq!(win.evicted_count(), 1);
241 assert_eq!(win.messages()[0]["content"], json!("b"));
242 }
243
244 #[test]
245 fn no_overflow_within_cap() {
246 let mut win = MessageWindow::new(5, true);
247 for i in 0..5 {
248 win.add(user(&i.to_string())).unwrap();
249 }
250 assert_eq!(win.len(), 5);
251 assert_eq!(win.evicted_count(), 0);
252 }
253
254 #[test]
255 fn paired_protect_drops_orphan_tool_result() {
256 let mut win = MessageWindow::new(2, true);
257 win.add(tool_use_msg("u1", "search")).unwrap();
258 win.add(tool_result_msg("u1")).unwrap();
259 win.add(user("follow-up")).unwrap();
261 win.add(assistant("done")).unwrap();
262 assert_eq!(win.len(), 2);
264 assert!(win.messages().iter().all(|m| {
265 m.get("content")
266 .and_then(|c| c.as_array())
267 .map(|arr| arr.iter().all(|b| b.get("type") != Some(&json!("tool_result"))))
268 .unwrap_or(true)
269 }));
270 }
271
272 #[test]
273 fn paired_protect_off_leaves_orphan() {
274 let mut win = MessageWindow::new(2, false);
275 win.add(tool_use_msg("u1", "search")).unwrap();
276 win.add(tool_result_msg("u1")).unwrap();
277 win.add(user("next")).unwrap();
278 win.add(assistant("done")).unwrap();
279 assert_eq!(win.len(), 2);
281 }
282
283 #[test]
284 fn clear_resets_all() {
285 let mut win = MessageWindow::new(3, true);
286 win.add(user("a")).unwrap();
287 win.add(user("b")).unwrap();
288 win.clear();
289 assert_eq!(win.len(), 0);
290 assert_eq!(win.evicted_count(), 0);
291 }
292
293 #[test]
294 fn extend_adds_in_order() {
295 let mut win = MessageWindow::new(5, true);
296 win.extend(vec![user("a"), user("b"), user("c")]).unwrap();
297 assert_eq!(win.len(), 3);
298 assert_eq!(win.messages()[1]["content"], json!("b"));
299 }
300
301 #[test]
302 fn not_object_error() {
303 let mut win = MessageWindow::new(3, true);
304 let err = win.add(json!([1, 2, 3])).unwrap_err();
305 assert_eq!(err, WindowError::NotAnObject);
306 }
307
308 #[test]
309 fn try_new_zero_cap_errors() {
310 assert!(matches!(
311 MessageWindow::try_new(0, true),
312 Err(WindowError::MaxTurnsMustBePositive)
313 ));
314 }
315
316 #[test]
317 fn try_new_valid() {
318 assert!(MessageWindow::try_new(1, true).is_ok());
319 }
320
321 #[test]
322 fn evicted_count_increments_correctly() {
323 let mut win = MessageWindow::new(2, false);
324 for i in 0..5u32 {
325 win.add(user(&i.to_string())).unwrap();
326 }
327 assert_eq!(win.evicted_count(), 3);
328 }
329
330 #[test]
331 fn multiple_tool_use_ids_in_one_message() {
332 let mut win = MessageWindow::new(2, true);
333 win.add(json!({
335 "role": "assistant",
336 "content": [
337 {"type": "tool_use", "id": "a1", "name": "x", "input": {}},
338 {"type": "tool_use", "id": "a2", "name": "y", "input": {}}
339 ]
340 }))
341 .unwrap();
342 win.add(tool_result_msg("a1")).unwrap();
343 win.add(tool_result_msg("a2")).unwrap();
344 win.add(user("next")).unwrap();
346 for m in win.messages() {
348 let content = m.get("content");
349 if let Some(Value::Array(blocks)) = content {
350 for b in blocks {
351 assert_ne!(b.get("type"), Some(&json!("tool_result")));
352 }
353 }
354 }
355 }
356
357 #[test]
358 fn messages_owned_clones() {
359 let mut win = MessageWindow::new(3, true);
360 win.add(user("hello")).unwrap();
361 let owned = win.messages_owned();
362 assert_eq!(owned[0]["content"], json!("hello"));
363 }
364
365 #[test]
366 fn single_capacity_window() {
367 let mut win = MessageWindow::new(1, true);
368 win.add(user("a")).unwrap();
369 win.add(user("b")).unwrap();
370 assert_eq!(win.len(), 1);
371 assert_eq!(win.messages()[0]["content"], json!("b"));
372 assert_eq!(win.evicted_count(), 1);
373 }
374
375 #[test]
376 fn tool_use_no_result_in_buffer_just_evicts_use() {
377 let mut win = MessageWindow::new(1, true);
378 win.add(tool_use_msg("u1", "search")).unwrap();
379 win.add(user("follow")).unwrap();
381 assert_eq!(win.len(), 1);
382 assert_eq!(win.messages()[0]["content"], json!("follow"));
383 }
384
385 #[test]
386 fn add_then_clear_then_add() {
387 let mut win = MessageWindow::new(3, true);
388 win.add(user("a")).unwrap();
389 win.clear();
390 win.add(user("b")).unwrap();
391 assert_eq!(win.len(), 1);
392 assert_eq!(win.messages()[0]["content"], json!("b"));
393 }
394
395 #[test]
396 fn is_empty_and_len() {
397 let mut win = MessageWindow::new(3, true);
398 assert!(win.is_empty());
399 win.add(user("x")).unwrap();
400 assert!(!win.is_empty());
401 assert_eq!(win.len(), 1);
402 }
403}