From 00b6ed36c26017fda7ee91d96809123e32da8f7c Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 13 Aug 2026 06:13:37 +0530 Subject: [PATCH 1/2] 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 6eb92e195ae9fa225c698a5e83d901101a53c656 Mon Sep 17 00:00:00 2001 From: Taksh Date: Thu, 13 Aug 2026 06:14:59 +0530 Subject: [PATCH 2/2] 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 3893c5b6425..af8f5344bf7 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, },