Files
galaxy/app/src/ai/blocklist/queued_query_tests.rs
T

968 lines
35 KiB
Rust

//! Unit tests for [`super::QueuedQueryModel`].
//!
//! Covers FIFO ordering, append from each origin, edit semantics, reorder semantics, the
//! per-conversation auto-queue toggle, and history-driven cleanup.
use std::cell::RefCell;
use std::rc::Rc;
use warpui::{App, SingletonEntity};
use super::{
AutofireAction, QueuedQuery, QueuedQueryEvent, QueuedQueryId, QueuedQueryModel,
QueuedQueryOrigin,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::ImageContext;
use crate::ai::blocklist::{BlocklistAIHistoryModel, PendingAttachment};
use crate::test_util::settings::initialize_history_persistence_for_tests;
/// Helper to drive the singleton `QueuedQueryModel` (plus its required `BlocklistAIHistoryModel`
/// singleton) inside a test app and capture emitted events.
fn with_model<F>(test: F)
where
F: FnOnce(App, warpui::ModelHandle<QueuedQueryModel>, Rc<RefCell<Vec<QueuedQueryEvent>>>)
+ 'static,
{
App::test((), |mut app| async move {
// Initializes settings (incl. `PrivatePreferences`) and registers
// `GlobalResourceHandlesProvider`. The provider is required because
// `BlocklistAIHistoryModel::delete_conversation` reads the global
// model-event sender to enqueue a sqlite delete.
initialize_history_persistence_for_tests(&mut app);
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let model = app.add_singleton_model(QueuedQueryModel::new);
let events: Rc<RefCell<Vec<QueuedQueryEvent>>> = Rc::new(RefCell::new(Vec::new()));
let events_clone = events.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&model, move |_, event: &QueuedQueryEvent, _| {
events_clone.borrow_mut().push(event.clone());
});
});
test(app, model, events);
});
}
fn user_query(text: &str) -> QueuedQuery {
QueuedQuery::new(text.to_owned(), QueuedQueryOrigin::QueueSlashCommand)
}
fn initial_cloud_mode_query(text: &str) -> QueuedQuery {
QueuedQuery::new(text.to_owned(), QueuedQueryOrigin::InitialCloudMode)
}
fn command_query(text: &str) -> QueuedQuery {
QueuedQuery::new_command(text.to_owned(), QueuedQueryOrigin::AutoQueueToggle)
}
fn image_attachment(file_name: &str) -> PendingAttachment {
PendingAttachment::Image(ImageContext {
data: String::new(),
mime_type: "image/png".to_owned(),
file_name: file_name.to_owned(),
is_figma: false,
})
}
fn append_user(
model: &warpui::ModelHandle<QueuedQueryModel>,
app: &mut App,
conversation_id: AIConversationId,
text: &str,
) -> QueuedQueryId {
model.update(app, |model, ctx| {
model.append(conversation_id, user_query(text), ctx)
})
}
#[test]
fn initial_cloud_mode_head_rejects_user_mutations_and_autofire() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let initial_id = model.update(&mut app, |model, ctx| {
model.append(conv, initial_cloud_mode_query("initial"), ctx)
});
let followup_id = append_user(&model, &mut app, conv, "follow up");
let removed = model.update(&mut app, |model, ctx| {
model.remove_by_id(conv, initial_id, ctx)
});
assert!(removed.is_none());
model.update(&mut app, |model, ctx| {
model.enter_edit_mode(conv, initial_id, ctx);
model.reorder(conv, initial_id, 1, ctx);
model.reorder(conv, followup_id, 0, ctx);
});
let action = model.read(&app, |model, _| model.peek_autofire(conv));
assert!(action.is_none());
model.read(&app, |model, _| {
let queue = model.queue(conv);
assert_eq!(queue.len(), 2);
assert_eq!(queue[0].id(), initial_id);
assert_eq!(queue[0].origin(), QueuedQueryOrigin::InitialCloudMode);
assert_eq!(queue[1].id(), followup_id);
assert_eq!(model.editing_row(conv), None);
});
});
}
#[test]
fn pop_front_no_ops_when_head_is_locked() {
// The Error/Cancelled drain path calls `pop_front` to restore a row to the editor. A locked
// initial Cloud Mode head must not be popped even if a status-transition arrives before the
// ambient-agent cleanup events fire `remove_initial_cloud_mode_row`.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let initial_id = model.update(&mut app, |model, ctx| {
model.append(conv, initial_cloud_mode_query("locked initial"), ctx)
});
let followup_id = append_user(&model, &mut app, conv, "follow up");
let popped = model.update(&mut app, |model, ctx| model.pop_front(conv, ctx));
assert!(popped.is_none());
model.read(&app, |model, _| {
let queue = model.queue(conv);
assert_eq!(queue.len(), 2);
assert_eq!(queue[0].id(), initial_id);
assert_eq!(queue[1].id(), followup_id);
});
});
}
#[test]
fn remove_initial_cloud_mode_row_only_removes_the_locked_head() {
with_model(|mut app, model, events| {
let conv = AIConversationId::new();
let initial_id = model.update(&mut app, |model, ctx| {
model.append(conv, initial_cloud_mode_query("initial"), ctx)
});
append_user(&model, &mut app, conv, "follow up");
events.borrow_mut().clear();
let removed = model.update(&mut app, |model, ctx| {
model.remove_initial_cloud_mode_row(conv, ctx)
});
assert_eq!(
removed.map(|query| query.text().to_owned()),
Some("initial".to_owned())
);
let removed_again = model.update(&mut app, |model, ctx| {
model.remove_initial_cloud_mode_row(conv, ctx)
});
assert!(removed_again.is_none());
let action = model.read(&app, |model, _| model.peek_autofire(conv));
match action {
Some(AutofireAction::Submit { text, .. }) => assert_eq!(text, "follow up"),
other => panic!("expected Submit, got {other:?}"),
}
let evts = events.borrow();
assert!(matches!(
evts.first(),
Some(QueuedQueryEvent::Removed { query_id, .. }) if *query_id == initial_id
));
});
}
#[test]
fn append_preserves_fifo_order() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "first");
let id_b = append_user(&model, &mut app, conv, "second");
let id_c = append_user(&model, &mut app, conv, "third");
model.read(&app, |model, _| {
let queue = model.queue(conv);
assert_eq!(queue.len(), 3);
assert_eq!(queue[0].id(), id_a);
assert_eq!(queue[0].text(), "first");
assert_eq!(queue[1].id(), id_b);
assert_eq!(queue[1].text(), "second");
assert_eq!(queue[2].id(), id_c);
assert_eq!(queue[2].text(), "third");
});
});
}
#[test]
fn append_from_each_user_origin_lands_in_the_queue() {
// /queue and the auto-queue toggle both land in the queue.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let origins = [
QueuedQueryOrigin::QueueSlashCommand,
QueuedQueryOrigin::AutoQueueToggle,
];
for (i, origin) in origins.iter().enumerate() {
let text = format!("p{i}");
model.update(&mut app, |m, ctx| {
m.append(conv, QueuedQuery::new(text, *origin), ctx)
});
}
model.read(&app, |model, _| {
let queue = model.queue(conv);
assert_eq!(queue.len(), 2);
for (i, origin) in origins.iter().enumerate() {
assert_eq!(queue[i].origin(), *origin);
}
});
});
}
#[test]
fn queue_next_prompt_toggle_defaults_false_and_emits_event() {
with_model(|mut app, model, events| {
let conv = AIConversationId::new();
model.read(&app, |model, _| {
assert!(!model.is_queue_next_prompt_toggle_enabled(conv));
});
model.update(&mut app, |model, ctx| {
model.toggle_queue_next_prompt(conv, ctx);
});
model.read(&app, |model, _| {
assert!(model.is_queue_next_prompt_toggle_enabled(conv));
});
let evts = events.borrow();
assert!(matches!(
evts.as_slice(),
[QueuedQueryEvent::QueueNextPromptToggled { conversation_id }] if *conversation_id == conv
));
});
}
#[test]
fn toggle_state_is_isolated_per_conversation() {
// Toggling for conversation A must not affect conversation B's toggle state.
with_model(|mut app, model, _events| {
let conv_a = AIConversationId::new();
let conv_b = AIConversationId::new();
model.update(&mut app, |m, ctx| m.toggle_queue_next_prompt(conv_a, ctx));
model.read(&app, |m, _| {
assert!(m.is_queue_next_prompt_toggle_enabled(conv_a));
assert!(!m.is_queue_next_prompt_toggle_enabled(conv_b));
});
});
}
#[test]
fn lrc_auto_queue_enables_queueing_by_default() {
// While an eligible LRC is active, queueing is on even though the conversation
// has no persistent override; outside the LRC it is off.
with_model(|app, model, _events| {
let conv = AIConversationId::new();
model.read(&app, |m, _| {
assert!(m.is_queue_next_prompt_enabled_during_lrc(conv));
assert!(!m.is_queue_next_prompt_toggle_enabled(conv));
});
});
}
#[test]
fn lrc_toggle_flips_only_the_lrc_override() {
with_model(|mut app, model, events| {
let conv = AIConversationId::new();
// Toggle off during the LRC: only the LRC-scoped state flips; the persistent
// per-conversation state is untouched.
model.update(&mut app, |m, ctx| {
m.toggle_queue_next_prompt_during_lrc(conv, ctx)
});
model.read(&app, |m, _| {
assert!(!m.is_queue_next_prompt_enabled_during_lrc(conv));
assert!(!m.is_queue_next_prompt_toggle_enabled(conv));
});
// Toggling back on re-enables for the remainder of the command.
model.update(&mut app, |m, ctx| {
m.toggle_queue_next_prompt_during_lrc(conv, ctx)
});
model.read(&app, |m, _| {
assert!(m.is_queue_next_prompt_enabled_during_lrc(conv));
});
let evts = events.borrow();
assert_eq!(evts.len(), 2);
assert!(evts.iter().all(|e| matches!(
e,
QueuedQueryEvent::QueueNextPromptToggled { conversation_id } if *conversation_id == conv
)));
});
}
#[test]
fn clearing_lrc_override_restores_auto_queue_for_the_next_command() {
// The LRC override is cleared when the command ends, so the next eligible LRC
// auto-enables again. Clearing an existing override emits a toggle event; clearing
// when none exists does not.
with_model(|mut app, model, events| {
let conv = AIConversationId::new();
model.update(&mut app, |m, ctx| {
m.toggle_queue_next_prompt_during_lrc(conv, ctx)
});
model.update(&mut app, |m, ctx| {
m.clear_queue_next_lrc_prompt_override(conv, ctx)
});
model.read(&app, |m, _| {
assert!(m.is_queue_next_prompt_enabled_during_lrc(conv));
});
assert_eq!(events.borrow().len(), 2);
model.update(&mut app, |m, ctx| {
m.clear_queue_next_lrc_prompt_override(conv, ctx)
});
assert_eq!(events.borrow().len(), 2);
});
}
#[test]
fn lrc_toggle_leaves_persistent_toggle_state_intact() {
// A conversation already in queue mode keeps that state after the LRC ends, regardless
// of toggles made during the command.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
model.update(&mut app, |m, ctx| m.toggle_queue_next_prompt(conv, ctx));
model.update(&mut app, |m, ctx| {
m.toggle_queue_next_prompt_during_lrc(conv, ctx)
});
model.read(&app, |m, _| {
assert!(!m.is_queue_next_prompt_enabled_during_lrc(conv));
// After the LRC ends, the persistent toggle still applies.
assert!(m.is_queue_next_prompt_toggle_enabled(conv));
});
});
}
#[test]
fn append_state_is_isolated_per_conversation() {
// Appending to one conversation's queue must not show up in another's.
with_model(|mut app, model, _events| {
let conv_a = AIConversationId::new();
let conv_b = AIConversationId::new();
append_user(&model, &mut app, conv_a, "a-first");
append_user(&model, &mut app, conv_b, "b-first");
append_user(&model, &mut app, conv_a, "a-second");
model.read(&app, |m, _| {
let a = m.queue(conv_a);
assert_eq!(a.len(), 2);
assert_eq!(a[0].text(), "a-first");
assert_eq!(a[1].text(), "a-second");
let b = m.queue(conv_b);
assert_eq!(b.len(), 1);
assert_eq!(b[0].text(), "b-first");
});
});
}
#[test]
fn pop_front_removes_head_and_emits_removed() {
with_model(|mut app, model, events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "first");
let _id_b = append_user(&model, &mut app, conv, "second");
events.borrow_mut().clear();
let popped = model.update(&mut app, |m, ctx| m.pop_front(conv, ctx));
let popped = popped.expect("queue had a head");
assert_eq!(popped.id(), id_a);
assert_eq!(popped.text(), "first");
model.read(&app, |model, _| {
assert_eq!(model.queue(conv).len(), 1);
});
let evts = events.borrow();
assert!(matches!(
evts.as_slice(),
[QueuedQueryEvent::Removed { conversation_id, query_id }]
if *conversation_id == conv && *query_id == id_a
));
});
}
#[test]
fn peek_autofire_leaves_row_until_remove_fired_row_drops_it() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let first_id = append_user(&model, &mut app, conv, "first");
append_user(&model, &mut app, conv, "second");
// peek_autofire reports the head's Submit action WITHOUT removing the row, so the send
// path can still read its attachments by id.
let action = model.read(&app, |model, _| model.peek_autofire(conv));
match action {
Some(AutofireAction::Submit { query_id, text }) => {
assert_eq!(query_id, first_id);
assert_eq!(text, "first");
}
other => panic!("expected Submit, got {other:?}"),
}
model.read(&app, |model, _| assert_eq!(model.queue(conv).len(), 2));
// remove_fired_row drops the fired head once the synchronous send completes.
model.update(&mut app, |model, ctx| {
model.remove_fired_row(conv, first_id, ctx)
});
model.read(&app, |model, _| {
let queue = model.queue(conv);
assert_eq!(queue.len(), 1);
assert_eq!(queue[0].text(), "second");
});
});
}
#[test]
fn restore_fired_row_reinserts_removed_row_for_retry() {
with_model(|mut app, model, events| {
let conv = AIConversationId::new();
let first_id = model.update(&mut app, |model, ctx| {
model.append(
conv,
QueuedQuery::new_with_attachments(
"first".to_owned(),
QueuedQueryOrigin::QueueSlashCommand,
vec![image_attachment("a.png")],
),
ctx,
)
});
append_user(&model, &mut app, conv, "second");
let (retry_index, retry_query) = model.read(&app, |model, _| {
model
.queue(conv)
.iter()
.enumerate()
.find(|(_, query)| query.id() == first_id)
.map(|(index, query)| (index, query.clone()))
.expect("queued row should exist")
});
model.update(&mut app, |model, ctx| {
model.remove_fired_row(conv, first_id, ctx);
});
events.borrow_mut().clear();
model.update(&mut app, |model, ctx| {
model.restore_fired_row(conv, retry_index, retry_query, ctx);
});
model.read(&app, |model, _| {
let queue = model.queue(conv);
assert_eq!(queue.len(), 2);
assert_eq!(queue[0].id(), first_id);
assert_eq!(queue[0].text(), "first");
assert_eq!(queue[0].attachments()[0].file_name(), "a.png");
assert_eq!(queue[1].text(), "second");
});
let evts = events.borrow();
assert!(matches!(
evts.as_slice(),
[QueuedQueryEvent::Appended {
conversation_id,
query_id
}] if *conversation_id == conv && *query_id == first_id
));
});
}
#[test]
fn peek_autofire_returns_pop_from_edit_mode_with_committed_text_and_attachments() {
// Per spec: when the first row is in edit mode, peek_autofire's PopFromEditMode action
// carries the row's last-committed text and its stored attachments (NOT any uncommitted
// live-editor buffer text). peek leaves edit state intact; remove_fired_row clears it.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = model.update(&mut app, |m, ctx| {
m.append(
conv,
QueuedQuery::new_with_attachments(
"first".to_owned(),
QueuedQueryOrigin::QueueSlashCommand,
vec![image_attachment("a.png")],
),
ctx,
)
});
append_user(&model, &mut app, conv, "second");
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx));
let action = model.read(&app, |m, _| m.peek_autofire(conv));
match action {
Some(AutofireAction::PopFromEditMode {
query_id,
text,
attachments,
is_command,
}) => {
assert_eq!(query_id, id_a);
assert_eq!(text, "first");
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].file_name(), "a.png");
assert!(!is_command);
}
other => panic!("expected PopFromEditMode, got {other:?}"),
}
// peek does not mutate: the row is still in edit mode.
model.read(&app, |m, _| assert_eq!(m.editing_row(conv), Some(id_a)));
model.update(&mut app, |m, ctx| m.remove_fired_row(conv, id_a, ctx));
model.read(&app, |m, _| {
assert_eq!(m.editing_row(conv), None);
assert_eq!(m.queue(conv).len(), 1);
});
});
}
#[test]
fn first_row_is_in_edit_mode_only_when_the_head_row_is_being_edited() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "first");
let id_b = append_user(&model, &mut app, conv, "second");
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_b, ctx));
model.read(&app, |m, _| {
assert!(!m.first_row_is_in_edit_mode(conv));
});
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx));
model.read(&app, |m, _| {
assert!(m.first_row_is_in_edit_mode(conv));
});
});
}
#[test]
fn enter_edit_mode_locks_to_one_row_at_a_time() {
// Entering edit mode on one row cancels the prior edit state.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "first");
let id_b = append_user(&model, &mut app, conv, "second");
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx));
model.read(&app, |m, _| assert_eq!(m.editing_row(conv), Some(id_a)));
// Entering edit mode on a different row replaces the prior edit.
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_b, ctx));
model.read(&app, |m, _| assert_eq!(m.editing_row(conv), Some(id_b)));
});
}
#[test]
fn commit_edit_with_text_replaces_row_and_clears_edit_state() {
// Non-empty edits replace the queued row's text.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "first");
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx));
model.update(&mut app, |m, ctx| {
m.commit_edit(conv, "first updated".to_owned(), ctx)
});
model.read(&app, |m, _| {
let queue = m.queue(conv);
assert_eq!(queue.len(), 1);
assert_eq!(queue[0].id(), id_a);
assert_eq!(queue[0].text(), "first updated");
assert_eq!(m.editing_row(conv), None);
});
});
}
#[test]
fn commit_edit_with_empty_text_restores_original_text() {
// Empty edits restore the original text.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "first");
append_user(&model, &mut app, conv, "second");
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx));
model.update(&mut app, |m, ctx| m.commit_edit(conv, String::new(), ctx));
model.read(&app, |m, _| {
let queue = m.queue(conv);
assert_eq!(queue.len(), 2);
assert_eq!(queue[0].id(), id_a);
assert_eq!(queue[0].text(), "first");
assert_eq!(queue[1].text(), "second");
assert_eq!(m.editing_row(conv), None);
});
});
}
#[test]
fn cancel_edit_leaves_row_unchanged_and_clears_edit_state() {
// Canceling an edit leaves the row unchanged.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "first");
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id_a, ctx));
model.update(&mut app, |m, ctx| m.cancel_edit(conv, ctx));
model.read(&app, |m, _| {
let queue = m.queue(conv);
assert_eq!(queue.len(), 1);
assert_eq!(queue[0].text(), "first");
assert_eq!(m.editing_row(conv), None);
});
});
}
#[test]
fn remove_by_id_removes_only_the_targeted_row() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "first");
let _id_b = append_user(&model, &mut app, conv, "second");
let _id_c = append_user(&model, &mut app, conv, "third");
let removed = model.update(&mut app, |m, ctx| m.remove_by_id(conv, id_a, ctx));
assert_eq!(
removed.map(|r| r.text().to_owned()),
Some("first".to_owned())
);
model.read(&app, |m, _| {
let queue = m.queue(conv);
assert_eq!(queue.len(), 2);
assert_eq!(queue[0].text(), "second");
assert_eq!(queue[1].text(), "third");
});
});
}
#[test]
fn reorder_moves_user_managed_rows_to_target_index() {
// Reordering moves user-managed rows to the requested target index.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "a");
let id_b = append_user(&model, &mut app, conv, "b");
let id_c = append_user(&model, &mut app, conv, "c");
// Move a (index 0) to the end (post-removal index 2).
model.update(&mut app, |m, ctx| m.reorder(conv, id_a, 2, ctx));
model.read(&app, |m, _| {
let queue = m.queue(conv);
assert_eq!(queue[0].id(), id_b);
assert_eq!(queue[1].id(), id_c);
assert_eq!(queue[2].id(), id_a);
});
});
}
#[test]
fn reorder_preserves_every_row_when_moving_last_to_front() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "a");
let id_b = append_user(&model, &mut app, conv, "b");
let id_c = append_user(&model, &mut app, conv, "c");
let id_d = append_user(&model, &mut app, conv, "d");
model.update(&mut app, |m, ctx| m.reorder(conv, id_d, 0, ctx));
model.read(&app, |m, _| {
let ids: Vec<_> = m.queue(conv).iter().map(|q| q.id()).collect();
assert_eq!(ids, vec![id_d, id_a, id_b, id_c]);
});
});
}
#[test]
fn reorder_clamps_target_index_to_queue_len() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id_a = append_user(&model, &mut app, conv, "a");
let id_b = append_user(&model, &mut app, conv, "b");
// Target index >= len after removal should clamp to the end.
model.update(&mut app, |m, ctx| m.reorder(conv, id_a, 99, ctx));
model.read(&app, |m, _| {
let queue = m.queue(conv);
assert_eq!(queue[0].id(), id_b);
assert_eq!(queue[1].id(), id_a);
});
});
}
#[test]
fn delete_conversation_drops_only_that_conversation_state() {
// Removing one conversation from history should drop its queue + toggle but leave others.
with_model(|mut app, model, _events| {
let history = BlocklistAIHistoryModel::handle(&app);
let terminal_view_id = warpui::EntityId::new();
let conv_a = history.update(&mut app, |h, ctx| {
h.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let conv_b = history.update(&mut app, |h, ctx| {
h.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
append_user(&model, &mut app, conv_a, "a1");
append_user(&model, &mut app, conv_b, "b1");
model.update(&mut app, |m, ctx| m.toggle_queue_next_prompt(conv_a, ctx));
history.update(&mut app, |h, ctx| {
h.delete_conversation(conv_a, Some(terminal_view_id), ctx);
});
model.read(&app, |m, _| {
assert!(!m.has_queue(conv_a));
assert!(!m.is_queue_next_prompt_toggle_enabled(conv_a));
let b = m.queue(conv_b);
assert_eq!(b.len(), 1);
assert_eq!(b[0].text(), "b1");
});
});
}
#[test]
fn command_rows_are_commands_without_attachments_and_prompts_are_not() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
model.update(&mut app, |m, ctx| {
m.append(conv, command_query("echo 1"), ctx)
});
model.update(&mut app, |m, ctx| {
m.append(
conv,
QueuedQuery::new_with_attachments(
"a prompt".to_owned(),
QueuedQueryOrigin::AutoQueueToggle,
vec![image_attachment("a.png")],
),
ctx,
)
});
model.read(&app, |m, _| {
let queue = m.queue(conv);
assert!(queue[0].is_command());
assert!(queue[0].attachments().is_empty());
assert!(!queue[1].is_command());
assert_eq!(queue[1].attachments().len(), 1);
});
});
}
#[test]
fn peek_autofire_returns_execute_command_for_a_command_head() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id = model.update(&mut app, |m, ctx| {
m.append(conv, command_query("echo 1"), ctx)
});
append_user(&model, &mut app, conv, "a prompt");
match model.read(&app, |m, _| m.peek_autofire(conv)) {
Some(AutofireAction::ExecuteCommand { query_id, command }) => {
assert_eq!(query_id, id);
assert_eq!(command, "echo 1");
}
other => panic!("expected ExecuteCommand, got {other:?}"),
}
});
}
#[test]
fn editing_a_command_head_pops_to_edit_mode_instead_of_executing() {
// Edit mode takes precedence over command execution so the drain restores the row's text.
// `is_command` rides along so the drain can keep the restored row in shell mode.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id = model.update(&mut app, |m, ctx| {
m.append(conv, command_query("echo 1"), ctx)
});
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id, ctx));
match model.read(&app, |m, _| m.peek_autofire(conv)) {
Some(AutofireAction::PopFromEditMode {
query_id,
text,
is_command,
..
}) => {
assert_eq!(query_id, id);
assert_eq!(text, "echo 1");
assert!(is_command);
}
other => panic!("expected PopFromEditMode, got {other:?}"),
}
});
}
#[test]
fn editing_a_prompt_head_pops_to_edit_mode_with_is_command_false() {
// The prompt counterpart: an edited prompt head pops with `is_command` false so the drain
// restores it as an agent prompt (and re-stages its attachments).
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let id = append_user(&model, &mut app, conv, "a prompt");
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, id, ctx));
match model.read(&app, |m, _| m.peek_autofire(conv)) {
Some(AutofireAction::PopFromEditMode {
query_id,
is_command,
..
}) => {
assert_eq!(query_id, id);
assert!(!is_command);
}
other => panic!("expected PopFromEditMode, got {other:?}"),
}
});
}
#[test]
fn command_rows_are_mutable_like_prompts() {
// Commands are not locked: they can be reordered, edited, and deleted.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
let cmd_id = model.update(&mut app, |m, ctx| {
m.append(conv, command_query("echo 1"), ctx)
});
let prompt_id = append_user(&model, &mut app, conv, "a prompt");
model.update(&mut app, |m, ctx| m.reorder(conv, cmd_id, 1, ctx));
model.read(&app, |m, _| {
assert_eq!(m.queue(conv)[0].id(), prompt_id);
assert_eq!(m.queue(conv)[1].id(), cmd_id);
});
model.update(&mut app, |m, ctx| m.enter_edit_mode(conv, cmd_id, ctx));
model.update(&mut app, |m, ctx| {
m.commit_edit(conv, "echo 2".to_owned(), ctx)
});
model.read(&app, |m, _| {
let command = m.queue(conv).iter().find(|q| q.id() == cmd_id).unwrap();
assert_eq!(command.text(), "echo 2");
});
let removed = model.update(&mut app, |m, ctx| m.remove_by_id(conv, cmd_id, ctx));
assert_eq!(
removed.map(|q| q.text().to_owned()),
Some("echo 2".to_owned())
);
});
}
#[test]
fn command_in_flight_flag_arms_and_clears() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
model.read(&app, |m, _| assert!(!m.has_command_in_flight(conv)));
// Arming works even with an empty queue (the gate keeps queueing while a command runs).
model.update(&mut app, |m, _| m.arm_command_in_flight(conv));
model.read(&app, |m, _| assert!(m.has_command_in_flight(conv)));
model.update(&mut app, |m, _| m.clear_command_in_flight(conv));
model.read(&app, |m, _| assert!(!m.has_command_in_flight(conv)));
});
}
#[test]
fn delete_conversation_clears_in_flight_command() {
with_model(|mut app, model, _events| {
let history = BlocklistAIHistoryModel::handle(&app);
let terminal_view_id = warpui::EntityId::new();
let conv = history.update(&mut app, |h, ctx| {
h.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
model.update(&mut app, |m, _| m.arm_command_in_flight(conv));
model.read(&app, |m, _| assert!(m.has_command_in_flight(conv)));
history.update(&mut app, |h, ctx| {
h.delete_conversation(conv, Some(terminal_view_id), ctx);
});
model.read(&app, |m, _| assert!(!m.has_command_in_flight(conv)));
});
}
#[test]
fn clear_conversations_for_terminal_surface_drops_every_listed_conversation() {
// ClearedConversationsForTerminalSurface with multiple ids must drop each listed conversation's queue.
with_model(|mut app, model, _events| {
let history = BlocklistAIHistoryModel::handle(&app);
let terminal_view_id = warpui::EntityId::new();
let conv_a = history.update(&mut app, |h, ctx| {
h.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
let conv_b = history.update(&mut app, |h, ctx| {
h.start_new_conversation(terminal_view_id, false, false, false, ctx)
});
append_user(&model, &mut app, conv_a, "a1");
append_user(&model, &mut app, conv_b, "b1");
history.update(&mut app, |h, ctx| {
h.clear_conversations_for_terminal_surface(terminal_view_id, ctx)
});
model.read(&app, |m, _| {
assert!(!m.has_queue(conv_a));
assert!(!m.has_queue(conv_b));
});
});
}
#[test]
fn has_autofireable_prompt_is_false_for_an_empty_queue() {
with_model(|app, model, _events| {
let conv = AIConversationId::new();
model.read(&app, |m, _| assert!(!m.has_autofireable_prompt(conv)));
});
}
#[test]
fn has_autofireable_prompt_is_true_for_a_queued_prompt() {
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
append_user(&model, &mut app, conv, "follow up");
model.read(&app, |m, _| assert!(m.has_autofireable_prompt(conv)));
});
}
#[test]
fn has_autofireable_prompt_is_false_when_only_a_locked_head_is_queued() {
// A locked initial Cloud Mode head never auto-fires on finish, so it must not count.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
model.update(&mut app, |m, ctx| {
m.append(conv, initial_cloud_mode_query("initial"), ctx)
});
model.read(&app, |m, _| assert!(!m.has_autofireable_prompt(conv)));
});
}
#[test]
fn has_autofireable_prompt_is_false_when_a_locked_head_precedes_a_prompt() {
// The head row gates auto-fire; a locked head blocks the trailing prompt from firing.
with_model(|mut app, model, _events| {
let conv = AIConversationId::new();
model.update(&mut app, |m, ctx| {
m.append(conv, initial_cloud_mode_query("initial"), ctx)
});
append_user(&model, &mut app, conv, "follow up");
model.read(&app, |m, _| assert!(!m.has_autofireable_prompt(conv)));
});
}