Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions app/src/ai/blocklist/context_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,16 @@ pub struct BlocklistAIContextModel {
/// instead of sending it immediately.
/// Persists across exchanges in the same conversation (like fast-forward).
queue_next_prompt_enabled: bool,

/// Number of image-attachment flows currently in progress.
///
/// Image attachment is asynchronous (file reads + resize/encode happen on a spawned task), so
/// at the moment a paste / drag-and-drop fires there are no entries in `pending_attachments`
/// yet. This counter is incremented synchronously when an image-attach pipeline starts and
/// decremented when it completes (or fails). It contributes to `has_locking_attachment` so the
/// input can be force-locked to AI mode for the entire duration of the attach, not only after
/// the actual `ImageContext` is appended.
pending_image_attachments_in_progress: usize,
}

pub fn block_context_from_terminal_model(
Expand Down Expand Up @@ -315,6 +325,36 @@ impl BlocklistAIContextModel {
pending_document_id: None,
auto_attached_agent_view_user_block_ids: Vec::new(),
queue_next_prompt_enabled: false,
pending_image_attachments_in_progress: 0,
}
}

/// Test-only constructor that skips every subscription and singleton lookup performed by
/// [`Self::new`], so unit tests can build a [`BlocklistAIContextModel`] without registering
/// `BlocklistAIHistoryModel`, `LLMPreferences`, `ModelEventDispatcher`, `Sessions`, or
/// `AppExecutionMode`. Callers still pass real [`TerminalModel`] and [`AgentViewController`]
/// handles to populate the struct fields, but neither needs to be functional for the
/// methods exercised by these tests.
#[cfg(test)]
pub(crate) fn new_for_test(
terminal_model: Arc<FairMutex<TerminalModel>>,
terminal_view_id: EntityId,
agent_view_controller: ModelHandle<AgentViewController>,
) -> Self {
Self {
terminal_model,
directory_context: Default::default(),
pending_context_block_ids: HashSet::new(),
pending_context_selected_text: None,
pending_attachments: Default::default(),
pending_query_state: PendingQueryState::default(),
terminal_view_id,
agent_view_controller,
pending_inline_diff_hunk_attachments: Default::default(),
pending_document_id: None,
auto_attached_agent_view_user_block_ids: Vec::new(),
queue_next_prompt_enabled: false,
pending_image_attachments_in_progress: 0,
}
}

Expand All @@ -327,6 +367,43 @@ impl BlocklistAIContextModel {
self.clear_diff_hunk_attachments();
self.set_pending_document(None, ctx);
self.auto_attached_agent_view_user_block_ids.clear();
self.pending_image_attachments_in_progress = 0;
}

/// Returns `true` if the next AI query has any context that should force the input to be
/// locked in AI mode (skipping NLD): a pending image, a pending block, pending selected text,
/// or an in-progress image-attachment pipeline.
pub fn has_locking_attachment(&self) -> bool {
self.pending_image_attachments_in_progress > 0
|| !self.pending_context_block_ids.is_empty()
|| self.pending_context_selected_text.is_some()
|| self
.pending_attachments
.iter()
.any(|attachment| matches!(attachment, PendingAttachment::Image(_)))
}

/// Marks the start of an asynchronous image-attachment pipeline. Must be paired with a call
/// to [`Self::note_image_attachment_completed`] when the pipeline finishes (or fails).
pub fn note_image_attachment_started(&mut self, ctx: &mut ModelContext<Self>) {
self.pending_image_attachments_in_progress += 1;
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}

/// Marks an asynchronous image-attachment pipeline as complete. Saturating-decrement so a
/// stray double-completion doesn't underflow the counter.
pub fn note_image_attachment_completed(&mut self, ctx: &mut ModelContext<Self>) {
self.pending_image_attachments_in_progress =
self.pending_image_attachments_in_progress.saturating_sub(1);
ctx.emit(BlocklistAIContextEvent::UpdatedPendingContext {
previous_block_ids: self.pending_context_block_ids.clone(),
requires_block_resync: false,
requires_text_resync: false,
});
}

