first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+603
View File
@@ -0,0 +1,603 @@
//! Unit tests for [`BlocklistAIContextModel`].
//!
//! These tests use [`BlocklistAIContextModel::new_for_test`] and a small conversation-selection
//! fake to avoid unrelated subscriptions while exercising context behavior.
use std::sync::Arc;
use parking_lot::FairMutex;
#[cfg(feature = "local_fs")]
use repo_metadata::DirectoryWatcher;
#[cfg(feature = "local_fs")]
use warp_util::standardized_path::StandardizedPath;
use warpui::r#async::executor::Background;
use warpui::{App, EntityId, ModelHandle, SingletonEntity};
use super::{BlocklistAIContextModel, PendingAttachment, PendingFile};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{AIAgentContext, ImageContext};
use crate::ai::blocklist::agent_view::{AgentViewEntryOrigin, EnterAgentViewError};
use crate::ai::blocklist::conversation_selection::{
ConversationSelection, ConversationSelectionEvent,
};
use crate::ai::blocklist::{
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, QueuedQuery, QueuedQueryModel,
QueuedQueryOrigin,
};
#[cfg(feature = "local_fs")]
use crate::code_review::git_repo_model::GitRepoStatusModel;
#[cfg(feature = "local_fs")]
use crate::code_review::github_repo_model::GitHubRepoModel;
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::test_util::settings::initialize_history_persistence_for_tests;
use crate::util::git::{PrInfo, RepositoryInfo};
impl BlocklistAIContextModel {
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;
}
}
struct TestConversationSelection {
terminal_surface_id: EntityId,
selected_conversation_id: Option<AIConversationId>,
}
impl TestConversationSelection {
fn new(
terminal_surface_id: EntityId,
_: &mut warpui::ModelContext<Box<dyn ConversationSelection>>,
) -> Self {
Self {
terminal_surface_id,
selected_conversation_id: None,
}
}
}
impl ConversationSelection for TestConversationSelection {
fn selected_conversation_id(&self, _: &warpui::AppContext) -> Option<AIConversationId> {
self.selected_conversation_id
}
fn is_conversation_active(&self, _: &warpui::AppContext) -> bool {
self.selected_conversation_id.is_some()
}
fn is_conversation_fullscreen(&self, _: &warpui::AppContext) -> bool {
self.selected_conversation_id.is_some()
}
fn select_existing_conversation(
&mut self,
conversation_id: AIConversationId,
_: AgentViewEntryOrigin,
ctx: &mut warpui::ModelContext<Box<dyn ConversationSelection>>,
) {
if self.selected_conversation_id != Some(conversation_id) {
self.selected_conversation_id = Some(conversation_id);
ctx.emit(ConversationSelectionEvent::Changed);
}
}
fn select_new_conversation(
&mut self,
_: AgentViewEntryOrigin,
ctx: &mut warpui::ModelContext<Box<dyn ConversationSelection>>,
) {
if self.selected_conversation_id.take().is_some() {
ctx.emit(ConversationSelectionEvent::Changed);
}
}
fn try_start_new_conversation(
&mut self,
_: AgentViewEntryOrigin,
ctx: &mut warpui::ModelContext<Box<dyn ConversationSelection>>,
) -> Result<AIConversationId, EnterAgentViewError> {
let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.start_new_conversation(self.terminal_surface_id, false, false, false, ctx)
});
self.select_existing_conversation(conversation_id, AgentViewEntryOrigin::Cli, ctx);
Ok(conversation_id)
}
fn pending_query_autoexecute_override(
&self,
app: &warpui::AppContext,
) -> crate::ai::agent::conversation::AIConversationAutoexecuteMode {
self.selected_conversation_id
.as_ref()
.and_then(|conversation_id| {
BlocklistAIHistoryModel::as_ref(app).conversation(conversation_id)
})
.map(|conversation| conversation.autoexecute_override())
.unwrap_or_default()
}
fn toggle_pending_query_autoexecute(
&mut self,
ctx: &mut warpui::ModelContext<Box<dyn ConversationSelection>>,
) {
if let Some(conversation_id) = self.selected_conversation_id {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.toggle_autoexecute_override(
&conversation_id,
self.terminal_surface_id,
ctx,
);
});
}
}
fn handle_history_event(
&mut self,
_: &BlocklistAIHistoryEvent,
_: &mut warpui::ModelContext<Box<dyn ConversationSelection>>,
) {
}
}
#[cfg(feature = "local_fs")]
#[test]
fn repository_context_reads_github_repo_model() {
App::test((), |mut app| async move {
let context_model = build_test_context_model(&mut app);
let temp_dir = tempfile::TempDir::new().unwrap();
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing);
let repository = watcher_handle.update(&mut app, |watcher, ctx| {
watcher
.add_directory(
StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap(),
ctx,
)
.unwrap()
});
let git_status =
app.add_model(move |ctx| GitRepoStatusModel::new_local_for_test(repository, None, ctx));
let github_repo_model =
app.add_model(move |ctx| GitHubRepoModel::new_local_for_test(git_status, ctx));
github_repo_model.update(&mut app, |model, ctx| {
model.set_repository_info_for_test(
Some(RepositoryInfo {
name: "warp-internal".to_owned(),
owner: Some("warpdotdev".to_owned()),
}),
ctx,
);
});
context_model.update(&mut app, |model, _| {
model.set_github_repo_model(Some(github_repo_model.downgrade()));
});
context_model.read(&app, |model, ctx| {
assert_eq!(
model.repository_context(ctx),
Some(AIAgentContext::Repository {
name: "warp-internal".to_owned(),
owner: Some("warpdotdev".to_owned()),
})
);
});
context_model.update(&mut app, |model, _| {
model.set_github_repo_model(None);
});
context_model.read(&app, |model, ctx| {
assert_eq!(model.repository_context(ctx), None);
});
});
}
/// 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> {
initialize_history_persistence_for_tests(app);
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
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 conversation_selection = app.add_model(|ctx| {
Box::new(TestConversationSelection::new(terminal_view_id, ctx))
as Box<dyn ConversationSelection>
});
app.add_model(|_| {
BlocklistAIContextModel::new_for_test(
terminal_model,
terminal_view_id,
conversation_selection,
)
})
}
/// Builds context state for a TUI conversation surface.
fn build_tui_context_model(app: &mut App) -> (ModelHandle<BlocklistAIContextModel>, EntityId) {
initialize_history_persistence_for_tests(app);
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
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,
None,
false,
false,
None,
)));
let terminal_surface_id = EntityId::new();
let conversation_selection = app.add_model(|ctx| {
Box::new(TestConversationSelection::new(terminal_surface_id, ctx))
as Box<dyn ConversationSelection>
});
let model = app.add_model(|_| {
BlocklistAIContextModel::new_for_test(
terminal_model,
terminal_surface_id,
conversation_selection,
)
});
(model, terminal_surface_id)
}
#[test]
fn tui_context_tracks_selected_conversation() {
App::test((), |mut app| async move {
let (model, _) = build_tui_context_model(&mut app);
let conversation_id = AIConversationId::new();
model.update(&mut app, |model, ctx| {
model.set_pending_query_state_for_existing_conversation(
conversation_id,
AgentViewEntryOrigin::Cli,
ctx,
);
});
model.read(&app, |model, ctx| {
assert_eq!(model.selected_conversation_id(ctx), Some(conversation_id));
});
model.update(&mut app, |model, ctx| {
model.set_pending_query_state_for_new_conversation(AgentViewEntryOrigin::Cli, ctx);
});
model.read(&app, |model, ctx| {
assert_eq!(model.selected_conversation_id(ctx), None);
});
});
}
#[test]
fn tui_new_conversation_is_selected_and_terminal_surface_scoped() {
App::test((), |mut app| async move {
let (model, terminal_surface_id) = build_tui_context_model(&mut app);
let history = BlocklistAIHistoryModel::handle(&app);
let conversation_id = model
.update(&mut app, |model, ctx| {
model.try_start_new_conversation(AgentViewEntryOrigin::Cli, ctx)
})
.expect("TUI conversation creation should succeed");
model.read(&app, |model, ctx| {
assert_eq!(model.selected_conversation_id(ctx), Some(conversation_id));
});
history.read(&app, |history, _| {
assert_eq!(
history
.all_live_conversations_for_terminal_surface(terminal_surface_id)
.map(|conversation| conversation.id())
.collect::<Vec<_>>(),
vec![conversation_id]
);
});
});
}
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());
});
});
}
#[test]
fn has_locking_attachment_is_false_with_only_pending_block_id() {
// A pending block alone is *not* a locking attachment: only image/file attachments
// should force the input into AI mode (skipping NLD).
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 repository_context_from_repository_info_converts_to_agent_context() {
let repository_info = RepositoryInfo {
name: "warp-internal".to_owned(),
owner: Some("warpdotdev".to_owned()),
};
assert_eq!(
BlocklistAIContextModel::repository_context_from_repository_info(&repository_info),
AIAgentContext::Repository {
name: "warp-internal".to_owned(),
owner: Some("warpdotdev".to_owned()),
}
);
}
#[test]
fn pull_request_context_from_pr_info_excludes_url() {
let pr_info = PrInfo {
number: 123,
url: "https://github.com/warpdotdev/warp/pull/123".to_owned(),
state: "OPEN".to_owned(),
draft: true,
base_branch: "main".to_owned(),
};
assert_eq!(
BlocklistAIContextModel::pull_request_context_from_pr_info(&pr_info),
Some(AIAgentContext::PullRequest {
number: 123,
state: "OPEN".to_owned(),
draft: true,
base_branch: "main".to_owned(),
})
);
}
#[test]
fn pull_request_context_from_pr_info_rejects_numbers_that_do_not_fit_agent_context() {
let pr_info = PrInfo {
number: i32::MAX as u64 + 1,
url: "https://github.com/warpdotdev/warp/pull/2147483648".to_owned(),
state: "OPEN".to_owned(),
draft: false,
base_branch: "main".to_owned(),
};
assert_eq!(
BlocklistAIContextModel::pull_request_context_from_pr_info(&pr_info),
None
);
}
#[cfg(feature = "local_fs")]
#[test]
fn pull_request_context_reads_github_repo_model() {
App::test((), |mut app| async move {
let context_model = build_test_context_model(&mut app);
let temp_dir = tempfile::TempDir::new().unwrap();
let watcher_handle = app.add_singleton_model(DirectoryWatcher::new_for_testing);
let repository = watcher_handle.update(&mut app, |watcher, ctx| {
watcher
.add_directory(
StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap(),
ctx,
)
.unwrap()
});
let git_status =
app.add_model(move |ctx| GitRepoStatusModel::new_local_for_test(repository, None, ctx));
let github_repo_model =
app.add_model(move |ctx| GitHubRepoModel::new_local_for_test(git_status, ctx));
github_repo_model.update(&mut app, |model, ctx| {
model.set_pr_info_for_test(
Some(PrInfo {
number: 123,
url: "https://github.com/warpdotdev/warp/pull/123".to_owned(),
state: "OPEN".to_owned(),
draft: false,
base_branch: "main".to_owned(),
}),
ctx,
);
});
context_model.update(&mut app, |model, _| {
model.set_github_repo_model(Some(github_repo_model.downgrade()));
});
context_model.read(&app, |model, ctx| {
assert_eq!(
model.pull_request_context(ctx),
Some(AIAgentContext::PullRequest {
number: 123,
state: "OPEN".to_owned(),
draft: false,
base_branch: "main".to_owned(),
})
);
});
context_model.update(&mut app, |model, _| {
model.set_github_repo_model(None);
});
context_model.read(&app, |model, ctx| {
assert_eq!(model.pull_request_context(ctx), None);
});
});
}
#[test]
fn has_locking_attachment_is_false_with_only_pending_selected_text() {
// Selected text alone is *not* a locking attachment: the user could be selecting shell
// command text (e.g. to copy a previously-run command), and forcing the input into AI
// mode in that case would be wrong. Only image or file attachments should force the lock.
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_true_with_only_file_attachments() {
// File attachments are locking attachments — the user has explicitly attached a file as
// context, which is unambiguously a signal that the next query is intended for the agent.
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_is_true_with_mixed_image_and_file_attachments() {
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 take_pending_attachments_drains_and_returns_all_staged() {
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"),
make_file_attachment("notes.txt"),
]);
});
let taken = model.update(&mut app, |m, ctx| m.take_pending_attachments(ctx));
assert_eq!(taken.len(), 2);
assert_eq!(taken[0].file_name(), "a.png");
assert_eq!(taken[1].file_name(), "notes.txt");
// Draining clears the live staging so the input's attachment chips disappear.
model.read(&app, |m, _| assert!(m.pending_attachments().is_empty()));
});
}
#[test]
fn enqueue_moves_staged_attachments_onto_the_row_and_clears_input() {
// Mirrors the enqueue sites in `input.rs`: `take_pending_attachments` drains the live input
// staging and the drained set is stored on the queued row via `new_with_attachments`, leaving
// no attachments behind in the input.
App::test((), |mut app| async move {
let model = build_test_context_model(&mut app);
let queued = app.add_singleton_model(QueuedQueryModel::new);
let conv = AIConversationId::new();
model.update(&mut app, |m, _| {
m.append_pending_attachments_for_test(vec![
make_image_attachment("a.png"),
make_file_attachment("notes.txt"),
]);
});
// Capture-and-clear, then store on the row (the exact composition used at enqueue time).
let taken = model.update(&mut app, |m, ctx| m.take_pending_attachments(ctx));
let id = queued.update(&mut app, |q, ctx| {
q.append(
conv,
QueuedQuery::new_with_attachments(
"queued".to_owned(),
QueuedQueryOrigin::AutoQueueToggle,
taken,
),
ctx,
)
});
// Live staging is cleared; the row owns the attachments.
model.read(&app, |m, _| assert!(m.pending_attachments().is_empty()));
queued.read(&app, |q, _| {
let attachments = q.attachments_for(conv, id);
assert_eq!(attachments.len(), 2);
assert_eq!(attachments[0].file_name(), "a.png");
assert_eq!(attachments[1].file_name(), "notes.txt");
});
});
}