Skip to main content

qcli/
conversation.rs

1//! A conversation with the model, kept in the Messages shape it is sent in.
2//!
3//! The messages are kept exactly as the API reads them, so sending the conversation is writing
4//! it out and nothing is translated on the way. What the screen shows is read from the same
5//! messages.
6
7use serde_json::{Value, json};
8
9/// A tool call the model made.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Call {
12    /// The id the result must name.
13    pub id: String,
14    /// The tool.
15    pub name: String,
16    /// What it was given.
17    pub input: Value,
18}
19
20/// The messages of one conversation.
21#[derive(Debug, Clone, PartialEq, Default)]
22pub struct Conversation {
23    messages: Vec<Value>,
24}
25
26impl Conversation {
27    /// The messages, oldest first.
28    #[must_use]
29    pub fn messages(&self) -> &[Value] {
30        &self.messages
31    }
32
33    /// Whether nothing has been said yet.
34    #[must_use]
35    pub fn is_empty(&self) -> bool {
36        self.messages.is_empty()
37    }
38
39    /// Adds what the person wrote. Right after another user message (a reply that was stopped
40    /// before it said anything) the text joins that message, since the API wants the roles to
41    /// take turns.
42    pub fn say(&mut self, text: &str) {
43        let block = json!({"type": "text", "text": text});
44        self.push_user(vec![block]);
45    }
46
47    /// Adds the model's reply. Nothing is added for a reply with no content.
48    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    /// Adds the results of tool calls, each a `tool_result` block.
55    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    /// The tool calls of the last message that have no result yet, in the order they were made.
73    #[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    /// The conversation as it is saved.
89    #[must_use]
90    pub fn to_json(&self) -> Value {
91        json!({"version": 1, "messages": self.messages})
92    }
93
94    /// A saved conversation. Anything that is not a message is left out, and calls that were
95    /// waiting when the program closed are answered as not done, so the conversation can go on.
96    #[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
117/// What a call that was waiting when the program closed is answered with.
118const CLOSED: &str = "Not done: the program was closed before this ran.";
119
120/// The blocks of `message`.
121pub fn blocks(message: &Value) -> impl Iterator<Item = &Value> {
122    message["content"].as_array().into_iter().flatten()
123}
124
125/// A `tool_result` block for the call `id`.
126#[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}