rho-coding-agent 2.9.1

A fast Rust agent harness with a small footprint and opinionated defaults
use super::*;
use crate::tui::tests::test_app;

#[test]
fn terminal_lifecycle_errors_bypass_sdk_failure_handling() {
    let error = sdk_failure_from_running_terminal_error(
        super::during_turn::RunningTerminalError::Terminal(anyhow::anyhow!("resume failed")),
    )
    .unwrap_err();

    assert_eq!(error.to_string(), "resume failed");
}

fn failed_turn() -> FailedTurn {
    FailedTurn {
        input: rho_sdk::UserInput::text("continue the existing goal turn"),
        display_user: vec![Message::user_text("continuing active goal")],
        display_commit: DisplayCommit::Unsaved,
        notification_context: None,
        boundary_recovery: Vec::new(),
        initial_tool_call: None,
        generate_session_title_after_completion: true,
        session_title_user: None,
        parent_action_required: false,
    }
}

// Covers: accepted in-turn findings are recovered after rollback, but not sent
// again once already present in durable history. Owner: retry input policy.
#[test]
fn running_boundary_recovery_follows_durable_receipts() {
    let first = BoundaryRecovery {
        model: "first findings".into(),
        display: Message::System("first receipt".into()),
    };
    let second = BoundaryRecovery {
        model: "second findings".into(),
        display: Message::System("second receipt".into()),
    };
    for (commit, retained) in [
        (DisplayCommit::Unsaved, vec![first.clone(), second.clone()]),
        (
            DisplayCommit::Checkpoint(vec![first.display.clone()]),
            vec![second.clone()],
        ),
        (DisplayCommit::Complete, Vec::new()),
    ] {
        let mut retry = failed_turn();
        retry.boundary_recovery = vec![first.clone(), second.clone()];
        retry
            .display_user
            .extend([first.display.clone(), second.display.clone()]);
        retry.display_commit = commit;
        retry.prepare_retry();
        let mut context = None;
        for recovery in &retained {
            context = Some(crate::tools::agent::merge_notification_context(
                context.as_deref(),
                &recovery.model,
            ));
        }
        let mut expected = context
            .into_iter()
            .map(ContentBlock::Text)
            .collect::<Vec<_>>();
        expected.extend_from_slice(retry.input.blocks());
        pretty_assertions::assert_eq!(retry.model_input().unwrap().blocks(), expected);
        pretty_assertions::assert_eq!(retry.boundary_recovery, retained);
    }
}

// Covers: newer failed deliveries must not hide an older action retry from the
// goal's child-wait decision. Owner: failed-turn scheduling policy.
#[test]
fn action_retry_keeps_queue_runnable_until_consumed() {
    let mut action = failed_turn();
    action.parent_action_required = true;
    let mut retries = VecDeque::from([action.clone()]);
    assert!(FailedTurn::retries_need_parent_action(&retries));

    // A later completion/workflow delivery fails and is retried first.
    retries.push_front(failed_turn());
    assert!(FailedTurn::retries_need_parent_action(&retries));
    retries.push_front(failed_turn());
    assert!(FailedTurn::retries_need_parent_action(&retries));
    retries.pop_front();
    retries.pop_front();
    pretty_assertions::assert_eq!(retries.pop_front(), Some(action));
    assert!(!FailedTurn::retries_need_parent_action(&retries));

    retries.push_back(failed_turn());
    assert!(!FailedTurn::retries_need_parent_action(&retries));
}

#[test]
fn approval_and_questionnaire_share_one_interaction_slot() {
    assert!(interaction_slot_available(false, false));
    assert!(!interaction_slot_available(true, false));
    assert!(!interaction_slot_available(false, true));
    assert!(!interaction_slot_available(true, true));
}

