rho-coding-agent 1.17.0

A lightweight agent harness inspired by Pi
Documentation
use std::time::Instant;

use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
use pretty_assertions::assert_eq;
use ratatui::{backend::TestBackend, Terminal};
use tempfile::TempDir;

use super::*;

#[test]
fn provider_retry_replaces_output_but_preserves_presented_events() {
    let directory = TempDir::new().unwrap();
    let mut app = AttachmentApp::new(
        "abc123",
        directory.path().to_path_buf(),
        HerdrReporter::default(),
    );
    app.apply_event(AttachmentEvent::Prompt("delegated task".into()));
    app.apply_event(AttachmentEvent::StepStarted);
    app.apply_event(AttachmentEvent::AssistantTextDelta("discard me".into()));
    app.apply_event(AttachmentEvent::Notice("keep notice".into()));
    app.apply_event(AttachmentEvent::ToolFinished {
        ok: true,
        display_style: rho_tools::tool::ToolDisplayStyle::default_tool(),
        display_lines: vec!["keep tool".into()],
    });
    app.apply_event(AttachmentEvent::ReasoningDelta("discard reasoning".into()));
    app.apply_event(AttachmentEvent::ProviderStreamReset);
    app.apply_event(AttachmentEvent::AssistantTextDelta("keep me".into()));

    assert!(matches!(
        app.transcript.as_slice(),
        [
            Entry::User(prompt),
            Entry::Notice(notice),
            Entry::Tool(tool),
            Entry::Assistant(answer)
        ] if prompt == "delegated task"
            && notice == "keep notice"
            && tool.display_lines == ["keep tool"]
            && answer == "keep me"
    ));
}

#[test]
fn attached_view_ignores_prompt_input() {
    let directory = TempDir::new().unwrap();
    let mut app = AttachmentApp::new(
        "abc123",
        directory.path().to_path_buf(),
        HerdrReporter::default(),
    );
    app.apply_event(AttachmentEvent::Prompt("delegated task".into()));

    app.handle_event(Event::Key(KeyEvent::new(
        KeyCode::Char('x'),
        KeyModifiers::NONE,
    )));
    app.handle_event(Event::Key(KeyEvent::new(
        KeyCode::Enter,
        KeyModifiers::NONE,
    )));

    assert_eq!(app.transcript.len(), 1);
    assert!(!app.should_quit);
    assert!(matches!(
        &app.transcript[0],
        Entry::User(prompt) if prompt == "delegated task"
    ));
}

#[test]
fn attached_view_renders_transcript_without_a_composer() {
    let directory = TempDir::new().unwrap();
    let mut app = AttachmentApp::new(
        "abc123",
        directory.path().to_path_buf(),
        HerdrReporter::default(),
    );
    app.status = Some(RunStatus {
        state: RunState::Running,
        agent_id: Some("explorer".into()),
        last_activity: Some("tool: read_file".into()),
        ..RunStatus::default()
    });
    app.apply_event(AttachmentEvent::Prompt("delegated task".into()));
    app.apply_event(AttachmentEvent::AssistantTextDelta(
        "watchable answer".into(),
    ));
    app.apply_event(AttachmentEvent::ContextUsage(ContextUsage::estimated(
        123,
        Some(456),
    )));
    app.apply_event(AttachmentEvent::Usage(ModelUsage {
        input_tokens: Some(10),
        output_tokens: Some(5),
        cache_read_tokens: Some(700),
        cache_write_tokens: Some(20),
        cost_usd_micros: Some(12_500),
        ..ModelUsage::default()
    }));
    let mut terminal = Terminal::new(TestBackend::new(100, 18)).unwrap();

    terminal.draw(|frame| app.draw(frame)).unwrap();

    let screen = terminal.backend().to_string();
    assert!(screen.contains("attached to abc123"));
    assert!(screen.contains("delegated task"));
    assert!(screen.contains("watchable answer"));
    assert!(screen.contains("context 123/456"));
    assert!(screen.contains("tokens in 10 · out 5 · cache r 700 · cache w 20"));
    assert!(screen.contains("$0.013"));
    assert!(screen.contains("read-only"));
    assert!(!screen.contains("Type a message"));
    assert!(!screen.contains("step tokens"));
    assert!(!screen.contains("tokens 10/5"));
}

#[test]
fn provider_retry_preserves_failed_attempt_usage() {
    let directory = TempDir::new().unwrap();
    let mut app = AttachmentApp::new(
        "abc123",
        directory.path().to_path_buf(),
        HerdrReporter::default(),
    );

    app.apply_event(AttachmentEvent::StepStarted);
    app.apply_event(AttachmentEvent::Usage(ModelUsage {
        input_tokens: Some(100),
        output_tokens: Some(10),
        cache_read_tokens: Some(50),
        ..ModelUsage::default()
    }));
    app.apply_event(AttachmentEvent::ProviderStreamReset);
    app.apply_event(AttachmentEvent::Usage(ModelUsage {
        input_tokens: Some(40),
        output_tokens: Some(4),
        cache_read_tokens: Some(20),
        ..ModelUsage::default()
    }));

    assert_eq!(
        app.run_usage.current(),
        Some(&ModelUsage {
            input_tokens: Some(140),
            output_tokens: Some(14),
            cache_read_tokens: Some(70),
            total_tokens: Some(224),
            ..ModelUsage::default()
        })
    );
}

