aether-wisp 0.7.2

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
#![cfg(feature = "testing")]

use acp_utils::client::AcpEvent;
use acp_utils::testing::{idle_notification, plan_notification, running_notification};
use agent_client_protocol::schema::MaybeUndefined;
use agent_client_protocol::schema::v2 as acp;
use wisp::conversation::tool_calls::ToolStatus;
use wisp::testing::{TestUi, session_update, text_chunk_with_id, tool_completed};

#[test]
fn user_ack_and_idle_preserve_one_foreground_turn() {
    let mut ui = TestUi::new();
    ui.submit("hello");
    ui.acp_event(session_update(acp::SessionUpdate::UserMessage(
        acp::UserMessage::new("user-1").content(vec!["hello".into()]),
    )));
    ui.acp_event(session_update(running_notification("test-session").update));
    assert!(ui.app().waiting_for_response());
    ui.acp_event(text_chunk_with_id("reply-1", "world"));
    ui.acp_event(idle_notification("other", Some(acp::StopReason::EndTurn)).into());
    assert!(ui.app().waiting_for_response());
    ui.acp_event(session_update(idle_notification("test-session", Some(acp::StopReason::EndTurn)).update));
    assert!(!ui.app().waiting_for_response());
    let text = ui.conversation_text();
    assert_eq!(text.matches("hello").count(), 1, "{text}");
    assert!(text.contains("world"), "{text}");
    let commands = ui.executor_mut().take_commands();
    assert_eq!(
        commands
            .iter()
            .filter(|command| matches!(
                command,
                wisp::command::Command::Terminal(wisp::command::TerminalCommand::RingBell)
            ))
            .count(),
        1
    );
    ui.complete_prompt(acp::StopReason::EndTurn);
    assert!(ui.executor_mut().take_commands().is_empty());
}

#[test]
fn prompt_rejection_recovers_before_and_after_native_idle() {
    use wisp::command::{AgentCommand, CommandResult};
    for idle_first in [false, true] {
        let mut ui = TestUi::new();
        ui.submit("first");
        ui.next_agent_command().unwrap();
        if idle_first {
            ui.acp_event(session_update(idle_notification("test-session", None).update));
            assert!(!ui.app().waiting_for_response());
        }
        ui.submit("next");
        assert!(ui.next_agent_command().is_none(), "pending result still owns submission");
        ui.deliver_result(CommandResult::Cancel(Ok(())));
        ui.key(crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE));
        assert!(ui.next_agent_command().is_none(), "unrelated acceptance cannot release a prompt");
        ui.deliver_result(CommandResult::Prompt(Err("rejected".into())));
        assert!(!ui.app().waiting_for_response());
        ui.key(crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE));
        assert!(matches!(ui.next_agent_command(), Some(AgentCommand::Prompt { text, .. }) if text == "next"));
        assert!(ui.app().waiting_for_response());
    }
}

#[test]
fn cancellation_keeps_final_output_and_ignores_late_acceptance() {
    use wisp::command::CommandResult;
    let mut ui = TestUi::new();
    ui.submit("hello");
    ui.next_agent_command().unwrap();
    ui.deliver_result(CommandResult::Cancel(Ok(())));
    assert!(ui.app().waiting_for_response(), "cancel is not completion");
    ui.acp_event(text_chunk_with_id("reply", "final output"));
    ui.acp_event(session_update(idle_notification("test-session", Some(acp::StopReason::Cancelled)).update));
    assert!(!ui.app().waiting_for_response());
    ui.deliver_result(CommandResult::Prompt(Ok(acp::PromptResponse::new())));
    assert!(!ui.app().waiting_for_response(), "late acceptance must not restart the turn");
    assert_eq!(ui.conversation_text().matches("final output").count(), 1);
}

