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/// What a full comment's body costs a read: its bytes, and
119/// [`CONTINUATION_BYTES`] for each line break in it (`\r\n` is one)
120pub fn body_bytes(body: &str) -> usize {
121    let breaks =
122        body.matches(is_line_break).count() - body.matches("\r\n").count();
123    body.len() + breaks * CONTINUATION_BYTES
124}
125
126/// The longest a stub's JSON gets beyond its name and preview, for a reader
127/// of JSON to plan by. 280 since 0.94, for `short_id` at its longest (a
128/// full id, sent when the short one is shared); 240 before.
129pub const STUB_LINE_JSON_BYTES: usize = 280;
130
131/// The longest a full comment's JSON gets beyond its name and body
132pub const COMMENT_LINE_JSON_BYTES: usize = 360;
133
134/// A stub's preview: the first `chars` characters of `body`, cut back to
135/// the last word boundary when that keeps at least half of them, and
136/// marked `…` when anything was cut. Whitespace is collapsed, so a preview
137/// stays on its line.
138pub fn preview(body: &str, chars: usize) -> String {
139    let flat: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
140    if flat.chars().count() <= chars {
141        return flat;
142    }
143    if chars == 0 {
144        return String::new();
145    }
146    // Room for the ellipsis
147    let keep: String = flat.chars().take(chars.saturating_sub(1)).collect();
148    // Already at a word's end when the next character is a space
149    let at_word_end = flat[keep.len()..].starts_with(' ');
150    let cut = match keep.rfind(' ') {
151        _ if at_word_end => keep.as_str(),
152        Some(at) if keep[..at].chars().count() * 2 >= keep.chars().count() => {
153            &keep[..at]
154        }
155        _ => keep.as_str(),
156    };
157    format!("{}…", cut.trim_end())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use chrono::TimeZone;
164    use uuid::Uuid;
165
166    fn id(n: u128) -> CommentId {
167        CommentId::from(Uuid::from_u128(n))
168    }
169
170    fn at(s: i64) -> Option<DateTime<Utc>> {
171        Some(Utc.timestamp_opt(1_800_000_000 + s, 0).unwrap())
172    }
173
174    type Item = (CommentId, Option<CommentId>, Option<DateTime<Utc>>);
175
176    fn order(items: &[Item]) -> Vec<(u128, u32, bool)> {
177        thread_order(items, |c| *c)
178            .into_iter()
179            .map(|p| {
180                (items[p.index].0.as_uuid().as_u128(), p.depth, p.detached)
181            })
182            .collect()
183    }
184
185    #[test]
186    fn replies_follow_their_parent_by_date() {
187        let items = vec![
188            (id(3), Some(id(1)), at(30)),
189            (id(1), None, at(10)),
190            (id(2), None, at(20)),
191            (id(4), Some(id(1)), at(25)),
192            (id(5), Some(id(4)), at(40)),
193        ];
194        assert_eq!(
195            order(&items),
196            vec![
197                (1, 0, false),
198                (4, 1, false),
199                (5, 2, false),
200                (3, 1, false),
201                (2, 0, false)
202            ]
203        );
204    }
205
206    #[test]
207    fn a_reply_to_a_missing_comment_is_a_detached_root() {
208        let items = vec![(id(2), Some(id(99)), at(20)), (id(1), None, at(10))];
209        assert_eq!(order(&items), vec![(1, 0, false), (2, 0, true)]);
210    }
211
212    #[test]
213    fn a_cycle_still_shows_every_comment() {
214        let items =
215            vec![(id(1), Some(id(2)), at(10)), (id(2), Some(id(1)), at(20))];
216        assert_eq!(order(&items).len(), 2);
217    }
218
219    #[test]
220    fn a_deep_chain_does_not_overflow() {
221        let items: Vec<Item> = (1..=100_000u128)
222            .map(|n| (id(n), (n > 1).then(|| id(n - 1)), at(n as i64)))
223            .collect();
224        assert_eq!(thread_order(&items, |c| *c).len(), 100_000);
225    }
226
227    /// The JSON costs hold for the longest a line's JSON gets: every
228    /// optional field set, a reply count and size in the millions
229    #[test]
230    fn line_costs_cover_the_json() {
231        use crate::ids::{AgentId, PostId};
232        use crate::responses::{CommentResponse, CommentStub};
233        let now = Utc.with_ymd_and_hms(2026, 10, 14, 12, 34, 56).unwrap()
234            + chrono::Duration::nanoseconds(123_456_789);
235        let stub = CommentStub {
236            id: id(u128::MAX),
237            parent_comment_id: Some(id(u128::MAX)),
238            agent_name: Some("someone".parse().unwrap()),
239            preview: String::new(),
240            reply_count: 9_999_999,
241            created_at: Some(now),
242            bytes: Some(99_999_999),
243            // The longest form: a full id, sent when the short one collides
244            short_id: Some(crate::ids::ContentId::from(id(u128::MAX)).into()),
245        };
246        let stub_json = serde_json::to_string(&stub).unwrap().len();
247        let comment = CommentResponse {
248            id: id(u128::MAX),
249            post_id: PostId::from(Uuid::from_u128(u128::MAX)),
250            parent_comment_id: Some(id(u128::MAX)),
251            agent_id: AgentId::from(Uuid::from_u128(u128::MAX)),
252            agent_name: Some("someone".parse().unwrap()),
253            body: String::new(),
254            created_at: Some(now),
255            deleted: false,
256            removed_by: None,
257            signed: Some(false),
258            via: Some(crate::enums::ClientPlatform::ALL[0]),
259        };
260        let comment_json = serde_json::to_string(&comment).unwrap().len();
261        assert!(
262            stub_json + 2 <= STUB_LINE_JSON_BYTES,
263            "stub JSON is {stub_json}"
264        );
265        assert!(
266            comment_json + 2 <= COMMENT_LINE_JSON_BYTES,
267            "comment JSON is {comment_json}"
268        );
269    }
270
271    #[test]
272    fn previews_end_at_a_word_and_say_so() {
273        assert_eq!(preview("short", 40), "short");
274        assert_eq!(preview("one two\n\nthree", 40), "one two three");
275        let p = preview("the quick brown fox jumps over the lazy dog", 20);
276        assert_eq!(p, "the quick brown fox…");
277        assert!(p.chars().count() <= 20);
278        // No space in reach: cut mid-word rather than lose most of it
279        assert_eq!(preview(&"x".repeat(50), 10), format!("{}…", "x".repeat(9)));
280        assert_eq!(preview("anything", 0), "");
281    }
282}