aether-agent-core 0.7.1

A minimal Rust library for building AI agents with MCP tool integration
Documentation
use aether_core::core::Prompt;
use aether_core::events::{AgentEvent, Command, ContextEvent, ToolEvent, UserCommand};
use aether_core::testing::{FakeMcpServer, FakeTool, FakeToolResponse, TestScenario, test_agent};
use llm::testing::llm_response;
use llm::{ChatMessage, ContentBlock, LlmResponse};
use rmcp::model::{CreateTaskResult, DetailedTask, Task, TaskPayload, TaskStatus};
use std::sync::Arc;
use tokio::sync::Notify;

#[tokio::test]
async fn clear_context_suppresses_cancelled_background_task_notification() -> Result<(), Box<dyn std::error::Error>> {
    let now = chrono::Utc::now().to_rfc3339();
    let task = Task::new("clear-task", TaskStatus::Working, now.clone(), now).with_poll_interval_ms(10);
    let server = FakeMcpServer::new()
        .with_tool(FakeTool::new("deferred").responds(FakeToolResponse::task(CreateTaskResult::new(task.clone()))))
        .with_task("clear-task", [DetailedTask::new(task, TaskPayload::Working)]);
    let server_state = server.state();
    let arguments = serde_json::json!({}).to_string();
    let release = Arc::new(Notify::new());

    let result = test_agent()
        .fake_mcp_server("tasks", server)
        .llm_responses(&[
            llm_response("msg_1").tool_call("clear-call", "tasks__deferred", &[&arguments]).build(),
            vec![LlmResponse::start("cancelled-followup"), LlmResponse::text("must not finish"), LlmResponse::done()],
            vec![LlmResponse::start("msg_2"), LlmResponse::text("fresh context"), LlmResponse::done()],
        ])
        .pause_turn_after(1, 0, release)
        .scenario(
            TestScenario::new()
                .user_text("start background task")
                .wait_for(|event| matches!(event, AgentEvent::Tool(ToolEvent::TaskCreated { request, .. }) if request.id == "clear-call"))
                .send(Command::UserCommand(UserCommand::ClearContext))
                .wait_for(|event| matches!(event, AgentEvent::Context(ContextEvent::Cleared)))
                .wait_for_turn_end()
                .user_text("new question")
                .wait_for_turn_end(),
        )
        .run_with_context()
        .await?;

    assert_eq!(server_state.task_cancel_ids(), ["clear-task"]);
    assert!(
        !result.messages.iter().any(|event| matches!(event, AgentEvent::Tool(ToolEvent::TaskCancelled { .. }))),
        "cancellation acknowledgement must not repopulate cleared context: {:?}",
        result.messages,
    );
    let contexts = result.captured_contexts.lock().unwrap();
    let final_context = contexts.last().expect("fresh request should reach the model");
    assert!(
        !final_context.messages().iter().any(|message| matches!(message, ChatMessage::User { content, .. } if ContentBlock::join_text(content).contains("clear-task"))),
        "fresh context must not contain stale task cancellation"
    );

    Ok(())
}

#[tokio::test]
async fn test_clear_context_resets_history_and_preserves_system_prompt() -> Result<(), Box<dyn std::error::Error>> {
    let result = test_agent()
        .without_mcp()
        .system_prompt(Prompt::text("You are a test agent."))
        .llm_responses(&[
            vec![LlmResponse::start("msg_1"), LlmResponse::text("First response"), LlmResponse::done()],
            vec![LlmResponse::start("msg_2"), LlmResponse::text("Second response"), LlmResponse::done()],
        ])
        .scenario(
            TestScenario::new()
                .user_text("first question")
                .wait_for_turn_end()
                .send(Command::UserCommand(UserCommand::ClearContext))
                .wait_for(|event| matches!(event, AgentEvent::Context(ContextEvent::Cleared)))
                .user_text("second question")
                .wait_for_turn_end(),
        )
        .run_with_context()
        .await?;

    let contexts = result.captured_contexts.lock().unwrap();
    assert_eq!(contexts.len(), 2, "expected two LLM requests");

    let second = &contexts[1];
    let messages = second.messages();

    assert!(
        matches!(messages.first(), Some(ChatMessage::System { .. })),
        "system prompt should be preserved after clear"
    );

    let has_first_question = messages.iter().any(|m| {
        matches!(
            m,
            ChatMessage::User { content, .. } if *content == vec![ContentBlock::text("first question")]
        )
    });
    assert!(!has_first_question, "first turn user text should be removed from cleared context");

    let has_second_question = messages.iter().any(|m| {
        matches!(
            m,
            ChatMessage::User { content, .. } if *content == vec![ContentBlock::text("second question")]
        )
    });
    assert!(has_second_question, "new prompt should be present after clear");

    Ok(())
}