#[test]
fn mixed_interactions_preserve_arrival_order() {
    let approval_request = rho_sdk::ApprovalRequest::new(
        rho_sdk::CapabilityRequest::read_path(
            "/workspace/file",
            rho_sdk::PathScope::PrimaryWorkspace,
            rho_sdk::CapabilitySource::built_in_tool("read_file"),
        ),
        "approval required",
    );
    let (approval, _approval_response) = rho_sdk::PendingApproval::new(approval_request);
    let question = rho_sdk::HostQuestion::new(
        "color",
        "Choose one color",
        vec![rho_sdk::HostChoice::new("blue", "blue")],
        rho_sdk::SelectionMode::One,
    )
    .unwrap();
    let parent_request =
        rho_sdk::HostInputRequest::questionnaire("parent", vec![question.clone()]).unwrap();
    let child_request = rho_sdk::HostInputRequest::questionnaire("child", vec![question]).unwrap();
    let (child_response, _child_response_rx) = tokio::sync::oneshot::channel();
    let child = crate::app::subagent_host_input::SubagentHostInputRequest {
        run_id: "abc123".into(),
        agent_id: "worker".into(),
        parent_session_id: rho_sdk::SessionId::new(),
        request: child_request,
        response: child_response,
    };

    let mut queue = RunningInteractionQueue::default();
    queue.push(QueuedRunningInteraction::Approval(approval));
    queue.push(QueuedRunningInteraction::SubagentQuestionnaire(child));
    queue.push(QueuedRunningInteraction::ParentQuestionnaire {
        call_id: rho_sdk::ToolCallId::from_string("parent-call").unwrap(),
        request: parent_request,
    });

    assert!(matches!(
        queue.pop(),
        Some(QueuedRunningInteraction::Approval(_))
    ));
    assert!(matches!(
        queue.pop(),
        Some(QueuedRunningInteraction::SubagentQuestionnaire(_))
    ));
    assert!(matches!(
        queue.pop(),
        Some(QueuedRunningInteraction::ParentQuestionnaire { .. })
    ));
    assert!(queue.pop().is_none());
}

// Covers: retries suppress saved display but retain unsaved human and boundary
// messages after checkpoint failure. Owner: failed-turn retry policy.
#[test]
fn retry_retains_only_uncommitted_display_without_changing_model_or_title_state() {
    let transcript = crate::display_transcript::DisplayTranscript(vec![
        crate::display_transcript::DisplayRow::Notice("worker update".into()),
    ]);
    for (commit, retained) in [
        (DisplayCommit::Unsaved, 2),
        (DisplayCommit::Complete, 0),
        (DisplayCommit::Checkpoint(failed_turn().display_user), 1),
    ] {
        let mut original = failed_turn();
        original.display_user.push(transcript.display_message());
        original.display_commit = commit.clone();
        let mut expected = original.clone();
        expected.display_commit = DisplayCommit::Unsaved;
        if !matches!(commit, DisplayCommit::Unsaved) {
            expected.session_title_user = Some(original.session_title_user_message());
            expected
                .display_user
                .drain(..expected.display_user.len() - retained);
        }
        let mut retry = original;
        retry.prepare_retry();
        pretty_assertions::assert_eq!(retry, expected);
        retry.display_commit = commit;
        retry.prepare_retry();
        pretty_assertions::assert_eq!(retry, expected);
    }
}

#[test]
fn persisted_display_excludes_notification_context_for_standard_and_command_prompts() {
    let cases = [
        (
            TurnPrompt::standard("model prompt".into(), "visible prompt".into()),
            "model prompt",
            "visible prompt",
        ),
        (
            TurnPrompt::command("expanded command context".into(), "/goal run tests".into()),
            "expanded command context",
            "/goal run tests",
        ),
    ];

    for (prompt, expected_model, expected_display) in cases {
        let mut failed_turn = FailedTurn::from_prompt(prompt, Vec::new()).unwrap();
        failed_turn.attach_notification_context("hidden agent result".into());
        let model_input = failed_turn.model_input().unwrap();

        assert_eq!(
            model_input.blocks(),
            &[
                ContentBlock::Text("hidden agent result".into()),
                ContentBlock::Text(expected_model.into()),
            ]
        );
        assert_eq!(
            failed_turn.display_user,
            vec![Message::user_text(expected_display)]
        );
    }
}

#[test]
fn retry_attachment_keeps_prior_batches_and_adds_each_new_batch_once() {
    let mut failed_turn = FailedTurn::from_prompt(
        TurnPrompt::standard("user prompt".into(), "user prompt".into()),
        Vec::new(),
    )
    .unwrap();
    failed_turn.attach_notification_context("first batch".into());

    let mut retry = failed_turn.clone();
    retry.attach_notification_context("second batch".into());
    let later_retry_without_new_notifications = retry.clone();
    let model_input = later_retry_without_new_notifications.model_input().unwrap();
    let ContentBlock::Text(context) = &model_input.blocks()[0] else {
        panic!("notification context must be text")
    };

    assert_eq!(context.matches("first batch").count(), 1);
    assert_eq!(context.matches("second batch").count(), 1);
    assert!(context.find("first batch") < context.find("second batch"));
    assert!(context.len() <= crate::tools::agent::NOTIFICATION_CONTEXT_BYTES);
    assert_eq!(
        &model_input.blocks()[1..],
        &[ContentBlock::Text("user prompt".into())]
    );
    assert_eq!(
        later_retry_without_new_notifications.display_user,
        vec![Message::user_text("user prompt")]
    );
}

