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}
32
33/// True iff `content` is a canonical agent-event payload
34/// (`format_event_for_agent`): `<event id=… …>…</event>`.
35pub fn is_event_payload(content: &str) -> bool {
36    content.starts_with("<event ") && content.ends_with("</event>")
37}
38
39/// Display items of ONE api message, in order. Empty for skipped messages.
40fn 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
82/// `items_of(msg).len()` without building the items: the same skip rules,
83/// no string clones (M4 — the daemon runs this over the whole history on
84/// every attach/compaction/resync).
85fn 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
111/// Every display item of `msgs` (no cap).
112pub 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
120/// The last `max_items` display items (`0` = unbounded). Walks from the back
121/// and stops once the cap is met; the head is only counted (no allocation),
122/// so a 20 MB history costs O(tail) allocations + one O(n) scan.
123pub 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    // Per-message item groups, newest first, until we hold >= max_items.
128    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    // Items before `idx` were never projected: count them without cloning.
139    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}