/// Returns the set `BlockId`s corresponding to blocks to be included as context with the next
Expand Down Expand Up @@ -991,3 +1068,29 @@ pub enum BlocklistAIContextEvent {
impl Entity for BlocklistAIContextModel {
type Event = BlocklistAIContextEvent;
}

#[cfg(test)]
#[path = "context_model_test.rs"]
mod tests;

#[cfg(test)]
impl BlocklistAIContextModel {
pub(crate) fn pending_image_attachments_in_progress_for_test(&self) -> usize {
self.pending_image_attachments_in_progress
}

pub(crate) fn append_pending_attachments_for_test(
&mut self,
attachments: Vec<PendingAttachment>,
) {
self.pending_attachments.extend(attachments);
}

pub(crate) fn insert_pending_block_id_for_test(&mut self, block_id: BlockId) {
self.pending_context_block_ids.insert(block_id);
}

pub(crate) fn set_pending_selected_text_for_test(&mut self, text: Option<String>) {
self.pending_context_selected_text = text;
}
}
256 changes: 256 additions & 0 deletions app/src/ai/blocklist/context_model_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
//! Unit tests for [`BlocklistAIContextModel::has_locking_attachment`] and the
//! `note_image_attachment_started` / `note_image_attachment_completed` counter mechanics.
//!
//! These tests deliberately bypass the production [`BlocklistAIContextModel::new`] constructor
//! (which subscribes to several singletons) and instead use [`BlocklistAIContextModel::new_for_test`]
//! together with [`super::agent_view::AgentViewController::new`] backed by
//! [`crate::terminal::view::ambient_agent::AmbientAgentViewModel::new_for_test`]. That keeps the
//! fixture small enough to focus on the lock/counter logic without standing up `BlocklistAIHistoryModel`,
//! `LLMPreferences`, `CloudModel`, `UpdateManager`, or `AppExecutionMode`.

use std::sync::Arc;

use parking_lot::FairMutex;
use warpui::r#async::executor::Background;
use warpui::{App, EntityId, ModelHandle};

use super::{BlocklistAIContextModel, PendingAttachment, PendingFile};
use crate::ai::agent::ImageContext;
use crate::ai::blocklist::agent_view::{AgentViewController, EphemeralMessageModel};
use crate::terminal::color::{self, Colors};
use crate::terminal::event_listener::ChannelEventListener;
use crate::terminal::model::test_utils::block_size;
use crate::terminal::model::{BlockId, TerminalModel};
use crate::terminal::view::ambient_agent::AmbientAgentViewModel;

/// Builds a [`BlocklistAIContextModel`] with stub dependencies. None of the dependencies are
/// exercised by the methods under test; they only need to satisfy the struct's field types.
fn build_test_context_model(app: &mut App) -> ModelHandle<BlocklistAIContextModel> {
let terminal_model = Arc::new(FairMutex::new(TerminalModel::new_for_test(
block_size(),
color::List::from(&Colors::default()),
ChannelEventListener::new_for_test(),
Arc::new(Background::default()),
false, /* should_show_bootstrap_block */
None, /* restored_blocks */
false, /* honor_ps1 */
false, /* is_inverted */
None, /* session_startup_path */
)));
let terminal_view_id = EntityId::new();

let ambient_agent_view_model = app.add_model(|ctx| {
AmbientAgentViewModel::new_for_test(
terminal_view_id,
false, /* has_parent_terminal */
ctx,
)
});
let ephemeral_message_model = app.add_model(|_| EphemeralMessageModel::new());
let agent_view_controller = app.add_model(|ctx| {
AgentViewController::new(
terminal_model.clone(),
terminal_view_id,
ambient_agent_view_model,
ephemeral_message_model,
ctx,
)
});

app.add_model(|_| {
BlocklistAIContextModel::new_for_test(
terminal_model,
terminal_view_id,
agent_view_controller,
)
})
}

fn make_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 make_file_attachment(file_name: &str) -> PendingAttachment {
PendingAttachment::File(PendingFile {
file_name: file_name.to_owned(),
file_path: file_name.into(),
mime_type: "text/plain".to_owned(),
})
}

#[test]
fn has_locking_attachment_is_false_for_default_state() {
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.read(&app, |m, _| {
assert!(!m.has_locking_attachment());
assert_eq!(m.pending_image_attachments_in_progress_for_test(), 0);
});
});
}