// Covers: mixed chat media must keep image blocks multimodal while injecting document text only
// into model context and retaining compact transcript content.
// Owner: TUI prompt assembly
#[test]
fn prompt_assembly_preserves_order_and_separates_document_display_from_model() {
    let image = ImageContent {
        data: "aW1hZ2U=".into(),
        mime_type: "image/png".into(),
    };
    let document = ChatTextDocument {
        name: "report.pdf".into(),
        mime: "application/pdf".into(),
        body: "extracted body".into(),
        truncated: true,
        warnings: vec!["some pages had no text".into()],
    };

    let failed_turn = FailedTurn::from_prompt(
        TurnPrompt::standard("model prompt".into(), "display prompt".into()),
        vec![
            ChatMedia::TextDocument(document),
            ChatMedia::Image(image.clone()),
        ],
    )
    .unwrap();

    assert_eq!(
        failed_turn.input.blocks(),
        &[
            ContentBlock::Text("model prompt".into()),
            ContentBlock::Text(
                "Attached file: report.pdf (application/pdf)\nExtraction notice: content was truncated to the attachment limit.\nExtraction warnings: some pages had no text\n<<<\nextracted body\n>>>"
                    .into(),
            ),
            ContentBlock::Image(image.clone()),
        ]
    );
    assert_eq!(
        failed_turn.display_user,
        vec![Message::User(vec![
            ContentBlock::Text("display prompt".into()),
            ContentBlock::Text("[pdf: report.pdf · 14 chars · truncated]".into()),
            ContentBlock::Image(image),
        ])]
    );
}

#[test]
fn failed_turn_keeps_live_partial_assistant_text_before_error() {
    let mut app = test_app();
    app.begin_provider_turn_ui();
    app.turn.set_current_turn_start(Some(0));
    app.streams
        .assistant_stream
        .push_delta("partial assistant before stream failure");

    let outcome =
        app.finalize_failed_turn("provider stream failed".into(), failed_turn(), Vec::new());

    assert_eq!(outcome.kind(), TurnOutcomeKind::Failed);
    assert!(matches!(
        app.history.entries(),
        [Entry::Assistant(assistant), Entry::Error(error)]
            if assistant.text == "partial assistant before stream failure"
                && error == "provider stream failed"
    ));
    assert!(!app.is_ui_busy());
    assert!(app.streams.assistant_stream.is_empty());
    assert_eq!(app.status(), "error");
}

// Covers: scheduled-turn notice batches stay restorable only until provider start
// accepts the input. A later failure must not put the batch back, or the next
// turn boundary would redeliver the same notice.
// Owner: TUI turn-boundary delivery
#[tokio::test]
async fn committed_boundary_batch_is_not_restored_after_post_start_failure() {
    let mut app = test_app();
    let mut agent =
        crate::app::interactive_runtime::test_edit_tool_runtime(crate::config::EditTool::default())
            .await;
    let session_id = agent.session_id().clone();
    app.subagent_inbox
        .push_notice_for_test(crate::app::subagent_messaging::SubagentNotice {
            acknowledged: Default::default(),
            run_id: "abc123".into(),
            agent_id: "worker".into(),
            parent_session_id: session_id.clone(),
            message: "schema inspection complete".into(),
            delivery: crate::app::subagent_messaging::NoticeDelivery::NextTurn,
        });

    let delivery = app
        .collect_turn_boundary_prompts(&mut agent)
        .expect("pending notice should drain");
    assert!(!app.subagent_inbox.has_pending_notices());

    // Pre-start abandon restores so a later turn can deliver again.
    let mut pending = Some(delivery);
    app.abandon_provider_turn_start(&mut agent, &mut pending);
    assert!(pending.is_none());
    assert!(
        app.subagent_inbox.has_pending_notices(),
        "failed provider start must restore the drained batch"
    );

    let delivery = app
        .collect_turn_boundary_prompts(&mut agent)
        .expect("restored notice should drain again");
    let mut pending = Some(delivery);
    // Provider start accepted the input: drop the restorable batch and free the
    // end-to-end notice budget, matching the production commit path.
    if let Some(boundary) = pending.take() {
        let delivered_notices = boundary.batch.notice_count();
        app.subagent_inbox
            .commit_delivered_notices(delivered_notices);
    }
    // Post-start failure path calls the same abandon helper; with nothing left
    // pending it must not resurrect the delivery.
    app.abandon_provider_turn_start(&mut agent, &mut pending);
    assert!(
        !app.subagent_inbox.has_pending_notices(),
        "committed delivery must not return after a post-start failure"
    );
    assert!(
        app.collect_turn_boundary_prompts(&mut agent).is_none(),
        "next turn boundary must not repeat the committed delivery"
    );
}