Read one forum topic, page the listings, and stop dropping search fields

343870bfdb29 · AtlantisPleb · · parent afea5551fa79

Read one forum topic, page the listings, and stop dropping search fields

`oa forum` had three of the eight subcommands the TypeScript CLI ships, and
two of the three it did have answered less than the server sent.

`forum topic <id>` is new. It reads `GET /api/v1/forum/topics/<id>`, the route
`packages/openagents-cli/src/forum-client.ts:176` builds, and prints the title
then `#N author: body` per post. The body preview is cut at 120 characters
through `floor_char_boundary`, so a post whose 120th byte lands inside a
multi-byte character shortens one line rather than aborting the read.

`forum topics` takes `--page`, and says what page it is on. The server pages
this route at 25 rows; `product-promises` has 107 topics across five pages, and
the old output was 25 rows with nothing saying the other four pages existed. A
reader took 25 for the whole board. The trailing line is built only from
numbers the server sent — no `pagination` block, no line — and an empty page of
a board that has topics now reports the board's own totals instead of claiming
"No topics found."

`forum search` keeps what it was dropping: the `[board]` suffix on the human
line, and `board`, `url`, `pinned`, `tip_count`, `tip_sats`, and `actor_ref` in
`--json`. The client decoded into a struct that modelled eight fields and
re-encoded it, so every field it did not model was lost. `--board` and `--page`
join it, and posts keep `topic_id` too.

A field the server withheld stays absent rather than becoming `false` or `0`:
`ForumPagination` and the new topic fields are all `Option`, and the `--json`
builders insert a key only when there is a value behind it. The write half —
`post`, `reply`, `claim`, `claims` — stays out; it needs signed Nostr event
authoring.

Tests assert the server's own values, not that something came back. Paging is
proven by two scripted responses: the page number reaches the query string,
page 2 differs from page 1, neither carries the other's rows, and both report
the 107 the server sent.

Refs #88, #80

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/forum.rs
  • modified crates/openagents-cli/tests/parity_test.rs

Diff

3 files changed, +1080 -101

crates/openagents-cli/src/cli.rs modified +45 -71

@@ -1137,11 +1137,26 @@ pub enum ForumAction {

1137 1137
    Topics {
1138 1138
        #[arg(long, help = "Board slug, as `oa forum boards` reports it")]
1139 1139
        board: String,
1140
        // The server pages this route at 25 rows. Without the flag a caller
1141
        // could read the first page and never learn the other four existed.
1142
        #[arg(long, value_parser = clap::value_parser!(u32).range(1..), help = "One-based page number")]
1143
        page: Option<u32>,
1140 1144
    },
1141 1145
    /// Search topics across boards
1142 1146
    Search {
1143 1147
        #[arg(help = "Search query")]
1144 1148
        query: String,
1149
        #[arg(long, help = "Narrow the search to one board slug")]
1150
        board: Option<String>,
1151
        #[arg(long, value_parser = clap::value_parser!(u32).range(1..), help = "One-based page number")]
1152
        page: Option<u32>,
1153
    },
1154
    /// Read one topic and its posts
1155
    Topic {
1156
        #[arg(help = "Topic id (the prefix a topic URL starts with works too)")]
1157
        id: String,
1158
        #[arg(long, value_parser = clap::value_parser!(u32).range(1..), help = "One-based page number")]
1159
        page: Option<u32>,
1145 1160
    },
1146 1161
}
1147 1162

@@ -1443,49 +1458,41 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1443 1458
                    });
1444 1459
                    emit(cli.json, &value, &human);
1445 1460
                }
1446
                ForumAction::Topics { board } => {
1447
                    let topics = client
1448
                        .list_topics(&board)
1461
                ForumAction::Topics { board, page } => {
1462
                    let list = client
1463
                        .list_topics(&board, page)
1449 1464
                        .await
1450 1465
                        .unwrap_or_else(|e| fail(&e.to_string()));
1451
                    let human: Vec<String> = if topics.is_empty() {
1452
                        vec!["No topics found.".to_string()]
1453
                    } else {
1454
                        topics
1455
                            .iter()
1456
                            .map(|t| {
1457
                                format!(
1458
                                    "{} — {} ({} posts)",
1459
                                    short_id(&t.id),
1460
                                    t.title,
1461
                                    t.posts_count
1462
                                )
1463
                            })
1464
                            .collect()
1465
                    };
1466
                    emit(cli.json, &forum_topics_value(&topics), &human);
1466
                    emit(
1467
                        cli.json,
1468
                        &crate::forum::topic_list_value(&list),
1469
                        &crate::forum::topic_rows(&list),
1470
                    );
1467 1471
                }
1468
                ForumAction::Search { query } => {
1469
                    let topics = client
1470
                        .search_topics(&query)
1472
                ForumAction::Search { query, board, page } => {
1473
                    if query.trim().is_empty() {
1474
                        fail("Pass the words to search for.");
1475
                    }
1476
                    let list = client
1477
                        .search_topics(&query, board.as_deref(), page)
1471 1478
                        .await
1472 1479
                        .unwrap_or_else(|e| fail(&e.to_string()));
1473
                    let human: Vec<String> = if topics.is_empty() {
1474
                        vec!["No topics match.".to_string()]
1475
                    } else {
1476
                        topics
1477
                            .iter()
1478
                            .map(|t| {
1479
                                format!(
1480
                                    "{} — {} — {}",
1481
                                    short_id(&t.id),
1482
                                    t.title,
1483
                                    t.author.as_deref().unwrap_or("?")
1484
                                )
1485
                            })
1486
                            .collect()
1487
                    };
1488
                    emit(cli.json, &forum_topics_value(&topics), &human);
1480
                    emit(
1481
                        cli.json,
1482
                        &crate::forum::topic_list_value(&list),
1483
                        &crate::forum::search_rows(&list),
1484
                    );
1485
                }
1486
                ForumAction::Topic { id, page } => {
1487
                    let topic = client
1488
                        .read_topic(&id, page)
1489
                        .await
1490
                        .unwrap_or_else(|e| fail(&e.to_string()));
1491
                    emit(
1492
                        cli.json,
1493
                        &crate::forum::topic_page_value(&topic),
1494
                        &crate::forum::topic_page_rows(&topic),
1495
                    );
1489 1496
                }
1490 1497
            }
1491 1498
        }

@@ -2075,16 +2082,6 @@ async fn run_repo(action: RepoAction, endpoint: &Endpoint, store: &CredentialSto

2075 2082
    }
2076 2083
}
2077 2084
2078
/// The first eight characters of a UUID, which is how the TypeScript CLI renders
2079
/// topic ids in a listing.
2080
///
2081
/// The id comes from the server, so the eight-byte bound is floored to a
2082
/// character boundary. An id that is not a UUID would otherwise panic the
2083
/// listing rather than render short.
2084
fn short_id(id: &str) -> &str {
2085
    &id[..crate::tracker::floor_char_boundary(id, 8)]
2086
}
2087
2088 2085
fn home_directory() -> std::path::PathBuf {
2089 2086
    std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
2090 2087
}

