Skip to main content

agent_engine/session/
display.rs

1//! Daemon-side display projection of `api_messages` (PLAN-phase4 §2.3).
2//!
3//! EXACTLY the filter the TUI's `rebuild_display_messages` applied at
4//! 741b6b60 (helpers.rs:170-224): skip `<context-summary>` messages, skip
5//! `<event …>…</event>` payloads, project user text and assistant
6//! thinking/text/tool_use blocks. Both `LocalTransport` and Digest socket
7//! clients build their transcript from this, so they cannot drift.
8//!
9//! No TUI types here — this crate never depends on agent-tui.
10
11use 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/// The last `items.len()` display items of a history; `omitted` = how many
25/// display items were dropped from the front to fit the cap.
26#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
27pub struct DisplayTail {
28    pub items: Vec<DisplayItem>,
29    #[serde(default)]
30    pub omitted: usize,
31    /// When each item's message happened (RFC 3339 UTC, the message's
32    /// `_synaps_at`), parallel to `items`. Empty when no item has a time
33    /// (legacy history, older daemons); `None` for an item without one.
34    /// Clients use [`DisplayTail::time`], never index it directly.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub times: Vec<Option<String>>,
37}
38
39impl DisplayTail {
40    /// The recorded time of `items[i]`, if any.
41    pub fn time(&self, i: usize) -> Option<&str> {
42        self.times.get(i)?.as_deref()
43    }
44}
45
46/// `times` for items projected from messages: one entry per item, dropped
47/// entirely when none has a time.
48fn 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
60/// True iff `content` is a canonical agent-event payload
61/// (`format_event_for_agent`): `<event id=… …>…</event>`.
62pub fn is_event_payload(content: &str) -> bool {
63    content.starts_with("<event ") && content.ends_with("</event>")
64}
65
66/// Display items of ONE api message, in order. Empty for skipped messages.
67fn 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
109/// `items_of(msg).len()` without building the items: the same skip rules,
110/// no string clones (M4 — the daemon runs this over the whole history on
111/// every attach/compaction/resync).
112fn 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
138/// Every display item of `msgs` (no cap).
139pub 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
147/// Every display item of `msgs` with its message's time.
148fn 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
160/// The last `max_items` display items (`0` = unbounded). Walks from the back
161/// and stops once the cap is met; the head is only counted (no allocation),
162/// so a 20 MB history costs O(tail) allocations + one O(n) scan.
163pub 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    // Per-message item groups, newest first, until we hold >= max_items.
169    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    // Items before `idx` were never projected: count them without cloning.
181    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        // Wire: absent when nothing has a time, and old payloads still parse.
346        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}