malvin 0.2.9

Non-interactive research and coding agent
use std::sync::atomic::Ordering;

use crate::acp::{AgentError, AgentIoOptions, CoderPromptOptions};

use super::session_fake::FAKE_BACKEND_ERROR_TURNS;

const fn retry_mock_io() -> AgentIoOptions {
    AgentIoOptions {
        no_tee: true,
        raw_output: true,
        show_thoughts_on_stdout: false,
        emit_stdout_markdown: false,
        log_full_outgoing_prompts: false,
    }
}

struct RetryMockEnv {
    _guard: std::sync::MutexGuard<'static, ()>,
}

impl RetryMockEnv {
    fn install() -> Self {
        let guard = crate::test_utils::test_env_lock();
        unsafe {
            std::env::set_var("OPENAI_API_KEY", "test-key");
            std::env::set_var(crate::acp::MALVIN_TEST_NO_REAL_AGENT_ENV, "1");
        }
        FAKE_BACKEND_ERROR_TURNS.store(0, Ordering::SeqCst);
        Self { _guard: guard }
    }
}

impl Drop for RetryMockEnv {
    fn drop(&mut self) {
        unsafe {
            std::env::remove_var(crate::acp::MALVIN_TEST_NO_REAL_AGENT_ENV);
        }
    }
}

fn retry_mock_client(
    run_dir: &std::path::Path,
    max_retries: u32,
) -> crate::agent_backend::SdkClient {
    let mut client =
        crate::pi_sdk::pi_sdk_client_from_raw("rpi:openai/gpt-4o", retry_mock_io(), max_retries);
    client.prompts_log_run_dir = Some(run_dir.to_path_buf());
    client
}

async fn run_failing_prompt(
    client: &mut crate::agent_backend::SdkClient,
    run_dir: &std::path::Path,
    prompt: &str,
) -> AgentError {
    client
        .active_coder_session()
        .expect("active coder session")
        .run_coder_prompt(
            prompt,
            &run_dir.join("prompts.log"),
            "coder",
            CoderPromptOptions {
                fresh_agent_on_retry: true,
                ..CoderPromptOptions::default()
            },
        )
        .await
        .expect_err("persistent backend error must fail the prompt")
}

fn fake_error_turns() -> u32 {
    FAKE_BACKEND_ERROR_TURNS.load(Ordering::SeqCst)
}

#[tokio::test]
async fn persistent_prompt_error_stops_after_max_retries_despite_respawn() {
    let _env = RetryMockEnv::install();
    let tmp = tempfile::tempdir().expect("tmp");
    let mut client = retry_mock_client(tmp.path(), 3);
    client.begin_coder_session(tmp.path()).await.expect("begin");
    let err = run_failing_prompt(&mut client, tmp.path(), "PERSISTENT_BACKEND_ERROR").await;
    assert_eq!(fake_error_turns(), 3, "error: {err}");
    assert!(
        err.message.contains("repeated 3 times in a row"),
        "error: {err}"
    );
    let _ = client.end_coder_session().await;
}

#[tokio::test]
async fn persistent_header_error_stops_after_max_retries_despite_respawn() {
    let _env = RetryMockEnv::install();
    let tmp = tempfile::tempdir().expect("tmp");
    let mut client = retry_mock_client(tmp.path(), 3);
    client.bind_session_header(
        "PERSISTENT_BACKEND_ERROR header".into(),
        tmp.path().join("prompts.log"),
        "header.md",
    );
    let err = client
        .start_coder_session(tmp.path())
        .await
        .expect_err("persistent header error must fail the session start");
    assert_eq!(fake_error_turns(), 3, "error: {err}");
    assert!(
        err.message.contains("repeated 3 times in a row"),
        "error: {err}"
    );
    let _ = client.end_coder_session().await;
}

#[tokio::test]
async fn persistent_header_error_inside_prompt_retry_stops_after_max_retries() {
    let _env = RetryMockEnv::install();
    let tmp = tempfile::tempdir().expect("tmp");
    let mut client = retry_mock_client(tmp.path(), 3);
    client.bind_session_header(
        "PERSISTENT_BACKEND_ERROR header".into(),
        tmp.path().join("prompts.log"),
        "header.md",
    );
    client.begin_coder_session(tmp.path()).await.expect("begin");
    client.end_coder_session().await.expect("end");
    let err = run_failing_prompt(&mut client, tmp.path(), "hello").await;
    assert_eq!(fake_error_turns(), 3, "error: {err}");
    assert!(
        err.message.contains("repeated 3 times in a row"),
        "error: {err}"
    );
    let _ = client.end_coder_session().await;
}

#[test]
fn header_error_then_persistent_respawn_failure_stops() {
    let _env = RetryMockEnv::install();
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .expect("runtime");
        let message = rt.block_on(async {
            let tmp = tempfile::tempdir().expect("tmp");
            let mut client = retry_mock_client(tmp.path(), 3);
            client.bind_session_header(
                "PERSISTENT_BACKEND_ERROR header".into(),
                tmp.path().join("prompts.log"),
                "header.md",
            );
            client.begin_coder_session(tmp.path()).await.expect("begin");
            client.model.slug = "no-provider".into();
            let err = client
                .start_coder_session(tmp.path())
                .await
                .expect_err("respawn failure must stop the header loop");
            err.message
        });
        let _ = tx.send(message);
    });
    let message = rx
        .recv_timeout(std::time::Duration::from_secs(4))
        .expect("header loop must stop instead of retrying without limit");
    assert!(message.contains("repeated 3 times in a row"), "{message}");
    assert!(message.contains("rpi model id must be"), "{message}");
}

#[tokio::test]
async fn successful_prompt_resets_backend_error_streak() {
    let _env = RetryMockEnv::install();
    let tmp = tempfile::tempdir().expect("tmp");
    let mut client = retry_mock_client(tmp.path(), 3);
    client.begin_coder_session(tmp.path()).await.expect("begin");
    assert!(!client.record_backend_error("Compute error"));
    assert!(!client.record_backend_error("Compute error"));
    client
        .active_coder_session()
        .expect("active coder session")
        .run_coder_prompt(
            "hello",
            &tmp.path().join("prompts.log"),
            "coder",
            CoderPromptOptions::default(),
        )
        .await
        .expect("prompt");
    assert_eq!(client.backend_error_tracker().consecutive_count(), 0);
    let _ = client.end_coder_session().await;
}

#[tokio::test]
async fn spawn_success_keeps_backend_error_streak() {
    let _env = RetryMockEnv::install();
    let tmp = tempfile::tempdir().expect("tmp");
    let mut client = retry_mock_client(tmp.path(), 3);
    assert!(!client.record_backend_error("Compute error"));
    client.begin_coder_session(tmp.path()).await.expect("begin");
    assert_eq!(client.backend_error_tracker().consecutive_count(), 1);
    let _ = client.end_coder_session().await;
}