1use serde::{Deserialize, Serialize};
12
13use crate::SharedMessage;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum DisplayItem {
18 User { text: String },
19 Thinking { text: String },
20 Text { text: String },
21 ToolUse { tool_id: String, tool_name: String, input: String },
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
27pub struct DisplayTail {
28 pub items: Vec<DisplayItem>,
29 #[serde(default)]
30 pub omitted: usize,
31}
32
33pub fn is_event_payload(content: &str) -> bool {
36 content.starts_with("<event ") && content.ends_with("</event>")
37}
38
39fn items_of(msg: &serde_json::Value, out: &mut Vec<DisplayItem>) {
41 if let Some(content) = msg["content"].as_str() {
42 if content.contains("<context-summary>") || is_event_payload(content) {
43 return;
44 }
45 }
46 match msg["role"].as_str() {
47 Some("user") => {
48 if let Some(content) = msg["content"].as_str() {
49 out.push(DisplayItem::User { text: content.to_string() });
50 }
51 }
52 Some("assistant") => {
53 if let Some(content) = msg["content"].as_array() {
54 for block in content {
55 match block["type"].as_str() {
56 Some("thinking") => {
57 if let Some(text) = block["thinking"].as_str() {
58 out.push(DisplayItem::Thinking { text: text.to_string() });
59 }
60 }
61 Some("text") => {
62 if let Some(text) = block["text"].as_str() {
63 out.push(DisplayItem::Text { text: text.to_string() });
64 }
65 }
66 Some("tool_use") => {
67 out.push(DisplayItem::ToolUse {
68 tool_id: block["id"].as_str().unwrap_or("").to_string(),
69 tool_name: block["name"].as_str().unwrap_or("").to_string(),
70 input: serde_json::to_string(&block["input"]).unwrap_or_default(),
71 });
72 }
73 _ => {}
74 }
75 }
76 }
77 }
78 _ => {}
79 }
80}
81
82fn count_items_of(msg: &serde_json::Value) -> usize {
86 if let Some(content) = msg["content"].as_str() {
87 if content.contains("<context-summary>") || is_event_payload(content) {
88 return 0;
89 }
90 }
91 match msg["role"].as_str() {
92 Some("user") => usize::from(msg["content"].is_string()),
93 Some("assistant") => msg["content"]
94 .as_array()
95 .map(|blocks| {
96 blocks
97 .iter()
98 .filter(|b| match b["type"].as_str() {
99 Some("thinking") => b["thinking"].is_string(),
100 Some("text") => b["text"].is_string(),
101 Some("tool_use") => true,
102 _ => false,
103 })
104 .count()
105 })
106 .unwrap_or(0),
107 _ => 0,
108 }
109}
110
111pub fn display_items(msgs: &[SharedMessage]) -> Vec<DisplayItem> {
113 let mut out = Vec::new();
114 for m in msgs {
115 items_of(m, &mut out);
116 }
117 out
118}
119
120pub fn display_tail(msgs: &[SharedMessage], max_items: usize) -> DisplayTail {
124 if max_items == 0 {
125 return DisplayTail { items: display_items(msgs), omitted: 0 };
126 }
127 let mut groups: Vec<Vec<DisplayItem>> = Vec::new();
129 let mut held = 0usize;
130 let mut idx = msgs.len();
131 while idx > 0 && held < max_items {
132 idx -= 1;
133 let mut g = Vec::new();
134 items_of(&msgs[idx], &mut g);
135 held += g.len();
136 groups.push(g);
137 }
138 let mut omitted: usize = msgs[..idx].iter().map(|m| count_items_of(m)).sum();
140 let mut items: Vec<DisplayItem> = groups.into_iter().rev().flatten().collect();
141 if items.len() > max_items {
142 let extra = items.len() - max_items;
143 items.drain(..extra);
144 omitted += extra;
145 }
146 DisplayTail { items, omitted }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use std::sync::Arc;
153
154 fn user(s: &str) -> SharedMessage {
155 Arc::new(serde_json::json!({"role": "user", "content": s}))
156 }
157 fn assistant(blocks: serde_json::Value) -> SharedMessage {
158 Arc::new(serde_json::json!({"role": "assistant", "content": blocks}))
159 }
160
161 #[test]
162 fn filter_matches_741b6b60() {
163 let msgs = vec![
164 user("hi"),
165 user("<context-summary>x</context-summary>"),
166 user("<event id=\"1\">e</event>"),
167 assistant(serde_json::json!([
168 {"type": "thinking", "thinking": "hm"},
169 {"type": "text", "text": "yo"},
170 {"type": "tool_use", "id": "t1", "name": "bash", "input": {"cmd": "ls"}},
171 {"type": "tool_result", "tool_use_id": "t1", "content": "x"}
172 ])),
173 Arc::new(serde_json::json!({"role": "user", "content": [{"type": "tool_result"}]})),
174 ];
175 let items = display_items(&msgs);
176 assert_eq!(
177 items,
178 vec![
179 DisplayItem::User { text: "hi".into() },
180 DisplayItem::Thinking { text: "hm".into() },
181 DisplayItem::Text { text: "yo".into() },
182 DisplayItem::ToolUse { tool_id: "t1".into(), tool_name: "bash".into(), input: "{\"cmd\":\"ls\"}".into() },
183 ]
184 );
185 }
186
187 #[test]
188 fn count_items_of_matches_items_of() {
189 let msgs: Vec<serde_json::Value> = vec![
190 serde_json::json!({"role": "user", "content": "hi"}),
191 serde_json::json!({"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "x"}]}),
192 serde_json::json!({"role": "user", "content": "<context-summary>s</context-summary>"}),
193 serde_json::json!({"role": "user", "content": "<event id=1>x</event>"}),
194 serde_json::json!({"role": "assistant", "content": "plain string"}),
195 serde_json::json!({"role": "assistant", "content": [
196 {"type": "thinking", "thinking": "t"},
197 {"type": "thinking", "signature": "no-text"},
198 {"type": "text", "text": "a"},
199 {"type": "text"},
200 {"type": "tool_use", "id": "t1", "name": "bash", "input": {"command": "ls"}},
201 {"type": "tool_use"},
202 {"type": "unknown"}
203 ]}),
204 serde_json::json!({"role": "system", "content": "s"}),
205 serde_json::json!({}),
206 ];
207 for m in &msgs {
208 let mut v = Vec::new();
209 items_of(m, &mut v);
210 assert_eq!(count_items_of(m), v.len(), "{m}");
211 }
212 }
213
214 #[test]
215 fn tail_caps_and_counts_omitted() {
216 let msgs: Vec<SharedMessage> = (0..10).map(|i| user(&format!("m{i}"))).collect();
217 let t = display_tail(&msgs, 3);
218 assert_eq!(t.omitted, 7);
219 assert_eq!(t.items.len(), 3);
220 assert_eq!(t.items[0], DisplayItem::User { text: "m7".into() });
221 let full = display_tail(&msgs, 0);
222 assert_eq!(full.omitted, 0);
223 assert_eq!(full.items.len(), 10);
224 let big = display_tail(&msgs, 100);
225 assert_eq!(big.omitted, 0);
226 assert_eq!(big.items.len(), 10);
227 }
228
229 #[test]
230 fn tail_splits_inside_a_multi_block_message() {
231 let msgs = vec![
232 user("a"),
233 assistant(serde_json::json!([{"type": "text", "text": "b"}, {"type": "text", "text": "c"}])),
234 ];
235 let t = display_tail(&msgs, 1);
236 assert_eq!(t.items, vec![DisplayItem::Text { text: "c".into() }]);
237 assert_eq!(t.omitted, 2);
238 let all = display_items(&msgs);
239 assert_eq!(all[all.len() - t.items.len()..], t.items[..]);
240 }
241
242 #[test]
243 fn tail_equals_items_suffix_for_every_cap() {
244 let msgs = vec![
245 user("a"),
246 user("<context-summary>s</context-summary>"),
247 assistant(serde_json::json!([{"type": "thinking", "thinking": "t"}, {"type": "text", "text": "b"}])),
248 user("<event id=\"1\">e</event>"),
249 assistant(serde_json::json!([{"type": "tool_use", "id": "x", "name": "bash", "input": {}}])),
250 user("c"),
251 ];
252 let all = display_items(&msgs);
253 for cap in 1..=all.len() + 2 {
254 let t = display_tail(&msgs, cap);
255 let keep = cap.min(all.len());
256 assert_eq!(t.items[..], all[all.len() - keep..], "cap={cap}");
257 assert_eq!(t.omitted, all.len() - keep, "cap={cap}");
258 }
259 }
260}