use crate::channels::telegram::flow::{
FlowEntry, FlowHeader, FlowOutcome, HeaderMarkup, extract_status_from_text, flow_header_text,
humanize_duration, latest_activity_preview, pop_trailing_folded_texts,
render_flow_details_with, render_flow_html_with,
};
use crate::channels::telegram::handler::{
FlowLine, folded_duplicates_final, humanize_elapsed, render_flow_details, render_flow_html,
render_flow_rich,
};
fn tline(label: &str, context: &str) -> FlowLine {
FlowLine::Tool {
label: label.to_string(),
context: context.to_string(),
raw_context: String::new(),
}
}
fn bash_line(raw_command: &str) -> FlowLine {
let input = serde_json::json!({ "command": raw_command });
FlowLine::Tool {
label: "⚙️ bash".to_string(),
context: crate::utils::tool_context_hint("bash", &input),
raw_context: crate::utils::tool_status_source("bash", &input),
}
}
#[test]
fn empty_group_renders_nothing() {
assert_eq!(render_flow_html(&[], None), "");
}
#[test]
fn single_tool_renders_plain_line_without_blockquote() {
let out = render_flow_html(&[tline("✅ bash", "git status")], None);
assert_eq!(out, "<b>✅ bash</b> <code>git status</code>");
assert!(!out.contains("<blockquote"));
}
#[test]
fn single_tool_without_context_omits_trailing_space() {
let out = render_flow_html(&[tline("⚙️ web_search", "")], None);
assert_eq!(out, "<b>⚙️ web_search</b>");
}
#[test]
fn multiple_tools_render_expandable_blockquote() {
let out = render_flow_html(
&[
tline("✅ bash", "cargo fmt"),
tline("✅ read_file", "handler.rs"),
tline("❌ grep", "pattern"),
],
None,
);
assert!(
out.starts_with(
"<blockquote expandable>⚙️ <b>❌ grep pattern</b> • <i>3 tool calls</i>\n\n"
)
);
assert!(out.ends_with("</blockquote>"));
assert!(out.contains("<b>✅ bash</b> <code>cargo fmt</code>"));
assert!(out.contains("<b>✅ read_file</b> <code>handler.rs</code>"));
assert!(out.contains("<b>❌ grep</b> <code>pattern</code>"));
}
#[test]
fn blocks_are_separated_by_blank_lines() {
let out = render_flow_html(
&[
tline("✅ bash", "cargo fmt"),
FlowLine::Text("Reformatted three files.".to_string()),
tline("✅ read_file", "handler.rs"),
],
None,
);
assert!(out.starts_with(
"<blockquote expandable>⚙️ <b>Reformatted three files.</b> • <i>2 tool calls</i>\n\n"
));
assert!(out.contains("<b>✅ bash</b> <code>cargo fmt</code>\n\nReformatted three files."));
assert!(
out.contains("Reformatted three files.\n\n<b>✅ read_file</b> <code>handler.rs</code>")
);
}
#[test]
fn tool_context_renders_as_monospace() {
let out = render_flow_html(
&[
tline("✅ read", "src/channels/telegram/handler.rs"),
tline("✅ bash", "cargo clippy --all-features"),
],
None,
);
assert!(out.contains("<b>✅ read</b> <code>src/channels/telegram/handler.rs</code>"));
assert!(out.contains("<b>✅ bash</b> <code>cargo clippy --all-features</code>"));
}
#[test]
fn intermediate_text_renders_inline_markdown() {
let out = render_flow_html(
&[
tline("✅ bash", "grep foo"),
FlowLine::Text("Calling `analyze_image` then **committing** the *fix*.".to_string()),
],
None,
);
assert!(out.contains("<code>analyze_image</code>"));
assert!(out.contains("<b>committing</b>"));
assert!(out.contains("<i>fix</i>"));
assert!(!out.contains("`analyze_image`"));
assert!(!out.contains("**committing**"));
}
#[test]
fn never_emits_details_tags() {
let out = render_flow_html(&[tline("✅ a", "x"), tline("✅ b", "y")], None);
assert!(!out.contains("<details>"));
assert!(!out.contains("<summary>"));
}
#[test]
fn escapes_html_in_labels_and_context() {
let out = render_flow_html(
&[
tline("✅ bash", "grep '<details>' & \"stuff\""),
tline("✅ edit_file", "a < b > c"),
],
None,
);
assert!(out.contains("grep '<details>' & \"stuff\""));
assert!(out.contains("a < b > c"));
assert!(!out.contains("'<details>'"));
}
#[test]
fn preview_uses_whole_human_readable_text_untruncated() {
let long = "First paragraph of the plan.\nSecond line with more detail.\nThird line that pushes well past the old ninety-six character truncation limit so the whole thing survives.";
let out = latest_activity_preview(&[
tline("✅ bash", "cargo test"),
FlowLine::Text(long.to_string()),
]);
assert_eq!(out.as_deref(), Some(long));
}
#[test]
fn preview_skips_json_last_entry_back_to_narration() {
let out = latest_activity_preview(&[
FlowLine::Text("Checking the model roster.".to_string()),
FlowLine::Text("{\"model\": \"deepseek-v4-flash\", \"ok\": true}".to_string()),
]);
assert_eq!(out.as_deref(), Some("Checking the model roster."));
}
#[test]
fn preview_skips_code_block_last_entry() {
let out = latest_activity_preview(&[
FlowLine::Text("Here is the fix.".to_string()),
FlowLine::Text("```rust\nfn main() {}\n```".to_string()),
]);
assert_eq!(out.as_deref(), Some("Here is the fix."));
}
#[test]
fn preview_strips_inline_markdown_markers() {
let out = latest_activity_preview(&[FlowLine::Text(
"Calling `grep` then **committing** the *fix*.".to_string(),
)]);
assert_eq!(
out.as_deref(),
Some("Calling grep then committing the fix.")
);
}
#[test]
fn preview_keeps_one_word_sentence_but_skips_bare_path() {
assert_eq!(
latest_activity_preview(&[FlowLine::Text("Done.".to_string())]).as_deref(),
Some("Done.")
);
let out = latest_activity_preview(&[
tline("✅ read_file", "handler.rs"),
FlowLine::Text("src/channels/telegram/flow.rs".to_string()),
]);
assert_eq!(out.as_deref(), Some("✅ read_file handler.rs"));
}
#[test]
fn preview_falls_back_to_tool_when_no_human_readable_text() {
let out = latest_activity_preview(&[
tline("✅ read_file", "handler.rs"),
FlowLine::Text("[1,2,3]".to_string()),
]);
assert_eq!(out.as_deref(), Some("✅ read_file handler.rs"));
}
#[test]
fn preview_is_none_when_empty() {
assert_eq!(latest_activity_preview(&[]), None);
}
#[test]
fn bash_comment_single_strips_decoration() {
assert_eq!(
extract_status_from_text("# --- Setup environment ---\nexport FOO=bar").as_deref(),
Some("Setup environment")
);
}
#[test]
fn bash_comment_multiple_join_with_newlines() {
let cmd = "# Step one\napt-get update\n# Step two\napt-get install foo";
assert_eq!(
extract_status_from_text(cmd).as_deref(),
Some("Step one\nStep two")
);
}
#[test]
fn bash_comment_ignores_inline_hash_and_shebang() {
let cmd = "#!/bin/bash\ncurl https://x.com/a#frag\necho ok";
assert_eq!(extract_status_from_text(cmd), None);
}
#[test]
fn bash_comment_none_when_no_comments() {
assert_eq!(extract_status_from_text("cargo build --release"), None);
}
#[test]
fn preview_uses_bash_comments_when_no_narration() {
let out = latest_activity_preview(&[bash_line("# --- Installing deps ---\nnpm install")]);
assert_eq!(out.as_deref(), Some("Installing deps"));
}
#[test]
fn preview_bash_comment_on_first_line_survives_decoration() {
let out = latest_activity_preview(&[bash_line("# --- Setup environment ---\nexport FOO=bar")]);
assert_eq!(out.as_deref(), Some("Setup environment"));
}
#[test]
fn preview_bash_comment_survives_long_command_no_truncation() {
let long = format!(
"# Build step\n{}\n# Deploy step\nmake deploy",
"echo padding-".repeat(12)
);
assert!(long.len() > 80);
let out = latest_activity_preview(&[bash_line(&long)]);
assert_eq!(out.as_deref(), Some("Build step\nDeploy step"));
}
#[test]
fn preview_narration_beats_bash_comments() {
let out = latest_activity_preview(&[
bash_line("# --- Installing deps ---\nnpm install"),
FlowLine::Text("Wiring up the new module.".to_string()),
]);
assert_eq!(out.as_deref(), Some("Wiring up the new module."));
}
#[test]
fn tool_plus_text_folds_into_one_blockquote() {
let out = render_flow_html(
&[
tline("✅ bash", "git status"),
FlowLine::Text("Checked the tree, all clean.".to_string()),
tline("✅ read_file", "handler.rs"),
],
None,
);
assert!(out.starts_with(
"<blockquote expandable>⚙️ <b>Checked the tree, all clean.</b> • <i>2 tool calls</i>\n\n"
));
assert!(out.ends_with("</blockquote>"));
assert!(out.contains("<b>✅ bash</b> <code>git status</code>"));
assert!(out.contains("Checked the tree, all clean."));
assert!(out.contains("<b>✅ read_file</b> <code>handler.rs</code>"));
assert!(!out.contains("<details>"));
}
#[test]
fn text_only_flow_uses_processing_log_header() {
let out = render_flow_html(&[FlowLine::Text("Switching provider…".to_string())], None);
assert!(out.starts_with(
"<blockquote expandable>⚙️ <b>Switching provider…</b> • <i>Processing log</i>\n\n"
));
assert!(out.contains("Switching provider…"));
assert!(!out.contains("tool calls"));
}
#[test]
fn intermediate_text_is_html_escaped_in_flow() {
let out = render_flow_html(
&[
tline("✅ bash", "echo hi"),
FlowLine::Text("result: <b>bold</b> & <script>alert(1)</script>".to_string()),
],
None,
);
assert!(out.contains("<b>bold</b> & <script>"));
assert!(!out.contains("<script>"));
}
#[test]
fn blank_text_entries_are_dropped() {
let out = render_flow_html(
&[tline("✅ bash", "x"), FlowLine::Text(" ".to_string())],
None,
);
assert_eq!(out, "<b>✅ bash</b> <code>x</code>");
}
#[test]
fn empty_flow_renders_nothing() {
assert_eq!(render_flow_html(&[], None), "");
}
#[test]
fn folded_dup_matches_exact_final() {
let answer = "The rebuild finished and the binary is swapped in.";
assert!(folded_duplicates_final(answer, answer));
}
#[test]
fn folded_dup_matches_truncated_prefix() {
let folded = "Yes, Adolfo. After you told me to search for the tool, I";
let final_text =
"Yes, Adolfo. After you told me to search for the tool, I found it and used it.";
assert!(folded_duplicates_final(folded, final_text));
}
#[test]
fn folded_dup_ignores_whitespace_differences() {
let folded = "line one\n\n line two three four five";
let final_text = "line one line two three four five and the rest of the answer here";
assert!(folded_duplicates_final(folded, final_text));
}
#[test]
fn folded_dup_rejects_distinct_narration() {
let folded = "Let me trace the delivery path first.";
let final_text = "The root cause is an exact-equality dedup that misses a truncated prefix.";
assert!(!folded_duplicates_final(folded, final_text));
}
#[test]
fn folded_dup_rejects_short_shared_opening() {
let folded = "Done.";
let final_text = "Done. Here is the full breakdown of everything that changed this turn.";
assert!(!folded_duplicates_final(folded, final_text));
}
#[test]
fn folded_dup_exact_short_answer_matches() {
assert!(folded_duplicates_final("Dropped it.", "Dropped it."));
}
#[test]
fn folded_dup_exact_short_with_whitespace_matches() {
assert!(folded_duplicates_final(" Dropped it.\n", "Dropped it."));
}
#[test]
fn folded_dup_empty_sides_never_match() {
assert!(!folded_duplicates_final("", ""));
assert!(!folded_duplicates_final("", "Dropped it."));
assert!(!folded_duplicates_final("Dropped it.", ""));
}
#[test]
fn live_status_rides_in_blockquote_header() {
let out = render_flow_html(
&[
tline("✅ bash", "cargo fmt"),
FlowLine::Text("Reading the handler.".to_string()),
tline("⚙️ read_file", "handler.rs"),
],
Some("45s"),
);
assert!(out.starts_with(
"<blockquote expandable>⚙️ <b>Reading the handler.</b> • <i>2 tool calls</i> • <i>45s</i>\n"
));
assert!(out.ends_with("</blockquote>"));
}
#[test]
fn no_duration_still_leads_with_activity() {
let out = render_flow_html(
&[
tline("✅ bash", "cargo fmt"),
tline("✅ read_file", "handler.rs"),
],
None,
);
assert!(out.starts_with(
"<blockquote expandable>⚙️ <b>✅ read_file handler.rs</b> • <i>2 tool calls</i>\n"
));
assert!(!out.contains("45s"));
}
#[test]
fn live_status_on_text_only_flow_uses_processing_log_header() {
let out = render_flow_html(
&[FlowLine::Text("Looking into it.".to_string())],
Some("15s"),
);
assert!(out.starts_with(
"<blockquote expandable>⚙️ <b>Looking into it.</b> • <i>Processing log</i> • <i>15s</i>\n"
));
}
#[test]
fn live_status_appends_to_single_tool_line() {
let out = render_flow_html(&[tline("⚙️ bash", "git status")], Some("bash • 5s"));
assert_eq!(out, "<b>⚙️ bash</b> <code>git status</code> • bash • 5s");
assert!(!out.contains("<blockquote"));
}
#[test]
fn humanize_elapsed_snaps_to_five_second_steps() {
assert_eq!(humanize_elapsed(0), "0s");
assert_eq!(humanize_elapsed(4), "0s");
assert_eq!(humanize_elapsed(7), "5s");
assert_eq!(humanize_elapsed(59), "55s");
assert_eq!(humanize_elapsed(61), "1m 0s");
assert_eq!(humanize_elapsed(93), "1m 30s");
assert_eq!(humanize_elapsed(3601), "60m 0s");
}
#[test]
fn humanize_duration_precise_then_minutes() {
assert_eq!(humanize_duration(0), "0s");
assert_eq!(humanize_duration(45), "45s");
assert_eq!(humanize_duration(59), "59s");
assert_eq!(humanize_duration(60), "1 min 0s");
assert_eq!(humanize_duration(90), "1 min 30s");
assert_eq!(humanize_duration(300), "5 min 0s");
assert_eq!(humanize_duration(3661), "61 min 1s");
}
#[test]
fn flow_header_live_and_settled_formats() {
assert_eq!(
flow_header_text(
3,
&FlowHeader::Live(Some("45s")),
Some("Reading logs"),
HeaderMarkup::Html
),
"⚙️ <b>Reading logs</b> • <i>3 tool calls</i> • <i>45s</i>"
);
assert_eq!(
flow_header_text(3, &FlowHeader::Live(Some("45s")), None, HeaderMarkup::Html),
"⚙️ <i>3 tool calls</i> • <i>45s</i>"
);
assert_eq!(
flow_header_text(3, &FlowHeader::Live(None), None, HeaderMarkup::Html),
"<b>3 tool calls</b>"
);
assert_eq!(
flow_header_text(0, &FlowHeader::Live(None), None, HeaderMarkup::Html),
"<b>Processing log</b>"
);
assert_eq!(
flow_header_text(
3,
&FlowHeader::Live(Some("45s")),
Some("Reading logs"),
HeaderMarkup::Markdown
),
"⚙️ **Reading logs** • _3 tool calls_ • _45s_"
);
assert_eq!(
flow_header_text(
12,
&FlowHeader::Settled {
icon: "✅",
verb: "Finished",
duration: "45s"
},
None,
HeaderMarkup::Html
),
"<b>✅ Finished (12 tool calls, 45s)</b>"
);
assert_eq!(
flow_header_text(
0,
&FlowHeader::Settled {
icon: "✅",
verb: "Finished",
duration: "45s"
},
None,
HeaderMarkup::Html
),
"<b>✅ Finished (45s)</b>"
);
}
#[test]
fn flow_outcome_icons_and_verbs() {
assert_eq!(FlowOutcome::Finished.icon_verb(), ("✅", "Finished"));
assert_eq!(FlowOutcome::Failed.icon_verb(), ("❌", "Failed"));
assert_eq!(FlowOutcome::TimedOut.icon_verb(), ("⏱", "Timed out"));
}
#[test]
fn settled_outcome_renders_block_header_over_lone_tool() {
let out = render_flow_html_with(
&[tline("✅ bash", "cargo test")],
&FlowHeader::Settled {
icon: "❌",
verb: "Failed",
duration: "12s",
},
);
assert!(
out.starts_with("<blockquote expandable><b>❌ Failed (1 tool calls, 12s)</b>"),
"settled header: {out}"
);
}
#[test]
fn settled_block_carries_no_activity_preview_classic() {
let out = render_flow_html_with(
&[
tline("✅ bash", "cargo test"),
FlowLine::Text("Running the test suite".to_string()),
],
&FlowHeader::Settled {
icon: "✅",
verb: "Finished",
duration: "3 min 15s",
},
);
assert!(out.starts_with("<blockquote expandable><b>✅ Finished (1 tool calls, 3 min 15s)</b>"));
assert!(
!out.contains("• <i>"),
"settled block must not carry a preview: {out}"
);
}
#[test]
fn settled_block_carries_no_activity_preview_rich() {
let out = render_flow_details_with(
&[
tline("✅ bash", "cargo test"),
FlowLine::Text("Running the test suite".to_string()),
],
&FlowHeader::Settled {
icon: "✅",
verb: "Finished",
duration: "3 min 15s",
},
);
let summary_end = out.find("</summary>").expect("summary");
let summary = &out[..summary_end];
assert!(summary.contains("✅ Finished (1 tool calls, 3 min 15s)"));
assert!(
!summary.contains("•"),
"settled summary must not carry a preview: {summary}"
);
}
#[test]
fn rich_empty_group_renders_nothing() {
assert_eq!(render_flow_rich(&[], None), "");
}
#[test]
fn rich_single_tool_renders_plain_line() {
let out = render_flow_rich(&[tline("✅ bash", "git status")], None);
assert_eq!(out, "**✅ bash** `git status`");
assert!(!out.contains(">"));
}
#[test]
fn rich_multiple_tools_render_markdown_header() {
let out = render_flow_rich(
&[tline("✅ bash", "git status"), tline("✅ read", "file.rs")],
None,
);
assert!(out.starts_with("⚙️ **✅ read file.rs** • _2 tool calls_\n\n"));
assert!(out.contains("**✅ bash** `git status`"));
assert!(out.contains("**✅ read** `file.rs`"));
}
#[test]
fn rich_live_status_in_header() {
let out = render_flow_rich(
&[
tline("✅ bash", "x"),
FlowLine::Text("Searching.".to_string()),
tline("⚙️ grep", "pattern"),
],
Some("10s"),
);
assert!(out.starts_with("⚙️ **Searching.** • _2 tool calls_ • _10s_\n\n"));
}
#[test]
fn rich_single_tool_live_status_appends() {
let out = render_flow_rich(&[tline("⚙️ bash", "git status")], Some("bash • 5s"));
assert_eq!(out, "**⚙️ bash** `git status` • bash • 5s");
}
#[test]
fn details_empty_group_renders_nothing() {
assert_eq!(render_flow_details(&[], None), "");
}
#[test]
fn details_single_tool_renders_plain_line() {
let out = render_flow_details(&[tline("✅ bash", "git status")], None);
assert_eq!(out, "<b>✅ bash</b> <code>git status</code>");
assert!(!out.contains("<details"));
}
#[test]
fn details_multiple_tools_wrap_in_collapsed_details() {
let out = render_flow_details(
&[tline("✅ bash", "git status"), tline("✅ read", "file.rs")],
None,
);
assert!(out.starts_with(
"<details><summary><sub>⚙️ <b>✅ read file.rs</b> • <i>2 tool calls</i></sub></summary>"
));
assert!(out.ends_with("</details>"));
assert!(!out.contains("<details open"));
assert!(out.contains("<p><b>✅ bash</b> <code>git status</code></p>"));
assert!(out.contains("<p><b>✅ read</b> <code>file.rs</code></p>"));
}
#[test]
fn details_summary_carries_live_status() {
let out = render_flow_details(
&[
tline("✅ bash", "x"),
FlowLine::Text("Grepping.".to_string()),
tline("⚙️ grep", "pattern"),
],
Some("10s"),
);
let summary_end = out.find("</summary>").expect("summary");
let summary = &out[..summary_end];
assert!(summary.contains("⚙️ <b>Grepping.</b> • <i>2 tool calls</i> • <i>10s</i>"));
assert!(summary.contains("<sub>⚙️ <b>Grepping.</b>"));
assert!(summary.contains("• <i>"));
}
#[test]
fn details_collapsed_summary_shows_intermediate_narration() {
let out = render_flow_details(
&[
FlowLine::Text("Running the test suite".to_string()),
tline("⚙️ bash", "cargo test"),
],
Some("45s"),
);
let summary_end = out.find("</summary>").expect("summary");
let summary = &out[..summary_end];
assert!(summary.contains("⚙️ <b>Running the test suite</b>"));
}
#[test]
fn details_escapes_html_in_tool_context() {
let out = render_flow_details(&[tline("✅ bash", "a < b"), tline("✅ read", "x.rs")], None);
assert!(out.contains("<code>a < b</code>"));
}
#[test]
fn collapsed_preview_prefers_narration_over_tool_line() {
let out = render_flow_html(
&[
FlowLine::Text("Checking how the scheduler resolves the next run".to_string()),
tline("✅ bash", "grep flow"),
tline("⚙️ read_file", "src/agent.rs"),
],
None,
);
let header_line = out.lines().next().expect("header line");
assert!(
header_line.contains("<b>Checking how the scheduler resolves the next run</b>"),
"header must lead with the narration: {header_line}"
);
assert!(out.contains("<b>⚙️ read_file</b> <code>src/agent.rs</code>"));
}
#[test]
fn preview_keeps_long_text_whole_and_strips_markdown() {
let long = format!("**{}**", "x".repeat(200));
let out = render_flow_html(&[tline("✅ bash", "a"), FlowLine::Text(long)], None);
let header_line = out.lines().next().unwrap();
assert!(!header_line.contains('…'), "not truncated: {header_line}");
assert!(
header_line.contains(&"x".repeat(200)),
"whole text kept: {header_line}"
);
assert!(
!header_line.contains("**"),
"markers stripped: {header_line}"
);
}
#[test]
fn trailing_text_run_pops_joined_in_order() {
let mut entries = vec![
FlowEntry::Tool(0),
FlowEntry::Text("first part of the answer".into()),
FlowEntry::Text("Already factored in. Standing by.".into()),
];
let reclaimed = pop_trailing_folded_texts(&mut entries).expect("reclaims");
assert_eq!(
reclaimed,
"first part of the answer\n\nAlready factored in. Standing by."
);
assert_eq!(entries.len(), 1, "tool entry stays");
assert!(matches!(entries[0], FlowEntry::Tool(0)));
}
#[test]
fn text_only_flow_pops_everything() {
let mut entries = vec![
FlowEntry::Text("the whole answer".into()),
FlowEntry::Text("plus a follow-up".into()),
];
let reclaimed = pop_trailing_folded_texts(&mut entries).expect("reclaims");
assert!(reclaimed.starts_with("the whole answer"));
assert!(entries.is_empty());
}
#[test]
fn tool_last_flow_reclaims_nothing() {
let mut entries = vec![FlowEntry::Text("narration".into()), FlowEntry::Tool(0)];
assert!(pop_trailing_folded_texts(&mut entries).is_none());
assert_eq!(entries.len(), 2, "nothing consumed");
}
#[test]
fn long_folded_narration_is_capped_in_the_block() {
let long = "x".repeat(1200);
let out = render_flow_html(
&[
tline("✅ bash", "cargo test"),
FlowLine::Text(long.clone()),
FlowLine::Text("all done here now".into()),
tline("✅ read", "flow.rs"),
],
None,
);
assert!(out.contains('…'), "capped body entry ends with an ellipsis");
assert!(!out.contains(&long), "body narration must be truncated");
}