@@ -2093,29 +2090,6 @@ fn home_directory() -> std::path::PathBuf {

2093 2090
// tracker: issues, projects, milestones
2094 2091
// ---------------------------------------------------------------------------
2095 2092
2096
/// The `--json` shape for a forum topic list.
2097
///
2098
/// The forum client parses the server's body into typed rows, so unlike the
2099
/// tracker there is no verbatim body to hand back; this rebuilds the fields it
2100
/// kept, which are the fields the human lines print.
2101
fn forum_topics_value(topics: &[crate::forum::ForumTopic]) -> serde_json::Value {
2102
    serde_json::json!({
2103
        "topics": topics
2104
            .iter()
2105
            .map(|t| serde_json::json!({
2106
                "id": t.id,
2107
                "slug": t.slug,
2108
                "title": t.title,
2109
                "state": t.state,
2110
                "author": t.author,
2111
                "created_at": t.created_at,
2112
                "updated_at": t.updated_at,
2113
                "posts_count": t.posts_count,
2114
            }))
2115
            .collect::<Vec<_>>()
2116
    })
2117
}
2118
2119 2093
/// Print the server's body verbatim under `--json`, or the human lines.
2120 2094
fn emit(json: bool, value: &serde_json::Value, human: &[String]) {
2121 2095
    if json {
crates/openagents-cli/src/forum.rs modified +741 -30

@@ -1,12 +1,18 @@

1
//! Forum board browsing and topic listing.
1
//! Forum board browsing, topic listing, search, and reading one topic.
2 2
//!
3 3
//! The routes are the ones `packages/openagents-cli/src/forum-client.ts` calls:
4
//! `GET /api/v1/forum` for boards and `GET /api/v1/forum/topics?forum=<slug>` for a
5
//! board's topics. An earlier version of this module called `/api/v1/forum/boards`,
6
//! which does not exist, and answered the resulting non-2xx with a hardcoded pair of
7
//! boards — inventing a `dev` board the server has never served. Nothing here
8
//! substitutes a value the server did not send: a refusal is returned as
9
//! [`ForumError`] and the command exits non-zero.
4
//! `GET /api/v1/forum` for boards, `GET /api/v1/forum/topics?forum=<slug>` for a
5
//! board's topics, `GET /api/v1/forum/topics?q=<query>` for search, and
6
//! `GET /api/v1/forum/topics/<id>` for one topic and its posts. Each accepts a
7
//! one-based `page`. An earlier version of this module called
8
//! `/api/v1/forum/boards`, which does not exist, and answered the resulting
9
//! non-2xx with a hardcoded pair of boards — inventing a `dev` board the server
10
//! has never served. Nothing here substitutes a value the server did not send: a
11
//! refusal is returned as [`ForumError`] and the command exits non-zero, and a
12
//! field the server omitted is `None` rather than a plausible default.
13
//!
14
//! The write half — `post`, `reply`, `claim`, `claims` — is deliberately absent.
15
//! It needs signed Nostr event authoring, which is out of scope here.
10 16
11 17
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
12 18
use serde::{Deserialize, Serialize};

@@ -25,6 +31,16 @@ pub struct ForumBoard {

25 31
    pub post_count: u64,
26 32
}
27 33
34
/// The board a search result belongs to, as the topic row named it.
35
///
36
/// The topic routes send this only on search results, where a row can come from
37
/// any board. Absent means the server did not send it, not `general`.
38
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39
pub struct ForumTopicBoard {
40
    pub slug: Option<String>,
41
    pub title: Option<String>,
42
}
43
28 44
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29 45
pub struct ForumTopic {
30 46
    pub id: String,

@@ -36,6 +52,63 @@ pub struct ForumTopic {

36 52
    pub created_at: Option<String>,
37 53
    pub updated_at: Option<String>,
38 54
    pub posts_count: u64,
55
    /// The topic's canonical web address, when the server sent one.
56
    pub url: Option<String>,
57
    /// The author's actor reference, which is not the display name.
58
    pub actor_ref: Option<String>,
59
    pub pinned: Option<bool>,
60
    pub tip_count: Option<u64>,
61
    pub tip_sats: Option<u64>,
62
    /// Which board the topic lives on. Search rows carry it; board listings,
63
    /// where the board is already the question, do not.
64
    pub board: Option<ForumTopicBoard>,
65
}
66
67
/// One post in a topic.
68
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69
pub struct ForumPost {
70
    pub id: String,
71
    /// The post's position in the topic. `None` when the server omitted it —
72
    /// printing `#0` for an unnumbered post would be a number nobody sent.
73
    pub post_number: Option<u64>,
74
    pub state: String,
75
    /// The topic this post belongs to, when the server named it.
76
    pub topic_id: Option<String>,
77
    pub author: Option<String>,
78
    pub actor_ref: Option<String>,
79
    pub body_text: Option<String>,
80
    pub created_at: Option<String>,
81
    pub url: Option<String>,
82
    pub tip_count: Option<u64>,
83
    pub tip_sats: Option<u64>,
84
}
85
86
/// The server's own account of where this page sits in the whole result.
87
///
88
/// Every field is optional because every field is the server's to send. A
89
/// listing that reports `page 1 of 5` when the server said nothing about pages
90
/// is the same class of defect as a board list nobody served.
91
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
92
pub struct ForumPagination {
93
    pub total: Option<u64>,
94
    pub page: Option<u64>,
95
    pub per_page: Option<u64>,
96
    pub total_pages: Option<u64>,
97
}
98
99
/// One page of topic rows, and the server's account of the rest.
100
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101
pub struct ForumTopicList {
102
    pub topics: Vec<ForumTopic>,
103
    pub pagination: Option<ForumPagination>,
104
}
105
106
/// One topic, one page of its posts, and the server's account of the rest.
107
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
108
pub struct ForumTopicPage {
109
    pub topic: ForumTopic,
110
    pub posts: Vec<ForumPost>,
111
    pub pagination: Option<ForumPagination>,
39 112
}
40 113
41 114
/// Why a forum read did not produce data. Never a substitute for data.

@@ -151,49 +224,148 @@ impl ForumClient {

151 224
    }
152 225
153 226
    /// List a board's topics. `board` is a slug, as `list_boards` reports it.
154
    pub async fn list_topics(&self, board: &str) -> Result<Vec<ForumTopic>, ForumError> {
155
        let body = self
156
            .get_json(&format!("forum/topics?forum={}", urlencode(board)))
157
            .await?;
158
        let items = body
159
            .get("topics")
160
            .and_then(|v| v.as_array())
161
            .ok_or_else(|| ForumError::Malformed("no `topics` array in the response".into()))?;
227
    ///
228
    /// `page` is one-based and sent only when the caller asked for one, so an
229
    /// unpaged call gets whatever the server calls page one.
230
    pub async fn list_topics(
231
        &self,
232
        board: &str,
233
        page: Option<u32>,
234
    ) -> Result<ForumTopicList, ForumError> {
235
        let mut path = format!("forum/topics?forum={}", urlencode(board));
236
        push_page(&mut path, page);
237
        let body = self.get_json(&path).await?;
238
        parse_topic_list(&body)
239
    }
162 240
163
        Ok(items.iter().map(parse_topic).collect())
241
    /// Search topics, optionally within one board, optionally on a later page.
242
    pub async fn search_topics(
243
        &self,
244
        query: &str,
245
        board: Option<&str>,
246
        page: Option<u32>,
247
    ) -> Result<ForumTopicList, ForumError> {
248
        let mut path = format!("forum/topics?q={}", urlencode(query));
249
        if let Some(slug) = board {
250
            path.push_str(&format!("&forum={}", urlencode(slug)));
251
        }
252
        push_page(&mut path, page);
253
        let body = self.get_json(&path).await?;
254
        parse_topic_list(&body)
164 255
    }
165 256
166
    /// Search topics across boards.
167
    pub async fn search_topics(&self, query: &str) -> Result<Vec<ForumTopic>, ForumError> {
168
        let body = self
169
            .get_json(&format!("forum/topics?q={}", urlencode(query)))
170
            .await?;
171
        let items = body
172
            .get("topics")
257
    /// Read one topic and a page of its posts. The server accepts a full topic
258
    /// id or the prefix a topic URL starts with.
259
    pub async fn read_topic(
260
        &self,
261
        id: &str,
262
        page: Option<u32>,
263
    ) -> Result<ForumTopicPage, ForumError> {
264
        let mut path = format!("forum/topics/{}", urlencode(id));
265
        if let Some(number) = page {
266
            path.push_str(&format!("?page={}", number));
267
        }
268
        let body = self.get_json(&path).await?;
269
270
        let topic = body
271
            .get("topic")
272
            .filter(|v| v.is_object())
273
            .ok_or_else(|| ForumError::Malformed("no `topic` object in the response".into()))?;
274
        let posts = body
275
            .get("posts")
173 276
            .and_then(|v| v.as_array())
174
            .ok_or_else(|| ForumError::Malformed("no `topics` array in the response".into()))?;
277
            .ok_or_else(|| ForumError::Malformed("no `posts` array in the response".into()))?;
278
279
        Ok(ForumTopicPage {
280
            topic: parse_topic(topic),
281
            posts: posts.iter().map(parse_post).collect(),
282
            pagination: parse_pagination(&body),
283
        })
284
    }
285
}
175 286
176
        Ok(items.iter().map(parse_topic).collect())
287
/// Append `&page=N`, and only when the caller named a page.
288
fn push_page(path: &mut String, page: Option<u32>) {
289
    if let Some(number) = page {
290
        path.push_str(&format!("&page={}", number));
177 291
    }
178 292
}
179 293
294
fn parse_topic_list(body: &serde_json::Value) -> Result<ForumTopicList, ForumError> {
295
    let items = body
296
        .get("topics")
297
        .and_then(|v| v.as_array())
298
        .ok_or_else(|| ForumError::Malformed("no `topics` array in the response".into()))?;
299
300
    Ok(ForumTopicList {
301
        topics: items.iter().map(parse_topic).collect(),
302
        pagination: parse_pagination(body),
303
    })
304
}
305
306
/// Read the `pagination` object, or `None` when the server sent none.
307
fn parse_pagination(body: &serde_json::Value) -> Option<ForumPagination> {
308
    let block = body.get("pagination").filter(|v| v.is_object())?;
309
    Some(ForumPagination {
310
        total: optional_number(block, "total"),
311
        page: optional_number(block, "page"),
312
        per_page: optional_number(block, "per_page"),
313
        total_pages: optional_number(block, "total_pages"),
314
    })
315
}
316
180 317
fn parse_topic(item: &serde_json::Value) -> ForumTopic {
181 318
    ForumTopic {
182 319
        id: string_field(item, "id"),
183 320
        slug: string_field(item, "slug"),
184 321
        title: string_field(item, "title"),
185 322
        state: string_field(item, "state"),
186
        author: item
323
        author: display_name(item),
324
        created_at: optional_string(item, "created_at"),
325
        updated_at: optional_string(item, "updated_at"),
326
        posts_count: number_field(item, "posts_count"),
327
        url: optional_string(item, "url"),
328
        actor_ref: optional_string(item, "actor_ref"),
329
        pinned: item.get("pinned").and_then(|v| v.as_bool()),
330
        tip_count: optional_number(item, "tip_count"),
331
        tip_sats: optional_number(item, "tip_sats"),
332
        board: item
333
            .get("board")
334
            .filter(|v| v.is_object())
335
            .map(|board| ForumTopicBoard {
336
                slug: optional_string(board, "slug"),
337
                title: optional_string(board, "title"),
338
            }),
339
    }
340
}
341
342
fn parse_post(item: &serde_json::Value) -> ForumPost {
343
    ForumPost {
344
        id: string_field(item, "id"),
345
        post_number: optional_number(item, "post_number"),
346
        state: string_field(item, "state"),
347
        topic_id: optional_string(item, "topic_id"),
348
        author: display_name(item),
349
        actor_ref: item
187 350
            .get("author")
188
            .and_then(|a| a.get("display_name"))
351
            .and_then(|a| a.get("ref"))
189 352
            .and_then(|v| v.as_str())
190 353
            .map(String::from),
191
        created_at: item.get("created_at").and_then(|v| v.as_str()).map(String::from),
192
        updated_at: item.get("updated_at").and_then(|v| v.as_str()).map(String::from),
193
        posts_count: number_field(item, "posts_count"),
354
        body_text: optional_string(item, "body_text"),
355
        created_at: optional_string(item, "created_at"),
356
        url: optional_string(item, "url"),
357
        tip_count: optional_number(item, "tip_count"),
358
        tip_sats: optional_number(item, "tip_sats"),
194 359
    }
195 360
}
196 361
362
fn display_name(item: &serde_json::Value) -> Option<String> {
363
    item.get("author")
364
        .and_then(|a| a.get("display_name"))
365
        .and_then(|v| v.as_str())
366
        .map(String::from)
367
}
368
197 369
/// Read a string field, or the empty string when the server omitted it. The empty
198 370
/// string is what the server sent — it is not a stand-in for a value it withheld.
199 371
fn string_field(item: &serde_json::Value, key: &str) -> String {

@@ -207,6 +379,16 @@ fn number_field(item: &serde_json::Value, key: &str) -> u64 {

207 379
    item.get(key).and_then(|v| v.as_u64()).unwrap_or(0)
208 380
}
209 381
382
/// A field the server may not have sent at all. `None` is "not sent"; it is not
383
/// zero, not false, and not the empty string.
384
fn optional_string(item: &serde_json::Value, key: &str) -> Option<String> {
385
    item.get(key).and_then(|v| v.as_str()).map(String::from)
386
}
387
388
fn optional_number(item: &serde_json::Value, key: &str) -> Option<u64> {
389
    item.get(key).and_then(|v| v.as_u64())
390
}
391
210 392
/// Percent-encode a query-string value. Only unreserved characters pass through.
211 393
fn urlencode(value: &str) -> String {
212 394
    let mut out = String::with_capacity(value.len());

@@ -220,3 +402,532 @@ fn urlencode(value: &str) -> String {

220 402
    }
221 403
    out
222 404
}
405
406
// ---------------------------------------------------------------------------
407
// Rendering
408
// ---------------------------------------------------------------------------
409
410
/// The first eight characters of an id, which is how both CLIs print one.
411
///
412
/// The id comes from the server, so the bound is floored to a character
413
/// boundary. An id that is not a UUID would otherwise panic the listing.
414
pub fn short_id(id: &str) -> &str {
415
    &id[..crate::tracker::floor_char_boundary(id, 8)]
416
}
417
418
/// How much of a post body a listing line shows. The TypeScript CLI cuts at 120
419
/// (`packages/openagents-cli/src/cli.ts`, `forumTopicCommand`).
420
const BODY_PREVIEW: usize = 120;
421
422
/// `{short id} — {title} ({n} posts)`, then the server's page note.
423
pub fn topic_rows(list: &ForumTopicList) -> Vec<String> {
424
    if list.topics.is_empty() {
425
        return vec![empty_line("No topics found.", "topics", list.pagination)];
426
    }
427
    let mut lines: Vec<String> = list
428
        .topics
429
        .iter()
430
        .map(|t| {
431
            format!(
432
                "{} — {} ({} posts)",
433
                short_id(&t.id),
434
                t.title,
435
                t.posts_count
436
            )
437
        })
438
        .collect();
439
    lines.extend(page_note(list.pagination, list.topics.len(), "topics"));
440
    lines
441
}
442
443
/// `{short id} — {title} — {author} [{board}]`, then the server's page note.
444
///
445
/// The board suffix and the `?` for a missing author are what the TypeScript
446
/// CLI prints; a search row that dropped its board read as if every match came
447
/// from the same place.
448
pub fn search_rows(list: &ForumTopicList) -> Vec<String> {
449
    if list.topics.is_empty() {
450
        return vec![empty_line("No topics match.", "matches", list.pagination)];
451
    }
452
    let mut lines: Vec<String> = list
453
        .topics
454
        .iter()
455
        .map(|t| {
456
            let who = t.author.as_deref().unwrap_or("?");
457
            let where_ = t
458
                .board
459
                .as_ref()
460
                .and_then(|b| b.slug.as_deref())
461
                .map(|slug| format!(" [{}]", slug))
462
                .unwrap_or_default();
463
            format!("{} — {} — {}{}", short_id(&t.id), t.title, who, where_)
464
        })
465
        .collect();
466
    lines.extend(page_note(list.pagination, list.topics.len(), "matches"));
467
    lines
468
}
469
470
/// The topic's title, then `#{n} {author}: {body}` per post, then the page note.
471
pub fn topic_page_rows(page: &ForumTopicPage) -> Vec<String> {
472
    let mut lines = vec![page.topic.title.clone()];
473
    if page.posts.is_empty() {
474
        lines.push(empty_line(
475
            "No posts on this topic.",
476
            "posts",
477
            page.pagination,
478
        ));
479
        return lines;
480
    }
481
    for post in &page.posts {
482
        let number = post
483
            .post_number
484
            .map(|n| n.to_string())
485
            .unwrap_or_else(|| "?".to_string());
486
        let who = post.author.as_deref().unwrap_or("?");
487
        let body = post.body_text.as_deref().unwrap_or("");
488
        // The body is the server's, so the preview bound is floored to a
489
        // character boundary rather than cutting the raw bytes.
490
        let end = crate::tracker::floor_char_boundary(body, BODY_PREVIEW);
491
        lines.push(format!("#{} {}: {}", number, who, &body[..end]));
492
    }
493
    lines.extend(page_note(page.pagination, page.posts.len(), "posts"));
494
    lines
495
}
496
497
/// What to say when the server sent no rows.
498
///
499
/// An empty page of a board that has 107 topics is not an empty board, and
500
/// saying "no topics found" there would be the same silence this fixes. When
501
/// the server's own pagination shows there is something to find, the line says
502
/// where it is instead.
503
fn empty_line(nothing_at_all: &str, noun: &str, pagination: Option<ForumPagination>) -> String {
504
    let Some(page) = pagination else {
505
        return nothing_at_all.to_string();
506
    };
507
    match (page.total, page.page, page.total_pages) {
508
        (Some(total), Some(number), Some(pages)) if total > 0 => format!(
509
            "No {} on page {}. The server reports {} {} across {} pages.",
510
            noun, number, total, noun, pages
511
        ),
512
        (Some(total), Some(number), None) if total > 0 => format!(
513
            "No {} on page {}. The server reports {} {} in total.",
514
            noun, number, total, noun
515
        ),
516
        _ => nothing_at_all.to_string(),
517
    }
518
}
519
520
/// The line that says this page is not the whole result.
521
///
522
/// Without it a 25-row page of a 107-topic board reads as the whole board, and
523
/// nothing in the output disagrees. Built only from numbers the server sent: no
524
/// pagination block, no line.
525
fn page_note(pagination: Option<ForumPagination>, shown: usize, noun: &str) -> Option<String> {
526
    let page = pagination?;
527
    if let (Some(number), Some(pages)) = (page.page, page.total_pages) {
528
        if pages > 1 {
529
            let mut note = match page.total {
530
                Some(total) => format!("Page {} of {} — {} {}", number, pages, total, noun),
531
                None => format!("Page {} of {}", number, pages),
532
            };
533
            if number < pages {
534
                note.push_str(&format!(". Pass --page {} for the next.", number + 1));
535
            } else {
536
                note.push('.');
537
            }
538
            return Some(note);
539
        }
540
        return None;
541
    }
542
    // No page count, but a total that the rows on screen do not account for.
543
    match page.total {
544
        Some(total) if total > shown as u64 => Some(format!(
545
            "The server reports {} {} in total; this page has {}.",
546
            total, noun, shown
547
        )),
548
        _ => None,
549
    }
550
}
551
552
// ---------------------------------------------------------------------------
553
// `--json`
554
// ---------------------------------------------------------------------------
555
556
/// The `--json` shape for a topic list.
557
///
558
/// The client parses the server's body into typed rows, so unlike the tracker
559
/// there is no verbatim body to hand back. A key appears here only when the
560
/// server sent it: an absent `board` is an absent key, never `null` standing in
561
/// for a board the row never named.
562
pub fn topic_list_value(list: &ForumTopicList) -> serde_json::Value {
563
    let mut out = serde_json::Map::new();
564
    out.insert(
565
        "topics".to_string(),
566
        serde_json::Value::Array(list.topics.iter().map(topic_value).collect()),
567
    );
568
    if let Some(page) = list.pagination {
569
        out.insert("pagination".to_string(), pagination_value(page));
570
    }
571
    serde_json::Value::Object(out)
572
}
573
574
/// The `--json` shape for one topic and a page of its posts.
575
pub fn topic_page_value(page: &ForumTopicPage) -> serde_json::Value {
576
    let mut out = serde_json::Map::new();
577
    out.insert("topic".to_string(), topic_value(&page.topic));
578
    out.insert(
579
        "posts".to_string(),
580
        serde_json::Value::Array(page.posts.iter().map(post_value).collect()),
581
    );
582
    if let Some(pagination) = page.pagination {
583
        out.insert("pagination".to_string(), pagination_value(pagination));
584
    }
585
    serde_json::Value::Object(out)
586
}
587
588
fn topic_value(topic: &ForumTopic) -> serde_json::Value {
589
    let mut out = serde_json::Map::new();
590
    out.insert("id".to_string(), topic.id.clone().into());
591
    out.insert("slug".to_string(), topic.slug.clone().into());
592
    out.insert("title".to_string(), topic.title.clone().into());
593
    out.insert("state".to_string(), topic.state.clone().into());
594
    out.insert(
595
        "author".to_string(),
596
        match &topic.author {
597
            Some(name) => name.clone().into(),
598
            None => serde_json::Value::Null,
599
        },
600
    );
601
    insert_string(&mut out, "created_at", &topic.created_at);
602
    insert_string(&mut out, "updated_at", &topic.updated_at);
603
    out.insert("posts_count".to_string(), topic.posts_count.into());
604
    insert_string(&mut out, "url", &topic.url);
605
    insert_string(&mut out, "actor_ref", &topic.actor_ref);
606
    if let Some(pinned) = topic.pinned {
607
        out.insert("pinned".to_string(), pinned.into());
608
    }
609
    insert_number(&mut out, "tip_count", topic.tip_count);
610
    insert_number(&mut out, "tip_sats", topic.tip_sats);
611
    if let Some(board) = &topic.board {
612
        let mut nested = serde_json::Map::new();
613
        insert_string(&mut nested, "slug", &board.slug);
614
        insert_string(&mut nested, "title", &board.title);
615
        out.insert("board".to_string(), serde_json::Value::Object(nested));
616
    }
617
    serde_json::Value::Object(out)
618
}
619
620
fn post_value(post: &ForumPost) -> serde_json::Value {
621
    let mut out = serde_json::Map::new();
622
    out.insert("id".to_string(), post.id.clone().into());
623
    insert_number(&mut out, "post_number", post.post_number);
624
    out.insert("state".to_string(), post.state.clone().into());
625
    insert_string(&mut out, "topic_id", &post.topic_id);
626
    out.insert(
627
        "author".to_string(),
628
        match &post.author {
629
            Some(name) => name.clone().into(),
630
            None => serde_json::Value::Null,
631
        },
632
    );
633
    insert_string(&mut out, "actor_ref", &post.actor_ref);
634
    insert_string(&mut out, "body_text", &post.body_text);
635
    insert_string(&mut out, "created_at", &post.created_at);
636
    insert_string(&mut out, "url", &post.url);
637
    insert_number(&mut out, "tip_count", post.tip_count);
638
    insert_number(&mut out, "tip_sats", post.tip_sats);
639
    serde_json::Value::Object(out)
640
}
641
642
fn pagination_value(page: ForumPagination) -> serde_json::Value {
643
    let mut out = serde_json::Map::new();
644
    insert_number(&mut out, "total", page.total);
645
    insert_number(&mut out, "page", page.page);
646
    insert_number(&mut out, "per_page", page.per_page);
647
    insert_number(&mut out, "total_pages", page.total_pages);
648
    serde_json::Value::Object(out)
649
}
650
651
fn insert_string(
652
    map: &mut serde_json::Map<String, serde_json::Value>,
653
    key: &str,
654
    value: &Option<String>,
655
) {
656
    if let Some(text) = value {
657
        map.insert(key.to_string(), text.clone().into());
658
    }
659
}
660
661
fn insert_number(
662
    map: &mut serde_json::Map<String, serde_json::Value>,
663
    key: &str,
664
    value: Option<u64>,
665
) {
666
    if let Some(number) = value {
667
        map.insert(key.to_string(), number.into());
668
    }
669
}
670
671
#[cfg(test)]
672
mod tests {
673
    use super::*;
674
675
    fn list(body: &str) -> ForumTopicList {
676
        parse_topic_list(&serde_json::from_str(body).unwrap()).unwrap()
677
    }
678
679
    /// A search row keeps the six fields the struct used to drop.
680
    ///
681
    /// The old `ForumTopic` modelled eight fields and re-encoded them, so
682
    /// `board`, `url`, `pinned`, `tip_count`, `tip_sats`, and `actor_ref` never
683
    /// reached `--json`. Each assertion below names the value this fixture sent.
684
    #[test]
685
    fn a_search_row_carries_the_fields_the_server_sent() {
686
        let parsed = list(
687
            r#"{"topics":[{"id":"9946bf38-788b-45f3-b17b-b0e36bb8dc60",
688
                "slug":"why-not","title":"Why not?","state":"open",
689
                "author":{"ref":"agent:user_b3ce","display_name":"Sneaky"},
690
                "url":"https://openagents.com/forum/t/9946bf38",
691
                "actor_ref":"agent:user_b3ce","pinned":true,
692
                "tip_count":3,"tip_sats":210,"posts_count":14,
693
                "board":{"title":"Work Requests","slug":"work-requests"}}]}"#,
694
        );
695
        let row = &parsed.topics[0];
696
        assert_eq!(
697
            row.url.as_deref(),
698
            Some("https://openagents.com/forum/t/9946bf38")
699
        );
700
        assert_eq!(row.actor_ref.as_deref(), Some("agent:user_b3ce"));
701
        assert_eq!(row.pinned, Some(true));
702
        assert_eq!(row.tip_count, Some(3));
703
        assert_eq!(row.tip_sats, Some(210));
704
        assert_eq!(
705
            row.board.as_ref().and_then(|b| b.slug.as_deref()),
706
            Some("work-requests")
707
        );
708
709
        let json = topic_list_value(&parsed);
710
        assert_eq!(json["topics"][0]["pinned"], true);
711
        assert_eq!(json["topics"][0]["tip_sats"], 210);
712
        assert_eq!(json["topics"][0]["tip_count"], 3);
713
        assert_eq!(json["topics"][0]["actor_ref"], "agent:user_b3ce");
714
        assert_eq!(
715
            json["topics"][0]["url"],
716
            "https://openagents.com/forum/t/9946bf38"
717
        );
718
        assert_eq!(json["topics"][0]["board"]["slug"], "work-requests");
719
720
        // And the human line carries the board suffix the TypeScript CLI
721
        // prints. Recorded from `openagents forum search "acceptance gate"`:
722
        //   9946bf38 — Why are you not running … — Sneaky [work-requests]
723
        assert_eq!(
724
            search_rows(&parsed)[0],
725
            "9946bf38 — Why not? — Sneaky [work-requests]"
726
        );
727
    }
728
729
    /// A field the server withheld stays absent rather than becoming a default.
730
    ///
731
    /// `pinned: false` and `tip_sats: 0` are values. Printing them for a row
732
    /// that carried neither is the fabrication this module exists to avoid.
733
    #[test]
734
    fn a_field_the_server_withheld_is_absent_not_defaulted() {
735
        let parsed =
736
            list(r#"{"topics":[{"id":"abc","title":"t","state":"open","posts_count":1}]}"#);
737
        let row = &parsed.topics[0];
738
        assert_eq!(row.pinned, None);
739
        assert_eq!(row.tip_sats, None);
740
        assert!(row.board.is_none());
741
742
        let json = topic_list_value(&parsed);
743
        let object = json["topics"][0].as_object().unwrap();
744
        assert!(
745
            !object.contains_key("pinned"),
746
            "invented `pinned`: {object:?}"
747
        );
748
        assert!(!object.contains_key("tip_sats"));
749
        assert!(!object.contains_key("board"));
750
751
        // With no board the suffix is empty, and the author falls back to `?`,
752
        // which is what the TypeScript CLI prints.
753
        assert_eq!(search_rows(&parsed)[0], "abc — t — ?");
754
    }
755
756
    /// A page of a longer result says so, and names the page to ask for next.
757
    #[test]
758
    fn a_page_that_is_not_the_whole_board_says_which_page_it_is() {
759
        let parsed = list(
760
            r#"{"topics":[{"id":"a1b2c3d4","title":"one","state":"open","posts_count":2}],
761
                "pagination":{"total":107,"page":1,"per_page":25,"total_pages":5}}"#,
762
        );
763
        let rows = topic_rows(&parsed);
764
        assert_eq!(rows[0], "a1b2c3d4 — one (2 posts)");
765
        assert_eq!(
766
            rows[1],
767
            "Page 1 of 5 — 107 topics. Pass --page 2 for the next.",
768
        );
769
770
        // The server's own numbers reach `--json` too.
771
        let json = topic_list_value(&parsed);
772
        assert_eq!(json["pagination"]["total"], 107);
773
        assert_eq!(json["pagination"]["total_pages"], 5);
774
        assert_eq!(json["pagination"]["page"], 1);
775
    }
776
777
    /// The last page does not advertise a page that does not exist.
778
    #[test]
779
    fn the_last_page_does_not_point_past_itself() {
780
        let parsed = list(
781
            r#"{"topics":[{"id":"z","title":"last","state":"open","posts_count":1}],
782
                "pagination":{"total":107,"page":5,"per_page":25,"total_pages":5}}"#,
783
        );
784
        let rows = topic_rows(&parsed);
785
        assert_eq!(rows[1], "Page 5 of 5 — 107 topics.");
786
        assert!(!rows[1].contains("--page 6"), "{}", rows[1]);
787
    }
788
789
    /// A single-page result says nothing about paging, because there is none.
790
    #[test]
791
    fn a_result_that_fits_on_one_page_gets_no_page_note() {
792
        let parsed = list(
793
            r#"{"topics":[{"id":"z","title":"only","state":"open","posts_count":1}],
794
                "pagination":{"total":1,"page":1,"per_page":25,"total_pages":1}}"#,
795
        );
796
        assert_eq!(topic_rows(&parsed).len(), 1);
797
    }
798
799
    /// A server that sent no pagination gets no invented page numbers.
800
    #[test]
801
    fn no_pagination_block_means_no_page_line() {
802
        let parsed =
803
            list(r#"{"topics":[{"id":"z","title":"only","state":"open","posts_count":1}]}"#);
804
        assert_eq!(topic_rows(&parsed), vec!["z — only (1 posts)"]);
805
        assert!(topic_list_value(&parsed).get("pagination").is_none());
806
    }
807
808
    /// An empty page of a board with topics is not reported as an empty board.
809
    ///
810
    /// The live route answers `?page=9` on a five-page board with `topics: []`
811
    /// and the same pagination block. "No topics found." there is false.
812
    #[test]
813
    fn an_empty_page_of_a_full_board_reports_the_board_not_a_void() {
814
        let parsed = list(
815
            r#"{"topics":[],"pagination":{"total":107,"page":9,"per_page":25,"total_pages":5}}"#,
816
        );
817
        assert_eq!(
818
            topic_rows(&parsed),
819
            vec!["No topics on page 9. The server reports 107 topics across 5 pages."]
820
        );
821
    }
822
823
    /// A board the server really says is empty still reads as empty.
824
    #[test]
825
    fn a_board_the_server_says_is_empty_reads_as_empty() {
826
        let parsed = list(
827
            r#"{"topics":[],"pagination":{"total":0,"page":1,"per_page":25,"total_pages":0}}"#,
828
        );
829
        assert_eq!(topic_rows(&parsed), vec!["No topics found."]);
830
    }
831
832
    /// A body with no `topics` array is malformed, not an empty board.
833
    #[test]
834
    fn a_body_without_topics_is_malformed_rather_than_empty() {
835
        let error = parse_topic_list(&serde_json::json!({"pagination": {"total": 3}}))
836
            .expect_err("a body with no `topics` array must not read as an empty board");
837
        match error {
838
            ForumError::Malformed(why) => assert!(why.contains("topics"), "{why}"),
839
            other => panic!("expected malformed, got {other}"),
840
        }
841
    }
842
843
    /// The topic reader prints the title, then one line per post.
844
    ///
845
    /// Recorded from `openagents forum topic 9946bf38-…`:
846
    ///   Why are you not running agents around the clock?
847
    ///   #1 Sneaky: A quiet observation for the agents arriving today: …
848
    #[test]
849
    fn a_topic_renders_its_title_then_its_posts() {
850
        let body = serde_json::json!({
851
            "topic": {"id": "9946bf38-788b", "title": "Why not?", "state": "open",
852
                      "posts_count": 2, "slug": "why-not"},
853
            "posts": [
854
                {"id": "p1", "post_number": 1, "state": "visible",
855
                 "author": {"display_name": "Sneaky", "ref": "agent:user_b3ce"},
856
                 "body_text": "A quiet observation.", "tip_sats": 21},
857
                {"id": "p2", "post_number": 2, "state": "visible",
858
                 "body_text": "Seconding this."}
859
            ],
860
            "pagination": {"total": 14, "page": 1, "per_page": 50, "total_pages": 1}
861
        });
862
        let page = ForumTopicPage {
863
            topic: parse_topic(&body["topic"]),
864
            posts: body["posts"]
865
                .as_array()
866
                .unwrap()
867
                .iter()
868
                .map(parse_post)
869
                .collect(),
870
            pagination: parse_pagination(&body),
871
        };
872
        assert_eq!(
873
            topic_page_rows(&page),
874
            vec![
875
                "Why not?",
876
                "#1 Sneaky: A quiet observation.",
877
                "#2 ?: Seconding this.",
878
            ]
879
        );
880
881
        let json = topic_page_value(&page);
882
        assert_eq!(json["topic"]["title"], "Why not?");
883
        assert_eq!(json["posts"][0]["actor_ref"], "agent:user_b3ce");
884
        assert_eq!(json["posts"][0]["tip_sats"], 21);
885
        assert!(
886
            json["posts"][1]
887
                .as_object()
888
                .unwrap()
889
                .get("tip_sats")
890
                .is_none(),
891
            "invented a tip count the server never sent"
892
        );
893
        assert_eq!(json["pagination"]["total"], 14);
894
    }
895
896
    /// The post preview is cut on a character boundary, not a byte index.
897
    ///
898
    /// A body whose 120th byte lands inside a multi-byte character used to
899
    /// abort the whole listing rather than shorten one line.
900
    #[test]
901
    fn a_post_preview_does_not_split_a_character() {
902
        let mut body = "A".repeat(119);
903
        body.push('é');
904
        body.push_str(&"B".repeat(40));
905
        assert!(
906
            !body.is_char_boundary(BODY_PREVIEW),
907
            "the fixture proves nothing"
908
        );
909
910
        let page = ForumTopicPage {
911
            topic: parse_topic(&serde_json::json!({"title": "t"})),
912
            posts: vec![parse_post(&serde_json::json!({
913
                "id": "p1", "post_number": 1, "body_text": body,
914
                "author": {"display_name": "Whoever"}
915
            }))],
916
            pagination: None,
917
        };
918
        let rows = topic_page_rows(&page);
919
        assert_eq!(rows[1], format!("#1 Whoever: {}", "A".repeat(119)));
920
    }
921
922
    /// The page number reaches the query string, and only when asked for.
923
    #[test]
924
    fn a_page_is_sent_only_when_the_caller_named_one() {
925
        let mut unpaged = "forum/topics?forum=general".to_string();
926
        push_page(&mut unpaged, None);
927
        assert_eq!(unpaged, "forum/topics?forum=general");
928
929
        let mut paged = "forum/topics?forum=general".to_string();
930
        push_page(&mut paged, Some(3));
931
        assert_eq!(paged, "forum/topics?forum=general&page=3");
932
    }
933
}
crates/openagents-cli/tests/parity_test.rs modified +294

@@ -830,3 +830,297 @@ fn a_trailing_global_flag_is_not_stored_as_data() {

830 830
        )
831 831
    });
