eli 0.5.0

Ease Lives Instantly — hook-first AI agent framework with multi-channel support
//! Integration test for taskboard worker executing tasks via Claude Code CLI.
//!
//! This test hits a real LLM API — it costs money and takes time.
//! Run explicitly: `cargo test -p eli --test taskboard_integration`

use std::time::Duration;

use eli::taskboard::store::TaskStore;
use eli::taskboard::worker::TaskWorker;
use eli::taskboard::{NewTask, TaskFilter};
use serde_json::json;
use tokio_util::sync::CancellationToken;

/// Test that the worker can claim a task, execute it via Claude Code, and complete it.
#[tokio::test]
async fn worker_executes_task_via_claude_code() {
    // Skip if no CLI available
    let cli_check = tokio::process::Command::new("which")
        .arg("claude")
        .output()
        .await;
    if cli_check.map(|o| !o.status.success()).unwrap_or(true) {
        eprintln!("skipping: claude CLI not found");
        return;
    }

    let store = TaskStore::open_memory().expect("open in-memory store");
    let workspace = std::env::current_dir().expect("current dir");

    // Create a simple task
    let task_id = store
        .create(NewTask {
            kind: "test".into(),
            session_origin: "integration-test".into(),
            context: json!({"prompt": "Reply with exactly the word 'pong'. Nothing else."}),
            parent: None,
            priority: 1,
            metadata: json!({}),
        })
        .await
        .expect("create task");

    // Start worker in background
    let cancel = CancellationToken::new();
    let worker = TaskWorker::new(
        store.clone(),
        vec!["test".into()],
        "test-worker".into(),
        workspace,
    )
    .with_poll_interval(Duration::from_millis(500));

    let cancel_clone = cancel.clone();
    let worker_handle = tokio::spawn(async move {
        worker.run(cancel_clone).await;
    });

    // Wait for the task to complete (with timeout)
    let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
    loop {
        if tokio::time::Instant::now() > deadline {
            cancel.cancel();
            worker_handle.await.unwrap();
            panic!("task did not complete within 120 seconds");
        }

        if let Some(task) = store.get(task_id).await {
            if task.status.is_terminal() {
                // Task finished
                println!("Task status: {}", task.status.label());
                println!(
                    "Result: {}",
                    task.result
                        .as_ref()
                        .map(|r| serde_json::to_string_pretty(r).unwrap_or_default())
                        .unwrap_or_else(|| "(none)".into())
                );

                assert_eq!(
                    task.status.label(),
                    "done",
                    "task should complete successfully"
                );
                let output = task
                    .result
                    .as_ref()
                    .and_then(|r| r.get("output"))
                    .and_then(|v| v.as_str())
                    .unwrap_or("");
                // The model should respond with something containing "pong"
                assert!(
                    output.to_lowercase().contains("pong"),
                    "output should contain 'pong', got: {output}"
                );
                break;
            }
        }

        tokio::time::sleep(Duration::from_secs(2)).await;
    }

    cancel.cancel();
    worker_handle.await.unwrap();
}

/// Test that multiple tasks are processed sequentially by the serial worker.
#[tokio::test]
async fn worker_processes_multiple_tasks_sequentially() {
    let cli_check = tokio::process::Command::new("which")
        .arg("claude")
        .output()
        .await;
    if cli_check.map(|o| !o.status.success()).unwrap_or(true) {
        eprintln!("skipping: claude CLI not found");
        return;
    }

    let store = TaskStore::open_memory().expect("open store");
    let workspace = std::env::current_dir().expect("cwd");

    // Create two tasks
    let id1 = store
        .create(NewTask {
            kind: "test".into(),
            session_origin: "seq-test".into(),
            context: json!({"prompt": "Reply with exactly: alpha"}),
            parent: None,
            priority: 2, // higher priority, should run first
            metadata: json!({}),
        })
        .await
        .unwrap();

    let id2 = store
        .create(NewTask {
            kind: "test".into(),
            session_origin: "seq-test".into(),
            context: json!({"prompt": "Reply with exactly: beta"}),
            parent: None,
            priority: 1,
            metadata: json!({}),
        })
        .await
        .unwrap();

    let cancel = CancellationToken::new();
    let worker = TaskWorker::new(
        store.clone(),
        vec!["test".into()],
        "seq-worker".into(),
        workspace,
    )
    .with_poll_interval(Duration::from_millis(500));

    let cancel_clone = cancel.clone();
    let worker_handle = tokio::spawn(async move {
        worker.run(cancel_clone).await;
    });

    // Wait for both tasks to complete
    let deadline = tokio::time::Instant::now() + Duration::from_secs(180);
    loop {
        if tokio::time::Instant::now() > deadline {
            cancel.cancel();
            worker_handle.await.unwrap();
            panic!("tasks did not complete within 180 seconds");
        }

        let t1 = store.get(id1).await;
        let t2 = store.get(id2).await;

        if t1.as_ref().map(|t| t.status.is_terminal()).unwrap_or(false)
            && t2.as_ref().map(|t| t.status.is_terminal()).unwrap_or(false)
        {
            let t1 = t1.unwrap();
            let t2 = t2.unwrap();
            println!("Task 1: {}{}", t1.status.label(), t1.result.is_some());
            println!("Task 2: {}{}", t2.status.label(), t2.result.is_some());

            // Both should be done
            assert_eq!(t1.status.label(), "done");
            assert_eq!(t2.status.label(), "done");
            break;
        }

        tokio::time::sleep(Duration::from_secs(2)).await;
    }

    cancel.cancel();
    worker_handle.await.unwrap();
}

/// Verify the task board CLI commands work end-to-end with the store.
#[tokio::test]
async fn task_store_lifecycle() {
    let store = TaskStore::open_memory().expect("open store");

    // Create
    let id = store
        .create(NewTask {
            kind: "review".into(),
            session_origin: "lifecycle-test".into(),
            context: json!({"prompt": "review this code"}),
            parent: None,
            priority: 1,
            metadata: json!({"tag": "test"}),
        })
        .await
        .unwrap();

    // List - should find it
    let tasks = store
        .list(TaskFilter {
            kind: Some("review".into()),
            ..Default::default()
        })
        .await;
    assert_eq!(tasks.len(), 1);
    assert_eq!(tasks[0].id, id);

    // Claim
    let claimed = store.claim_next(&["review".into()], "agent-1").await;
    assert!(claimed.is_some());

    // Verify claimed state
    let task = store.get(id).await.unwrap();
    assert_eq!(task.status.label(), "claimed");
    assert_eq!(task.assigned_to.as_deref(), Some("agent-1"));

    // Complete
    store
        .complete(id, json!({"output": "looks good"}))
        .await
        .unwrap();

    let task = store.get(id).await.unwrap();
    assert_eq!(task.status.label(), "done");
    assert_eq!(task.result.unwrap()["output"], "looks good");

    // Can't cancel a completed task
    let cancel_result = store.cancel(id, "oops".into()).await;
    assert!(cancel_result.is_err());

    // Stats
    let active = store.active_count().await;
    assert_eq!(active, 0);
}