From b7eeee172d12114f3e89a41184384bead4ef729a Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 13 Aug 2026 06:13:37 +0530 Subject: [PATCH 01/12] fix(cli): report when messages get comes back at its limit buzz messages get returns the newest 50 messages when --limit is omitted, with nothing in the output to say the result is a prefix. A truncated history is byte-for-byte indistinguishable from a complete one, so an agent rebuilding context from a channel read reconstructs a plausible-but-wrong past and acts on it. Emit a note on stderr when the read comes back at its limit, naming the bound that applied - the default, the requested --limit, or the cap it was silently clamped to - and how to see more. stdout keeps the plain JSON array, so the machine contract is unchanged. The note says "may exist" rather than reporting a total: the relay answers a filter, not a count, and a result set exactly the size of the limit is possible. A short read is the only provably complete case and stays silent. Refs #5595 Signed-off-by: Taksh --- crates/buzz-cli/src/commands/messages.rs | 115 ++++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b56..8f39527d6b2 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -12,6 +12,51 @@ use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, }; +/// Default and maximum `--limit` for `messages get`. +const GET_LIMIT_DEFAULT: u32 = 50; +const GET_LIMIT_MAX: u32 = 200; + +/// Resolve a `--limit` against a command's default and cap. +fn effective_limit(requested: Option, default: u32, max: u32) -> u32 { + requested.unwrap_or(default).min(max) +} + +/// Build the stderr note for a read that came back full. +/// +/// A read that returns exactly its limit is indistinguishable from a complete +/// one on stdout, which is how an agent rebuilding context from `messages get` +/// silently reconstructs a prefix of the conversation as if it were the whole +/// thing. There is no total to report — the relay answers a filter, not a +/// count — so the note states what bound was hit and how to raise it, and says +/// "may" because a result set exactly the size of the limit is also possible. +/// +/// Returns `None` for a short read, which is the only case that is provably +/// complete. +fn truncation_notice( + returned: usize, + requested: Option, + default: u32, + max: u32, +) -> Option { + let limit = effective_limit(requested, default, max); + if returned < limit as usize { + return None; + } + let bound = match requested { + None => format!("the default limit of {default}"), + Some(r) if r > max => format!("--limit {r}, capped at {max}"), + Some(r) => format!("--limit {r}"), + }; + let advice = if limit < max { + format!("pass a larger --limit (max {max})") + } else { + "narrow the window with --since / --before to page through the rest".to_string() + }; + Some(format!( + "showing {returned} results — {bound} was reached, so more may exist; {advice}" + )) +} + /// Extract the thread root event ID from a Nostr tag array. /// /// Parses `"e"` tags with NIP-10 markers: @@ -360,7 +405,8 @@ pub async fn cmd_get_messages( format: &crate::OutputFormat, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - let limit = limit.unwrap_or(50).min(200); + let requested_limit = limit; + let limit = effective_limit(requested_limit, GET_LIMIT_DEFAULT, GET_LIMIT_MAX); let mut filter = serde_json::json!({ "kinds": [9, 40002, 40008, 45001, 45003], @@ -386,6 +432,14 @@ pub async fn cmd_get_messages( let resp = client.query(&filter).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); + if let Some(notice) = truncation_notice( + events.len(), + requested_limit, + GET_LIMIT_DEFAULT, + GET_LIMIT_MAX, + ) { + eprintln!("{notice}"); + } let normalized = normalize_events(&events); println!("{}", format_events(&normalized, format)); Ok(()) @@ -1373,3 +1427,62 @@ mod tests { assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1); } } + +#[cfg(test)] +mod truncation_tests { + use super::{truncation_notice, GET_LIMIT_DEFAULT, GET_LIMIT_MAX}; + + #[test] + fn a_short_read_is_provably_complete_and_says_nothing() { + assert_eq!( + truncation_notice(49, None, GET_LIMIT_DEFAULT, GET_LIMIT_MAX), + None + ); + assert_eq!( + truncation_notice(0, Some(10), GET_LIMIT_DEFAULT, GET_LIMIT_MAX), + None + ); + } + + #[test] + fn a_full_read_on_the_default_names_the_default() { + let notice = truncation_notice(50, None, GET_LIMIT_DEFAULT, GET_LIMIT_MAX) + .expect("a full read must be reported"); + assert!(notice.contains("showing 50 results"), "{notice}"); + assert!(notice.contains("the default limit of 50"), "{notice}"); + assert!(notice.contains("max 200"), "{notice}"); + } + + #[test] + fn a_full_read_on_an_explicit_limit_names_that_limit() { + let notice = truncation_notice(30, Some(30), GET_LIMIT_DEFAULT, GET_LIMIT_MAX) + .expect("a full read must be reported"); + assert!(notice.contains("--limit 30"), "{notice}"); + assert!(!notice.contains("capped"), "{notice}"); + } + + #[test] + fn a_limit_above_the_cap_reports_the_cap_that_actually_applied() { + // The silent case from the report: a --limit far above the cap comes + // back at the cap with nothing saying so. + let notice = truncation_notice(200, Some(1000), GET_LIMIT_DEFAULT, GET_LIMIT_MAX) + .expect("a capped read must be reported"); + assert!(notice.contains("--limit 1000, capped at 200"), "{notice}"); + // At the cap there is no larger limit to suggest. + assert!(notice.contains("--since / --before"), "{notice}"); + } + + #[test] + fn a_read_at_the_cap_suggests_paging_not_a_bigger_limit() { + let notice = truncation_notice(200, Some(200), GET_LIMIT_DEFAULT, GET_LIMIT_MAX) + .expect("a full read must be reported"); + assert!(!notice.contains("pass a larger"), "{notice}"); + } + + #[test] + fn more_rows_than_the_limit_still_reports() { + // Defensive: the relay is not supposed to overshoot the filter limit, + // but a longer response is still not a complete-read proof. + assert!(truncation_notice(51, None, GET_LIMIT_DEFAULT, GET_LIMIT_MAX).is_some()); + } +} From 2c72032bd06b64ff706812a90a643eabb8d870bf Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 13 Aug 2026 06:14:59 +0530 Subject: [PATCH 02/12] fix(cli): report truncation on messages search and thread too search defaults to 20 and hard-caps at 100, so `--limit 500` returns exactly 100 with nothing indicating the flag was ignored. thread has the same shape at 100/500. Both now emit the same stderr note. thread counts only the replies: the root event rides along in the same response but is not part of the reply page, so including it would report truncation one reply early. Also state the defaults and caps in --limit's help, which previously read "Maximum number of results to return" with no hint that a default existed, and mark --before / --since inclusive - `until` and `since` are both inclusive comparisons in the filter matcher, so a naive pager double-counts the boundary event without that note. Closes #5595 Signed-off-by: Taksh --- crates/buzz-cli/src/commands/messages.rs | 54 ++++++++++++++++++++++-- crates/buzz-cli/src/lib.rs | 10 ++--- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 8f39527d6b2..8f842beaf6e 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -15,6 +15,12 @@ use buzz_sdk::mentions::{ /// Default and maximum `--limit` for `messages get`. const GET_LIMIT_DEFAULT: u32 = 50; const GET_LIMIT_MAX: u32 = 200; +/// Default and maximum `--limit` for `messages thread`. +const THREAD_LIMIT_DEFAULT: u32 = 100; +const THREAD_LIMIT_MAX: u32 = 500; +/// Default and maximum `--limit` for `messages search`. +const SEARCH_LIMIT_DEFAULT: u32 = 20; +const SEARCH_LIMIT_MAX: u32 = 100; /// Resolve a `--limit` against a command's default and cap. fn effective_limit(requested: Option, default: u32, max: u32) -> u32 { @@ -455,7 +461,8 @@ pub async fn cmd_get_thread( ) -> Result<(), CliError> { validate_uuid(channel_id)?; validate_hex64(event_id)?; - let limit = limit.unwrap_or(100).min(500); + let requested_limit = limit; + let limit = effective_limit(requested_limit, THREAD_LIMIT_DEFAULT, THREAD_LIMIT_MAX); // Two filters ORed in a single HTTP call: // 1. Replies referencing this event via e-tag (no kind restriction) @@ -476,6 +483,20 @@ pub async fn cmd_get_thread( let resp = client.query_multi(&[reply_filter, root_filter]).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); + // The root rides along in the same response but is not part of the reply + // page, so it must not count toward the limit. + let reply_count = events + .iter() + .filter(|e| e.get("id").and_then(|v| v.as_str()) != Some(event_id)) + .count(); + if let Some(notice) = truncation_notice( + reply_count, + requested_limit, + THREAD_LIMIT_DEFAULT, + THREAD_LIMIT_MAX, + ) { + eprintln!("{notice}"); + } let normalized = normalize_events(&events); println!("{}", format_events(&normalized, format)); Ok(()) @@ -494,7 +515,8 @@ pub async fn cmd_search( "at least one of --query or --author is required".into(), )); } - let limit = limit.unwrap_or(20).min(100); + let requested_limit = limit; + let limit = effective_limit(requested_limit, SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX); let author_hex = match author { Some(a) => Some(resolve_author(client, a).await?), @@ -523,6 +545,14 @@ pub async fn cmd_search( std::cmp::Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)) }); } + if let Some(notice) = truncation_notice( + events.len(), + requested_limit, + SEARCH_LIMIT_DEFAULT, + SEARCH_LIMIT_MAX, + ) { + eprintln!("{notice}"); + } let normalized = normalize_events(&events); println!("{}", format_events(&normalized, format)); Ok(()) @@ -1430,7 +1460,9 @@ mod tests { #[cfg(test)] mod truncation_tests { - use super::{truncation_notice, GET_LIMIT_DEFAULT, GET_LIMIT_MAX}; + use super::{ + truncation_notice, GET_LIMIT_DEFAULT, GET_LIMIT_MAX, SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX, + }; #[test] fn a_short_read_is_provably_complete_and_says_nothing() { @@ -1479,6 +1511,22 @@ mod truncation_tests { assert!(!notice.contains("pass a larger"), "{notice}"); } + #[test] + fn the_search_cap_is_reported_even_though_the_flag_asked_for_more() { + // The reported case: `messages search --limit 500` returns 100. + let notice = truncation_notice(100, Some(500), SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX) + .expect("a capped search must be reported"); + assert!(notice.contains("--limit 500, capped at 100"), "{notice}"); + } + + #[test] + fn the_search_default_of_20_is_named() { + let notice = truncation_notice(20, None, SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX) + .expect("a full search must be reported"); + assert!(notice.contains("the default limit of 20"), "{notice}"); + assert!(notice.contains("max 100"), "{notice}"); + } + #[test] fn more_rows_than_the_limit_still_reports() { // Defensive: the relay is not supposed to overshoot the filter limit, diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 2b041da57b5..3ea16ac8fa5 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -467,10 +467,10 @@ pub enum MessagesCmd { /// Channel UUID #[arg(long)] channel: String, - /// Maximum number of results to return + /// Maximum number of results to return [default: 50] [max: 200] #[arg(long)] limit: Option, - /// Unix timestamp — return messages before this time + /// Unix timestamp — return messages before this time (inclusive) #[arg(long)] before: Option, /// Unix timestamp — return messages after this time @@ -488,7 +488,7 @@ pub enum MessagesCmd { /// Root message event ID (64-char hex) #[arg(long)] event: String, - /// Maximum number of results to return + /// Maximum number of replies to return [default: 100] [max: 500] #[arg(long)] limit: Option, /// Maximum reply nesting depth to include @@ -506,10 +506,10 @@ pub enum MessagesCmd { /// Filter by author: 64-char hex pubkey, npub, or display name #[arg(long)] author: Option, - /// Unix timestamp — return messages after this time + /// Unix timestamp — return messages after this time (inclusive) #[arg(long)] since: Option, - /// Maximum number of results to return + /// Maximum number of results to return [default: 20] [max: 100] #[arg(long)] limit: Option, }, From bfae3f023eed315be638e032460a290d13c9d296 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 11:20:28 +0530 Subject: [PATCH 03/12] refactor(cli): move the limit helpers into their own module `effective_limit` and `truncation_notice` are not specific to `messages`: every list-shaped read has a default and a cap and can come back at either. Give them a module so the other read commands can adopt them without importing from a sibling command. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/messages.rs | 42 +-------------------- crates/buzz-cli/src/lib.rs | 1 + crates/buzz-cli/src/limits.rs | 48 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 41 deletions(-) create mode 100644 crates/buzz-cli/src/limits.rs diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 8f842beaf6e..27607957e97 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -4,6 +4,7 @@ use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; +use crate::limits::{effective_limit, truncation_notice}; use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, @@ -22,47 +23,6 @@ const THREAD_LIMIT_MAX: u32 = 500; const SEARCH_LIMIT_DEFAULT: u32 = 20; const SEARCH_LIMIT_MAX: u32 = 100; -/// Resolve a `--limit` against a command's default and cap. -fn effective_limit(requested: Option, default: u32, max: u32) -> u32 { - requested.unwrap_or(default).min(max) -} - -/// Build the stderr note for a read that came back full. -/// -/// A read that returns exactly its limit is indistinguishable from a complete -/// one on stdout, which is how an agent rebuilding context from `messages get` -/// silently reconstructs a prefix of the conversation as if it were the whole -/// thing. There is no total to report — the relay answers a filter, not a -/// count — so the note states what bound was hit and how to raise it, and says -/// "may" because a result set exactly the size of the limit is also possible. -/// -/// Returns `None` for a short read, which is the only case that is provably -/// complete. -fn truncation_notice( - returned: usize, - requested: Option, - default: u32, - max: u32, -) -> Option { - let limit = effective_limit(requested, default, max); - if returned < limit as usize { - return None; - } - let bound = match requested { - None => format!("the default limit of {default}"), - Some(r) if r > max => format!("--limit {r}, capped at {max}"), - Some(r) => format!("--limit {r}"), - }; - let advice = if limit < max { - format!("pass a larger --limit (max {max})") - } else { - "narrow the window with --since / --before to page through the rest".to_string() - }; - Some(format!( - "showing {returned} results — {bound} was reached, so more may exist; {advice}" - )) -} - /// Extract the thread root event ID from a Nostr tag array. /// /// Parses `"e"` tags with NIP-10 markers: diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 3ea16ac8fa5..0545a190585 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod limits; mod links; mod validate; diff --git a/crates/buzz-cli/src/limits.rs b/crates/buzz-cli/src/limits.rs new file mode 100644 index 00000000000..16ffe7cf530 --- /dev/null +++ b/crates/buzz-cli/src/limits.rs @@ -0,0 +1,48 @@ +//! Shared `--limit` bookkeeping for the read commands. +//! +//! Every list-shaped read has a default limit and a hard cap, and a response +//! that comes back at that bound looks exactly like a complete one on stdout. +//! An agent rebuilding context from such a read reconstructs a prefix of the +//! truth and acts on it. These helpers resolve the bound that actually applied +//! and phrase the note the commands print to stderr. + +/// Resolve a `--limit` against a command's default and cap. +pub fn effective_limit(requested: Option, default: u32, max: u32) -> u32 { + requested.unwrap_or(default).min(max) +} + +/// Build the stderr note for a read that came back full. +/// +/// A read that returns exactly its limit is indistinguishable from a complete +/// one on stdout, which is how an agent rebuilding context from `messages get` +/// silently reconstructs a prefix of the conversation as if it were the whole +/// thing. There is no total to report — the relay answers a filter, not a +/// count — so the note states what bound was hit and how to raise it, and says +/// "may" because a result set exactly the size of the limit is also possible. +/// +/// Returns `None` for a short read, which is the only case that is provably +/// complete. +pub fn truncation_notice( + returned: usize, + requested: Option, + default: u32, + max: u32, +) -> Option { + let limit = effective_limit(requested, default, max); + if returned < limit as usize { + return None; + } + let bound = match requested { + None => format!("the default limit of {default}"), + Some(r) if r > max => format!("--limit {r}, capped at {max}"), + Some(r) => format!("--limit {r}"), + }; + let advice = if limit < max { + format!("pass a larger --limit (max {max})") + } else { + "narrow the window with --since / --before to page through the rest".to_string() + }; + Some(format!( + "showing {returned} results — {bound} was reached, so more may exist; {advice}" + )) +} From 3204a176de9325363a3da0875e8c6caf1900fe0f Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 11:20:44 +0530 Subject: [PATCH 04/12] fix(cli): report when dms list comes back at its limit `dms list` returns the newest 50 conversations by default and silently clamps `--limit` at 200. A caller enumerating conversations to pick one has no way to tell a full list from a page of it. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/dms.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 589e4118270..2d4ba9e0af7 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -2,12 +2,20 @@ use uuid::Uuid; use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; +use crate::limits::{effective_limit, truncation_notice}; use crate::validate::{parse_uuid, sdk_err, validate_hex64}; +/// Default and maximum `--limit` for `dms list`. +const LIST_LIMIT_DEFAULT: u32 = 50; +const LIST_LIMIT_MAX: u32 = 200; + /// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey. -pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), CliError> { +pub async fn cmd_list_dms( + client: &BuzzClient, + requested_limit: Option, +) -> Result<(), CliError> { let my_pk = client.keys().public_key().to_hex(); - let limit = limit.unwrap_or(50).min(200); + let limit = effective_limit(requested_limit, LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX); let filter = serde_json::json!({ "kinds": [41001], "#p": [my_pk], @@ -44,6 +52,14 @@ pub async fn cmd_list_dms(client: &BuzzClient, limit: Option) -> Result<(), .collect(); let output = serde_json::to_string(&dms).unwrap_or_default(); println!("{output}"); + if let Some(notice) = truncation_notice( + dms.len(), + requested_limit, + LIST_LIMIT_DEFAULT, + LIST_LIMIT_MAX, + ) { + eprintln!("{notice}"); + } Ok(()) } From 48dc1e06354986df93e6733b077752f82ddcbbbf Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 12:30:16 +0530 Subject: [PATCH 05/12] fix(cli): report when notes ls comes back at its limit Same shape as the other reads: 50 by default, clamped at 200, and a full page is indistinguishable from the whole set. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/notes.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/notes.rs b/crates/buzz-cli/src/commands/notes.rs index 08ef345be11..d8328cb0170 100644 --- a/crates/buzz-cli/src/commands/notes.rs +++ b/crates/buzz-cli/src/commands/notes.rs @@ -32,8 +32,13 @@ use nostr::{Event, EventBuilder, Kind, PublicKey, Tag, Timestamp, ToBech32}; use crate::client::BuzzClient; use crate::error::CliError; +use crate::limits::{effective_limit, truncation_notice}; use crate::validate::validate_hex64; +/// Default and maximum `--limit` for `notes ls`. +const LS_LIMIT_DEFAULT: u32 = 50; +const LS_LIMIT_MAX: u32 = 200; + /// NIP-23 long-form content kind. pub const KIND_LONG_FORM: u16 = 30023; @@ -672,9 +677,9 @@ pub async fn cmd_ls( client: &BuzzClient, author: Option<&str>, tag: Option<&str>, - limit: Option, + requested_limit: Option, ) -> Result<(), CliError> { - let limit = limit.unwrap_or(50).min(200); + let limit = effective_limit(requested_limit, LS_LIMIT_DEFAULT, LS_LIMIT_MAX); let author = author.unwrap_or("me"); let mut filter = serde_json::json!({ @@ -698,6 +703,14 @@ pub async fn cmd_ls( let mut snapshots = snapshots_from_events(parse_events(&raw)?)?; sort_snapshots_newest_first(&mut snapshots); print_snapshot_list_json(&snapshots)?; + if let Some(notice) = truncation_notice( + snapshots.len(), + requested_limit, + LS_LIMIT_DEFAULT, + LS_LIMIT_MAX, + ) { + eprintln!("{notice}"); + } Ok(()) } From 4fb2f5f023750443af85a3165a4262c4b5d46cb2 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 12:31:09 +0530 Subject: [PATCH 06/12] fix(cli): report when feed get comes back at its limit The feed defaults to 20 and caps at 50, the tightest bounds of any read here, so a busy inbox hits the ceiling routinely and reads as "that is everything addressed to me". Signed-off-by: Taksh --- crates/buzz-cli/src/commands/feed.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index d3d5c7f81a4..1728d2badd1 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -2,19 +2,24 @@ use std::cmp::Reverse; use crate::client::{normalize_events, BuzzClient}; use crate::error::CliError; +use crate::limits::{effective_limit, truncation_notice}; const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"]; +/// Default and maximum `--limit` for `feed get`. +const FEED_LIMIT_DEFAULT: u32 = 20; +const FEED_LIMIT_MAX: u32 = 50; + /// Get activity feed — query events mentioning our pubkey (via p-tag). pub async fn cmd_get_feed( client: &BuzzClient, since: Option, - limit: Option, + requested_limit: Option, types: Option<&str>, format: &crate::OutputFormat, ) -> Result<(), CliError> { let my_pk = client.keys().public_key().to_hex(); - let limit = limit.unwrap_or(20).min(50); + let limit = effective_limit(requested_limit, FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX); let mut filter = serde_json::json!({ "#p": [my_pk], @@ -61,6 +66,14 @@ pub async fn cmd_get_feed( crate::OutputFormat::Json => normalized, }; println!("{output}"); + if let Some(notice) = truncation_notice( + events.len(), + requested_limit, + FEED_LIMIT_DEFAULT, + FEED_LIMIT_MAX, + ) { + eprintln!("{notice}"); + } Ok(()) } From b3a86482cf8b96a33a0400e796702daf46062b67 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 12:31:10 +0530 Subject: [PATCH 07/12] fix(cli): report when social notes comes back at its limit Counts the events the relay actually returned rather than assuming the page was full: this path prints the relay response verbatim, so the note has to parse it back to know. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/social.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/social.rs b/crates/buzz-cli/src/commands/social.rs index 89c028a8ae3..a9eee585201 100644 --- a/crates/buzz-cli/src/commands/social.rs +++ b/crates/buzz-cli/src/commands/social.rs @@ -7,8 +7,13 @@ use serde::Deserialize; use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; +use crate::limits::{effective_limit, truncation_notice}; use crate::validate::{parse_event_id, validate_hex64}; +/// Default and maximum `--limit` for `social notes`. +const NOTES_LIMIT_DEFAULT: u32 = 50; +const NOTES_LIMIT_MAX: u32 = 100; + /// A single contact entry (CLI-local, not from buzz-sdk). #[derive(Debug, Deserialize)] pub struct ContactEntry { @@ -83,7 +88,7 @@ pub async fn cmd_get_event(client: &BuzzClient, event_id: &str) -> Result<(), Cl pub async fn cmd_get_user_notes( client: &BuzzClient, pubkey: &str, - limit: Option, + requested_limit: Option, before: Option, before_id: Option<&str>, ) -> Result<(), CliError> { @@ -91,7 +96,7 @@ pub async fn cmd_get_user_notes( if let Some(bid) = before_id { validate_hex64(bid)?; } - let limit = limit.unwrap_or(50).min(100); + let limit = effective_limit(requested_limit, NOTES_LIMIT_DEFAULT, NOTES_LIMIT_MAX); let mut filter = serde_json::json!({ "kinds": [1], @@ -108,6 +113,17 @@ pub async fn cmd_get_user_notes( let resp = client.query(&filter).await?; println!("{resp}"); + let returned = serde_json::from_str::>(&resp) + .map(|events| events.len()) + .unwrap_or(0); + if let Some(notice) = truncation_notice( + returned, + requested_limit, + NOTES_LIMIT_DEFAULT, + NOTES_LIMIT_MAX, + ) { + eprintln!("{notice}"); + } Ok(()) } From 60fcb664a6f6033874c91050fd813b6c26842323 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 12:31:33 +0530 Subject: [PATCH 08/12] fix(cli): report when workflows runs comes back at its limit Run history is the read most likely to be paged through, and the one where a silent prefix reads as "the workflow stopped running". Signed-off-by: Taksh --- crates/buzz-cli/src/commands/workflows.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/buzz-cli/src/commands/workflows.rs b/crates/buzz-cli/src/commands/workflows.rs index 2786d2c5088..8488ad69bac 100644 --- a/crates/buzz-cli/src/commands/workflows.rs +++ b/crates/buzz-cli/src/commands/workflows.rs @@ -5,6 +5,7 @@ use crate::client::{ BuzzClient, }; use crate::error::CliError; +use crate::limits::{effective_limit, truncation_notice}; use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid}; // TODO(phase-4): Replace raw nostr::EventBuilder usage with buzz-sdk builder functions @@ -57,6 +58,10 @@ pub async fn cmd_get_workflow(client: &BuzzClient, workflow_id: &str) -> Result< Ok(()) } +/// Default and maximum `--limit` for `workflows runs`. +const RUNS_LIMIT_DEFAULT: u32 = 20; +const RUNS_LIMIT_MAX: u32 = 100; + /// Get workflow run history — query kinds [46001, 46002, 46003]. /// /// NOTE: The relay does not currently emit workflow execution events (46001-46003). @@ -66,10 +71,10 @@ pub async fn cmd_get_workflow(client: &BuzzClient, workflow_id: &str) -> Result< pub async fn cmd_get_workflow_runs( client: &BuzzClient, workflow_id: &str, - limit: Option, + requested_limit: Option, ) -> Result<(), CliError> { validate_uuid(workflow_id)?; - let limit = limit.unwrap_or(20).min(100); + let limit = effective_limit(requested_limit, RUNS_LIMIT_DEFAULT, RUNS_LIMIT_MAX); let filter = serde_json::json!({ "kinds": [46001, 46002, 46003], "#d": [workflow_id], @@ -91,6 +96,14 @@ pub async fn cmd_get_workflow_runs( .collect(); let output = serde_json::to_string(&normalized).unwrap_or_default(); println!("{output}"); + if let Some(notice) = truncation_notice( + normalized.len(), + requested_limit, + RUNS_LIMIT_DEFAULT, + RUNS_LIMIT_MAX, + ) { + eprintln!("{notice}"); + } Ok(()) } From e17e983e7baf3a221b9427393aec667ea5e2ed7b Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 12:32:20 +0530 Subject: [PATCH 09/12] feat(cli): state the default and cap on the remaining --limit flags `notes ls` already documented its bounds; `dms list`, `feed get` and `workflows runs` read "Maximum number of results to return" with no hint that a default existed or that a larger value would be clamped. Signed-off-by: Taksh --- crates/buzz-cli/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0545a190585..081a889604d 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -804,7 +804,7 @@ pub enum EmojiCmd { pub enum DmsCmd { /// List direct message conversations List { - /// Maximum number of results to return + /// Maximum number of results to return (default 50, hard cap 200). #[arg(long)] limit: Option, }, @@ -947,7 +947,7 @@ pub enum WorkflowsCmd { /// Workflow UUID #[arg(long)] workflow: String, - /// Maximum number of results to return + /// Maximum number of results to return (default 20, hard cap 100). #[arg(long)] limit: Option, }, @@ -975,7 +975,7 @@ pub enum FeedCmd { /// Unix timestamp — return entries after this time #[arg(long)] since: Option, - /// Maximum number of results to return + /// Maximum number of results to return (default 20, hard cap 50). #[arg(long)] limit: Option, /// Comma-separated feed types to include: mentions, needs_action, activity, agent_activity From 6a6840d37182ff3f4bf246d5e2a1d1823c558d5e Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 12:33:01 +0530 Subject: [PATCH 10/12] test(cli): cover the shared limit helpers Moves the bound cases onto the module that now owns them: short reads stay silent, a full default read names the default, a clamped --limit says so and stops suggesting a larger one, and a relay that overshoots the bound still warns. Signed-off-by: Taksh --- crates/buzz-cli/src/limits.rs | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/buzz-cli/src/limits.rs b/crates/buzz-cli/src/limits.rs index 16ffe7cf530..2d107716efd 100644 --- a/crates/buzz-cli/src/limits.rs +++ b/crates/buzz-cli/src/limits.rs @@ -46,3 +46,51 @@ pub fn truncation_notice( "showing {returned} results — {bound} was reached, so more may exist; {advice}" )) } + +#[cfg(test)] +mod tests { + use super::{effective_limit, truncation_notice}; + + #[test] + fn effective_limit_applies_the_default_then_the_cap() { + assert_eq!(effective_limit(None, 50, 200), 50); + assert_eq!(effective_limit(Some(10), 50, 200), 10); + assert_eq!(effective_limit(Some(1_000), 50, 200), 200); + } + + #[test] + fn a_short_read_is_silent() { + // The only provably complete case. + assert_eq!(truncation_notice(19, None, 20, 50), None); + assert_eq!(truncation_notice(0, Some(10), 20, 50), None); + } + + #[test] + fn a_full_default_read_names_the_default() { + let notice = truncation_notice(20, None, 20, 50).expect("full read must warn"); + assert!(notice.contains("the default limit of 20"), "{notice}"); + assert!(notice.contains("max 50"), "{notice}"); + } + + #[test] + fn a_clamped_limit_says_it_was_clamped() { + let notice = truncation_notice(50, Some(500), 20, 50).expect("full read must warn"); + assert!(notice.contains("--limit 500, capped at 50"), "{notice}"); + // At the cap there is no larger limit to suggest. + assert!(notice.contains("--since"), "{notice}"); + } + + #[test] + fn a_read_at_the_requested_limit_names_that_limit() { + let notice = truncation_notice(30, Some(30), 20, 50).expect("full read must warn"); + assert!(notice.contains("--limit 30"), "{notice}"); + assert!(!notice.contains("capped"), "{notice}"); + } + + #[test] + fn an_overlong_read_still_warns() { + // A relay that ignores the limit and returns more must not read as + // complete just because the count is above the bound. + assert!(truncation_notice(60, None, 20, 50).is_some()); + } +} From 667f18c83baf64dddf30000a9ce660e21e7ef263 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 21:26:07 +0530 Subject: [PATCH 11/12] fix(cli): tailor the hard-cap advice to each command's real flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review points out the shared notice told every caller to page with --since / --before, but dms list, messages thread, notes ls and workflows runs expose neither, and feed get and messages search expose only --since, which cannot reach older results. An agent following that advice burns a call on a flag that does not exist, or believes data is reachable when it is not. Each command now declares its contract once as a ReadLimits — default, cap and a Paging value naming what it can actually do: - messages get TimestampWindow (--since and --before) - social notes BeforeCursor (--before + --before-id) - feed get, SinceOnly (narrows to newer only; says it cannot messages search request an older page) - dms list, notes ls, None (says it cannot request a larger page) messages thread, workflows runs Below the cap every command still says 'pass a larger --limit', which is true regardless of paging. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/dms.rs | 21 ++- crates/buzz-cli/src/commands/feed.rs | 21 ++- crates/buzz-cli/src/commands/messages.rs | 110 ++++++++-------- crates/buzz-cli/src/commands/notes.rs | 21 ++- crates/buzz-cli/src/commands/social.rs | 32 +++-- crates/buzz-cli/src/commands/workflows.rs | 20 ++- crates/buzz-cli/src/limits.rs | 153 +++++++++++++++++++--- 7 files changed, 250 insertions(+), 128 deletions(-) diff --git a/crates/buzz-cli/src/commands/dms.rs b/crates/buzz-cli/src/commands/dms.rs index 2d4ba9e0af7..af6b89586d1 100644 --- a/crates/buzz-cli/src/commands/dms.rs +++ b/crates/buzz-cli/src/commands/dms.rs @@ -2,12 +2,16 @@ use uuid::Uuid; use crate::client::{extract_d_tag, normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::limits::{effective_limit, truncation_notice}; +use crate::limits::{truncation_notice, Paging, ReadLimits}; use crate::validate::{parse_uuid, sdk_err, validate_hex64}; -/// Default and maximum `--limit` for `dms list`. -const LIST_LIMIT_DEFAULT: u32 = 50; -const LIST_LIMIT_MAX: u32 = 200; +/// `dms list` exposes `--limit` and nothing else, so a caller who hits the cap +/// has no older page to ask for. +const LIST_LIMITS: ReadLimits = ReadLimits { + default: 50, + max: 200, + paging: Paging::None, +}; /// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey. pub async fn cmd_list_dms( @@ -15,7 +19,7 @@ pub async fn cmd_list_dms( requested_limit: Option, ) -> Result<(), CliError> { let my_pk = client.keys().public_key().to_hex(); - let limit = effective_limit(requested_limit, LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX); + let limit = LIST_LIMITS.effective(requested_limit); let filter = serde_json::json!({ "kinds": [41001], "#p": [my_pk], @@ -52,12 +56,7 @@ pub async fn cmd_list_dms( .collect(); let output = serde_json::to_string(&dms).unwrap_or_default(); println!("{output}"); - if let Some(notice) = truncation_notice( - dms.len(), - requested_limit, - LIST_LIMIT_DEFAULT, - LIST_LIMIT_MAX, - ) { + if let Some(notice) = truncation_notice(dms.len(), requested_limit, LIST_LIMITS) { eprintln!("{notice}"); } Ok(()) diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index 1728d2badd1..265d7ac8bba 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -2,13 +2,17 @@ use std::cmp::Reverse; use crate::client::{normalize_events, BuzzClient}; use crate::error::CliError; -use crate::limits::{effective_limit, truncation_notice}; +use crate::limits::{truncation_notice, Paging, ReadLimits}; const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"]; -/// Default and maximum `--limit` for `feed get`. -const FEED_LIMIT_DEFAULT: u32 = 20; -const FEED_LIMIT_MAX: u32 = 50; +/// `feed get` exposes `--since` but no `--before`, so the window can only be +/// narrowed to newer entries — never walked backwards. +const FEED_LIMITS: ReadLimits = ReadLimits { + default: 20, + max: 50, + paging: Paging::SinceOnly, +}; /// Get activity feed — query events mentioning our pubkey (via p-tag). pub async fn cmd_get_feed( @@ -19,7 +23,7 @@ pub async fn cmd_get_feed( format: &crate::OutputFormat, ) -> Result<(), CliError> { let my_pk = client.keys().public_key().to_hex(); - let limit = effective_limit(requested_limit, FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX); + let limit = FEED_LIMITS.effective(requested_limit); let mut filter = serde_json::json!({ "#p": [my_pk], @@ -66,12 +70,7 @@ pub async fn cmd_get_feed( crate::OutputFormat::Json => normalized, }; println!("{output}"); - if let Some(notice) = truncation_notice( - events.len(), - requested_limit, - FEED_LIMIT_DEFAULT, - FEED_LIMIT_MAX, - ) { + if let Some(notice) = truncation_notice(events.len(), requested_limit, FEED_LIMITS) { eprintln!("{notice}"); } Ok(()) diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 27607957e97..f18687adb81 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::limits::{effective_limit, truncation_notice}; +use crate::limits::{truncation_notice, Paging, ReadLimits}; use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, @@ -13,15 +13,27 @@ use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, }; -/// Default and maximum `--limit` for `messages get`. -const GET_LIMIT_DEFAULT: u32 = 50; -const GET_LIMIT_MAX: u32 = 200; -/// Default and maximum `--limit` for `messages thread`. -const THREAD_LIMIT_DEFAULT: u32 = 100; -const THREAD_LIMIT_MAX: u32 = 500; -/// Default and maximum `--limit` for `messages search`. -const SEARCH_LIMIT_DEFAULT: u32 = 20; -const SEARCH_LIMIT_MAX: u32 = 100; +/// `messages get` is the one read here with a movable window: `--since` and +/// `--before` both exist. +const GET_LIMITS: ReadLimits = ReadLimits { + default: 50, + max: 200, + paging: Paging::TimestampWindow, +}; +/// `messages thread` takes `--channel`, `--event` and `--depth-limit`; there is +/// no timestamp window to walk, so the cap bounds what a thread read can show. +const THREAD_LIMITS: ReadLimits = ReadLimits { + default: 100, + max: 500, + paging: Paging::None, +}; +/// `messages search` exposes `--since` but no `--before`, so it cannot ask for +/// results older than the ones it just returned. +const SEARCH_LIMITS: ReadLimits = ReadLimits { + default: 20, + max: 100, + paging: Paging::SinceOnly, +}; /// Extract the thread root event ID from a Nostr tag array. /// @@ -372,7 +384,7 @@ pub async fn cmd_get_messages( ) -> Result<(), CliError> { validate_uuid(channel_id)?; let requested_limit = limit; - let limit = effective_limit(requested_limit, GET_LIMIT_DEFAULT, GET_LIMIT_MAX); + let limit = GET_LIMITS.effective(requested_limit); let mut filter = serde_json::json!({ "kinds": [9, 40002, 40008, 45001, 45003], @@ -398,12 +410,7 @@ pub async fn cmd_get_messages( let resp = client.query(&filter).await?; let mut events: Vec = serde_json::from_str(&resp).unwrap_or_default(); events.sort_by_key(|e| e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)); - if let Some(notice) = truncation_notice( - events.len(), - requested_limit, - GET_LIMIT_DEFAULT, - GET_LIMIT_MAX, - ) { + if let Some(notice) = truncation_notice(events.len(), requested_limit, GET_LIMITS) { eprintln!("{notice}"); } let normalized = normalize_events(&events); @@ -422,7 +429,7 @@ pub async fn cmd_get_thread( validate_uuid(channel_id)?; validate_hex64(event_id)?; let requested_limit = limit; - let limit = effective_limit(requested_limit, THREAD_LIMIT_DEFAULT, THREAD_LIMIT_MAX); + let limit = THREAD_LIMITS.effective(requested_limit); // Two filters ORed in a single HTTP call: // 1. Replies referencing this event via e-tag (no kind restriction) @@ -449,12 +456,7 @@ pub async fn cmd_get_thread( .iter() .filter(|e| e.get("id").and_then(|v| v.as_str()) != Some(event_id)) .count(); - if let Some(notice) = truncation_notice( - reply_count, - requested_limit, - THREAD_LIMIT_DEFAULT, - THREAD_LIMIT_MAX, - ) { + if let Some(notice) = truncation_notice(reply_count, requested_limit, THREAD_LIMITS) { eprintln!("{notice}"); } let normalized = normalize_events(&events); @@ -476,7 +478,7 @@ pub async fn cmd_search( )); } let requested_limit = limit; - let limit = effective_limit(requested_limit, SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX); + let limit = SEARCH_LIMITS.effective(requested_limit); let author_hex = match author { Some(a) => Some(resolve_author(client, a).await?), @@ -505,12 +507,7 @@ pub async fn cmd_search( std::cmp::Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)) }); } - if let Some(notice) = truncation_notice( - events.len(), - requested_limit, - SEARCH_LIMIT_DEFAULT, - SEARCH_LIMIT_MAX, - ) { + if let Some(notice) = truncation_notice(events.len(), requested_limit, SEARCH_LIMITS) { eprintln!("{notice}"); } let normalized = normalize_events(&events); @@ -1420,26 +1417,17 @@ mod tests { #[cfg(test)] mod truncation_tests { - use super::{ - truncation_notice, GET_LIMIT_DEFAULT, GET_LIMIT_MAX, SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX, - }; + use super::{truncation_notice, GET_LIMITS, SEARCH_LIMITS, THREAD_LIMITS}; #[test] fn a_short_read_is_provably_complete_and_says_nothing() { - assert_eq!( - truncation_notice(49, None, GET_LIMIT_DEFAULT, GET_LIMIT_MAX), - None - ); - assert_eq!( - truncation_notice(0, Some(10), GET_LIMIT_DEFAULT, GET_LIMIT_MAX), - None - ); + assert_eq!(truncation_notice(49, None, GET_LIMITS), None); + assert_eq!(truncation_notice(0, Some(10), GET_LIMITS), None); } #[test] fn a_full_read_on_the_default_names_the_default() { - let notice = truncation_notice(50, None, GET_LIMIT_DEFAULT, GET_LIMIT_MAX) - .expect("a full read must be reported"); + let notice = truncation_notice(50, None, GET_LIMITS).expect("a full read must be reported"); assert!(notice.contains("showing 50 results"), "{notice}"); assert!(notice.contains("the default limit of 50"), "{notice}"); assert!(notice.contains("max 200"), "{notice}"); @@ -1447,8 +1435,8 @@ mod truncation_tests { #[test] fn a_full_read_on_an_explicit_limit_names_that_limit() { - let notice = truncation_notice(30, Some(30), GET_LIMIT_DEFAULT, GET_LIMIT_MAX) - .expect("a full read must be reported"); + let notice = + truncation_notice(30, Some(30), GET_LIMITS).expect("a full read must be reported"); assert!(notice.contains("--limit 30"), "{notice}"); assert!(!notice.contains("capped"), "{notice}"); } @@ -1457,8 +1445,8 @@ mod truncation_tests { fn a_limit_above_the_cap_reports_the_cap_that_actually_applied() { // The silent case from the report: a --limit far above the cap comes // back at the cap with nothing saying so. - let notice = truncation_notice(200, Some(1000), GET_LIMIT_DEFAULT, GET_LIMIT_MAX) - .expect("a capped read must be reported"); + let notice = + truncation_notice(200, Some(1000), GET_LIMITS).expect("a capped read must be reported"); assert!(notice.contains("--limit 1000, capped at 200"), "{notice}"); // At the cap there is no larger limit to suggest. assert!(notice.contains("--since / --before"), "{notice}"); @@ -1466,23 +1454,37 @@ mod truncation_tests { #[test] fn a_read_at_the_cap_suggests_paging_not_a_bigger_limit() { - let notice = truncation_notice(200, Some(200), GET_LIMIT_DEFAULT, GET_LIMIT_MAX) - .expect("a full read must be reported"); + let notice = + truncation_notice(200, Some(200), GET_LIMITS).expect("a full read must be reported"); assert!(!notice.contains("pass a larger"), "{notice}"); } #[test] fn the_search_cap_is_reported_even_though_the_flag_asked_for_more() { // The reported case: `messages search --limit 500` returns 100. - let notice = truncation_notice(100, Some(500), SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX) + let notice = truncation_notice(100, Some(500), SEARCH_LIMITS) .expect("a capped search must be reported"); assert!(notice.contains("--limit 500, capped at 100"), "{notice}"); + // search has --since but no --before, so it must not promise an older + // page it cannot fetch. + assert!(notice.contains("cannot request an older page"), "{notice}"); + assert!(!notice.contains("--before"), "{notice}"); + } + + #[test] + fn a_capped_thread_read_does_not_name_flags_it_lacks() { + // `messages thread` has neither --since nor --before. + let notice = truncation_notice(500, Some(500), THREAD_LIMITS) + .expect("a capped thread read must be reported"); + assert!(notice.contains("cannot request a larger page"), "{notice}"); + assert!(!notice.contains("--since"), "{notice}"); + assert!(!notice.contains("--before"), "{notice}"); } #[test] fn the_search_default_of_20_is_named() { - let notice = truncation_notice(20, None, SEARCH_LIMIT_DEFAULT, SEARCH_LIMIT_MAX) - .expect("a full search must be reported"); + let notice = + truncation_notice(20, None, SEARCH_LIMITS).expect("a full search must be reported"); assert!(notice.contains("the default limit of 20"), "{notice}"); assert!(notice.contains("max 100"), "{notice}"); } @@ -1491,6 +1493,6 @@ mod truncation_tests { fn more_rows_than_the_limit_still_reports() { // Defensive: the relay is not supposed to overshoot the filter limit, // but a longer response is still not a complete-read proof. - assert!(truncation_notice(51, None, GET_LIMIT_DEFAULT, GET_LIMIT_MAX).is_some()); + assert!(truncation_notice(51, None, GET_LIMITS).is_some()); } } diff --git a/crates/buzz-cli/src/commands/notes.rs b/crates/buzz-cli/src/commands/notes.rs index d8328cb0170..d68a6c2345e 100644 --- a/crates/buzz-cli/src/commands/notes.rs +++ b/crates/buzz-cli/src/commands/notes.rs @@ -32,12 +32,16 @@ use nostr::{Event, EventBuilder, Kind, PublicKey, Tag, Timestamp, ToBech32}; use crate::client::BuzzClient; use crate::error::CliError; -use crate::limits::{effective_limit, truncation_notice}; +use crate::limits::{truncation_notice, Paging, ReadLimits}; use crate::validate::validate_hex64; -/// Default and maximum `--limit` for `notes ls`. -const LS_LIMIT_DEFAULT: u32 = 50; -const LS_LIMIT_MAX: u32 = 200; +/// `notes ls` filters by `--author` / `--tag` only: no timestamp window, so +/// the cap is the end of what it can return. +const LS_LIMITS: ReadLimits = ReadLimits { + default: 50, + max: 200, + paging: Paging::None, +}; /// NIP-23 long-form content kind. pub const KIND_LONG_FORM: u16 = 30023; @@ -679,7 +683,7 @@ pub async fn cmd_ls( tag: Option<&str>, requested_limit: Option, ) -> Result<(), CliError> { - let limit = effective_limit(requested_limit, LS_LIMIT_DEFAULT, LS_LIMIT_MAX); + let limit = LS_LIMITS.effective(requested_limit); let author = author.unwrap_or("me"); let mut filter = serde_json::json!({ @@ -703,12 +707,7 @@ pub async fn cmd_ls( let mut snapshots = snapshots_from_events(parse_events(&raw)?)?; sort_snapshots_newest_first(&mut snapshots); print_snapshot_list_json(&snapshots)?; - if let Some(notice) = truncation_notice( - snapshots.len(), - requested_limit, - LS_LIMIT_DEFAULT, - LS_LIMIT_MAX, - ) { + if let Some(notice) = truncation_notice(snapshots.len(), requested_limit, LS_LIMITS) { eprintln!("{notice}"); } Ok(()) diff --git a/crates/buzz-cli/src/commands/social.rs b/crates/buzz-cli/src/commands/social.rs index a9eee585201..5bccf90f3b8 100644 --- a/crates/buzz-cli/src/commands/social.rs +++ b/crates/buzz-cli/src/commands/social.rs @@ -7,12 +7,16 @@ use serde::Deserialize; use crate::client::{normalize_write_response, BuzzClient}; use crate::error::CliError; -use crate::limits::{effective_limit, truncation_notice}; +use crate::limits::{truncation_notice, Paging, ReadLimits}; use crate::validate::{parse_event_id, validate_hex64}; -/// Default and maximum `--limit` for `social notes`. -const NOTES_LIMIT_DEFAULT: u32 = 50; -const NOTES_LIMIT_MAX: u32 = 100; +/// `social notes` pages backwards with the composite `--before` + +/// `--before-id` cursor, so a caller at the cap has a real next call to make. +const NOTES_LIMITS: ReadLimits = ReadLimits { + default: 50, + max: 100, + paging: Paging::BeforeCursor, +}; /// A single contact entry (CLI-local, not from buzz-sdk). #[derive(Debug, Deserialize)] @@ -96,7 +100,7 @@ pub async fn cmd_get_user_notes( if let Some(bid) = before_id { validate_hex64(bid)?; } - let limit = effective_limit(requested_limit, NOTES_LIMIT_DEFAULT, NOTES_LIMIT_MAX); + let limit = NOTES_LIMITS.effective(requested_limit); let mut filter = serde_json::json!({ "kinds": [1], @@ -116,12 +120,7 @@ pub async fn cmd_get_user_notes( let returned = serde_json::from_str::>(&resp) .map(|events| events.len()) .unwrap_or(0); - if let Some(notice) = truncation_notice( - returned, - requested_limit, - NOTES_LIMIT_DEFAULT, - NOTES_LIMIT_MAX, - ) { + if let Some(notice) = truncation_notice(returned, requested_limit, NOTES_LIMITS) { eprintln!("{notice}"); } Ok(()) @@ -298,3 +297,14 @@ mod tests { assert!(!is_parameterized_social_list_kind(KIND_MUTE_LIST)); } } + +#[cfg(test)] +mod note_count_tests { + use super::{truncation_notice, NOTES_LIMITS}; + + #[test] + fn a_full_page_of_notes_advertises_the_cursor_it_really_has() { + let notice = truncation_notice(100, Some(100), NOTES_LIMITS).expect("full read must warn"); + assert!(notice.contains("--before / --before-id"), "{notice}"); + } +} diff --git a/crates/buzz-cli/src/commands/workflows.rs b/crates/buzz-cli/src/commands/workflows.rs index 8488ad69bac..62b7e309b73 100644 --- a/crates/buzz-cli/src/commands/workflows.rs +++ b/crates/buzz-cli/src/commands/workflows.rs @@ -5,7 +5,7 @@ use crate::client::{ BuzzClient, }; use crate::error::CliError; -use crate::limits::{effective_limit, truncation_notice}; +use crate::limits::{truncation_notice, Paging, ReadLimits}; use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid}; // TODO(phase-4): Replace raw nostr::EventBuilder usage with buzz-sdk builder functions @@ -58,9 +58,12 @@ pub async fn cmd_get_workflow(client: &BuzzClient, workflow_id: &str) -> Result< Ok(()) } -/// Default and maximum `--limit` for `workflows runs`. -const RUNS_LIMIT_DEFAULT: u32 = 20; -const RUNS_LIMIT_MAX: u32 = 100; +/// `workflows runs` takes `--workflow` and `--limit`, nothing else. +const RUNS_LIMITS: ReadLimits = ReadLimits { + default: 20, + max: 100, + paging: Paging::None, +}; /// Get workflow run history — query kinds [46001, 46002, 46003]. /// @@ -74,7 +77,7 @@ pub async fn cmd_get_workflow_runs( requested_limit: Option, ) -> Result<(), CliError> { validate_uuid(workflow_id)?; - let limit = effective_limit(requested_limit, RUNS_LIMIT_DEFAULT, RUNS_LIMIT_MAX); + let limit = RUNS_LIMITS.effective(requested_limit); let filter = serde_json::json!({ "kinds": [46001, 46002, 46003], "#d": [workflow_id], @@ -96,12 +99,7 @@ pub async fn cmd_get_workflow_runs( .collect(); let output = serde_json::to_string(&normalized).unwrap_or_default(); println!("{output}"); - if let Some(notice) = truncation_notice( - normalized.len(), - requested_limit, - RUNS_LIMIT_DEFAULT, - RUNS_LIMIT_MAX, - ) { + if let Some(notice) = truncation_notice(normalized.len(), requested_limit, RUNS_LIMITS) { eprintln!("{notice}"); } Ok(()) diff --git a/crates/buzz-cli/src/limits.rs b/crates/buzz-cli/src/limits.rs index 2d107716efd..a0eb1aedc77 100644 --- a/crates/buzz-cli/src/limits.rs +++ b/crates/buzz-cli/src/limits.rs @@ -6,9 +6,40 @@ //! truth and acts on it. These helpers resolve the bound that actually applied //! and phrase the note the commands print to stderr. -/// Resolve a `--limit` against a command's default and cap. -pub fn effective_limit(requested: Option, default: u32, max: u32) -> u32 { - requested.unwrap_or(default).min(max) +/// What a command can actually do about a read that came back at its cap. +/// +/// The recovery instruction has to name flags the command really exposes: an +/// agent told to retry with `--before` on a command that has no `--before` +/// burns a call on a parse error, and one told to "page through the rest" when +/// there is no older page believes the data is reachable when it is not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Paging { + /// Both `--since` and `--before`: the window can be moved either way. + TimestampWindow, + /// `--before` plus `--before-id`, the composite cursor `social notes` + /// uses to walk backwards through a pubkey's notes. + BeforeCursor, + /// `--since` only. The window can be narrowed to *newer* results, which + /// does not help when the missing ones are older. + SinceOnly, + /// No windowing flags at all — the cap is the end of the road. + None, +} + +/// A read command's `--limit` contract: its default, its hard cap, and how (or +/// whether) a caller can reach results beyond the cap. +#[derive(Debug, Clone, Copy)] +pub struct ReadLimits { + pub default: u32, + pub max: u32, + pub paging: Paging, +} + +impl ReadLimits { + /// Resolve a `--limit` against this command's default and cap. + pub fn effective(&self, requested: Option) -> u32 { + requested.unwrap_or(self.default).min(self.max) + } } /// Build the stderr note for a read that came back full. @@ -20,27 +51,44 @@ pub fn effective_limit(requested: Option, default: u32, max: u32) -> u32 { /// count — so the note states what bound was hit and how to raise it, and says /// "may" because a result set exactly the size of the limit is also possible. /// +/// Below the cap the advice is always "ask for more"; at the cap it depends on +/// what the command exposes, which is what [`Paging`] carries. +/// /// Returns `None` for a short read, which is the only case that is provably /// complete. pub fn truncation_notice( returned: usize, requested: Option, - default: u32, - max: u32, + limits: ReadLimits, ) -> Option { - let limit = effective_limit(requested, default, max); + let limit = limits.effective(requested); if returned < limit as usize { return None; } + let max = limits.max; let bound = match requested { - None => format!("the default limit of {default}"), + None => format!("the default limit of {}", limits.default), Some(r) if r > max => format!("--limit {r}, capped at {max}"), Some(r) => format!("--limit {r}"), }; let advice = if limit < max { format!("pass a larger --limit (max {max})") } else { - "narrow the window with --since / --before to page through the rest".to_string() + match limits.paging { + Paging::TimestampWindow => { + "narrow the window with --since / --before to page through the rest".to_string() + } + Paging::BeforeCursor => { + "page backwards with --before / --before-id, taking both from the oldest note shown" + .to_string() + } + Paging::SinceOnly => format!( + "--limit {max} is the hard cap, and --since only narrows to newer results — this command cannot request an older page" + ), + Paging::None => format!( + "--limit {max} is the hard cap, and this command has no windowing flags — it cannot request a larger page" + ), + } }; Some(format!( "showing {returned} results — {bound} was reached, so more may exist; {advice}" @@ -49,40 +97,51 @@ pub fn truncation_notice( #[cfg(test)] mod tests { - use super::{effective_limit, truncation_notice}; + use super::{truncation_notice, Paging, ReadLimits}; + + const WINDOWED: ReadLimits = ReadLimits { + default: 20, + max: 50, + paging: Paging::TimestampWindow, + }; #[test] fn effective_limit_applies_the_default_then_the_cap() { - assert_eq!(effective_limit(None, 50, 200), 50); - assert_eq!(effective_limit(Some(10), 50, 200), 10); - assert_eq!(effective_limit(Some(1_000), 50, 200), 200); + let limits = ReadLimits { + default: 50, + max: 200, + paging: Paging::None, + }; + assert_eq!(limits.effective(None), 50); + assert_eq!(limits.effective(Some(10)), 10); + assert_eq!(limits.effective(Some(1_000)), 200); } #[test] fn a_short_read_is_silent() { // The only provably complete case. - assert_eq!(truncation_notice(19, None, 20, 50), None); - assert_eq!(truncation_notice(0, Some(10), 20, 50), None); + assert_eq!(truncation_notice(19, None, WINDOWED), None); + assert_eq!(truncation_notice(0, Some(10), WINDOWED), None); } #[test] fn a_full_default_read_names_the_default() { - let notice = truncation_notice(20, None, 20, 50).expect("full read must warn"); + let notice = truncation_notice(20, None, WINDOWED).expect("full read must warn"); assert!(notice.contains("the default limit of 20"), "{notice}"); assert!(notice.contains("max 50"), "{notice}"); } #[test] fn a_clamped_limit_says_it_was_clamped() { - let notice = truncation_notice(50, Some(500), 20, 50).expect("full read must warn"); + let notice = truncation_notice(50, Some(500), WINDOWED).expect("full read must warn"); assert!(notice.contains("--limit 500, capped at 50"), "{notice}"); // At the cap there is no larger limit to suggest. - assert!(notice.contains("--since"), "{notice}"); + assert!(notice.contains("--since / --before"), "{notice}"); } #[test] fn a_read_at_the_requested_limit_names_that_limit() { - let notice = truncation_notice(30, Some(30), 20, 50).expect("full read must warn"); + let notice = truncation_notice(30, Some(30), WINDOWED).expect("full read must warn"); assert!(notice.contains("--limit 30"), "{notice}"); assert!(!notice.contains("capped"), "{notice}"); } @@ -91,6 +150,62 @@ mod tests { fn an_overlong_read_still_warns() { // A relay that ignores the limit and returns more must not read as // complete just because the count is above the bound. - assert!(truncation_notice(60, None, 20, 50).is_some()); + assert!(truncation_notice(60, None, WINDOWED).is_some()); + } + + #[test] + fn below_the_cap_every_command_is_told_to_ask_for_more() { + // Whatever the command can do about paging is irrelevant while there + // is still headroom under the cap. + for paging in [ + Paging::TimestampWindow, + Paging::BeforeCursor, + Paging::SinceOnly, + Paging::None, + ] { + let limits = ReadLimits { + default: 20, + max: 50, + paging, + }; + let notice = truncation_notice(20, None, limits).expect("full read must warn"); + assert!( + notice.contains("pass a larger --limit (max 50)"), + "{notice}" + ); + } + } + + #[test] + fn at_the_cap_the_advice_names_only_flags_the_command_has() { + let at_cap = |paging| { + let limits = ReadLimits { + default: 20, + max: 50, + paging, + }; + truncation_notice(50, Some(50), limits).expect("full read must warn") + }; + + let window = at_cap(Paging::TimestampWindow); + assert!(window.contains("--since / --before"), "{window}"); + + let cursor = at_cap(Paging::BeforeCursor); + assert!(cursor.contains("--before / --before-id"), "{cursor}"); + assert!(!cursor.contains("--since"), "{cursor}"); + + // The two dead ends must not send a caller after a flag that does not + // exist, or one that cannot reach older results. + let since_only = at_cap(Paging::SinceOnly); + assert!( + since_only.contains("cannot request an older page"), + "{since_only}" + ); + assert!(!since_only.contains("--before"), "{since_only}"); + + let none = at_cap(Paging::None); + assert!(none.contains("cannot request a larger page"), "{none}"); + assert!(!none.contains("--since"), "{none}"); + assert!(!none.contains("--before"), "{none}"); } } From 45f5565b3578e656bde4bc5e907bb4c1e9b6bb74 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 21:26:28 +0530 Subject: [PATCH 12/12] fix(cli): fail social notes on a body it cannot count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review points out the command printed the relay body verbatim and then counted it with unwrap_or(0), so a non-array response both emitted invalid machine-readable output and made an unknown result set look safely short — suppressing the very notice this PR adds. count_events now validates the body before anything is written to stdout: it must parse as a JSON array whose every element is an object. Anything else is a CLI error saying the result count is unknown. Signed-off-by: Taksh --- crates/buzz-cli/src/commands/social.rs | 56 ++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/buzz-cli/src/commands/social.rs b/crates/buzz-cli/src/commands/social.rs index 5bccf90f3b8..abf73409a59 100644 --- a/crates/buzz-cli/src/commands/social.rs +++ b/crates/buzz-cli/src/commands/social.rs @@ -18,6 +18,27 @@ const NOTES_LIMITS: ReadLimits = ReadLimits { paging: Paging::BeforeCursor, }; +/// Count the events in a relay query response. +/// +/// `social notes` prints the relay's body verbatim, so the count and the +/// output come from the same bytes. A body that is not a JSON array of events +/// cannot be counted, and must not be silently treated as an empty page: that +/// turns an unknown result set into "safely short" and suppresses the +/// truncation notice altogether. +fn count_events(body: &str) -> Result { + let unknown = |detail: String| { + CliError::Other(format!( + "relay returned a body that is not an event array, so the result count is unknown: {detail}" + )) + }; + let events: Vec = + serde_json::from_str(body).map_err(|e| unknown(e.to_string()))?; + if let Some(position) = events.iter().position(|event| !event.is_object()) { + return Err(unknown(format!("element {position} is not an object"))); + } + Ok(events.len()) +} + /// A single contact entry (CLI-local, not from buzz-sdk). #[derive(Debug, Deserialize)] pub struct ContactEntry { @@ -116,10 +137,13 @@ pub async fn cmd_get_user_notes( } let resp = client.query(&filter).await?; + // Parse before printing. This command forwards the relay's body verbatim, + // so a body that is not an event array is both invalid machine-readable + // output and a result count we cannot determine — and an undeterminable + // count read as 0 would report a full page as safely short, which is the + // exact silence this notice exists to break. + let returned = count_events(&resp)?; println!("{resp}"); - let returned = serde_json::from_str::>(&resp) - .map(|events| events.len()) - .unwrap_or(0); if let Some(notice) = truncation_notice(returned, requested_limit, NOTES_LIMITS) { eprintln!("{notice}"); } @@ -300,7 +324,31 @@ mod tests { #[cfg(test)] mod note_count_tests { - use super::{truncation_notice, NOTES_LIMITS}; + use super::{count_events, truncation_notice, NOTES_LIMITS}; + + #[test] + fn counts_the_events_in_an_array_body() { + assert_eq!(count_events("[]").unwrap(), 0); + assert_eq!(count_events(r#"[{"id":"a"},{"id":"b"}]"#).unwrap(), 2); + } + + #[test] + fn a_body_that_is_not_an_event_array_is_an_error_not_a_zero() { + // Reading these as 0 results suppressed the notice entirely: a full + // page of notes would have been reported as safely short. + for body in [ + r#"{"error":"rate limited"}"#, + "not json at all", + "", + r#"["a","b"]"#, + ] { + let err = count_events(body).unwrap_err(); + assert!( + err.to_string().contains("result count is unknown"), + "body {body:?} produced: {err}" + ); + } + } #[test] fn a_full_page_of_notes_advertises_the_cursor_it_really_has() {