832 832
}
833
834
// ---------------------------------------------------------------------------
835
// 6. The forum read half: one topic, later pages, and the fields search drops
836
// ---------------------------------------------------------------------------
837
838
/// `forum topic` asks for the route the TypeScript client asks for.
839
///
840
/// The path is built at `packages/openagents-cli/src/forum-client.ts:176`:
841
/// `${API_VERSION_PATH}/forum/topics/${encodeURIComponent(id)}`. Reading the
842
/// request the stub received is what separates "parsed the subcommand" from
843
/// "asked the server for the topic".
844
#[test]
845
fn forum_topic_asks_for_the_topic_route() {
846
    let body = br#"{"topic":{"id":"9946bf38-788b","title":"Why not?","state":"open",
847
        "slug":"why-not","posts_count":1},
848
        "posts":[{"id":"p1","post_number":1,"state":"visible",
849
                  "author":{"display_name":"Sneaky","ref":"agent:user_b3ce"},
850
                  "body_text":"A quiet observation."}],
851
        "pagination":{"total":1,"page":1,"per_page":50,"total_pages":1}}"#;
852
    let server = StubServer::always(200, "application/json", body.to_vec());
853
    let run = oa(&server.origin(), &["forum", "topic", "9946bf38-788b"]);
854
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
855
856
    let routes: Vec<String> = server.hits().iter().map(Hit::route).collect();
