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("!ÄÖÜ"), "!ÄÖÜ"); + } }