#[test]
fn config_result_for_a_replaced_conversation_is_ignored() {
    use wisp::command::CommandResult;
    for restored_id in ["restored", "other"] {
        let mut ui = TestUi::new();
        ui.deliver_result(CommandResult::NewSession(Ok(acp::NewSessionResponse::new("test-session"))));
        let stale = ui.app().conversation_id();
        ui.deliver_result(CommandResult::ConfigOptionsUpdated {
            conversation_id: stale,
            result: Ok(acp::SetSessionConfigOptionResponse::new(vec![model_option("stale")])),
        });
        assert_eq!(ui.app().config_options().len(), 1);

        ui.begin_resume(restored_id, "/tmp");
        ui.deliver_result(CommandResult::ResumeSession {
            session_id: restored_id.into(),
            result: Ok(acp::ResumeSessionResponse::new().config_options(vec![model_option("restored")])),
        });
        ui.deliver_result(CommandResult::ConfigOptionsUpdated {
            conversation_id: stale,
            result: Ok(acp::SetSessionConfigOptionResponse::new(vec![])),
        });
        assert_eq!(ui.app().config_options().len(), 1, "stale result must not erase restored configuration");
    }
}

#[test]
fn replay_snapshot_cannot_replace_a_live_turn() {
    let mut ui = TestUi::new();
    ui.submit("current");
    ui.deliver_result(wisp::command::CommandResult::ResumeSession {
        session_id: "test-session".into(),
        result: Ok(acp::ResumeSessionResponse::new()),
    });
    assert!(ui.app().waiting_for_response());
    ui.assert_conversation_contains("current");
}

#[test]
fn live_messages_can_be_replaced_cleared_and_appended_after_idle() {
    let mut ui = TestUi::new();
    ui.submit("question");
    ui.acp_event(message("reply", "obsolete answer"));
    ui.complete_prompt(acp::StopReason::EndTurn);
    ui.assert_conversation_contains("obsolete answer");
    ui.acp_event(message("reply", "corrected answer"));
    ui.assert_conversation_contains("corrected answer");
    ui.assert_conversation_not_contains("obsolete answer");
    ui.acp_event(session_update(acp::SessionUpdate::AgentMessage(
        acp::AgentMessage::new("reply").content(MaybeUndefined::Null),
    )));
    ui.assert_conversation_not_contains("corrected answer");
    ui.acp_event(text_chunk_with_id("reply", "appended answer"));
    ui.assert_conversation_contains("appended answer");
    assert!(!ui.app().progress_indicator().is_active());
}

#[test]
fn ready_and_replayed_updates_do_not_start_activity() {
    let mut ui = TestUi::new();
    for event in [
        session_update(idle_notification("test-session", None).update),
        message("history", "saved response"),
        text_chunk_with_id("history", " tail"),
    ] {
        ui.acp_event(event);
    }
    assert!(!ui.app().waiting_for_response());
    assert!(!ui.app().progress_indicator().is_active());
    ui.assert_conversation_contains("saved response tail");
}

#[test]
fn requires_action_and_unknown_updates_preserve_the_turn() {
    let mut ui = TestUi::with_dimensions(80, 20);
    ui.submit("question");
    ui.acp_event(session_update(acp::SessionUpdate::StateUpdate(acp::StateUpdate::RequiresAction(
        acp::RequiresActionStateUpdate::new(),
    ))));
    ui.assert_viewport_contains("Waiting for action");
    for value in [
        serde_json::json!({"sessionUpdate":"future_update", "field":true}),
        serde_json::json!({"sessionUpdate":"state_update", "state":"future_state"}),
        serde_json::json!({"sessionUpdate":"terminal_update", "terminalId":"terminal"}),
        serde_json::json!({"sessionUpdate":"terminal_output_chunk", "terminalId":"terminal", "data":"ignored"}),
    ] {
        ui.acp_event(update(value));
    }
    assert!(ui.app().waiting_for_response());
    ui.complete_prompt(acp::StopReason::Cancelled);
    assert!(!ui.app().waiting_for_response());
    assert!(!ui.app().progress_indicator().is_active());
}

