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