diff --git a/crates/ironclaw_safety/src/validator.rs b/crates/ironclaw_safety/src/validator.rs index a5e57917af..b77dda429d 100644 --- a/crates/ironclaw_safety/src/validator.rs +++ b/crates/ironclaw_safety/src/validator.rs @@ -468,4 +468,24 @@ mod tests { "Strings within depth limit should still be validated" ); } + + /// Regression: empty input is rejected by validate(), but image-only messages + /// (empty text + attachments) should bypass this check at the caller level. + /// This test confirms the validator correctly rejects empty strings — the + /// bypass logic lives in thread_ops.rs, not in the validator itself. + #[test] + fn test_empty_input_rejected() { + let validator = Validator::new(); + let result = validator.validate(""); + assert!(!result.is_valid, "Empty input must be rejected"); + assert_eq!(result.errors[0].code, ValidationErrorCode::Empty); + } + + /// Non-empty input with valid content should pass validation. + #[test] + fn test_nonempty_input_passes() { + let validator = Validator::new(); + let result = validator.validate("Hello, world!"); + assert!(result.is_valid, "Non-empty valid input must pass"); + } } diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index f367378119..d8641a93a7 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -240,8 +240,16 @@ impl Agent { } } - // Safety validation for user input - let validation = self.safety().validate_input(content); + // Safety validation for user input. + // Skip empty-content check when the message has attachments (e.g., image-only messages). + // The attachment pipeline (augment_with_attachments) will provide content downstream. + let has_attachments = !message.attachments.is_empty(); + let validation = if content.is_empty() && has_attachments { + // Image/file-only message — bypass empty-input validation + ironclaw_safety::ValidationResult::ok() + } else { + self.safety().validate_input(content) + }; if !validation.is_valid { let details = validation .errors