857
    assert_eq!(routes, vec!["GET /api/v1/forum/topics/9946bf38-788b"]);
858
859
    // The title, then one line per post. Recorded from `openagents forum topic
860
    // 9946bf38-788b-45f3-b17b-b0e36bb8dc60` at 0.4.0:
861
    //   Why are you not running agents around the clock?
862
    //   #1 Sneaky: A quiet observation for the agents arriving today: …
863
    let lines: Vec<&str> = run.stdout.lines().collect();
864
    assert_eq!(lines[0], "Why not?");
865
    assert_eq!(lines[1], "#1 Sneaky: A quiet observation.");
866
}
867
868
/// A topic the server refuses is a refusal, not an empty topic.
869
#[test]
870
fn a_refused_topic_read_exits_non_zero_with_the_server_status() {
871
    let server = StubServer::always(
872
        404,
873
        "application/json",
874
        br#"{"error":"not_found"}"#.to_vec(),
875
    );
876
    let run = oa(
877
        &server.origin(),
878
        &["forum", "topic", "deadbeef-0000-0000-0000-000000000000"],
879
    );
880
    assert_ne!(run.code(), 0, "a 404 must not read as a topic");
881
    assert!(
882
        run.stderr.contains("404"),
883
        "the status the server sent is missing: {}",
884
        run.stderr
885
    );
886
    assert!(
887
        run.stdout.trim().is_empty(),
888
        "a refused read printed topic-shaped output: {}",
889
        run.stdout
890
    );
891
}
892
893
/// `--page 2` is sent, and the page it returns is not the page 1 returned.
894
///
895
/// The regression this guards is silence, not absence: `forum topics` returned
896
/// the server's first page — 25 rows of a 107-topic board — with nothing saying
897
/// the other four pages existed. Asserting that rows came back passes against
898
/// exactly that bug, so this asserts three things it cannot satisfy: the page
899
/// number reaches the query string, page 2 differs from page 1, and both runs
900
/// report the server's own total.
901
#[test]
902
fn forum_topics_pages_and_says_how_much_it_is_not_showing() {
903
    let page_one = br#"{"topics":[{"id":"415e16a7-183c","title":"first page topic",
904
        "state":"open","slug":"a","posts_count":132}],
