use serde_json::{Value, json};
#[derive(Debug, Clone, PartialEq)]
pub struct Call {
pub id: String,
pub name: String,
pub input: Value,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Conversation {
messages: Vec<Value>,
}
impl Conversation {
#[must_use]
pub fn messages(&self) -> &[Value] {
&self.messages
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
pub fn say(&mut self, text: &str) {
let block = json!({"type": "text", "text": text});
self.push_user(vec![block]);
}
pub fn reply(&mut self, content: Vec<Value>) {
if !content.is_empty() {
self.messages.push(json!({"role": "assistant", "content": Value::Array(content)}));
}
}
pub fn results(&mut self, results: Vec<Value>) {
if !results.is_empty() {
self.push_user(results);
}
}
fn push_user(&mut self, blocks: Vec<Value>) {
if let Some(last) = self.messages.last_mut()
&& last["role"] == "user"
&& let Some(content) = last.get_mut("content").and_then(Value::as_array_mut)
{
content.extend(blocks);
return;
}
self.messages.push(json!({"role": "user", "content": blocks}));
}
#[must_use]
pub fn open_calls(&self) -> Vec<Call> {
let Some(last) = self.messages.last().filter(|message| message["role"] == "assistant") else {
return Vec::new();
};
blocks(last)
.filter(|block| block["type"] == "tool_use")
.map(|block| Call {
id: block["id"].as_str().unwrap_or_default().to_owned(),
name: block["name"].as_str().unwrap_or_default().to_owned(),
input: block.get("input").cloned().unwrap_or(Value::Null),
})
.collect()
}
#[must_use]
pub fn to_json(&self) -> Value {
json!({"version": 1, "messages": self.messages})
}
#[must_use]
pub fn from_json(value: &Value) -> Self {
let messages = value["messages"]
.as_array()
.map(|messages| {
messages
.iter()
.filter(|message| {
matches!(message["role"].as_str(), Some("user" | "assistant")) && message["content"].is_array()
})
.cloned()
.collect()
})
.unwrap_or_default();
let mut conversation = Self { messages };
let open = conversation.open_calls();
conversation.results(open.iter().map(|call| result(&call.id, CLOSED, true)).collect());
conversation
}
}
const CLOSED: &str = "Not done: the program was closed before this ran.";
pub fn blocks(message: &Value) -> impl Iterator<Item = &Value> {
message["content"].as_array().into_iter().flatten()
}
#[must_use]
pub fn result(id: &str, text: &str, is_error: bool) -> Value {
json!({"type": "tool_result", "tool_use_id": id, "content": text, "is_error": is_error})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roles_take_turns_even_after_a_reply_that_said_nothing() {
let mut conversation = Conversation::default();
conversation.say("first");
conversation.reply(Vec::new());
conversation.say("second");
assert_eq!(conversation.messages().len(), 1, "the two texts share one user message");
assert_eq!(conversation.messages()[0]["content"][1]["text"], "second");
conversation.reply(vec![json!({"type": "text", "text": "answer"})]);
assert_eq!(conversation.messages()[1]["role"], "assistant");
}
#[test]
fn open_calls_are_the_last_replys_tool_uses() {
let mut conversation = Conversation::default();
conversation.say("look");
conversation.reply(vec![
json!({"type": "text", "text": "Looking."}),
json!({"type": "tool_use", "id": "a", "name": "list_dir", "input": {"path": "."}}),
json!({"type": "tool_use", "id": "b", "name": "read_file", "input": {"path": "x"}}),
]);
let open = conversation.open_calls();
assert_eq!(open.iter().map(|call| call.id.as_str()).collect::<Vec<_>>(), ["a", "b"]);
assert_eq!(open[1].input, json!({"path": "x"}));
conversation.results(vec![result("a", "x/", false), result("b", "text", false)]);
assert!(conversation.open_calls().is_empty());
}
#[test]
fn a_saved_conversation_comes_back_and_waiting_calls_are_answered() {
let mut conversation = Conversation::default();
conversation.say("run it");
conversation
.reply(vec![json!({"type": "tool_use", "id": "r", "name": "run_command", "input": {"command": "ls"}})]);
let saved = conversation.to_json();
let back = Conversation::from_json(&saved);
assert!(back.open_calls().is_empty(), "the waiting call is answered");
let last = back.messages().last().expect("a message");
assert_eq!(last["content"][0]["tool_use_id"], "r");
assert_eq!(last["content"][0]["is_error"], true);
assert_eq!(Conversation::from_json(&json!({"messages": [{"role": "system"}, 3]})), Conversation::default());
}
}