aidaemon 0.11.9

A personal AI agent that runs as a background daemon, accessible via Telegram, Slack, or Discord, with tool use, MCP integration, and persistent memory
Documentation
use serde_json::json;
use tempfile::TempDir;

use super::test_tool;
use crate::config::ComputerUseConfig;
use crate::traits::Tool;

#[tokio::test]
async fn schema_includes_action_enum() {
    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let schema = tool.schema();
    let actions = schema["parameters"]["properties"]["action"]["enum"]
        .as_array()
        .unwrap();
    assert!(actions.iter().any(|v| v == "get_app_state"));
}

fn test_model_args() -> serde_json::Value {
    json!({
        "_model": "gpt-4o",
        "_model_chain": ["gpt-4o"],
        "_provider_kind": "OpenaiCompatible"
    })
}

#[tokio::test]
async fn mock_get_app_state_returns_generation_and_attachment() {
    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let mut args = json!({
        "action": "get_app_state",
        "app": "Calculator",
        "_session_id": "telegram:1",
        "_task_id": "task-1"
    });
    if let Some(obj) = args.as_object_mut() {
        obj.extend(test_model_args().as_object().unwrap().clone());
    }
    let outcome = tool
        .call_with_status_outcome(&args.to_string(), None)
        .await
        .unwrap();
    assert!(outcome.output.contains("snapshot_generation="));
    assert_eq!(outcome.metadata.attachments.len(), 1);
    assert_eq!(
        outcome.metadata.attachments[0].source_tool.as_deref(),
        Some("computer_use")
    );
}

#[tokio::test]
async fn stale_generation_is_rejected() {
    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let mut inspect = json!({
        "action": "get_app_state",
        "app": "Calculator",
        "_session_id": "telegram:1",
        "_task_id": "task-1"
    });
    if let Some(obj) = inspect.as_object_mut() {
        obj.extend(test_model_args().as_object().unwrap().clone());
    }
    tool.call_with_status_outcome(&inspect.to_string(), None)
        .await
        .unwrap();
    tool.call_with_status_outcome(&inspect.to_string(), None)
        .await
        .unwrap();
    let mut click = json!({
        "action": "click",
        "app": "Calculator",
        "snapshot_generation": 1,
        "element_index": 1,
        "_session_id": "telegram:1",
        "_task_id": "task-1"
    });
    if let Some(obj) = click.as_object_mut() {
        obj.extend(test_model_args().as_object().unwrap().clone());
    }
    let outcome = tool
        .call_with_status_outcome(&click.to_string(), None)
        .await
        .unwrap();
    assert!(outcome.output.contains("Stale snapshot_generation"));
}

#[tokio::test]
async fn list_apps_works_without_session() {
    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let args = json!({ "action": "list_apps" });
    let out = tool.call(&args.to_string()).await.unwrap();
    assert!(
        out.contains("Calculator"),
        "unexpected list_apps output: {out:?}"
    );
}

#[tokio::test]
async fn model_pin_is_set_on_first_gui_action() {
    use super::pin_registry::ComputerUsePinRegistry;

    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let mut args = json!({
        "action": "get_app_state",
        "app": "Calculator",
        "_session_id": "telegram:1",
        "_task_id": "task-pin"
    });
    if let Some(obj) = args.as_object_mut() {
        obj.extend(test_model_args().as_object().unwrap().clone());
    }
    tool.call_with_status_outcome(&args.to_string(), None)
        .await
        .unwrap();
    let pinned = ComputerUsePinRegistry::shared()
        .get("task-pin")
        .await
        .expect("expected model pin");
    assert_eq!(pinned, "gpt-4o");
}

#[tokio::test]
async fn consequential_allow_always_proceeds_as_one_time_allow() {
    use super::approvals::ApprovalState;
    use crate::tools::ApprovalBroker;
    use crate::types::ApprovalResponse;
    use tokio::sync::mpsc;

    let (tx, mut rx) = mpsc::channel(1);
    let broker = ApprovalBroker::new(tx);
    let state = ApprovalState::new();
    let responder = tokio::spawn(async move {
        let req = rx.recv().await.expect("approval request");
        let _ = req.response_tx.send(ApprovalResponse::AllowAlways);
    });
    let result = state
        .ensure_consequential(&broker, "telegram:1", "task-1", "Click 'Delete'")
        .await;
    responder.await.unwrap();
    assert!(
        result.is_ok(),
        "AllowAlways on a consequential action should proceed as a one-time allow: {result:?}"
    );
}

