From c0230b4109e1eccd74996d9880ef3dd25201f5f7 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 17 Oct 2026 20:58:24 -0400 Subject: [PATCH] fix: cut a long conversation opening on a character boundary `standing::openings` shortens an opening longer than 300 bytes to its last word in the first 300 bytes, and took those bytes as `text[a..a + 300]`. When byte 300 falls inside a multi-byte character (any accented, Cyrillic or CJK text) the slice panics before the boundary fix that follows it runs, so the observer's conversation outline (`observer::outline`, which calls `openings` on each conversation document it outlines) panics on such a document. The 300-byte cut now steps back to a character boundary before the slice; the result is unchanged whenever the old code did not panic. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Rudy Celekli --- crates/graphicle-memory/src/standing.rs | 26 ++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/graphicle-memory/src/standing.rs b/crates/graphicle-memory/src/standing.rs index 35a4eec..6b8bfbd 100644 --- a/crates/graphicle-memory/src/standing.rs +++ b/crates/graphicle-memory/src/standing.rs @@ -151,10 +151,13 @@ pub fn openings(text: &str) -> Vec<(usize, usize)> { } let mut b = b; if b - a > 300 { - b = a + text[a..a + 300].rfind(' ').unwrap_or(300); - while !text.is_char_boundary(b) { - b -= 1; + // The first 300 bytes, back to a character boundary: byte 300 + // can fall inside a multi-byte character. + let mut cut = a + 300; + while !text.is_char_boundary(cut) { + cut -= 1; } + b = text[a..cut].rfind(' ').map_or(cut, |space| a + space); } found.push((a, b)); } @@ -387,6 +390,23 @@ mod tests { ); } + #[test] + fn a_long_opening_ends_on_a_character_boundary() { + // Byte 300 of the opening falls inside an "é" (two bytes). + let text = format!("User: I need help with {}\n", "\u{e9}".repeat(200)); + let spans = openings(&text); + assert_eq!(spans.len(), 1, "{spans:?}"); + let (a, b) = spans[0]; + assert_eq!(&text[a..b], "I need help with"); + // Without a space in the first 300 bytes the cut is at the last + // whole character. + let text = format!("User: x{} b c d\n", "\u{4f60}".repeat(120)); + let spans = openings(&text); + assert_eq!(spans.len(), 1, "{spans:?}"); + let (a, b) = spans[0]; + assert_eq!(text[a..b], format!("x{}", "\u{4f60}".repeat(99))); + } + #[test] fn conflicts_pair_a_denial_with_the_users_own_statements_on_its_subject() { let early = "Conversation date: 2024/03/10 (Sun) 00:00\n\n\