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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub times: Vec<Option<String>>,
37}
38
39impl DisplayTail {
40 pub fn time(&self, i: usize) -> Option<&str> {
42 self.times.get(i)?.as_deref()
43 }
44}
45
46fn item_times(times: Vec<Option<String>>) -> Vec<Option<String>> {
49 if times.iter().all(Option::is_none) {
50 Vec::new()
51 } else {
52 times
53 }
54}
55
56fn time_of(msg: &serde_json::Value) -> Option<String> {
57 crate::runtime::message_time::message_time(msg).map(str::to_owned)
58}
59
60pub fn is_event_payload(content: &str) -> bool {
63 content.starts_with("<event ") && content.ends_with("</event>")
64}
65
66fn items_of(msg: &serde_json::Value, out: &mut Vec<DisplayItem>) {
68 if let Some(content) = msg["content"].as_str() {
69 if content.contains("<context-summary>") || is_event_payload(content) {
70 return;
71 }
72 }
73 match msg["role"].as_str() {
74 Some("user") => {
75 if let Some(content) = msg["content"].as_str() {
76 out.push(DisplayItem::User { text: content.to_string() });
77 }
78 }
79 Some("assistant") => {
80 if let Some(content) = msg["content"].as_array() {
81 for block in content {
82 match block["type"].as_str() {
83 Some("thinking") => {
84 if let Some(text) = block["thinking"].as_str() {
85 out.push(DisplayItem::Thinking { text: text.to_string() });
86 }
87 }
88 Some("text") => {
89 if let Some(text) = block["text"].as_str() {
90 out.push(DisplayItem::Text { text: text.to_string() });
91 }
92 }
93 Some("tool_use") => {
94 out.push(DisplayItem::ToolUse {
95 tool_id: block["id"].as_str().unwrap_or("").to_string(),
96 tool_name: block["name"].as_str().unwrap_or("").to_string(),
97 input: serde_json::to_string(&block["input"]).unwrap_or_default(),
98 });
99 }
100 _ => {}
101 }
102 }
103 }
104 }
105 _ => {}
106 }
107}
108
109fn count_items_of(msg: &serde_json::Value) -> usize {
113 if let Some(content) = msg["content"].as_str() {
114 if content.contains("<context-summary>") || is_event_payload(content) {
115 return 0;
116 }
117 }
118 match msg["role"].as_str() {
119 Some("user") => usize::from(msg["content"].is_string()),
120 Some("assistant") => msg["content"]
121 .as_array()
122 .map(|blocks| {
123 blocks
124 .iter()
125 .filter(|b| match b["type"].as_str() {
126 Some("thinking") => b["thinking"].is_string(),
127 Some("text") => b["text"].is_string(),
128 Some("tool_use") => true,
129 _ => false,
130 })
131 .count()
132 })
133 .unwrap_or(0),
134 _ => 0,
135 }
136}
137
138pub fn display_items(msgs: &[SharedMessage]) -> Vec<DisplayItem> {
140 let mut out = Vec::new();
141 for m in msgs {
142 items_of(m, &mut out);
143 }
144 out
145}
146
147fn timed_items(msgs: &[SharedMessage]) -> (Vec<DisplayItem>, Vec<Option<String>>) {
149 let mut items = Vec::new();
150 let mut times = Vec::new();
151 for m in msgs {
152 items_of(m, &mut items);
153 if times.len() < items.len() {
154 times.resize(items.len(), time_of(m));
155 }
156 }
157 (items, times)
158}
159
160pub fn display_tail(msgs: &[SharedMessage], max_items: usize) -> DisplayTail {
164 if max_items == 0 {
165 let (items, times) = timed_items(msgs);
166 return DisplayTail { items, omitted: 0, times: item_times(times) };
167 }
168 let mut groups: Vec<(Vec<DisplayItem>, Option<String>)> = Vec::new();
170 let mut held = 0usize;
171 let mut idx = msgs.len();
172 while idx > 0 && held < max_items {
173 idx -= 1;
174 let mut g = Vec::new();
175 items_of(&msgs[idx], &mut g);
176 held += g.len();
177 let at = if g.is_empty() { None } else { time_of(&msgs[idx]) };
178 groups.push((g, at));
179 }
180 let mut omitted: usize = msgs[..idx].iter().map(|m| count_items_of(m)).sum();
182 let mut items: Vec<DisplayItem> = Vec::with_capacity(held);
183 let mut times: Vec<Option<String>> = Vec::with_capacity(held);
184 for (g, at) in groups.into_iter().rev() {
185 times.extend(std::iter::repeat_n(at, g.len()));
186 items.extend(g);
187 }
188 if items.len() > max_items {
189 let extra = items.len() - max_items;
190 items.drain(..extra);
191 times.drain(..extra);
192 omitted += extra;
193 }
194 DisplayTail { items, omitted, times: item_times(times) }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use std::sync::Arc;
201
202 fn user(s: &str) -> SharedMessage {
203 Arc::new(serde_json::json!({"role": "user", "content": s}))
204 }
205 fn assistant(blocks: serde_json::Value) -> SharedMessage {
206 Arc::new(serde_json::json!({"role": "assistant", "content": blocks}))
207 }
208
209 #[test]
210 fn filter_matches_741b6b60() {
211 let msgs = vec![
212 user("hi"),
213 user("<context-summary>x</context-summary>"),
214 user("<event id=\"1\">e</event>"),
215 assistant(serde_json::json!([
216 {"type": "thinking", "thinking": "hm"},
217 {"type": "text", "text": "yo"},
218 {"type": "tool_use", "id": "t1", "name": "bash", "input": {"cmd": "ls"}},
219 {"type": "tool_result", "tool_use_id": "t1", "content": "x"}
220 ])),
221 Arc::new(serde_json::json!({"role": "user", "content": [{"type": "tool_result"}]})),
222 ];
223 let items = display_items(&msgs);
224 assert_eq!(
225 items,
226 vec![
227 DisplayItem::User { text: "hi".into() },
228 DisplayItem::Thinking { text: "hm".into() },
229 DisplayItem::Text { text: "yo".into() },
230 DisplayItem::ToolUse { tool_id: "t1".into(), tool_name: "bash".into(), input: "{\"cmd\":\"ls\"}".into() },
231 ]
232 );
233 }
234
235 #[test]
236 fn count_items_of_matches_items_of() {
237 let msgs: Vec<serde_json::Value> = vec![
238 serde_json::json!({"role": "user", "content": "hi"}),
239 serde_json::json!({"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "x"}]}),
240 serde_json::json!({"role": "user", "content": "<context-summary>s</context-summary>"}),
241 serde_json::json!({"role": "user", "content": "<event id=1>x</event>"}),
242 serde_json::json!({"role": "assistant", "content": "plain string"}),
243 serde_json::json!({"role": "assistant", "content": [
244 {"type": "thinking", "thinking": "t"},
245 {"type": "thinking", "signature": "no-text"},
246 {"type": "text", "text": "a"},
247 {"type": "text"},
248 {"type": "tool_use", "id": "t1", "name": "bash", "input": {"command": "ls"}},
249 {"type": "tool_use"},
250 {"type": "unknown"}
251 ]}),
252 serde_json::json!({"role": "system", "content": "s"}),
253 serde_json::json!({}),
254 ];
255 for m in &msgs {
256 let mut v = Vec::new();
257 items_of(m, &mut v);
258 assert_eq!(count_items_of(m), v.len(), "{m}");
259 }
260 }
261
262 #[test]
263 fn tail_caps_and_counts_omitted() {
264 let msgs: Vec<SharedMessage> = (0..10).map(|i| user(&format!("m{i}"))).collect();
265 let t = display_tail(&msgs, 3);
266 assert_eq!(t.omitted, 7);
267 assert_eq!(t.items.len(), 3);
268 assert_eq!(t.items[0], DisplayItem::User { text: "m7".into() });
269 let full = display_tail(&msgs, 0);
270 assert_eq!(full.omitted, 0);
271 assert_eq!(full.items.len(), 10);
272 let big = display_tail(&msgs, 100);
273 assert_eq!(big.omitted, 0);
274 assert_eq!(big.items.len(), 10);
275 }
276
277 #[test]
278 fn tail_splits_inside_a_multi_block_message() {
279 let msgs = vec![
280 user("a"),
281 assistant(serde_json::json!([{"type": "text", "text": "b"}, {"type": "text", "text": "c"}])),
282 ];
283 let t = display_tail(&msgs, 1);
284 assert_eq!(t.items, vec![DisplayItem::Text { text: "c".into() }]);
285 assert_eq!(t.omitted, 2);
286 let all = display_items(&msgs);
287 assert_eq!(all[all.len() - t.items.len()..], t.items[..]);
288 }
289
290 #[test]
291 fn tail_equals_items_suffix_for_every_cap() {
292 let msgs = vec![
293 user("a"),
294 user("<context-summary>s</context-summary>"),
295 assistant(serde_json::json!([{"type": "thinking", "thinking": "t"}, {"type": "text", "text": "b"}])),
296 user("<event id=\"1\">e</event>"),
297 assistant(serde_json::json!([{"type": "tool_use", "id": "x", "name": "bash", "input": {}}])),
298 user("c"),
299 ];
300 let all = display_items(&msgs);
301 for cap in 1..=all.len() + 2 {
302 let t = display_tail(&msgs, cap);
303 let keep = cap.min(all.len());
304 assert_eq!(t.items[..], all[all.len() - keep..], "cap={cap}");
305 assert_eq!(t.omitted, all.len() - keep, "cap={cap}");
306 }
307 }
308
309 fn timed(mut m: serde_json::Value, ts: &str) -> SharedMessage {
310 m["_synaps_at"] = ts.into();
311 Arc::new(m)
312 }
313
314 #[test]
315 fn tail_carries_each_items_message_time() {
316 let msgs = vec![
317 user("legacy, no time"),
318 timed(serde_json::json!({"role": "user", "content": "q"}), "2026-10-25T05:47:00Z"),
319 timed(
320 serde_json::json!({"role": "assistant", "content": [
321 {"type": "thinking", "thinking": "t"}, {"type": "text", "text": "a"}
322 ]}),
323 "2026-10-25T05:48:00Z",
324 ),
325 timed(serde_json::json!({"role": "user", "content": [{"type": "tool_result"}]}), "2026-10-25T05:49:00Z"),
326 ];
327 let expect = |t: &DisplayTail| {
328 (0..t.items.len()).map(|i| t.time(i).map(str::to_owned)).collect::<Vec<_>>()
329 };
330 let all = display_tail(&msgs, 0);
331 assert_eq!(
332 expect(&all),
333 vec![
334 None,
335 Some("2026-10-25T05:47:00Z".into()),
336 Some("2026-10-25T05:48:00Z".into()),
337 Some("2026-10-25T05:48:00Z".into()),
338 ]
339 );
340 for cap in 1..=5 {
341 let t = display_tail(&msgs, cap);
342 let keep = cap.min(4);
343 assert_eq!(expect(&t), expect(&all)[4 - keep..], "cap={cap}");
344 }
345 let legacy = display_tail(&[user("a"), user("b")], 0);
347 assert!(legacy.times.is_empty());
348 let wire = serde_json::to_value(&legacy).unwrap();
349 assert!(wire.get("times").is_none(), "{wire}");
350 let old: DisplayTail = serde_json::from_value(serde_json::json!({
351 "items": [{"kind": "user", "text": "a"}], "omitted": 0
352 }))
353 .unwrap();
354 assert_eq!(old.time(0), None);
355 let round: DisplayTail = serde_json::from_value(serde_json::to_value(&all).unwrap()).unwrap();
356 assert_eq!(round, all);
357 }
358}