diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index ea273336e38..875c6a7063b 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -6,7 +6,7 @@ use crate::client::{normalize_events, normalize_write_response, BuzzClient}; use crate::error::CliError; 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, + validate_content_present, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, @@ -618,6 +618,11 @@ pub async fn cmd_send_message( // bugs for agent and human users alike. p.content = read_or_stdin(&p.content)?; validate_content_size(&p.content)?; + // A message with neither text nor media is never what the caller meant, and + // publishing it silently is how an agent's blocker stayed invisible for ten + // hours (ENG-4064). Files are checked rather than assumed: an image-only + // message is legitimate and must still go through. + validate_content_present(&p.content, !p.files.is_empty())?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; } diff --git a/crates/buzz-cli/src/validate.rs b/crates/buzz-cli/src/validate.rs index 4985b441417..6d154e50453 100644 --- a/crates/buzz-cli/src/validate.rs +++ b/crates/buzz-cli/src/validate.rs @@ -72,6 +72,41 @@ pub fn validate_content_size(content: &str) -> Result<(), CliError> { Ok(()) } +/// Refuse to publish a message with no text. +/// +/// `--content -` reads stdin verbatim, so a command whose pipeline produced +/// nothing (a `printf` that rendered empty, a heredoc that collapsed, a failed +/// substitution) sends an empty message rather than failing. On 2026-08-17 an +/// agent did exactly that five times over ten hours while trying to report that +/// it was unauthorized for a repo. Every attempt published successfully and +/// carried nothing. +/// +/// An empty message is worse than no message. It resolves the mention, lands in +/// the thread at the expected moment, and says nothing — so it reads as an +/// acknowledgement from an agent that has stopped working. The blocker stayed +/// invisible, and the remedy that behaviour invited was restarting a healthy +/// agent, which would have destroyed its in-flight work. +/// +/// Failing loudly here gives the caller something it can act on: an agent sees +/// a non-zero exit and can retry, and a human sees an error instead of a +/// message that appears to have been delivered. +/// +/// Callers that legitimately publish without text — an image-only message — +/// must pass `allow_empty` once they have confirmed other content exists. +pub fn validate_content_present(content: &str, allow_empty: bool) -> Result<(), CliError> { + if !allow_empty && content.trim().is_empty() { + return Err(CliError::Usage( + "refusing to send an empty message: content is blank after reading \ + stdin. An empty message is indistinguishable from an acknowledgement \ + and hides whatever you meant to say. If you piped `--content -`, the \ + producing command wrote nothing — check it succeeded. To publish \ + media with no text, pass --files." + .to_string(), + )); + } + Ok(()) +} + /// Percent-encode for URL path segments and query parameter values. /// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~ #[cfg(test)] @@ -504,3 +539,52 @@ mod tests { assert!(matches!(err, CliError::Usage(_))); } } + +#[cfg(test)] +mod empty_content_tests { + use super::*; + + // ENG-4064. An agent spent ten hours trying to report that it was + // unauthorized for a repo. Every attempt published successfully and carried + // nothing, so the blocker was invisible and the agent looked wedged. + + #[test] + fn an_empty_message_is_refused() { + assert!(validate_content_present("", false).is_err()); + } + + #[test] + fn whitespace_only_is_refused_too() { + // `printf '\n' | buzz messages send --content -` is the same failure + // wearing a different hat: a producing command that emitted only a + // newline still says nothing. + for blank in ["\n", " ", "\t\n \n"] { + assert!( + validate_content_present(blank, false).is_err(), + "blank content {blank:?} was accepted" + ); + } + } + + #[test] + fn real_content_passes() { + assert!(validate_content_present("autopilot-brain is unauthorized", false).is_ok()); + } + + #[test] + fn an_image_only_message_is_still_allowed() { + // Media with no caption is legitimate. The guard must not block it, or + // it will be removed rather than fixed. + assert!(validate_content_present("", true).is_ok()); + } + + #[test] + fn the_error_says_what_to_check() { + // A refusal that does not name the likely cause sends the caller + // hunting the relay instead of their own pipeline. + let err = validate_content_present("", false).unwrap_err().to_string(); + assert!(err.contains("empty message"), "{err}"); + assert!(err.contains("--content -"), "{err}"); + assert!(err.contains("--files"), "{err}"); + } +}