#[tokio::test]
async fn activate_app_without_generation_succeeds() {
    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let mut args = json!({
        "action": "activate_app",
        "app": "Calculator",
        "_session_id": "telegram:1",
        "_task_id": "task-activate"
    });
    if let Some(obj) = args.as_object_mut() {
        obj.extend(test_model_args().as_object().unwrap().clone());
    }
    let outcome = tool
        .call_with_status_outcome(&args.to_string(), None)
        .await
        .unwrap();
    assert!(
        !outcome.output.starts_with("Error:"),
        "activate_app should not require snapshot_generation: {}",
        outcome.output
    );
    assert!(outcome.output.contains("Calculator"));
}

#[tokio::test]
async fn missing_app_error_is_instructional() {
    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let mut args = json!({
        "action": "click",
        "snapshot_generation": 1,
        "element_index": 2,
        "_session_id": "telegram:1",
        "_task_id": "task-noapp"
    });
    if let Some(obj) = args.as_object_mut() {
        obj.extend(test_model_args().as_object().unwrap().clone());
    }
    let outcome = tool
        .call_with_status_outcome(&args.to_string(), None)
        .await
        .unwrap();
    assert!(
        outcome.output.contains("repeat the same call with app set"),
        "missing-app error should tell the model the literal next step: {}",
        outcome.output
    );
}

#[tokio::test]
async fn missing_generation_error_is_instructional() {
    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let mut args = json!({
        "action": "click",
        "app": "Calculator",
        "element_index": 2,
        "_session_id": "telegram:1",
        "_task_id": "task-nogen"
    });
    if let Some(obj) = args.as_object_mut() {
        obj.extend(test_model_args().as_object().unwrap().clone());
    }
    let outcome = tool
        .call_with_status_outcome(&args.to_string(), None)
        .await
        .unwrap();
    assert!(
        outcome
            .output
            .contains("copy the snapshot_generation value"),
        "missing-generation error should tell the model the literal next step: {}",
        outcome.output
    );
}

#[tokio::test]
async fn schema_documents_requirements_and_verification() {
    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let schema = tool.schema();
    let description = schema["description"].as_str().unwrap();
    assert!(
        description.contains("before reporting success"),
        "tool description should require verifying state before claiming success: {description}"
    );
    let props = &schema["parameters"]["properties"];
    let app_desc = props["app"]["description"].as_str().unwrap();
    assert!(
        app_desc.contains("Required for every action except list_apps"),
        "app param should document when it is required: {app_desc}"
    );
    let gen_desc = props["snapshot_generation"]["description"]
        .as_str()
        .unwrap();
    assert!(
        gen_desc.contains("optional for activate_app"),
        "snapshot_generation should document the activate_app exemption: {gen_desc}"
    );
}

#[tokio::test]
async fn mutation_budget_blocks_after_limit() {
    let dir = TempDir::new().unwrap();
    let tool = test_tool(
        ComputerUseConfig {
            enabled: true,
            max_mutating_actions: 1,
            ..Default::default()
        },
        dir.path().to_path_buf(),
    )
    .await;
    let mut inspect = json!({
        "action": "get_app_state",
        "app": "Calculator",
        "_session_id": "telegram:1",
        "_task_id": "task-budget"
    });
    if let Some(obj) = inspect.as_object_mut() {
        obj.extend(test_model_args().as_object().unwrap().clone());
    }
    tool.call_with_status_outcome(&inspect.to_string(), None)
        .await
        .unwrap();

    let click = |generation: u64, index: u32| {
        let mut args = json!({
            "action": "click",
            "app": "Calculator",
            "snapshot_generation": generation,
            "element_index": index,
            "_session_id": "telegram:1",
            "_task_id": "task-budget"
        });
        if let Some(obj) = args.as_object_mut() {
            obj.extend(test_model_args().as_object().unwrap().clone());
        }
        args
    };

    let first = tool
        .call_with_status_outcome(&click(1, 1).to_string(), None)
        .await
        .unwrap();
    assert!(!first.output.contains("budget exceeded"));

    let second = tool
        .call_with_status_outcome(&click(2, 2).to_string(), None)
        .await
        .unwrap();
    assert!(second.output.contains("budget exceeded"));
}