rho-coding-agent 1.48.0

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

use ratatui::text::Line;

use super::*;
use crate::tui::{tests::test_app, Entry, StreamKind};

fn prompt(text: &str) -> QueuedPrompt {
    QueuedPrompt {
        prompt: text.into(),
        display_prompt: text.into(),
        paste_segments: Vec::new(),
        media: Vec::new(),
    }
}

fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
    KeyEvent::new(code, modifiers)
}

fn line_text(line: &Line<'_>) -> String {
    line.spans
        .iter()
        .map(|span| span.content.as_ref())
        .collect()
}

#[test]
fn alt_up_prioritizes_latest_local_steer_over_follow_up() {
    let mut app = test_app();
    app.pending.push_follow_up(prompt("future turn"));
    app.pending
        .steering_prompts_mut()
        .push_back(prompt("first steer"));
    app.pending
        .steering_prompts_mut()
        .push_back(prompt("latest steer"));

    assert!(app.handle_pending_input_key(key(KeyCode::Up, KeyModifiers::ALT)));

    assert_eq!(app.input_ui.text(), "latest steer");
    assert_eq!(
        *app.pending.steering_prompts(),
        VecDeque::from([prompt("first steer")])
    );
    assert_eq!(
        *app.pending.queued_prompts(),
        VecDeque::from([prompt("future turn")])
    );
}

#[test]
fn alt_up_requests_retraction_for_accepted_steer() {
    let mut app = test_app();
    let id = rho_sdk::SteeringId::new();
    app.pending
        .accepted_steering_mut()
        .push_back(AcceptedSteering {
            id: id.clone(),
            prompt: prompt("retract me"),
        });

    app.handle_pending_input_key(key(KeyCode::Up, KeyModifiers::ALT));

    assert!(matches!(
        app.pending.input_action(),
        Some(PendingInputAction::EditAccepted {
            id: ref action_id,
            ..
        }) if action_id == &id
    ));
    assert!(app.input_ui.text().is_empty());
    assert_eq!(app.pending.accepted_steering().len(), 1);
}

#[test]
fn alt_up_preserves_nonempty_composer() {
    let mut app = test_app();
    app.input_ui.set_text("draft".to_string());
    app.input_ui.set_cursor(app.input_char_len());
    app.pending.push_follow_up(prompt("future turn"));

    app.handle_pending_input_key(key(KeyCode::Up, KeyModifiers::ALT));

    assert_eq!(app.input_ui.text(), "draft");
    assert_eq!(
        *app.pending.queued_prompts(),
        VecDeque::from([prompt("future turn")])
    );
    assert_eq!(
        app.status(),
        "clear the composer before editing pending input"
    );
}

#[test]
fn applied_event_preserves_selection_of_a_later_pending_item() {
    let mut app = test_app();
    let applied = rho_sdk::SteeringId::new();
    app.pending
        .accepted_steering_mut()
        .push_back(AcceptedSteering {
            id: applied.clone(),
            prompt: prompt("first steer"),
        });
    app.pending
        .accepted_steering_mut()
        .push_back(AcceptedSteering {
            id: rho_sdk::SteeringId::new(),
            prompt: prompt("second steer"),
        });
    app.pending.push_follow_up(prompt("future turn"));
    app.pending.input_panel_mut().selected = 2;

    app.record_applied_steering(&[applied]);

    assert_eq!(app.pending.input_panel().selected, 1);
    let lines = app.pending_input_lines(80);
    assert!(line_text(&lines[2]).contains("â–¸ NEXT"));
    assert!(matches!(
        app.history.entries(),
        [Entry::User(text)] if text == "first steer"
    ));
}

#[test]
fn backspace_removes_the_selected_follow_up() {
    let mut app = test_app();
    app.pending.push_follow_up(prompt("first"));
    app.pending.push_follow_up(prompt("second"));
    app.select_pending_recall_target();

    app.handle_pending_input_key(key(KeyCode::Char('q'), KeyModifiers::ALT));
    app.handle_pending_input_key(key(KeyCode::Up, KeyModifiers::NONE));
    app.handle_pending_input_key(key(KeyCode::Backspace, KeyModifiers::NONE));

    assert_eq!(
        *app.pending.queued_prompts(),
        VecDeque::from([prompt("second")])
    );
}