#[test]
fn has_locking_attachment_is_true_with_pending_block_id() {
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.update(&mut app, |m, _| {
m.insert_pending_block_id_for_test(BlockId::new());
});

model.read(&app, |m, _| assert!(m.has_locking_attachment()));
});
}

#[test]
fn has_locking_attachment_is_true_with_pending_selected_text() {
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.update(&mut app, |m, _| {
m.set_pending_selected_text_for_test(Some("hello".to_owned()));
});

model.read(&app, |m, _| assert!(m.has_locking_attachment()));
});
}

#[test]
fn has_locking_attachment_is_true_with_pending_image_attachment() {
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.update(&mut app, |m, _| {
m.append_pending_attachments_for_test(vec![make_image_attachment("a.png")]);
});

model.read(&app, |m, _| assert!(m.has_locking_attachment()));
});
}

#[test]
fn has_locking_attachment_is_false_with_only_file_attachments() {
// Files are explicitly *not* locking attachments — only images, blocks, selected text, or an
// in-progress image-attach pipeline force the input into AI mode.
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.update(&mut app, |m, _| {
m.append_pending_attachments_for_test(vec![
make_file_attachment("notes.txt"),
make_file_attachment("readme.md"),
]);
});

model.read(&app, |m, _| assert!(!m.has_locking_attachment()));
});
}

#[test]
fn has_locking_attachment_ignores_non_image_attachments_when_image_present() {
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.update(&mut app, |m, _| {
m.append_pending_attachments_for_test(vec![
make_file_attachment("notes.txt"),
make_image_attachment("a.png"),
]);
});

model.read(&app, |m, _| assert!(m.has_locking_attachment()));
});
}

#[test]
fn note_image_attachment_started_increments_counter_and_locks_input() {
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.update(&mut app, |m, ctx| m.note_image_attachment_started(ctx));

model.read(&app, |m, _| {
assert_eq!(m.pending_image_attachments_in_progress_for_test(), 1);
// The counter alone — without any actual `pending_attachments` entry — must lock the
// input so paste / drag-and-drop flows can't slip into NLD before the async pipeline
// appends the resulting `ImageContext`.
assert!(m.has_locking_attachment());
});
});
}

#[test]
fn note_image_attachment_completed_decrements_counter_and_unlocks_input() {
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.update(&mut app, |m, ctx| {
m.note_image_attachment_started(ctx);
m.note_image_attachment_completed(ctx);
});

model.read(&app, |m, _| {
assert_eq!(m.pending_image_attachments_in_progress_for_test(), 0);
assert!(!m.has_locking_attachment());
});
});
}

#[test]
fn note_image_attachment_started_supports_multiple_concurrent_pipelines() {
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.update(&mut app, |m, ctx| {
m.note_image_attachment_started(ctx);
m.note_image_attachment_started(ctx);
m.note_image_attachment_started(ctx);
});

model.read(&app, |m, _| {
assert_eq!(m.pending_image_attachments_in_progress_for_test(), 3);
assert!(m.has_locking_attachment());
});

// One completion should not release the lock while two pipelines are still in flight.
model.update(&mut app, |m, ctx| m.note_image_attachment_completed(ctx));
model.read(&app, |m, _| {
assert_eq!(m.pending_image_attachments_in_progress_for_test(), 2);
assert!(m.has_locking_attachment());
});

model.update(&mut app, |m, ctx| {
m.note_image_attachment_completed(ctx);
m.note_image_attachment_completed(ctx);
});
model.read(&app, |m, _| {
assert_eq!(m.pending_image_attachments_in_progress_for_test(), 0);
assert!(!m.has_locking_attachment());
});
});
}

#[test]
fn note_image_attachment_completed_saturates_at_zero() {
// Defensive: a stray `_completed` without a matching `_started` (or a double-completion) must
// not underflow `usize` and silently lock the input forever.
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);

model.update(&mut app, |m, ctx| {
m.note_image_attachment_completed(ctx);
m.note_image_attachment_completed(ctx);
});

model.read(&app, |m, _| {
assert_eq!(m.pending_image_attachments_in_progress_for_test(), 0);
assert!(!m.has_locking_attachment());
});
});
}
Loading
Loading