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