905
        "pagination":{"total":107,"page":1,"per_page":25,"total_pages":5}}"#;
906
    let page_two = br#"{"topics":[{"id":"9e7b4f18-0000","title":"second page topic",
907
        "state":"open","slug":"b","posts_count":12}],
908
        "pagination":{"total":107,"page":2,"per_page":25,"total_pages":5}}"#;
909
    let server = StubServer::start(vec![
910
        (200, "application/json", page_one.to_vec()),
911
        (200, "application/json", page_two.to_vec()),
912
    ]);
913
914
    let first = oa(
915
        &server.origin(),
916
        &["forum", "topics", "--board", "product-promises"],
917
    );
918
    assert_eq!(first.code(), 0, "stderr: {}", first.stderr);
919
    let second = oa(
920
        &server.origin(),
921
        &[
922
            "forum",
923
            "topics",
924
            "--board",
925
            "product-promises",
926
            "--page",
927
            "2",
928
        ],
929
    );
930
    assert_eq!(second.code(), 0, "stderr: {}", second.stderr);
931
932
    let routes: Vec<String> = server.hits().iter().map(Hit::route).collect();
933
    assert_eq!(
934
        routes,
935
        vec![
936
            "GET /api/v1/forum/topics?forum=product-promises",
937
            "GET /api/v1/forum/topics?forum=product-promises&page=2",
938
        ],
939
        "the page number never reached the server"
940
    );
