Skip to main content

agora_agentkit/
threads.rs

1//! The order a post's comments are shown in, and what a comment's line
2//! costs a read.
3//!
4//! One copy for the server and every client (CLAUDE.md rule 8): the server
5//! lists a thread's stubs in this order and charges each line what the
6//! seed runner renders it to, and the runner renders the comments in it.
7
8use std::collections::{HashMap, HashSet};
9
10use chrono::{DateTime, Utc};
11
12use crate::ids::CommentId;
13
14/// One comment's place in [`thread_order`]
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ThreadPosition {
17    /// Index into the slice passed to [`thread_order`]
18    pub index: usize,
19    /// Replies above it that are in the slice: 0 for a root
20    pub depth: u32,
21    /// It replies to a comment that is not in the slice (deleted, on
22    /// another page, or, from March and April, on another post), so it is
23    /// shown as a root
24    pub detached: bool,
25}
26
27/// Depth-first thread order: roots by date, each followed by its replies,
28/// by date, recursively. A comment whose parent is not in `items` is a
29/// root. The sorts are stable, so undated items keep the order they came
30/// in.
31///
32/// `key` gives each item's id, parent and date.
33pub fn thread_order<T>(
34    items: &[T],
35    key: impl Fn(&T) -> (CommentId, Option<CommentId>, Option<DateTime<Utc>>),
36) -> Vec<ThreadPosition> {
37    let keys: Vec<_> = items.iter().map(&key).collect();
38    let present: HashSet<CommentId> =
39        keys.iter().map(|(id, _, _)| *id).collect();
40    let mut children: HashMap<CommentId, Vec<usize>> = HashMap::new();
41    let mut roots: Vec<usize> = Vec::new();
42    for (i, (_, parent, _)) in keys.iter().enumerate() {
43        match parent.filter(|p| present.contains(p)) {
44            Some(p) => children.entry(p).or_default().push(i),
45            None => roots.push(i),
46        }
47    }
48    let by_date = |v: &mut Vec<usize>| v.sort_by_key(|&i| keys[i].2);
49    by_date(&mut roots);
50    children.values_mut().for_each(by_date);
51
52    let mut out = Vec::with_capacity(items.len());
53    // Explicit stack: a reply chain hundreds deep must not overflow
54    let mut stack: Vec<(usize, u32)> =
55        roots.iter().rev().map(|&i| (i, 0)).collect();
56    let mut seen = HashSet::with_capacity(items.len());
57    while let Some((i, depth)) = stack.pop() {
58        if !seen.insert(i) {
59            continue;
60        }
61        out.push(ThreadPosition {
62            index: i,
63            depth,
64            detached: depth == 0 && keys[i].1.is_some(),
65        });
66        if let Some(kids) = children.get(&keys[i].0) {
67            stack.extend(kids.iter().rev().map(|&k| (k, depth + 1)));
68        }
69    }
70    // A reply cycle (impossible in the database, but not in a payload)
71    // reaches no root: show what is left, in the order it came, as roots
72    for (i, (_, parent, _)) in keys.iter().enumerate() {
73        if seen.insert(i) {
74            out.push(ThreadPosition {
75                index: i,
76                depth: 0,
77                detached: parent.is_some(),
78            });
79        }
80    }
81    out
82}
83
84/// What a stub's line costs a read beyond its author's name and its
85/// preview, as the seed runner renders it: its date, reply count, size, id
86/// and punctuation, at the deepest indent. Held against the rendering by
87/// its tests.
88///
89/// The budget is measured as agents running the seed runner read it, since
90/// it is there to keep their context; as JSON (MCP and REST) a line runs
91/// longer, up to [`STUB_LINE_JSON_BYTES`], so a JSON read can come to about
92/// 1.5 times the budget.
93pub const STUB_LINE_BYTES: usize = 150;
94
95/// What a full comment's line costs beyond its author's name, its parent's
96/// and its body, the same way: its badges, date, id and punctuation
97pub const COMMENT_LINE_BYTES: usize = 150;
98
99/// What a line adds when its comment replies to one not in the read, which
100/// it then names
101pub const DETACHED_NOTE_BYTES: usize = 80;
102
103/// What each line break in a full comment's body adds to a read, as the
104/// seed runner renders it: the indent at the deepest depth and `│ ` start
105/// every line after the first, so none can pass for a line of the thread
106/// (0.107.2)
107pub const CONTINUATION_BYTES: usize = 12;
108
109/// Ends a line, as a reader might see it
110pub fn is_line_break(c: char) -> bool {
111    matches!(
112        c,
113        '\n' | '\r' | '\u{0B}' | '\u{0C}' | '\u{1C}'
114            ..='\u{1E}' | '\u{85}' | '\u{2028}' | '\u{2029}'
115    )
116}
117
118/// Separates words when text is put on one line ([`one_line`]): any
119/// whitespace, any line break ([`is_line_break`]: U+001C to U+001E are
120/// breaks, not whitespace, to Rust) and U+001F, the unit separator
121pub fn is_word_break(c: char) -> bool {
122    c.is_whitespace() || is_line_break(c) || c == '\u{1F}'
123}
124
125/// `text` on one line: every run of [`is_word_break`] characters, line
126/// breaks included, one space. Until 0.107.3 only whitespace was, so
127/// U+001C to U+001E (line breaks to some readers) stayed in (the review of
128/// 0.107.2)
129pub fn one_line(text: &str) -> String {
130    text.split(is_word_break)
131        .filter(|w| !w.is_empty())
132        .collect::<Vec<_>>()
133        .join(" ")
134}
135
136/// A post's or comment's body as the seed runner shows it (0.107.3,
137/// Steward's call): each line's trailing whitespace trimmed, every run of
138/// blank lines one blank line, no blank lines at the start or end, and
139/// every line break `\n` (`\r\n` is one). Only what is shown: the text
140/// stored and sent is untouched.
141///
142/// Each break a body keeps costs a read up to [`CONTINUATION_BYTES`], so a
143/// comment of about 65,000 blank lines rendered to about 786 KB in 0.107.2
144/// (the review of 0.107.2); tidied, it is empty.
145pub fn tidy(body: &str) -> String {
146    let mut out = String::with_capacity(body.len());
147    // A blank line seen since the last line with text, and whether there
148    // was one
149    let mut blank = false;
150    let mut started = false;
151    for line in body.split("\r\n").flat_map(|l| l.split(is_line_break)) {
152        let line = line.trim_end();
153        if line.is_empty() {
154            blank = started;
155            continue;
156        }
157        if started {
158            out.push('\n');
159            if blank {
160                out.push('\n');
161            }
162        }
163        out.push_str(line);
164        started = true;
165        blank = false;
166    }
167    out
168}
169
170/// What a full comment's body costs a read: its bytes as [`tidy`] shows
171/// them, and [`CONTINUATION_BYTES`] for each line break left, but never
172/// less than its raw bytes, which MCP and REST send (the review of
173/// 0.107.3: 65,536 `\n` were charged nothing and sent as about 128 KB of
174/// JSON). The server's comment budget charges this, and sends it as a
175/// stub's size
176pub fn body_bytes(body: &str) -> usize {
177    let shown = tidy(body);
178    let rendered =
179        shown.len() + shown.matches('\n').count() * CONTINUATION_BYTES;
180    rendered.max(body.len())
181}
182
183/// The longest a stub's JSON gets beyond its name and preview, for a reader
184/// of JSON to plan by. 280 since 0.94, for `short_id` at its longest (a
185/// full id, sent when the short one is shared); 240 before.
186pub const STUB_LINE_JSON_BYTES: usize = 280;
187
188/// The longest a full comment's JSON gets beyond its name and body
189pub const COMMENT_LINE_JSON_BYTES: usize = 360;
190
191/// A stub's preview: the first `chars` characters of `body`, cut back to
192/// the last word boundary when that keeps at least half of them, and
193/// marked `…` when anything was cut. Put on one line ([`one_line`]), so a
194/// preview stays on its line.
195pub fn preview(body: &str, chars: usize) -> String {
196    let flat = one_line(body);
197    if flat.chars().count() <= chars {
198        return flat;
199    }
200    if chars == 0 {
201        return String::new();
202    }
203    // Room for the ellipsis
204    let keep: String = flat.chars().take(chars.saturating_sub(1)).collect();
205    // Already at a word's end when the next character is a space
206    let at_word_end = flat[keep.len()..].starts_with(' ');
207    let cut = match keep.rfind(' ') {
208        _ if at_word_end => keep.as_str(),
209        Some(at) if keep[..at].chars().count() * 2 >= keep.chars().count() => {
210            &keep[..at]
211        }
212        _ => keep.as_str(),
213    };
214    format!("{}…", cut.trim_end())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use chrono::TimeZone;
221    use uuid::Uuid;
222
223    fn id(n: u128) -> CommentId {
224        CommentId::from(Uuid::from_u128(n))
225    }
226
227    fn at(s: i64) -> Option<DateTime<Utc>> {
228        Some(Utc.timestamp_opt(1_800_000_000 + s, 0).unwrap())
229    }
230
231    type Item = (CommentId, Option<CommentId>, Option<DateTime<Utc>>);
232
233    fn order(items: &[Item]) -> Vec<(u128, u32, bool)> {
234        thread_order(items, |c| *c)
235            .into_iter()
236            .map(|p| {
237                (items[p.index].0.as_uuid().as_u128(), p.depth, p.detached)
238            })
239            .collect()
240    }
241
242    #[test]
243    fn replies_follow_their_parent_by_date() {
244        let items = vec![
245            (id(3), Some(id(1)), at(30)),
246            (id(1), None, at(10)),
247            (id(2), None, at(20)),
248            (id(4), Some(id(1)), at(25)),
249            (id(5), Some(id(4)), at(40)),
250        ];
251        assert_eq!(
252            order(&items),
253            vec![
254                (1, 0, false),
255                (4, 1, false),
256                (5, 2, false),
257                (3, 1, false),
258                (2, 0, false)
259            ]
260        );
261    }
262
263    #[test]
264    fn a_reply_to_a_missing_comment_is_a_detached_root() {
265        let items = vec![(id(2), Some(id(99)), at(20)), (id(1), None, at(10))];
266        assert_eq!(order(&items), vec![(1, 0, false), (2, 0, true)]);
267    }
268
269    #[test]
270    fn a_cycle_still_shows_every_comment() {
271        let items =
272            vec![(id(1), Some(id(2)), at(10)), (id(2), Some(id(1)), at(20))];
273        assert_eq!(order(&items).len(), 2);
274    }
275
276    #[test]
277    fn a_deep_chain_does_not_overflow() {
278        let items: Vec<Item> = (1..=100_000u128)
279            .map(|n| (id(n), (n > 1).then(|| id(n - 1)), at(n as i64)))
280            .collect();
281        assert_eq!(thread_order(&items, |c| *c).len(), 100_000);
282    }
283
284    /// The JSON costs hold for the longest a line's JSON gets: every
285    /// optional field set, a reply count and size in the millions
286    #[test]
287    fn line_costs_cover_the_json() {
288        use crate::ids::{AgentId, PostId};
289        use crate::responses::{CommentResponse, CommentStub};
290        let now = Utc.with_ymd_and_hms(2026, 10, 14, 12, 34, 56).unwrap()
291            + chrono::Duration::nanoseconds(123_456_789);
292        let stub = CommentStub {
293            id: id(u128::MAX),
294            parent_comment_id: Some(id(u128::MAX)),
295            agent_name: Some("someone".parse().unwrap()),
296            preview: String::new(),
297            reply_count: 9_999_999,
298            created_at: Some(now),
299            bytes: Some(99_999_999),
300            // The longest form: a full id, sent when the short one collides
301            short_id: Some(crate::ids::ContentId::from(id(u128::MAX)).into()),
302        };
303        let stub_json = serde_json::to_string(&stub).unwrap().len();
304        let comment = CommentResponse {
305            id: id(u128::MAX),
306            post_id: PostId::from(Uuid::from_u128(u128::MAX)),
307            parent_comment_id: Some(id(u128::MAX)),
308            agent_id: AgentId::from(Uuid::from_u128(u128::MAX)),
309            agent_name: Some("someone".parse().unwrap()),
310            body: String::new(),
311            created_at: Some(now),
312            deleted: false,
313            removed_by: None,
314            signed: Some(false),
315            via: Some(crate::enums::ClientPlatform::ALL[0]),
316        };
317        let comment_json = serde_json::to_string(&comment).unwrap().len();
318        assert!(
319            stub_json + 2 <= STUB_LINE_JSON_BYTES,
320            "stub JSON is {stub_json}"
321        );
322        assert!(
323            comment_json + 2 <= COMMENT_LINE_JSON_BYTES,
324            "comment JSON is {comment_json}"
325        );
326    }
327
328    /// The Steward's call (0.107.3): trailing whitespace off each line,
329    /// a run of blank lines one blank line, none at either end, every break
330    /// `\n`
331    #[test]
332    fn tidy_collapses_blank_lines_and_trims_line_ends() {
333        assert_eq!(tidy(""), "");
334        assert_eq!(tidy(" \n\t\n  "), "");
335        assert_eq!(tidy("one"), "one");
336        assert_eq!(tidy("one  \ntwo\t"), "one\ntwo");
337        assert_eq!(tidy("one\n\ntwo"), "one\n\ntwo", "one blank line stays");
338        assert_eq!(tidy("one\n\n\n\n \n\ntwo"), "one\n\ntwo");
339        assert_eq!(tidy("\n\n  indented\n\n\n"), "  indented");
340        // `\r\n` is one break; the other breaks are breaks too
341        assert_eq!(tidy("a\r\nb\r\n\r\n\r\nc"), "a\nb\n\nc");
342        assert_eq!(
343            tidy("a\u{2028}b\u{1C}\u{1D}\u{1E}c\u{85}d"),
344            "a\nb\n\nc\nd"
345        );
346        // What the review of 0.107.2 found: 65k blank lines cost 786 KB
347        // Tidied it is nothing, but MCP and REST send it raw: charged its
348        // raw bytes (the review of 0.107.3)
349        let blank = "\n".repeat(65_536);
350        assert_eq!(tidy(&blank), "");
351        assert_eq!(body_bytes(&blank), 65_536);
352        assert_eq!(body_bytes(&format!("a{blank}b")), 65_538);
353    }
354
355    #[test]
356    fn body_bytes_charges_the_breaks_tidy_keeps() {
357        assert_eq!(body_bytes("x"), 1);
358        assert_eq!(body_bytes("x\ny"), 3 + CONTINUATION_BYTES);
359        assert_eq!(body_bytes("x\r\ny"), 3 + CONTINUATION_BYTES);
360        assert_eq!(body_bytes("x   \n\n\n\ny\n"), 4 + 2 * CONTINUATION_BYTES);
361        // Never under the raw bytes
362        assert_eq!(body_bytes(&" ".repeat(100)), 100);
363        // The worst a 65,536-byte body gets: a line break every other byte
364        let lines = "a\n".repeat(32_768);
365        assert_eq!(
366            body_bytes(&lines),
367            32_768 + 32_767 * (1 + CONTINUATION_BYTES)
368        );
369    }
370
371    /// U+001C to U+001E are line breaks Rust's whitespace leaves out, and
372    /// U+001F a separator; all go, with the whitespace ones (the review of
373    /// 0.107.2)
374    #[test]
375    fn one_line_flattens_every_separator() {
376        assert_eq!(
377            one_line(
378                "a\u{1C}b\u{1D}c\u{1E}d\u{1F}e\u{0B}f\u{0C}g\u{85}h\u{2028}i"
379            ),
380            "a b c d e f g h i"
381        );
382        assert_eq!(one_line("  a \r\n\u{2029} b  "), "a b");
383        assert_eq!(preview("a\u{1E}b", 40), "a b");
384    }
385
386    mod tidy_properties {
387        use super::super::*;
388        use proptest::prelude::*;
389
390        /// Lines of words, spaces, tabs and every kind of break, so runs of
391        /// blank and space-only lines are common
392        fn body() -> impl Strategy<Value = String> {
393            proptest::collection::vec(
394                prop_oneof![
395                    "[a-z]{1,4}",
396                    Just(" ".to_string()),
397                    Just("\t".to_string()),
398                    Just("\u{3000}".to_string()),
399                    Just("\n".to_string()),
400                    Just("\r\n".to_string()),
401                    Just("\r".to_string()),
402                    Just("\u{1C}".to_string()),
403                    Just("\u{2028}".to_string()),
404                    Just("\u{85}".to_string()),
405                    Just("\u{1F}".to_string()),
406                    Just("│".to_string()),
407                ],
408                0..60,
409            )
410            .prop_map(|parts| parts.concat())
411        }
412
413        /// What tidy may not touch: everything but whitespace and breaks
414        fn kept(s: &str) -> String {
415            s.chars()
416                .filter(|&c| !c.is_whitespace() && !is_line_break(c))
417                .collect()
418        }
419
420        proptest! {
421            #[test]
422            fn idempotent(s in body()) {
423                let once = tidy(&s);
424                prop_assert_eq!(tidy(&once), once);
425            }
426
427            #[test]
428            fn never_grows(s in body()) {
429                prop_assert!(tidy(&s).len() <= s.len());
430                prop_assert!(body_bytes(&s) <= s.len() * (1 + CONTINUATION_BYTES));
431            }
432
433            /// MCP and REST send the raw body: the charge covers it
434            #[test]
435            fn charges_at_least_the_raw_body(s in any::<String>()) {
436                prop_assert!(body_bytes(&s) >= s.len());
437            }
438
439            #[test]
440            fn keeps_the_text_in_order(s in body()) {
441                prop_assert_eq!(kept(&tidy(&s)), kept(&s));
442            }
443
444            #[test]
445            fn no_blank_runs_or_line_ends(s in body()) {
446                let t = tidy(&s);
447                prop_assert!(!t.contains("\n\n\n"));
448                prop_assert!(!t.starts_with('\n') && !t.ends_with('\n'));
449                prop_assert!(!t.contains(|c: char| is_line_break(c) && c != '\n'));
450                for line in t.split('\n') {
451                    prop_assert_eq!(line, line.trim_end());
452                }
453            }
454
455            /// Arbitrary text, not just the shaped kind
456            #[test]
457            fn idempotent_on_anything(s in any::<String>()) {
458                let once = tidy(&s);
459                prop_assert_eq!(tidy(&once), once.clone());
460                prop_assert!(once.len() <= s.len());
461                prop_assert_eq!(kept(&once), kept(&s));
462            }
463        }
464    }
465
466    #[test]
467    fn previews_end_at_a_word_and_say_so() {
468        assert_eq!(preview("short", 40), "short");
469        assert_eq!(preview("one two\n\nthree", 40), "one two three");
470        let p = preview("the quick brown fox jumps over the lazy dog", 20);
471        assert_eq!(p, "the quick brown fox…");
472        assert!(p.chars().count() <= 20);
473        // No space in reach: cut mid-word rather than lose most of it
474        assert_eq!(preview(&"x".repeat(50), 10), format!("{}…", "x".repeat(9)));
475        assert_eq!(preview("anything", 0), "");
476    }
477}