From ab3013625533e5007a943c67649463f1698fa2eb Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 2 Sep 2026 10:42:33 +0200 Subject: [PATCH] hive-agent: fmt_room spent a byte offset as a character budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `r.find(':')` returns a BYTE offset; it was being passed to `r.chars().take(colon.min(9))` as a CHARACTER budget. For a multi-byte room-id local part the two disagree, and the extra characters come out of the server half: fmt_room("!ÄÖÜ:server") == "!ÄÖÜ:se" // want "!ÄÖÜ" The doc comment above it already said the `chars().take()` was there to handle non-ASCII, so the intent was recorded and the implementation was half of it. Split on the colon first, then take 9 characters of the local part. ASCII behaviour is unchanged and pinned by the existing case: `!abcdefghijkl:server` -> `!abcdefgh` before and after. Found by the tests in the previous commit — written red, then fixed. --- hive-agent/src/stream_enrich.rs | 37 +++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/hive-agent/src/stream_enrich.rs b/hive-agent/src/stream_enrich.rs index 654ac714..81724b38 100644 --- a/hive-agent/src/stream_enrich.rs +++ b/hive-agent/src/stream_enrich.rs @@ -1009,9 +1009,11 @@ fn fmt_tok(n: u64) -> String { fn fmt_room(r: &str) -> String { if r.starts_with('!') { // Room id: keep only the local part before the colon (up to 9 chars). - // Use chars().take() so we never slice on a non-ASCII byte boundary. - let colon = r.find(':').unwrap_or(r.len()); - r.chars().take(colon.min(9)).collect() + // Both halves of that are character counts — `find` returns a byte + // offset, and spending it as a character budget lets a multi-byte + // local part buy extra characters from the server half. + let local = r.split(':').next().unwrap_or(r); + local.chars().take(9).collect() } else if r.starts_with('#') { // Alias: keep `#name` part before the server. r.split(':').next().unwrap_or(r).to_owned() @@ -1089,7 +1091,11 @@ mod tests { #[test] fn catch_all_truncates_a_huge_payload() { let m = one(&json!({ "type": "unknown", "blob": "x".repeat(5_000) })); - assert_eq!(m.summary.chars().count(), 201, "200 chars plus the ellipsis"); + assert_eq!( + m.summary.chars().count(), + 201, + "200 chars plus the ellipsis" + ); assert!(m.summary.ends_with('…')); } @@ -1105,7 +1111,11 @@ mod tests { "task_id": "abcdef1234567890", "description": "do a thing", })); - assert!(m.summary.starts_with("task abcdef12 started"), "{}", m.summary); + assert!( + m.summary.starts_with("task abcdef12 started"), + "{}", + m.summary + ); assert!( !m.summary.contains("1234567890"), "task id is truncated to 8 chars" @@ -1323,7 +1333,11 @@ mod tests { &mut ctx, ); let warm = classify_stream_value(&tool_result(body, false, "call-1"), &mut ctx); - assert!(warm[0].summary.starts_with("recv ← "), "{}", warm[0].summary); + assert!( + warm[0].summary.starts_with("recv ← "), + "{}", + warm[0].summary + ); assert_eq!(warm[0].body_format, Some(BodyFormat::Markdown)); assert_eq!(warm[0].body.as_deref(), Some(body)); } @@ -1425,4 +1439,15 @@ mod tests { assert_eq!(fmt_room("!abcdefghijkl:server"), "!abcdefgh"); assert_eq!(fmt_room("plain name"), "plain name"); } + + /// A short room id keeps its whole local part and stops at the colon. + /// The non-ASCII case is the one that used to leak: `find(':')` is a + /// *byte* offset and it was being spent as a *character* budget, so a + /// multi-byte local part bought extra characters from the server half. + #[test] + fn a_room_id_never_shows_part_of_the_server() { + assert_eq!(fmt_room("!short:server"), "!short"); + assert_eq!(fmt_room("!ÄÖÜ:server"), "!ÄÖÜ"); + assert_eq!(fmt_room("!ÄÖÜ"), "!ÄÖÜ"); + } }