941
942
    assert!(
943
        first.stdout.contains("first page topic"),
944
        "page 1 rows: {}",
945
        first.stdout
946
    );
947
    assert!(
948
        second.stdout.contains("second page topic"),
949
        "page 2 rows: {}",
950
        second.stdout
951
    );
952
    assert_ne!(
953
        first.stdout, second.stdout,
954
        "page 2 returned page 1; the flag was parsed and dropped"
955
    );
956
    assert!(
957
        !second.stdout.contains("first page topic"),
958
        "page 2 still carries page 1's rows: {}",
959
        second.stdout
960
    );
961
962
    // The total, and the next page to ask for, are the server's own numbers.
963
    assert!(
964
        first.stdout.contains("Page 1 of 5 — 107 topics"),
965
        "page 1 did not say how much of the board it was not showing: {}",
966
        first.stdout
967
    );
968
    assert!(
969
        first.stdout.contains("--page 2"),
970
        "page 1 named no next page: {}",
971
        first.stdout
972
    );
973
    assert!(
974
        second.stdout.contains("Page 2 of 5 — 107 topics"),
975
        "page 2 did not report its place: {}",
976
        second.stdout
977
    );
978
}
979
980
/// `forum topics --json` carries the server's pagination block.
981
#[test]
982
fn forum_topics_json_carries_the_servers_pagination() {
983
    let body = br#"{"topics":[{"id":"415e16a7-183c","title":"t","state":"open",
984
        "slug":"a","posts_count":2}],
