Skip to main content

rhema_transcript/
summary.rs

1//! The extractive line text: what the view says about messages it does
2//! not carry whole.
3//!
4//! Everything here is mechanical — heads and counts read off the
5//! messages themselves. No model call belongs to the transcript in v1
6//! (spec §7.4): the lines cost no latency and cannot be wrong about
7//! what happened, only short. A later compactor replaces exactly the
8//! text generators below; the addresses stay.
9
10use rhema_core::{Content, Message, Role};
11
12use crate::tree::Range;
13
14/// Byte cap on one quoted head inside a line.
15pub const HEAD_BYTES: usize = 160;
16
17/// Byte cap on one structural line covering a range.
18pub const RANGE_HEAD_BYTES: usize = 300;
19
20/// The bytes a message costs on the wire: text, tool-call arguments,
21/// and tool-result content, summed. This is the unit the view's budget
22/// spends.
23pub fn message_bytes(message: &Message) -> usize {
24    message
25        .content
26        .iter()
27        .map(|content| match content {
28            Content::Text(text) => text.len(),
29            Content::ToolCall(call) => call.name.len() + call.args.to_string().len(),
30            Content::ToolResult(result) => {
31                result.content.len()
32                    + result
33                        .images
34                        .iter()
35                        .map(|image| image.data.len())
36                        .sum::<usize>()
37            }
38            Content::Image(image) => image.data.len(),
39        })
40        .sum()
41}
42
43/// `text` on one line, at most `cap` bytes, cut on a char boundary
44/// with an ellipsis when longer.
45pub fn head(text: &str, cap: usize) -> String {
46    let flat = one_line(text);
47    if flat.len() <= cap {
48        return flat;
49    }
50    let mut end = cap;
51    while end > 0 && !flat.is_char_boundary(end) {
52        end -= 1;
53    }
54    format!("{}\u{2026}", &flat[..end])
55}
56
57/// Newlines and tab runs become single spaces: a line is one line.
58pub fn one_line(text: &str) -> String {
59    let mut flat = String::with_capacity(text.len());
60    let mut gap = false;
61    for ch in text.chars() {
62        if ch == '\n' || ch == '\r' || ch == '\t' {
63            gap = true;
64        } else {
65            if gap && !flat.is_empty() {
66                flat.push(' ');
67            }
68            gap = false;
69            flat.push(ch);
70        }
71    }
72    flat
73}
74
75/// The role's label in a line: `user`, `assistant`, `system`,
76/// `result`.
77fn role_label(role: Role) -> &'static str {
78    match role {
79        Role::User => "user",
80        Role::Assistant => "assistant",
81        Role::System => "system",
82        Role::Tool => "result",
83    }
84}
85
86/// The aggregate parts of a multi-message range, before addressing.
87/// Also the body of the extractive section digest
88/// ([`crate::compact::section_digest`]).
89pub(crate) fn summarize_range(messages: &[Message], range: Range) -> ((usize, usize), String) {
90    let mut user_heads: Vec<String> = Vec::new();
91    let mut assistant_heads: Vec<String> = Vec::new();
92    let mut calls: Vec<(String, usize)> = Vec::new();
93    let mut results = 0usize;
94    let mut failures = 0usize;
95
96    let push_head = |heads: &mut Vec<String>, text: &str| {
97        if heads.len() < 2 {
98            heads.push(head(text, 80));
99        }
100    };
101    for message in &messages[..range.n.min(messages.len())] {
102        for content in &message.content {
103            match content {
104                Content::Text(text) => {
105                    if message.role == Role::User {
106                        push_head(&mut user_heads, text);
107                    } else if message.role == Role::Assistant {
108                        push_head(&mut assistant_heads, text);
109                    }
110                }
111                Content::ToolCall(call) => {
112                    if let Some(seen) = calls.iter_mut().find(|(name, _)| *name == call.name) {
113                        seen.1 += 1;
114                    } else {
115                        calls.push((call.name.clone(), 1));
116                    }
117                }
118                Content::ToolResult(result) => {
119                    results += 1;
120                    failures += usize::from(result.is_error);
121                }
122                // A head quotes text; an image has none.
123                Content::Image(_) => {}
124            }
125        }
126    }
127
128    let mut parts: Vec<String> = Vec::new();
129    parts.extend(user_heads.iter().map(|h| format!("user: {h}")));
130    if !calls.is_empty() {
131        calls.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
132        let named: Vec<String> = calls
133            .iter()
134            .take(3)
135            .map(|(name, count)| format!("{name}\u{d7}{count}"))
136            .collect();
137        let mut tool_line = if results == 0 {
138            // Calls the range opens but its results land elsewhere:
139            // say what was called, not that nothing answered.
140            format!(
141                "{} tool {} ({})",
142                calls.iter().map(|(_, n)| n).sum::<usize>(),
143                if calls.iter().map(|(_, n)| n).sum::<usize>() == 1 {
144                    "call"
145                } else {
146                    "calls"
147                },
148                named.join(", ")
149            )
150        } else {
151            format!(
152                "{} tool {} ({})",
153                results,
154                if results == 1 { "result" } else { "results" },
155                named.join(", ")
156            )
157        };
158        if calls.len() > 3 {
159            tool_line.push_str(", \u{2026}");
160        }
161        parts.push(tool_line);
162    } else if results > 0 {
163        parts.push(format!(
164            "{} tool {}",
165            results,
166            if results == 1 { "result" } else { "results" }
167        ));
168    }
169    if failures > 0 {
170        parts.push(format!("{failures} failed"));
171    }
172    parts.extend(assistant_heads.iter().map(|h| format!("assistant: {h}")));
173    ((range.id, range.n), parts.join("; "))
174}
175
176/// The whole render of one message, as `zoom(id, 1)` returns it: user
177/// and assistant text verbatim, tool calls and results spelled out.
178/// Uncapped — the zoom pages.
179pub fn render_message(message: &Message) -> String {
180    let mut out = String::new();
181    for content in &message.content {
182        match content {
183            Content::Text(text) => {
184                if !out.is_empty() {
185                    out.push('\n');
186                }
187                out.push_str(text);
188            }
189            Content::ToolCall(call) => {
190                if !out.is_empty() {
191                    out.push('\n');
192                }
193                out.push_str(&format!("[calls {}({})]", call.name, call.args));
194            }
195            Content::ToolResult(result) => {
196                if !out.is_empty() {
197                    out.push('\n');
198                }
199                out.push_str(&format!(
200                    "[{}: {}]",
201                    if result.is_error { "failed" } else { "ok" },
202                    result.content
203                ));
204                for image in &result.images {
205                    out.push_str(&format!("\n[image: {}]", image.media_type.as_str()));
206                }
207            }
208            Content::Image(image) => {
209                if !out.is_empty() {
210                    out.push('\n');
211                }
212                out.push_str(&format!("[image: {}]", image.media_type.as_str()));
213            }
214        }
215    }
216    if out.is_empty() {
217        out.push_str(&format!("({} message, empty)", role_label(message.role)));
218    }
219    out
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use rhema_core::{ToolCall, ToolResult};
226
227    #[test]
228    fn a_results_images_count_and_describe() {
229        let message = Message {
230            role: Role::Tool,
231            content: vec![Content::ToolResult(
232                ToolResult::success("c1", "out").with_images(vec![rhema_core::Image::new(
233                    rhema_core::ImageMediaType::Png,
234                    "cG5n",
235                )]),
236            )],
237        };
238        // "out" plus the base64 data's four bytes.
239        assert_eq!(message_bytes(&message), 7);
240        let rendered = render_message(&message);
241        assert!(rendered.contains("[image: image/png]"), "{rendered}");
242    }
243
244    #[test]
245    fn heads_cut_on_char_boundaries() {
246        // Each 'é' is two bytes; a byte cap of 5 lands mid-char.
247        let text = "ééééé";
248        let h = head(text, 5);
249        // Cap 5 lands mid-char; the cut backs up to the boundary and
250        // the ellipsis rides on top: "éé" + "…".
251        assert_eq!(h, "\u{e9}\u{e9}\u{2026}");
252        assert_eq!(head("short", 10), "short");
253    }
254
255    #[test]
256    fn one_line_flattens_newlines_but_keeps_inner_spaces() {
257        assert_eq!(one_line("a\nb\r\nc\td"), "a b c d");
258        assert_eq!(one_line("\n\nlead"), "lead");
259    }
260
261    #[test]
262    fn bytes_count_text_arguments_and_results() {
263        let call = Message {
264            role: Role::Assistant,
265            content: vec![
266                Content::Text("running".into()),
267                Content::ToolCall(ToolCall {
268                    id: "c1".into(),
269                    name: "bash".into(),
270                    args: serde_json::json!({ "command": "ls" }),
271                    provider_data: serde_json::Value::Null,
272                }),
273            ],
274        };
275        // "running" (7) + "bash" (4) + {"command":"ls"} (16)
276        assert_eq!(message_bytes(&call), 7 + 4 + 16);
277    }
278
279    #[test]
280    fn a_single_message_renders_whole_for_zoom() {
281        let message = Message {
282            role: Role::Assistant,
283            content: vec![
284                Content::Text("step one".into()),
285                Content::ToolCall(ToolCall {
286                    id: "c9".into(),
287                    name: "edit".into(),
288                    args: serde_json::json!({ "path": "a.rs" }),
289                    provider_data: serde_json::Value::Null,
290                }),
291            ],
292        };
293        let out = render_message(&message);
294        assert_eq!(out, "step one\n[calls edit({\"path\":\"a.rs\"})]");
295    }
296}