From 151451f66e8768bf9e5360df36ce2f3a0540268c Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Thu, 30 Apr 2026 21:23:22 +0530 Subject: [PATCH] fix(channels): preserve overflow segments instead of dropping them (#1041) (#1051) Co-authored-by: Claude Opus 4.7 (1M context) --- .../channels/providers/presentation.rs | 36 ++++++- .../channels/providers/presentation_tests.rs | 95 ++++++++++++++++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/openhuman/channels/providers/presentation.rs b/src/openhuman/channels/providers/presentation.rs index 8864f9196..269328b7a 100644 --- a/src/openhuman/channels/providers/presentation.rs +++ b/src/openhuman/channels/providers/presentation.rs @@ -187,7 +187,7 @@ fn segment_for_delivery(text: &str) -> Vec { segments = merged.len(), "[presentation:segment] split by paragraphs" ); - return merged.into_iter().take(MAX_SEGMENTS).collect(); + return cap_segments(merged, MAX_SEGMENTS, "\n\n"); } } @@ -200,7 +200,7 @@ fn segment_for_delivery(text: &str) -> Vec { segments = grouped.len(), "[presentation:segment] split by sentences" ); - return grouped.into_iter().take(MAX_SEGMENTS).collect(); + return cap_segments(grouped, MAX_SEGMENTS, " "); } } @@ -249,6 +249,38 @@ fn is_numbered_list_item(line: &str) -> bool { i > 0 && i <= 3 && bytes.get(i) == Some(&b'.') && bytes.get(i + 1) == Some(&b' ') } +/// Cap the number of delivered segments at `max` without losing content: +/// the first `max - 1` segments are kept as-is, and any overflow is +/// concatenated into a single trailing segment using `joiner`. +/// +/// The earlier behavior (`.take(MAX_SEGMENTS)`) silently dropped every +/// segment past the cap, which truncated long agent replies in the UI +/// (issue #1041). Merging into the tail preserves all content while +/// still bounding the inter-bubble delay budget. +fn cap_segments(segments: Vec, max: usize, joiner: &str) -> Vec { + if max == 0 || segments.len() <= max { + return segments; + } + let original_len = segments.len(); + let mut iter = segments.into_iter(); + let mut result: Vec = (&mut iter).take(max - 1).collect(); + let tail: Vec = iter.collect(); + let tail_count = tail.len(); + let merged = tail.join(joiner); + tracing::debug!( + target: "presentation", + max, + original_len, + tail_count, + tail_len = merged.len(), + joiner_len = joiner.len(), + "[presentation:segment] merging {} overflow segments into tail", + tail_count + ); + result.push(merged); + result +} + /// Merge adjacent segments shorter than MIN_SEGMENT_CHARS. fn merge_short(parts: &[&str], joiner: &str) -> Vec { let mut result: Vec = Vec::new(); diff --git a/src/openhuman/channels/providers/presentation_tests.rs b/src/openhuman/channels/providers/presentation_tests.rs index 4bb017003..e3e1dc70b 100644 --- a/src/openhuman/channels/providers/presentation_tests.rs +++ b/src/openhuman/channels/providers/presentation_tests.rs @@ -56,7 +56,10 @@ fn numbered_list_detection() { } #[test] -fn max_segments_respected() { +fn max_segments_respected_without_dropping_content() { + // Regression: the prior `.take(MAX_SEGMENTS)` silently dropped every + // paragraph past the cap (issue #1041). Verify the cap holds AND no + // input paragraph disappears from the delivered output. let paras: Vec = (0..10) .map(|i| { format!( @@ -68,6 +71,96 @@ fn max_segments_respected() { let text = paras.join("\n\n"); let result = segment_for_delivery(&text); assert!(result.len() <= MAX_SEGMENTS); + let joined = result.join("\n\n"); + // Assert the full paragraph body survives, not just the prefix — + // a mid-text truncation would slip past a substring-only check. + for (i, original) in paras.iter().enumerate() { + assert!( + joined.contains(original), + "paragraph {} truncated or missing from delivered segments", + i + ); + } +} + +#[test] +fn cap_segments_passthrough_when_under_cap() { + let segs = vec!["one".to_string(), "two".to_string(), "three".to_string()]; + assert_eq!(cap_segments(segs.clone(), 5, "\n\n"), segs); + assert_eq!(cap_segments(segs.clone(), 3, "\n\n"), segs); +} + +#[test] +fn cap_segments_merges_overflow_into_tail() { + let segs = vec![ + "one".to_string(), + "two".to_string(), + "three".to_string(), + "four".to_string(), + "five".to_string(), + "six".to_string(), + ]; + let out = cap_segments(segs, 3, "\n\n"); + assert_eq!(out.len(), 3); + assert_eq!(out[0], "one"); + assert_eq!(out[1], "two"); + assert_eq!(out[2], "three\n\nfour\n\nfive\n\nsix"); +} + +#[test] +fn cap_segments_handles_zero_max() { + let segs = vec!["one".to_string(), "two".to_string()]; + // max=0 is a no-op: returns input unchanged rather than panicking. + assert_eq!(cap_segments(segs.clone(), 0, " "), segs); +} + +#[test] +fn issue_1041_transcript_preserves_bullets_and_trailing_paragraphs() { + // The exact shape of the agent reply that triggered issue #1041: + // 11 paragraphs including a bullet list and 4 trailing paragraphs. + // Pre-fix `.take(MAX_SEGMENTS)` dropped paragraphs 6-11 entirely; + // post-fix they must survive (merged into the final segment). + let text = "here's the full message with a sharper CTA:\n\n\ + ---\n\n\ + hey [name], great meeting you at Startup Grind SF last week. really enjoyed our conversation about [insert 1 specific detail if you remember, otherwise remove this line].\n\n\ + i'm reaching out to share what we're building at TinyHumans. we just launched OpenHuman, and the insight is simple: AI agents are incredibly powerful, but today they're locked behind developer workflows. setup, API keys, terminals. the 99% who can't code are completely left out.\n\n\ + OpenHuman fixes that. it's AI agents that work out of the box. no setup, no API keys, no copy-paste. just plain English.\n\n\ + we launched a week ago and the response has been strong:\n\n\ + - 100+ paying users\n- 200+ GitHub stars, growing ~150% week over week\n- 50+ outside PRs merged\n\n\ + attaching a screenshot of our growth curve since day one.\n\n\ + we're second-time founders. previously scaled products to 100k DAUs, had a 1.5M exit, and built to 3M ARR. now we're going after the \"Apple moment\" for AI agents, making them actually usable for everyone.\n\n\ + are you open to a 15-min demo next week? happy to work around your schedule.\n\n\ + cheers,\n[your name]"; + + let result = segment_for_delivery(text); + assert!( + result.len() <= MAX_SEGMENTS, + "segment count {} exceeds cap", + result.len() + ); + + let joined = result.join("\n\n"); + // Bullet list — the highest-priority symptom of #1041 + assert!(joined.contains("100+ paying users"), "bullet 1 dropped"); + assert!(joined.contains("200+ GitHub stars"), "bullet 2 dropped"); + assert!( + joined.contains("50+ outside PRs merged"), + "bullet 3 dropped" + ); + // Trailing paragraphs — also dropped pre-fix + assert!( + joined.contains("attaching a screenshot"), + "screenshot paragraph dropped" + ); + assert!( + joined.contains("second-time founders"), + "founders paragraph dropped" + ); + assert!( + joined.contains("15-min demo next week"), + "demo ask paragraph dropped" + ); + assert!(joined.contains("[your name]"), "signature dropped"); } #[test]