985
        "pagination":{"total":107,"page":1,"per_page":25,"total_pages":5}}"#;
986
    let server = StubServer::always(200, "application/json", body.to_vec());
987
    let run = oa(
988
        &server.origin(),
989
        &["--json", "forum", "topics", "--board", "product-promises"],
990
    );
991
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
992
    let parsed: serde_json::Value =
993
        serde_json::from_str(&run.stdout).expect("--json must print one JSON document");
994
    assert_eq!(parsed["pagination"]["total"], 107);
995
    assert_eq!(parsed["pagination"]["total_pages"], 5);
996
    assert_eq!(parsed["pagination"]["page"], 1);
997
    assert_eq!(parsed["pagination"]["per_page"], 25);
998
}
999
1000
/// `forum search` keeps the six fields it dropped, in both renderings.
1001
///
1002
/// The client decoded into a struct that modelled eight fields and re-encoded
1003
/// it, so `board`, `url`, `pinned`, `tip_count`, `tip_sats`, and `actor_ref`
1004
/// never reached `--json`, and the `[board]` suffix never reached the human
1005
/// line. Every value asserted below is one this fixture sent.
1006
#[test]
1007
fn forum_search_keeps_the_fields_the_server_sent() {
1008
    let body = br#"{"query":"acceptance gate","topics":[
1009
        {"id":"9946bf38-788b","slug":"why-not","title":"Why not?","state":"open",
1010
         "author":{"ref":"agent:user_b3ce","display_name":"Sneaky","is_agent":true},
1011
         "url":"https://openagents.com/forum/t/9946bf38","actor_ref":"agent:user_b3ce",
1012
         "pinned":true,"tip_count":3,"tip_sats":210,"posts_count":14,
1013
         "board":{"title":"Work Requests","slug":"work-requests"}}],
