opencrabs 0.3.79

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Recommended: the 40MB prebuilt binary for macOS, Linux and Windows: https://github.com/adolfousier/opencrabs/releases
//! Regression (#943): the agent's between-tool narration folds into the
//! collapsible step group instead of being posted as its own chat message.
//!
//! Posted standalone it sat in the channel looking like an answer, and on a
//! turn that ended with an empty final the empty-final guard promoted it to
//! one — permanently, because that path skips the delete-the-intermediates
//! step. Folding it in matches what Telegram's flow block already does.

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 {
    GroupState {
        channel: SlackChannelId::new("C1".into()),
        entries,
        expanded,
    }
}

fn text_of(content: &SlackMessageContent) -> String {
    content.text.clone().unwrap_or_default()
}

/// The narration from the reported thread.
const NARRATION: &str = "Let me verify one thing properly before I report it";

#[test]
fn collapsed_group_hides_narration() {
    // The whole point: thinking must not be visible until asked for.
    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(NARRATION),
        "collapsed group must not show narration. Got:\n{text}"
    );
}

#[test]
fn expanded_group_shows_narration_in_order() {
    // Folding it away must not lose it — expanding shows the real sequence.
    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.find(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() {
    // "3 tool calls" beside five folded lines would misdescribe the contents.
    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() {
    // A tools-only turn must read exactly as before this change.
    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() {
    // A note is a record of something already said. Counting it as running
    // would leave the group showing "running" forever after the tools finish.
    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() {
    // Single-entry groups render as one plain line; a note must follow the
    // same rule rather than falling through to a bare summary with no body.
    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}");
}

// ── Salvage when the final response is empty (#951) ──────────────────────────
//
// #943 stopped narration being posted standalone, which also emptied the
// buffer the empty-final guard fed on. A turn whose final came back empty then
// posted nothing at all — the answer stayed sealed in a collapsed group. These
// pin the salvage path.

use crate::channels::slack::tool_group::notes_text;

#[test]
fn a_group_with_no_narration_has_nothing_to_salvage() {
    // Tool rows are a record of what ran, not something to say back. Posting
    // them as an answer would be worse than posting nothing.
    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() {
    // When the final is empty this text IS the answer, so order and content
    // both have to survive.
    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() {
    // Posting a whitespace-only message reads as a broken reply; returning
    // None lets the caller log that it had nothing instead.
    let entries = vec![
        GroupEntry::Note("   ".to_string()),
        GroupEntry::Note("\n".to_string()),
    ];
    assert_eq!(notes_text(&entries), None);
}