use crate::channels::slack::tool_group::{GroupEntry, GroupState, render};
use slack_morphism::prelude::{SlackChannelId, SlackMessageContent, SlackTs};
fn tool(name: &str, status: Option<bool>) -> GroupEntry {
GroupEntry::Tool {
name: name.to_string(),
context: String::new(),
status,
}
}
fn group(entries: Vec<GroupEntry>, expanded: bool) -> GroupState {
let mut g = GroupState::new(SlackChannelId::new("C1".into()), entries);
g.expanded = expanded;
g
}
fn text_of(content: &SlackMessageContent) -> String {
content.text.clone().unwrap_or_default()
}
const NARRATION: &str = "Let me verify one thing properly before I report it";
#[test]
fn collapsed_group_hides_narration() {
let g = group(
vec![
tool("bash", Some(true)),
GroupEntry::Note(NARRATION.to_string()),
tool("read_file", Some(true)),
],
false,
);
let text = text_of(&render(&g, &SlackTs::new("1.0".into())));
assert!(
!text.contains("💭"),
"collapsed group must not list narration rows. Got:\n{text}"
);
}
#[test]
fn expanded_group_shows_narration_in_order() {
let g = group(
vec![
tool("bash", Some(true)),
GroupEntry::Note(NARRATION.to_string()),
tool("read_file", Some(true)),
],
true,
);
let text = text_of(&render(&g, &SlackTs::new("1.0".into())));
assert!(text.contains(NARRATION), "expanded must show it: {text}");
let note_at = text.rfind(NARRATION).expect("narration present");
let bash_at = text.find("bash").expect("bash present");
let read_at = text.find("read_file").expect("read_file present");
assert!(
bash_at < note_at && note_at < read_at,
"steps must render in the order they happened. Got:\n{text}"
);
}
#[test]
fn summary_counts_steps_and_tools_separately_when_they_differ() {
let g = group(
vec![
tool("bash", Some(true)),
GroupEntry::Note("thinking".to_string()),
GroupEntry::Note("more thinking".to_string()),
tool("read_file", Some(true)),
],
false,
);
let text = text_of(&render(&g, &SlackTs::new("1.0".into())));
assert!(text.contains("4 steps"), "got: {text}");
assert!(text.contains("2 tool calls"), "got: {text}");
}
#[test]
fn summary_stays_tool_only_when_there_is_no_narration() {
let g = group(
vec![tool("bash", Some(true)), tool("read_file", Some(true))],
false,
);
let text = text_of(&render(&g, &SlackTs::new("1.0".into())));
assert!(text.contains("2 tool calls"), "got: {text}");
assert!(
!text.contains("step"),
"no narration means no step count to explain. Got:\n{text}"
);
}
#[test]
fn narration_does_not_count_as_a_running_step() {
let g = group(
vec![
tool("bash", Some(true)),
GroupEntry::Note(NARRATION.to_string()),
],
false,
);
let text = text_of(&render(&g, &SlackTs::new("1.0".into())));
assert!(
!text.contains("running"),
"a finished turn must not report a running step. Got:\n{text}"
);
assert!(text.contains("✅"), "got: {text}");
}
#[test]
fn a_running_tool_is_still_reported_alongside_narration() {
let g = group(
vec![GroupEntry::Note(NARRATION.to_string()), tool("bash", None)],
false,
);
let text = text_of(&render(&g, &SlackTs::new("1.0".into())));
assert!(text.contains("1 running"), "got: {text}");
}
#[test]
fn a_failed_tool_is_still_reported_alongside_narration() {
let g = group(
vec![
GroupEntry::Note(NARRATION.to_string()),
tool("bash", Some(false)),
],
false,
);
let text = text_of(&render(&g, &SlackTs::new("1.0".into())));
assert!(text.contains("1 failed"), "got: {text}");
assert!(text.contains("❌"), "got: {text}");
}
#[test]
fn a_lone_narration_step_renders_without_a_summary_header() {
let g = group(vec![GroupEntry::Note(NARRATION.to_string())], false);
let text = text_of(&render(&g, &SlackTs::new("1.0".into())));
assert!(text.contains(NARRATION), "got: {text}");
}
use crate::channels::slack::tool_group::{consume_notes, notes_text};
#[test]
fn a_group_with_no_narration_has_nothing_to_salvage() {
let entries = vec![tool("bash", Some(true)), tool("read_file", Some(true))];
assert_eq!(notes_text(&entries), None);
}
#[test]
fn an_empty_group_has_nothing_to_salvage() {
assert_eq!(notes_text(&[]), None);
}
#[test]
fn narration_is_recovered_in_order() {
let entries = vec![
GroupEntry::Note("First finding.".to_string()),
tool("bash", Some(true)),
GroupEntry::Note("Second finding.".to_string()),
];
let salvaged = notes_text(&entries).expect("two notes are present");
let first = salvaged.find("First finding.").expect("first present");
let second = salvaged.find("Second finding.").expect("second present");
assert!(first < second, "order must hold:\n{salvaged}");
assert!(
!salvaged.contains("bash"),
"tool rows must not leak into the answer:\n{salvaged}"
);
}
#[test]
fn blank_notes_do_not_produce_an_empty_answer() {
let entries = vec![
GroupEntry::Note(" ".to_string()),
GroupEntry::Note("\n".to_string()),
];
assert_eq!(notes_text(&entries), None);
}
#[test]
fn consuming_notes_keeps_the_tool_record_and_drops_the_narration() {
let entries = vec![
GroupEntry::Note("First finding.".to_string()),
tool("bash", Some(true)),
GroupEntry::Note("Second finding.".to_string()),
];
let remaining = consume_notes(&entries);
assert_eq!(
remaining.len(),
1,
"only the tool row survives:\n{remaining:?}"
);
assert!(
matches!(&remaining[0], GroupEntry::Tool { name, .. } if name == "bash"),
"the surviving entry must be the tool row:\n{remaining:?}"
);
assert!(
!matches!(remaining[0], GroupEntry::Note(_)),
"no note may survive the consume"
);
}
#[test]
fn consuming_a_note_only_group_empties_it_so_the_shell_can_be_deleted() {
let entries = vec![
GroupEntry::Note("Only speech.".to_string()),
GroupEntry::Note("More speech.".to_string()),
];
assert!(
consume_notes(&entries).is_empty(),
"a note-only group must consume to empty"
);
}
#[test]
fn consuming_a_tool_only_group_changes_nothing() {
let entries = vec![tool("bash", Some(true)), tool("read_file", None)];
let remaining = consume_notes(&entries);
assert_eq!(remaining.len(), 2);
assert!(
remaining
.iter()
.all(|e| matches!(e, GroupEntry::Tool { .. })),
"tool rows pass through untouched"
);
}
#[test]
fn salvage_then_consume_never_show_the_same_text_twice() {
let entries = vec![
GroupEntry::Note("Finding one.".to_string()),
tool("bash", Some(true)),
GroupEntry::Note("Finding two.".to_string()),
];
let answer = notes_text(&entries).expect("salvage has the narration");
let remaining = consume_notes(&entries);
for entry in &remaining {
if let GroupEntry::Note(text) = entry {
assert!(
!answer.contains(text.as_str()),
"a consumed note must not survive in the group:\n{answer}"
);
}
}
assert!(
!remaining.iter().any(|e| matches!(e, GroupEntry::Note(_))),
"no notes may remain after consume"
);
}