1use serde_json::{Value, json};
8
9#[derive(Debug, Clone, PartialEq)]
11pub struct Call {
12 pub id: String,
14 pub name: String,
16 pub input: Value,
18}
19
20#[derive(Debug, Clone, PartialEq, Default)]
22pub struct Conversation {
23 messages: Vec<Value>,
24}
25
26impl Conversation {
27 #[must_use]
29 pub fn messages(&self) -> &[Value] {
30 &self.messages
31 }
32
33 #[must_use]
35 pub fn is_empty(&self) -> bool {
36 self.messages.is_empty()
37 }
38
39 pub fn say(&mut self, text: &str) {
43 let block = json!({"type": "text", "text": text});
44 self.push_user(vec![block]);
45 }
46
47 pub fn reply(&mut self, content: Vec<Value>) {
49 if !content.is_empty() {
50 self.messages.push(json!({"role": "assistant", "content": Value::Array(content)}));
51 }
52 }
53
54 pub fn results(&mut self, results: Vec<Value>) {
56 if !results.is_empty() {
57 self.push_user(results);
58 }
59 }
60
61 fn push_user(&mut self, blocks: Vec<Value>) {
62 if let Some(last) = self.messages.last_mut()
63 && last["role"] == "user"
64 && let Some(content) = last.get_mut("content").and_then(Value::as_array_mut)
65 {
66 content.extend(blocks);
67 return;
68 }
69 self.messages.push(json!({"role": "user", "content": blocks}));
70 }
71
72 #[must_use]
74 pub fn open_calls(&self) -> Vec<Call> {
75 let Some(last) = self.messages.last().filter(|message| message["role"] == "assistant") else {
76 return Vec::new();
77 };
78 blocks(last)
79 .filter(|block| block["type"] == "tool_use")
80 .map(|block| Call {
81 id: block["id"].as_str().unwrap_or_default().to_owned(),
82 name: block["name"].as_str().unwrap_or_default().to_owned(),
83 input: block.get("input").cloned().unwrap_or(Value::Null),
84 })
85 .collect()
86 }
87
88 #[must_use]
90 pub fn to_json(&self) -> Value {
91 json!({"version": 1, "messages": self.messages})
92 }
93
94 #[must_use]
97 pub fn from_json(value: &Value) -> Self {
98 let messages = value["messages"]
99 .as_array()
100 .map(|messages| {
101 messages
102 .iter()
103 .filter(|message| {
104 matches!(message["role"].as_str(), Some("user" | "assistant")) && message["content"].is_array()
105 })
106 .cloned()
107 .collect()
108 })
109 .unwrap_or_default();
110 let mut conversation = Self { messages };
111 let open = conversation.open_calls();
112 conversation.results(open.iter().map(|call| result(&call.id, CLOSED, true)).collect());
113 conversation
114 }
115}
116
117const CLOSED: &str = "Not done: the program was closed before this ran.";
119
120pub fn blocks(message: &Value) -> impl Iterator<Item = &Value> {
122 message["content"].as_array().into_iter().flatten()
123}
124
125#[must_use]
127pub fn result(id: &str, text: &str, is_error: bool) -> Value {
128 json!({"type": "tool_result", "tool_use_id": id, "content": text, "is_error": is_error})
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn roles_take_turns_even_after_a_reply_that_said_nothing() {
137 let mut conversation = Conversation::default();
138 conversation.say("first");
139 conversation.reply(Vec::new());
140 conversation.say("second");
141 assert_eq!(conversation.messages().len(), 1, "the two texts share one user message");
142 assert_eq!(conversation.messages()[0]["content"][1]["text"], "second");
143 conversation.reply(vec![json!({"type": "text", "text": "answer"})]);
144 assert_eq!(conversation.messages()[1]["role"], "assistant");
145 }
146
147 #[test]
148 fn open_calls_are_the_last_replys_tool_uses() {
149 let mut conversation = Conversation::default();
150 conversation.say("look");
151 conversation.reply(vec![
152 json!({"type": "text", "text": "Looking."}),
153 json!({"type": "tool_use", "id": "a", "name": "list_dir", "input": {"path": "."}}),
154 json!({"type": "tool_use", "id": "b", "name": "read_file", "input": {"path": "x"}}),
155 ]);
156 let open = conversation.open_calls();
157 assert_eq!(open.iter().map(|call| call.id.as_str()).collect::<Vec<_>>(), ["a", "b"]);
158 assert_eq!(open[1].input, json!({"path": "x"}));
159 conversation.results(vec![result("a", "x/", false), result("b", "text", false)]);
160 assert!(conversation.open_calls().is_empty());
161 }
162
163 #[test]
164 fn a_saved_conversation_comes_back_and_waiting_calls_are_answered() {
165 let mut conversation = Conversation::default();
166 conversation.say("run it");
167 conversation
168 .reply(vec![json!({"type": "tool_use", "id": "r", "name": "run_command", "input": {"command": "ls"}})]);
169 let saved = conversation.to_json();
170 let back = Conversation::from_json(&saved);
171 assert!(back.open_calls().is_empty(), "the waiting call is answered");
172 let last = back.messages().last().expect("a message");
173 assert_eq!(last["content"][0]["tool_use_id"], "r");
174 assert_eq!(last["content"][0]["is_error"], true);
175 assert_eq!(Conversation::from_json(&json!({"messages": [{"role": "system"}, 3]})), Conversation::default());
176 }
177}