Skip to main content

wcode_harness/
message.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
4#[serde(tag = "type", rename_all = "snake_case")]
5pub enum ContentBlock {
6    Text {
7        text: String,
8    },
9    Thinking {
10        text: String,
11    },
12    ToolCall {
13        id: String,
14        name: String,
15        arguments: serde_json::Value,
16    },
17}
18
19#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
20#[serde(rename_all = "snake_case")]
21pub struct Usage {
22    pub input_tokens: u64,
23    pub output_tokens: u64,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub cache_read_tokens: Option<u64>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub cache_write_tokens: Option<u64>,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum StopReason {
33    Stop,
34    Length,
35    ToolUse,
36    Aborted,
37    Error,
38    /// Run stopped at the per-run turn cap (`LoopConfig::max_turns`) before the
39    /// model signalled completion — the task may be incomplete, but the history
40    /// is intact and the run can be resumed.
41    MaxTurns,
42}
43
44#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
45#[serde(tag = "role", rename_all = "snake_case")]
46pub enum AgentMessage {
47    User {
48        content: Vec<ContentBlock>,
49    },
50    Assistant {
51        content: Vec<ContentBlock>,
52        stop_reason: StopReason,
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        usage: Option<Usage>,
55        #[serde(default, skip_serializing_if = "Option::is_none")]
56        model: Option<String>,
57    },
58    ToolResult {
59        tool_call_id: String,
60        name: String,
61        output: String,
62        is_error: bool,
63    },
64}
65
66impl AgentMessage {
67    pub fn user_text(s: impl Into<String>) -> AgentMessage {
68        AgentMessage::User {
69            content: vec![ContentBlock::Text { text: s.into() }],
70        }
71    }
72
73    /// A synthetic user message carrying a compaction summary that stands in
74    /// for the summarized prefix of the conversation.
75    pub fn summary(text: impl Into<String>) -> AgentMessage {
76        AgentMessage::User {
77            content: vec![ContentBlock::Text {
78                text: format!(
79                    "Summary of the earlier conversation (auto-generated by compaction):\n\n{}",
80                    text.into()
81                ),
82            }],
83        }
84    }
85
86    pub fn as_text(&self) -> String {
87        let content = match self {
88            AgentMessage::User { content } | AgentMessage::Assistant { content, .. } => content,
89            AgentMessage::ToolResult { .. } => return String::new(),
90        };
91        content
92            .iter()
93            .filter_map(|b| match b {
94                ContentBlock::Text { text } => Some(text.as_str()),
95                _ => None,
96            })
97            .collect::<Vec<_>>()
98            .join("")
99    }
100
101    pub fn tool_calls(&self) -> Vec<&ContentBlock> {
102        match self {
103            AgentMessage::Assistant { content, .. } => content
104                .iter()
105                .filter(|b| matches!(b, ContentBlock::ToolCall { .. }))
106                .collect(),
107            _ => Vec::new(),
108        }
109    }
110}
111
112/// The text of the most recent `Assistant` message, if any — a compact "what it
113/// last said" for a status read (see `crate::protocol::Request::Status`). Joins
114/// the assistant's `Text` blocks with newlines; `None` when there is no assistant
115/// message or its text is blank.
116pub fn last_assistant_text(messages: &[AgentMessage]) -> Option<String> {
117    let message = messages
118        .iter()
119        .rev()
120        .find(|m| matches!(m, AgentMessage::Assistant { .. }))?;
121    let AgentMessage::Assistant { content, .. } = message else {
122        return None;
123    };
124    let text = content
125        .iter()
126        .filter_map(|b| match b {
127            ContentBlock::Text { text } => Some(text.as_str()),
128            _ => None,
129        })
130        .collect::<Vec<_>>()
131        .join("\n");
132    (!text.trim().is_empty()).then_some(text)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use serde_json::json;
139
140    #[test]
141    fn content_block_roundtrip_and_tags() {
142        let blocks = vec![
143            ContentBlock::Text { text: "hi".into() },
144            ContentBlock::Thinking { text: "hmm".into() },
145            ContentBlock::ToolCall {
146                id: "t1".into(),
147                name: "run".into(),
148                arguments: json!({"x": 1}),
149            },
150        ];
151        for b in blocks {
152            let v = serde_json::to_value(&b).unwrap();
153            let back: ContentBlock = serde_json::from_value(v.clone()).unwrap();
154            assert_eq!(back, b);
155            let expected = match &b {
156                ContentBlock::Text { .. } => "text",
157                ContentBlock::Thinking { .. } => "thinking",
158                ContentBlock::ToolCall { .. } => "tool_call",
159            };
160            assert_eq!(v["type"], expected);
161        }
162    }
163
164    #[test]
165    fn usage_roundtrip_and_omits_none() {
166        let u = Usage::default();
167        let v = serde_json::to_value(u).unwrap();
168        assert_eq!(v, json!({"input_tokens": 0, "output_tokens": 0}));
169        let back: Usage = serde_json::from_value(v).unwrap();
170        assert_eq!(back, u);
171
172        let u2 = Usage {
173            input_tokens: 10,
174            output_tokens: 20,
175            cache_read_tokens: Some(5),
176            cache_write_tokens: Some(7),
177        };
178        let v2 = serde_json::to_value(u2).unwrap();
179        assert_eq!(
180            v2,
181            json!({
182                "input_tokens": 10,
183                "output_tokens": 20,
184                "cache_read_tokens": 5,
185                "cache_write_tokens": 7
186            })
187        );
188        assert_eq!(serde_json::from_value::<Usage>(v2).unwrap(), u2);
189    }
190
191    #[test]
192    fn stop_reason_roundtrip_snake_case() {
193        for (r, s) in [
194            (StopReason::Stop, "stop"),
195            (StopReason::Length, "length"),
196            (StopReason::ToolUse, "tool_use"),
197            (StopReason::Aborted, "aborted"),
198            (StopReason::Error, "error"),
199            (StopReason::MaxTurns, "max_turns"),
200        ] {
201            let v = serde_json::to_value(r).unwrap();
202            assert_eq!(v, json!(s));
203            assert_eq!(serde_json::from_value::<StopReason>(v).unwrap(), r);
204        }
205    }
206
207    #[test]
208    fn agent_message_roundtrip_and_role_tags() {
209        let msgs = vec![
210            AgentMessage::User {
211                content: vec![ContentBlock::Text { text: "q".into() }],
212            },
213            AgentMessage::Assistant {
214                content: vec![ContentBlock::Text { text: "a".into() }],
215                stop_reason: StopReason::Stop,
216                usage: Some(Usage::default()),
217                model: Some("m".into()),
218            },
219            AgentMessage::ToolResult {
220                tool_call_id: "t1".into(),
221                name: "run".into(),
222                output: "out".into(),
223                is_error: false,
224            },
225        ];
226        for m in msgs {
227            let v = serde_json::to_value(&m).unwrap();
228            let back: AgentMessage = serde_json::from_value(v.clone()).unwrap();
229            assert_eq!(back, m);
230            let expected = match &m {
231                AgentMessage::User { .. } => "user",
232                AgentMessage::Assistant { .. } => "assistant",
233                AgentMessage::ToolResult { .. } => "tool_result",
234            };
235            assert_eq!(v["role"], expected);
236        }
237    }
238
239    #[test]
240    fn assistant_optional_fields_default_and_skip() {
241        let v = json!({
242            "role": "assistant",
243            "content": [{"type": "text", "text": "a"}],
244            "stop_reason": "stop"
245        });
246        let m: AgentMessage = serde_json::from_value(v).unwrap();
247        match &m {
248            AgentMessage::Assistant {
249                content,
250                usage,
251                model,
252                ..
253            } => {
254                assert_eq!(content.len(), 1);
255                assert_eq!(*usage, None);
256                assert_eq!(*model, None);
257            }
258            _ => panic!("wrong variant"),
259        }
260        let out = serde_json::to_value(&m).unwrap();
261        assert!(out.get("usage").is_none());
262        assert!(out.get("model").is_none());
263    }
264
265    #[test]
266    fn user_text_and_as_text() {
267        let m = AgentMessage::user_text("hello");
268        assert_eq!(
269            m,
270            AgentMessage::User {
271                content: vec![ContentBlock::Text {
272                    text: "hello".into()
273                }]
274            }
275        );
276        assert_eq!(m.as_text(), "hello");
277
278        let m2 = AgentMessage::Assistant {
279            content: vec![
280                ContentBlock::Text { text: "a".into() },
281                ContentBlock::Thinking { text: "t".into() },
282                ContentBlock::Text { text: "b".into() },
283            ],
284            stop_reason: StopReason::Stop,
285            usage: None,
286            model: None,
287        };
288        assert_eq!(m2.as_text(), "ab");
289    }
290
291    #[test]
292    fn tool_calls_returns_assistant_tool_call_blocks() {
293        let m = AgentMessage::Assistant {
294            content: vec![
295                ContentBlock::Text { text: "a".into() },
296                ContentBlock::ToolCall {
297                    id: "t1".into(),
298                    name: "run".into(),
299                    arguments: json!({}),
300                },
301                ContentBlock::ToolCall {
302                    id: "t2".into(),
303                    name: "ls".into(),
304                    arguments: json!({}),
305                },
306            ],
307            stop_reason: StopReason::ToolUse,
308            usage: None,
309            model: None,
310        };
311        assert_eq!(m.tool_calls().len(), 2);
312        assert!(matches!(m.tool_calls()[0], ContentBlock::ToolCall { .. }));
313        assert!(
314            m.tool_calls().iter().all(
315                |b| matches!(b, ContentBlock::ToolCall { id, .. } if id == "t1" || id == "t2")
316            )
317        );
318
319        let u = AgentMessage::user_text("x");
320        assert!(u.tool_calls().is_empty());
321    }
322}