#[test]
fn rejected_steering_acceptance_becomes_a_follow_up_without_failing_the_turn() {
    let mut app = test_app();
    let queued = prompt("continue after this turn");
    let request = PendingInputRequest::Accept {
        prompt: queued.clone(),
        receipt: Box::pin(std::future::pending()),
    };
    let completion = PendingInputCompletion::Accepted(Err(rho_sdk::Error::InvalidHostResponse {
        message: "run completed before accepting steering input".into(),
    }));

    let failure = app.finish_pending_input_request(request, completion);

    assert_eq!(failure, None);
    assert!(app.pending.steering_prompts().is_empty());
    assert_eq!(*app.pending.queued_prompts(), VecDeque::from([queued]));
    assert_eq!(
        app.status(),
        "steer queued as follow-up: invalid host response: run completed before accepting steering input"
    );
}

#[test]
fn applied_event_removes_only_matching_steering() {
    let mut app = test_app();
    let applied = rho_sdk::SteeringId::new();
    let pending = rho_sdk::SteeringId::new();
    app.pending
        .accepted_steering_mut()
        .push_back(AcceptedSteering {
            id: applied.clone(),
            prompt: prompt("applied"),
        });
    app.pending
        .accepted_steering_mut()
        .push_back(AcceptedSteering {
            id: pending.clone(),
            prompt: prompt("pending"),
        });

    app.record_applied_steering(&[applied]);

    assert_eq!(app.pending.accepted_steering().len(), 1);
    assert_eq!(app.pending.accepted_steering()[0].id, pending);
    assert!(matches!(
        app.history.entries(),
        [Entry::User(text)] if text == "applied"
    ));
}

// Covers: AlreadyApplied must still show the steer after flushing any held stream
// Owner: interactive TUI pending-input
#[test]
fn already_applied_retraction_inserts_the_user_message() {
    let mut app = test_app();
    let id = rho_sdk::SteeringId::new();
    app.pending
        .accepted_steering_mut()
        .push_back(AcceptedSteering {
            id: id.clone(),
            prompt: prompt("keep me"),
        });
    app.streams.current_stream_kind = Some(StreamKind::Assistant);
    app.streams
        .push_delta(StreamKind::Assistant, "held assistant tail", Instant::now());
    let request = PendingInputRequest::Retract {
        action: PendingInputAction::DiscardAccepted { id },
        receipt: Box::pin(std::future::pending()),
    };
    let completion =
        PendingInputCompletion::Retracted(Ok(rho_sdk::SteeringRetraction::AlreadyApplied));

    let failure = app.finish_pending_input_request(request, completion);

    assert_eq!(failure, None);
    assert!(app.pending.accepted_steering().is_empty());
    assert!(app.streams.assistant_stream.pending_text().is_empty());
    assert!(matches!(
        app.history.entries(),
        [Entry::Assistant(assistant), Entry::User(text)]
            if assistant.text == "held assistant tail" && text == "keep me"
    ));
    assert_eq!(
        app.status(),
        "steer was already applied and can no longer be changed"
    );
}

// Covers: a late or empty SteeringApplied must not cut a live post-steer stream
// Owner: interactive TUI pending-input
#[test]
fn late_applied_event_does_not_cut_a_live_stream() {
    let mut app = test_app();
    app.streams.current_stream_kind = Some(StreamKind::Assistant);
    app.streams
        .push_delta(StreamKind::Assistant, "post-steer reply", Instant::now());

    app.record_applied_steering(&[rho_sdk::SteeringId::new()]);

    assert!(app.history.entries().is_empty());
    assert!(!app.streams.hold.is_empty());
    assert_eq!(app.streams.current_stream_kind, Some(StreamKind::Assistant));
}