1014
        "pagination":{"total":1,"page":1,"per_page":25,"total_pages":1},"board":null}"#;
1015
1016
    let human_server = StubServer::always(200, "application/json", body.to_vec());
1017
    let human = oa(
1018
        &human_server.origin(),
1019
        &["forum", "search", "acceptance gate"],
1020
    );
1021
    assert_eq!(human.code(), 0, "stderr: {}", human.stderr);
1022
    // Recorded from `openagents forum search "acceptance gate"` at 0.4.0:
1023
    //   9946bf38 — Why are you not running agents … — Sneaky [work-requests]
1024
    assert_eq!(
1025
        human.stdout.trim(),
1026
        "9946bf38 — Why not? — Sneaky [work-requests]"
1027
    );
1028
1029
    let routes: Vec<String> = human_server.hits().iter().map(Hit::route).collect();
1030
    assert_eq!(routes, vec!["GET /api/v1/forum/topics?q=acceptance%20gate"]);
1031
1032
    let json_server = StubServer::always(200, "application/json", body.to_vec());
1033
    let json = oa(
1034
        &json_server.origin(),
1035
        &["--json", "forum", "search", "acceptance gate"],
1036
    );
1037
    assert_eq!(json.code(), 0, "stderr: {}", json.stderr);
1038
    let parsed: serde_json::Value =
1039
        serde_json::from_str(&json.stdout).expect("--json must print one JSON document");
1040
    let row = &parsed["topics"][0];
1041
    assert_eq!(row["board"]["slug"], "work-requests");
1042
    assert_eq!(row["url"], "https://openagents.com/forum/t/9946bf38");
1043
    assert_eq!(row["pinned"], true);
1044
    assert_eq!(row["tip_count"], 3);
1045
    assert_eq!(row["tip_sats"], 210);
1046
    assert_eq!(row["actor_ref"], "agent:user_b3ce");
1047
}
1048
1049
/// `forum search --board` narrows the query the way the TypeScript client does.
1050
///
1051
/// `packages/openagents-cli/src/forum-client.ts:167` appends `&forum=<slug>`
1052
/// after `q`, in that order.
1053
#[test]
1054
fn forum_search_can_narrow_to_one_board() {
1055
    let server = StubServer::always(200, "application/json", br#"{"topics":[]}"#.to_vec());
1056
    let run = oa(
1057
        &server.origin(),
1058
        &[
1059
            "forum",
1060
            "search",
1061
            "gate",
1062
            "--board",
1063
            "work-requests",
1064
            "--page",
1065
            "3",
1066
        ],
1067
    );
1068
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
1069
    let routes: Vec<String> = server.hits().iter().map(Hit::route).collect();
1070
    assert_eq!(
1071
        routes,
1072
        vec!["GET /api/v1/forum/topics?q=gate&forum=work-requests&page=3"]
1073
    );
1074
}
1075
1076
/// An empty page of a board that has topics is not reported as an empty board.
1077
///
1078
/// The live route answers `?page=9` on a five-page board with `topics: []` and
1079
/// the same pagination block. Printing "No topics found." there claims the
1080
/// server said the board is empty, which it did not.
1081
#[test]
1082
fn an_empty_page_does_not_claim_the_board_is_empty() {
1083
    let body = br#"{"topics":[],
1084
        "pagination":{"total":107,"page":9,"per_page":25,"total_pages":5}}"#;
1085
    let server = StubServer::always(200, "application/json", body.to_vec());
1086
    let run = oa(
1087
        &server.origin(),
1088
        &[
1089
            "forum",
1090
            "topics",
1091
            "--board",
1092
            "product-promises",
1093
            "--page",
1094
            "9",
1095
        ],
1096
    );
1097
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
1098
    assert!(
1099
        run.stdout.contains("107") && run.stdout.contains("page 9"),
1100
        "the server's own numbers are missing: {}",
1101
        run.stdout
1102
    );
1103
    assert!(
1104
        !run.stdout.contains("No topics found."),
1105
        "an empty page claimed the board is empty: {}",
1106
        run.stdout
1107
    );
1108
}
1109
1110
/// A body with no `topics` array is a malformed answer, not an empty board.
1111
#[test]
1112
fn a_two_hundred_without_topics_is_refused_rather_than_rendered_empty() {
1113
    let server = StubServer::always(200, "application/json", br#"{"ok":true}"#.to_vec());
1114
    let run = oa(&server.origin(), &["forum", "topics", "--board", "general"]);
1115
    assert_ne!(
1116
        run.code(),
1117
        0,
1118
        "a body with no `topics` array printed a board listing: {}",
1119
        run.stdout
1120
    );
1121
    assert!(
1122
        !run.stdout.contains("No topics found."),
1123
        "an unreadable body was rendered as an empty board: {}",
1124
        run.stdout
1125
    );
1126
}

This page updates live while a promote is in flight · changelog