#[test]
fn multi_step_usage_replaces_live_run_snapshot() {
    let directory = TempDir::new().unwrap();
    let mut app = AttachmentApp::new(
        "abc123",
        directory.path().to_path_buf(),
        HerdrReporter::default(),
    );

    app.apply_event(AttachmentEvent::StepStarted);
    app.apply_event(AttachmentEvent::Usage(ModelUsage {
        input_tokens: Some(100),
        output_tokens: Some(20),
        cache_read_tokens: Some(50),
        ..ModelUsage::default()
    }));
    app.apply_event(AttachmentEvent::StepStarted);
    app.apply_event(AttachmentEvent::Usage(ModelUsage {
        input_tokens: Some(200),
        output_tokens: Some(60),
        cache_read_tokens: Some(150),
        ..ModelUsage::default()
    }));

    assert_eq!(
        app.run_usage.current(),
        Some(&ModelUsage {
            input_tokens: Some(200),
            output_tokens: Some(60),
            cache_read_tokens: Some(150),
            ..ModelUsage::default()
        })
    );
}

#[test]
fn scrolling_up_reveals_history_scrollbar() {
    let directory = TempDir::new().unwrap();
    let mut app = AttachmentApp::new(
        "abc123",
        directory.path().to_path_buf(),
        HerdrReporter::default(),
    );
    for index in 0..40 {
        app.apply_event(AttachmentEvent::AssistantTextDelta(format!(
            "line {index} of a long attached transcript"
        )));
        app.apply_event(AttachmentEvent::Notice(format!("notice {index}")));
    }

    let mut terminal = Terminal::new(TestBackend::new(80, 16)).unwrap();
    terminal.draw(|frame| app.draw(frame)).unwrap();
    assert!(!app.scroll.should_render(Instant::now()));

    app.handle_event(Event::Key(KeyEvent::new(
        KeyCode::PageUp,
        KeyModifiers::NONE,
    )));
    terminal.draw(|frame| app.draw(frame)).unwrap();

    assert!(matches!(app.scroll.scroll(), HistoryScroll::Manual { .. }));
    assert!(app.scroll.should_render(Instant::now()));
    assert!(app.history_scrollbar().is_some());

    let screen = terminal.backend().to_string();
    assert!(
        screen.contains('') || screen.contains(''),
        "expected scrollbar glyphs in attached view:\n{screen}"
    );
}

#[test]
fn mouse_wheel_scrolls_attached_transcript() {
    let directory = TempDir::new().unwrap();
    let mut app = AttachmentApp::new(
        "abc123",
        directory.path().to_path_buf(),
        HerdrReporter::default(),
    );
    for index in 0..40 {
        app.apply_event(AttachmentEvent::Notice(format!(
            "scrollable notice {index}"
        )));
    }
    let mut terminal = Terminal::new(TestBackend::new(80, 16)).unwrap();
    terminal.draw(|frame| app.draw(frame)).unwrap();

    app.handle_event(Event::Mouse(crossterm::event::MouseEvent {
        kind: MouseEventKind::ScrollUp,
        column: 10,
        row: 8,
        modifiers: KeyModifiers::NONE,
    }));

    assert!(matches!(app.scroll.scroll(), HistoryScroll::Manual { .. }));
    assert!(app.scroll.should_render(Instant::now()));
}

#[test]
fn herdr_state_follows_attached_subagent_state() {
    let status = |state| RunStatus {
        state,
        last_activity: Some("working".into()),
        ..RunStatus::default()
    };

    assert_eq!(
        herdr_status("abc123", &status(RunState::Starting)).0,
        HerdrState::Working
    );
    assert_eq!(
        herdr_status("abc123", &status(RunState::Running)).0,
        HerdrState::Working
    );
    assert_eq!(
        herdr_status("abc123", &status(RunState::Ok)).0,
        HerdrState::Idle
    );
    assert_eq!(
        herdr_status("abc123", &status(RunState::Stopped)).0,
        HerdrState::Idle
    );
    assert_eq!(
        herdr_status("abc123", &status(RunState::Error)).0,
        HerdrState::Blocked
    );
}

#[test]
fn status_token_fallback_uses_run_status_totals() {
    let summary = live_metrics_line(
        None,
        None,
        Some(&RunStatus {
            input_tokens: Some(1_200),
            output_tokens: Some(300),
            ..RunStatus::default()
        }),
    );
    assert_eq!(summary, "tokens in 1.2K · out 300");
}

#[test]
fn format_run_cost_prefers_status_total_via_shared_usd_helper() {
    assert_eq!(
        format_run_cost(
            &RunStatus {
                total_cost_usd: Some(0.0388),
                ..RunStatus::default()
            },
            Some(&ModelUsage {
                cost_usd_micros: Some(99),
                ..ModelUsage::default()
            }),
        )
        .as_deref(),
        Some("$0.039")
    );
    assert_eq!(
        format_run_cost(
            &RunStatus::default(),
            Some(&ModelUsage {
                cost_usd_micros: Some(12_500),
                ..ModelUsage::default()
            }),
        )
        .as_deref(),
        Some("$0.013")
    );
}