#[test]
fn tool_and_plan_updates_are_upserts_and_chunks_append() {
    let mut ui = TestUi::new();
    ui.submit("work");
    ui.acp_event(session_update(acp::SessionUpdate::ToolCallUpdate(
        acp::ToolCallUpdate::new("tool").title("Read file"),
    )));
    ui.acp_event(session_update(acp::SessionUpdate::ToolCallContentChunk(acp::ToolCallContentChunk::new(
        "tool",
        acp::ToolCallContent::Content(Box::new(acp::Content::new("output"))),
    ))));
    ui.acp_event(tool_completed("tool"));
    let tools: Vec<_> = ui
        .app()
        .conversation_items()
        .iter()
        .filter_map(|item| match item.content() {
            wisp::conversation::ConversationContent::Tool(tool) => Some(tool),
            _ => None,
        })
        .collect();
    assert_eq!(tools.len(), 1);
    assert_eq!(tools[0].title(), "Read file");
    assert_eq!(tools[0].status, ToolStatus::Success);
    for (id, text) in [("a", "old"), ("b", "preserved"), ("a", "replacement")] {
        ui.acp_event(session_update(
            plan_notification(
                "test-session",
                id,
                vec![acp::PlanEntry::new(text, acp::PlanEntryPriority::Medium, acp::PlanEntryStatus::Pending)],
            )
            .update,
        ));
    }
    let entries = ui.app().plan_entries();
    assert_eq!(entries.iter().map(|entry| entry.content.as_str()).collect::<Vec<_>>(), ["replacement"]);
}

#[test]
fn repeated_resume_replaces_history_without_adopting_a_live_echo() {
    let mut ui = TestUi::new();
    ui.submit("old prompt");
    ui.complete_prompt(acp::StopReason::EndTurn);
    for id in ["restored", "restored-again"] {
        ui.begin_resume(id, "/tmp");
        let replay: Vec<_> = [
            acp::SessionUpdate::UserMessage(acp::UserMessage::new("user").content(vec!["saved prompt".into()])),
            acp::SessionUpdate::AgentMessage(acp::AgentMessage::new("reply").content(vec!["saved answer".into()])),
            idle_notification("restored", None).update,
        ]
        .into_iter()
        .map(|update| AcpEvent::SessionUpdate(Box::new(acp::UpdateSessionNotification::new(id, update))))
        .collect();
        for event in replay {
            ui.acp_event(event);
        }
        assert_eq!(ui.conversation_text().matches("saved answer").count(), 1);
        ui.deliver_result(wisp::command::CommandResult::ResumeSession {
            session_id: id.into(),
            result: Ok(acp::ResumeSessionResponse::new()),
        });
        assert_eq!(ui.app().conversation_items().len(), 2);
        assert!(!ui.app().waiting_for_response());
        assert!(!ui.app().progress_indicator().is_active());
        ui.assert_conversation_contains("saved answer");
        ui.assert_conversation_not_contains("old prompt");
    }
}

#[test]
fn seeded_history_uses_distinct_message_ids_and_finishes_each_turn() {
    let mut ui = TestUi::new();
    ui.seed_long_history(3);
    let users: Vec<_> = ui
        .app()
        .conversation_items()
        .iter()
        .filter(|item| matches!(item.content(), wisp::conversation::ConversationContent::User(_)))
        .collect();
    assert_eq!(users.len(), 3);
    assert_ne!(users[0].message_id(), users[1].message_id());
    assert!(!ui.app().waiting_for_response());
}

fn model_option(value: &str) -> acp::SessionConfigOption {
    acp::SessionConfigOption::select("model", "Model", value, vec![acp::SessionConfigSelectOption::new(value, value)])
}

fn message(id: &str, text: &str) -> AcpEvent {
    session_update(acp::SessionUpdate::AgentMessage(acp::AgentMessage::new(id).content(vec![text.into()])))
}

fn update(value: serde_json::Value) -> AcpEvent {
    session_update(serde_json::from_value(value).unwrap())
}