1use std::collections::HashMap;
8use std::ops::Range;
9
10use serde_json::{Value, json};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct Call {
15 pub id: String,
17 pub name: String,
19 pub input: Value,
21}
22
23#[derive(Debug, Clone, PartialEq, Default)]
25pub struct Conversation {
26 messages: Vec<Value>,
27}
28
29impl Conversation {
30 #[must_use]
32 pub fn messages(&self) -> &[Value] {
33 &self.messages
34 }
35
36 #[must_use]
49 pub fn view(&self, budget: usize) -> Vec<Value> {
50 let mut view = self.messages.clone();
51 if bytes(&view) <= budget {
52 return view;
53 }
54 let names = tool_names(&self.messages);
55 let old = 1..view.len().saturating_sub(1);
56 if shorten(&mut view, &old, budget, "tool_result", &names) {
57 return view;
58 }
59 shorten(&mut view, &old, budget, "text", &names);
60 view
61 }
62
63 #[must_use]
65 pub fn is_empty(&self) -> bool {
66 self.messages.is_empty()
67 }
68
69 pub fn say(&mut self, text: &str) {
73 let block = json!({"type": "text", "text": text});
74 self.push_user(vec![block]);
75 }
76
77 pub fn reply(&mut self, content: Vec<Value>) {
79 if !content.is_empty() {
80 self.messages.push(json!({"role": "assistant", "content": Value::Array(content)}));
81 }
82 }
83
84 pub fn results(&mut self, results: Vec<Value>) {
86 if !results.is_empty() {
87 self.push_user(results);
88 }
89 }
90
91 fn push_user(&mut self, blocks: Vec<Value>) {
92 if let Some(last) = self.messages.last_mut()
93 && last["role"] == "user"
94 && let Some(content) = last.get_mut("content").and_then(Value::as_array_mut)
95 {
96 content.extend(blocks);
97 return;
98 }
99 self.messages.push(json!({"role": "user", "content": blocks}));
100 }
101
102 #[must_use]
104 pub fn open_calls(&self) -> Vec<Call> {
105 let Some(last) = self.messages.last().filter(|message| message["role"] == "assistant") else {
106 return Vec::new();
107 };
108 blocks(last)
109 .filter(|block| block["type"] == "tool_use")
110 .map(|block| Call {
111 id: block["id"].as_str().unwrap_or_default().to_owned(),
112 name: block["name"].as_str().unwrap_or_default().to_owned(),
113 input: block.get("input").cloned().unwrap_or(Value::Null),
114 })
115 .collect()
116 }
117
118 #[must_use]
120 pub fn to_json(&self) -> Value {
121 json!({"version": 1, "messages": self.messages})
122 }
123
124 #[must_use]
128 pub fn holds(value: &Value) -> bool {
129 value["messages"].is_array()
130 }
131
132 #[must_use]
135 pub fn from_json(value: &Value) -> Self {
136 let messages = value["messages"]
137 .as_array()
138 .map(|messages| {
139 messages
140 .iter()
141 .filter(|message| {
142 matches!(message["role"].as_str(), Some("user" | "assistant")) && message["content"].is_array()
143 })
144 .cloned()
145 .collect()
146 })
147 .unwrap_or_default();
148 let mut conversation = Self { messages: mend(messages) };
149 let open = conversation.open_calls();
150 conversation.results(open.iter().map(|call| result(&call.id, CLOSED, true)).collect());
151 conversation
152 }
153}
154
155fn mend(messages: Vec<Value>) -> Vec<Value> {
161 let mut mended: Vec<Value> = Vec::with_capacity(messages.len());
162 let mut waiting: Vec<String> = Vec::new();
163 for mut message in messages {
164 if !waiting.is_empty() {
165 let mut calls = std::mem::take(&mut waiting);
166 if message["role"] == "user" {
167 for block in blocks(&message).filter(|block| block["type"] == "tool_result") {
169 let id = block["tool_use_id"].as_str().unwrap_or_default();
170 if let Some(at) = calls.iter().position(|call| call == id) {
171 calls.remove(at);
172 }
173 }
174 }
175 let answers: Vec<Value> = calls.iter().map(|id| result(id, CLOSED, true)).collect();
176 if answers.is_empty() {
177 } else if message["role"] == "user"
178 && let Some(content) = message.get_mut("content").and_then(Value::as_array_mut)
179 {
180 content.splice(0..0, answers);
181 } else {
182 mended.push(json!({"role": "user", "content": answers}));
183 }
184 }
185 if message["role"] == "assistant" {
186 waiting = calls_of(&mut message);
187 }
188 mended.push(message);
189 }
190 mended
191}
192
193fn calls_of(message: &mut Value) -> Vec<String> {
195 let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) else { return Vec::new() };
196 let mut ids = Vec::new();
197 for block in content.iter_mut().filter(|block| block["type"] == "tool_use") {
198 if !block["input"].is_object() {
199 let kept = match &block["input"] {
200 Value::String(text) => text.clone(),
201 Value::Null => String::new(),
202 other => other.to_string(),
203 };
204 block["input"] = if kept.is_empty() { json!({}) } else { json!({ crate::provider::UNPARSED: kept }) };
205 }
206 ids.push(block["id"].as_str().unwrap_or_default().to_owned());
207 }
208 ids
209}
210
211const CLOSED: &str = "Not done: the program was closed before this ran.";
213
214pub fn blocks(message: &Value) -> impl Iterator<Item = &Value> {
216 message["content"].as_array().into_iter().flatten()
217}
218
219#[must_use]
221pub fn result(id: &str, text: &str, is_error: bool) -> Value {
222 json!({"type": "tool_result", "tool_use_id": id, "content": text, "is_error": is_error})
223}
224
225fn bytes(messages: &[Value]) -> usize {
227 struct Count(usize);
230 impl std::io::Write for Count {
231 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
232 self.0 += buf.len();
233 Ok(buf.len())
234 }
235 fn flush(&mut self) -> std::io::Result<()> {
236 Ok(())
237 }
238 }
239 let mut count = Count(0);
240 serde_json::to_writer(&mut count, messages).map_or(0, |()| count.0)
241}
242
243fn tool_names(messages: &[Value]) -> HashMap<&str, &str> {
246 messages
247 .iter()
248 .flat_map(blocks)
249 .filter(|block| block["type"] == "tool_use")
250 .filter_map(|block| Some((block["id"].as_str()?, block["name"].as_str()?)))
251 .collect()
252}
253
254fn note_for(block: &Value, wanted: &str, names: &HashMap<&str, &str>) -> Option<Value> {
258 if block["type"].as_str()? != wanted {
259 return None;
260 }
261 let (field, what) = match wanted {
262 "tool_result" => {
263 let id = block["tool_use_id"].as_str().unwrap_or_default();
264 ("content", format!("output from {}", names.get(id).copied().unwrap_or("an earlier call")))
265 }
266 "text" => ("text", "earlier text".to_owned()),
267 _ => return None,
268 };
269 let mut note = block.clone();
270 note[field] = Value::String(format!(
271 "(Left out: {} bytes of {what}. The whole of it is in the conversation saved for this folder.)",
272 block[field].as_str()?.len()
273 ));
274 (bytes(std::slice::from_ref(¬e)) < bytes(std::slice::from_ref(block))).then_some(note)
275}
276
277fn shorten(view: &mut [Value], old: &Range<usize>, budget: usize, wanted: &str, names: &HashMap<&str, &str>) -> bool {
280 let mut over = bytes(view).saturating_sub(budget);
281 for message in &mut view[old.clone()] {
282 if over == 0 {
283 return true;
284 }
285 let Some(blocks) = message.get_mut("content").and_then(Value::as_array_mut) else { continue };
286 for block in blocks.iter_mut() {
287 if over == 0 {
288 return true;
289 }
290 let Some(note) = note_for(block, wanted, names) else { continue };
291 over = over.saturating_sub(bytes(std::slice::from_ref(block)) - bytes(std::slice::from_ref(¬e)));
292 *block = note;
293 }
294 }
295 over == 0
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn roles_take_turns_even_after_a_reply_that_said_nothing() {
304 let mut conversation = Conversation::default();
305 conversation.say("first");
306 conversation.reply(Vec::new());
307 conversation.say("second");
308 assert_eq!(conversation.messages().len(), 1, "the two texts share one user message");
309 assert_eq!(conversation.messages()[0]["content"][1]["text"], "second");
310 conversation.reply(vec![json!({"type": "text", "text": "answer"})]);
311 assert_eq!(conversation.messages()[1]["role"], "assistant");
312 }
313
314 #[test]
315 fn open_calls_are_the_last_replys_tool_uses() {
316 let mut conversation = Conversation::default();
317 conversation.say("look");
318 conversation.reply(vec![
319 json!({"type": "text", "text": "Looking."}),
320 json!({"type": "tool_use", "id": "a", "name": "list_dir", "input": {"path": "."}}),
321 json!({"type": "tool_use", "id": "b", "name": "read_file", "input": {"path": "x"}}),
322 ]);
323 let open = conversation.open_calls();
324 assert_eq!(open.iter().map(|call| call.id.as_str()).collect::<Vec<_>>(), ["a", "b"]);
325 assert_eq!(open[1].input, json!({"path": "x"}));
326 conversation.results(vec![result("a", "x/", false), result("b", "text", false)]);
327 assert!(conversation.open_calls().is_empty());
328 }
329
330 #[test]
331 fn a_saved_conversation_comes_back_and_waiting_calls_are_answered() {
332 let mut conversation = Conversation::default();
333 conversation.say("run it");
334 conversation
335 .reply(vec![json!({"type": "tool_use", "id": "r", "name": "run_command", "input": {"command": "ls"}})]);
336 let saved = conversation.to_json();
337 let back = Conversation::from_json(&saved);
338 assert!(back.open_calls().is_empty(), "the waiting call is answered");
339 let last = back.messages().last().expect("a message");
340 assert_eq!(last["content"][0]["tool_use_id"], "r");
341 assert_eq!(last["content"][0]["is_error"], true);
342 assert_eq!(Conversation::from_json(&json!({"messages": [{"role": "system"}, 3]})), Conversation::default());
343 }
344
345 #[test]
346 fn a_message_whose_content_is_not_a_list_is_left_out_and_the_rest_stays() {
347 let back = Conversation::from_json(&json!({"messages": [
348 {"role": "user", "content": [{"type": "text", "text": "kept"}]},
349 {"role": "assistant", "content": 7},
350 {"role": "user", "content": "a plain string"},
351 {"role": "user", "content": [{"type": "text", "text": "kept too"}]},
352 ]}));
353 let texts: Vec<_> = back.messages().iter().map(|message| message["content"][0]["text"].clone()).collect();
354 assert_eq!(texts, [json!("kept"), json!("kept too")], "the two good messages, in order");
355 }
356
357 #[test]
358 fn a_call_with_no_id_gets_one_and_never_panics_on_a_missing_field() {
359 let mut conversation = Conversation::default();
360 conversation.reply(vec![
361 json!({"type": "tool_use", "name": "list_dir", "input": {"path": "."}}),
362 json!({"type": "tool_use", "id": 5, "name": null}),
363 ]);
364 let open = conversation.open_calls();
365 assert_eq!(open.len(), 2, "both blocks are calls to be taken");
366 assert_eq!(open[0].id, "", "a call that names no id gets none");
367 assert_eq!(open[1].name, "", "nor a call that names no tool");
368 assert_eq!(open[1].input, Value::Null, "and no input either");
369
370 let back = Conversation::from_json(&conversation.to_json());
372 let answered: Vec<_> = blocks(back.messages().last().expect("the results"))
373 .map(|block| block["tool_use_id"].as_str().unwrap_or_default().to_owned())
374 .collect();
375 assert_eq!(answered, ["", ""], "both are answered, so the next request is one the API accepts");
376 }
377
378 #[test]
379 fn two_calls_with_one_id_are_answered_one_for_one() {
380 let back = Conversation::from_json(&json!({"messages": [
381 {"role": "assistant", "content": [
382 {"type": "tool_use", "id": "same", "name": "list_dir", "input": {}},
383 {"type": "tool_use", "id": "same", "name": "read_file", "input": {}},
384 ]},
385 ]}));
386 assert!(back.open_calls().is_empty());
387 let answered: Vec<_> = blocks(back.messages().last().expect("the results"))
388 .map(|block| block["tool_use_id"].as_str().unwrap_or_default().to_owned())
389 .collect();
390 assert_eq!(answered, ["same", "same"], "both calls are answered, each in its own place");
391 }
392
393 #[test]
394 fn a_result_of_a_call_that_is_not_there_is_kept_and_the_call_it_left_waiting_is_answered() {
395 let back = Conversation::from_json(&json!({"messages": [
396 {"role": "assistant", "content": [{"type": "tool_use", "id": "here", "name": "list_dir", "input": {}}]},
397 {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "elsewhere", "content": "stray"}]},
398 ]}));
399 assert_eq!(back.messages().len(), 2);
402 let last = back.messages().last().expect("a message");
403 let said: Vec<_> = blocks(last)
404 .map(|block| {
405 (block["tool_use_id"].as_str().unwrap_or_default(), block["content"].as_str().unwrap_or_default())
406 })
407 .collect();
408 assert_eq!(said, [("here", CLOSED), ("elsewhere", "stray")]);
409 }
410
411 #[test]
412 fn a_call_answered_in_the_file_is_not_answered_twice() {
413 let saved = json!({"messages": [
414 {"role": "user", "content": [{"type": "text", "text": "look"}]},
415 {"role": "assistant", "content": [{"type": "tool_use", "id": "a", "name": "list_dir", "input": {}}]},
416 {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "a", "content": "x/"}]},
417 ]});
418 let back = Conversation::from_json(&saved);
419 assert_eq!(
420 back.messages(),
421 saved["messages"].as_array().expect("messages").as_slice(),
422 "a whole thread is kept as it is"
423 );
424 }
425}
426
427#[cfg(test)]
428mod view_tests {
429 use super::*;
430
431 fn long(answer: usize, result: usize, turns: usize) -> Conversation {
434 let mut conversation = Conversation::default();
435 conversation.say("the task");
436 for n in 0..turns {
437 let id = format!("c{n}");
438 conversation.reply(vec![
439 json!({"type": "text", "text": "a".repeat(answer)}),
440 json!({"type": "tool_use", "id": id, "name": "read_file", "input": {"path": "a.rs"}}),
441 ]);
442 conversation.results(vec![result_of(&id, &"x".repeat(result))]);
443 }
444 conversation
445 }
446
447 fn result_of(id: &str, text: &str) -> Value {
449 super::result(id, text, false)
450 }
451
452 fn results(view: &[Value]) -> Vec<String> {
454 view.iter()
455 .flat_map(blocks)
456 .filter(|block| block["type"] == "tool_result")
457 .map(|block| block["content"].as_str().unwrap_or_default().to_owned())
458 .collect()
459 }
460
461 fn left_out(text: &str) -> bool {
463 text.starts_with("(Left out:")
464 }
465
466 #[test]
467 fn a_conversation_under_the_budget_is_sent_whole() {
468 let conversation = long(7, 100, 3);
469 let budget = bytes(conversation.messages());
470 assert_eq!(conversation.view(budget), conversation.messages());
471 assert_eq!(conversation.view(budget + 1), conversation.messages());
472 }
473
474 #[test]
475 fn the_oldest_results_are_left_out_first_and_the_newest_turn_is_whole() {
476 let conversation = long(7, 4_000, 4);
477 let view = conversation.view(5_500);
478 assert!(bytes(&view) <= 5_500, "{} bytes", bytes(&view));
479
480 let sent = results(&view);
481 assert_eq!(sent.len(), 4, "no result is dropped, only shortened");
482 assert!(sent[..3].iter().all(|text| left_out(text)), "the oldest three go first: {sent:?}");
483 assert!(sent[3..].iter().all(|text| text.len() == 4_000), "and what still fits stays whole: {sent:?}");
484
485 let note = &sent[0];
486 assert!(note.starts_with("(Left out: 4000 bytes of output from read_file."), "{note}");
487 assert!(note.ends_with("The whole of it is in the conversation saved for this folder.)"), "{note}");
488 assert_eq!(view.first(), conversation.messages().first(), "the first message is never touched");
489 }
490
491 #[test]
492 fn a_result_the_model_waits_for_keeps_its_answer_and_its_call() {
493 let view = long(7, 4_000, 4).view(1_000);
494 for wanted in ["tool_use", "tool_result"] {
495 let found = view.iter().flat_map(blocks).filter(|block| block["type"] == wanted).count();
496 assert_eq!(found, 4, "every {wanted} is still there");
497 }
498 for call in view.iter().flat_map(blocks).filter(|block| block["type"] == "tool_use") {
499 let id = call["id"].as_str().expect("an id");
500 let answered =
501 view.iter().flat_map(blocks).any(|block| block["type"] == "tool_result" && block["tool_use_id"] == id);
502 assert!(answered, "{id} is still answered");
503 }
504 }
505
506 #[test]
507 fn a_result_too_small_to_gain_from_a_note_is_left_whole() {
508 let conversation = long(7, 20, 3);
509 let sent = results(&conversation.view(0));
510 assert!(sent.iter().all(|text| !left_out(text)), "{sent:?}: a note would be bigger than the result");
511 }
512
513 #[test]
514 fn old_text_is_shortened_when_there_is_nothing_to_gain_in_the_results() {
515 let conversation = long(4_000, 20, 4);
516 let view = conversation.view(5_500);
517 assert!(bytes(&view) <= 5_500, "{} bytes", bytes(&view));
518
519 let answers: Vec<&str> = view
520 .iter()
521 .filter(|message| message["role"] == "assistant")
522 .flat_map(blocks)
523 .filter(|block| block["type"] == "text")
524 .map(|block| block["text"].as_str().unwrap_or_default())
525 .collect();
526 assert_eq!(answers.len(), 4, "no text is dropped, only shortened");
527 assert!(answers[..3].iter().all(|text| left_out(text)), "the oldest three go first: {answers:?}");
528 assert_eq!(answers[3].len(), 4_000, "and the newest answer is whole");
529 for note in &answers[..3] {
530 assert!(note.contains("4000 bytes of earlier text"), "{note}");
531 }
532 }
533
534 #[test]
535 fn a_thinking_block_is_never_touched() {
536 let mut conversation = long(4_000, 20, 3);
537 conversation.reply(vec![json!({"type": "thinking", "thinking": "z".repeat(4_000), "signature": "sig"})]);
538 let view = conversation.view(1_000);
539 assert!(bytes(&view) > 1_000, "what cannot be shortened goes out as it is: {} bytes", bytes(&view));
540 let thinking = view.iter().flat_map(blocks).find(|block| block["type"] == "thinking").expect("the thinking");
541 assert_eq!(thinking["thinking"], json!("z".repeat(4_000)), "it is whole");
542 assert_eq!(thinking["signature"], json!("sig"), "and its signature is not broken");
543 }
544
545 #[test]
546 fn what_cannot_be_shortened_goes_out_whole_even_past_the_budget() {
547 let conversation = long(7, 4_000, 3);
548 let view = conversation.view(500);
549 assert!(bytes(&view) > 500, "the newest result alone is over the budget: {} bytes", bytes(&view));
550 assert_eq!(view.last(), conversation.messages().last());
551 }
552
553 #[test]
554 fn the_stored_conversation_is_never_changed_by_a_view() {
555 let conversation = long(7, 4_000, 4);
556 let kept = conversation.clone();
557 let _ = conversation.view(500);
558 assert_eq!(conversation, kept);
559 }
560}