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
+483
View File
@@ -0,0 +1,483 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use chrono::Local;
use uuid::Uuid;
use warp_multi_agent_api::response_event;
use warpui::{App, SingletonEntity};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentAttachment, AIAgentContext, AIAgentInput, CancellationReason, ImageContext,
PassiveSuggestionTrigger, UserQueryMode,
};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::{
BlocklistAIHistoryEvent, BlocklistAIHistoryModel, PendingAttachment, PendingFile, RequestInput,
ResponseStream, ResponseStreamId,
};
use crate::ai::llms::LLMId;
use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view};
fn new_ambient_agent_task_id() -> AmbientAgentTaskId {
Uuid::new_v4().to_string().parse().unwrap()
}
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 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 passive_suggestions_request_params_omit_ambient_agent_task_id() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |terminal, ctx| {
let task_id = new_ambient_agent_task_id();
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_conversation(terminal.id(), false, false, false, ctx)
});
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.set_ambient_agent_task_id(Some(task_id), ctx);
assert_eq!(controller.get_ambient_agent_task_id(), Some(task_id));
assert_eq!(
controller
.build_passive_suggestions_request_params(
Some(conversation_id),
PassiveSuggestionTrigger::FilesChanged,
vec![],
ctx,
)
.expect("existing conversation should build passive suggestion params")
.1
.ambient_agent_task_id,
None
);
assert_eq!(
controller
.build_passive_suggestions_request_params(
None,
PassiveSuggestionTrigger::FilesChanged,
vec![],
ctx,
)
.expect("new conversation should build passive suggestion params")
.1
.ambient_agent_task_id,
None
);
});
});
});
}
#[test]
fn input_for_query_converts_prompt_attachments_and_ignores_live_staging() {
// `input_for_query` builds its image/file context purely from the explicitly-provided
// attachment set (resolved by `send_query` from either the queued row or live staging),
// never from the context model's pending attachments.
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |terminal, ctx| {
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_conversation(terminal.id(), false, false, false, ctx)
});
let controller = terminal.ai_controller();
let context_model = controller.as_ref(ctx).context_model.clone();
let active_session = controller.as_ref(ctx).active_session.clone();
// Stage *live* attachments that must NOT leak into a query built from a different,
// explicitly-provided attachment set.
context_model.update(ctx, |m, ctx| {
m.append_pending_attachments(
vec![image_attachment("live.png"), file_attachment("live.txt")],
ctx,
);
});
let task_id = TaskId::new("test-task".to_owned());
// Two files sharing a basename to exercise duplicate-basename suffixing.
let prompt_attachments = vec![
image_attachment("queued.png"),
file_attachment("notes.txt"),
file_attachment("notes.txt"),
];
let input = super::input_for_query(
"build a query".to_owned(),
&task_id,
conversation_id,
None,
UserQueryMode::Normal,
None,
HashMap::new(),
prompt_attachments,
context_model.as_ref(ctx),
active_session.as_ref(ctx),
ctx,
);
let AIAgentInput::UserQuery {
context,
referenced_attachments,
..
} = input
else {
panic!("expected UserQuery");
};
// The provided image is attached as image context; the live-staged image is not.
let image_names: Vec<&str> = context
.iter()
.filter_map(|c| match c {
AIAgentContext::Image(img) => Some(img.file_name.as_str()),
_ => None,
})
.collect();
assert_eq!(image_names, vec!["queued.png"]);
// The provided files are attached as FilePathReference with duplicate-basename
// suffixing; the live-staged file is not.
let mut file_names: Vec<String> = referenced_attachments
.values()
.filter_map(|a| match a {
AIAgentAttachment::FilePathReference { file_name, .. } => {
Some(file_name.clone())
}
_ => None,
})
.collect();
file_names.sort();
assert_eq!(
file_names,
vec!["notes.txt".to_owned(), "notes.txt".to_owned()]
);
assert!(referenced_attachments.contains_key("notes.txt"));
assert!(referenced_attachments.contains_key("notes.txt (1)"));
assert!(!referenced_attachments.contains_key("live.txt"));
});
});
}
#[test]
fn cancelling_conversation_aborts_pending_auto_resume() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
// An ID with no backing conversation: if the scheduled wait ever
// completes, the resume is a harmless no-op.
let conversation_id = AIConversationId::new();
terminal.update(&mut app, |terminal, ctx| {
terminal.ai_controller().update(ctx, |controller, ctx| {
controller.schedule_auto_resume_after_error(conversation_id, ctx);
assert!(controller
.pending_auto_resume_handles
.contains_key(&conversation_id));
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::ManuallyCancelled,
ctx,
);
assert!(!controller
.pending_auto_resume_handles
.contains_key(&conversation_id));
});
});
});
}
#[test]
fn mock_response_stream_updates_history_through_controller() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
let captured_events = Arc::new(Mutex::new(Vec::new()));
let events_for_subscription = Arc::clone(&captured_events);
app.update(|ctx| {
ctx.subscribe_to_model(&BlocklistAIHistoryModel::handle(ctx), move |_, event, _| {
events_for_subscription.lock().unwrap().push(event.clone())
});
});
let (conversation_id, stream) = terminal.update(&mut app, |view, ctx| {
let terminal_surface_id = view.id();
let stream_id = ResponseStreamId::new_for_test();
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let conversation_id = history.start_new_conversation(
terminal_surface_id,
false,
false,
false,
ctx,
);
let task_id = history
.conversation(&conversation_id)
.unwrap()
.get_root_task_id()
.clone();
history
.update_conversation_for_new_request_input(
RequestInput {
conversation_id,
input_messages: HashMap::from([(task_id, vec![])]),
working_directory: None,
model_id: LLMId::from("test-model"),
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
shared_session_response_initiator: None,
request_start_ts: Local::now(),
supported_tools_override: None,
},
stream_id.clone(),
terminal_surface_id,
ctx,
)
.unwrap();
conversation_id
});
let stream = ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
view.ai_controller().update(ctx, |controller, ctx| {
controller.register_mock_stream_for_test(
stream_id,
conversation_id,
stream.clone(),
ctx,
);
});
(conversation_id, stream)
});
stream.update(&mut app, |stream, ctx| {
stream.emit_response_event_for_test(
warp_multi_agent_api::ResponseEvent {
r#type: Some(response_event::Type::Init(response_event::StreamInit {
request_id: "test-request".to_string(),
conversation_id: "test-server-conversation".to_string(),
run_id: String::new(),
})),
},
ctx,
);
stream.emit_response_event_for_test(
warp_multi_agent_api::ResponseEvent {
r#type: Some(response_event::Type::Finished(
response_event::StreamFinished {
reason: Some(response_event::stream_finished::Reason::Done(
response_event::stream_finished::Done {},
)),
conversation_usage_metadata: None,
token_usage: vec![],
should_refresh_model_config: false,
request_cost: None,
},
)),
},
ctx,
);
});
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
assert_eq!(
history.conversation(&conversation_id).map(|c| c.status()),
Some(&crate::ai::agent::conversation::ConversationStatus::Success)
);
});
let events = captured_events.lock().unwrap();
assert!(events.iter().any(|event| matches!(
event,
BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
conversation_id: id,
..
} if *id == conversation_id
)));
assert!(events.iter().any(|event| matches!(
event,
BlocklistAIHistoryEvent::UpdatedStreamingExchange {
conversation_id: id,
..
} if *id == conversation_id
)));
});
}
/// When an agent command exits the shell, the conversation must be finalized as
/// `Error` (not `Cancelled`), and a subsequent `ManuallyCancelled` (as fired by
/// the pane-close path) must not overwrite that failure.
#[test]
fn fail_conversation_due_to_shell_exit_reports_error_and_survives_manual_cancel() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
let conversation_id = terminal.update(&mut app, |view, ctx| {
let terminal_surface_id = view.id();
let stream_id = ResponseStreamId::new_for_test();
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let conversation_id = history.start_new_conversation(
terminal_surface_id,
false,
false,
false,
ctx,
);
let task_id = history
.conversation(&conversation_id)
.unwrap()
.get_root_task_id()
.clone();
history
.update_conversation_for_new_request_input(
RequestInput {
conversation_id,
input_messages: HashMap::from([(task_id, vec![])]),
working_directory: None,
model_id: LLMId::from("test-model"),
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
shared_session_response_initiator: None,
request_start_ts: Local::now(),
supported_tools_override: None,
},
stream_id.clone(),
terminal_surface_id,
ctx,
)
.unwrap();
conversation_id
});
let stream = ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
view.ai_controller().update(ctx, |controller, ctx| {
controller.register_mock_stream_for_test(stream_id, conversation_id, stream, ctx);
controller.fail_conversation_due_to_shell_exit(conversation_id, ctx);
});
conversation_id
});
// The in-flight request is finalized as Error (with the shell-exit error
// on its exchange), not Cancelled.
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
assert_eq!(
history.conversation(&conversation_id).map(|c| c.status()),
Some(&crate::ai::agent::conversation::ConversationStatus::Error)
);
});
// The pane-close cancellation path must be a no-op now that the
// conversation is terminal.
terminal.update(&mut app, |view, ctx| {
view.ai_controller().update(ctx, |controller, ctx| {
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::ManuallyCancelled,
ctx,
);
});
});
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
assert_eq!(
history.conversation(&conversation_id).map(|c| c.status()),
Some(&crate::ai::agent::conversation::ConversationStatus::Error)
);
});
});
}
/// An optimistic long-running-command completion that cancels an in-flight
/// stream must finalize the conversation as `Success`, not `Cancelled`. This is
/// a regression test for the reason -> status mapping living in a single place
/// (`CancellationReason::conversation_outcome`).
#[test]
fn optimistic_cli_subagent_completion_with_in_flight_stream_reports_success() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
let conversation_id = terminal.update(&mut app, |view, ctx| {
let terminal_surface_id = view.id();
let stream_id = ResponseStreamId::new_for_test();
let conversation_id =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let conversation_id = history.start_new_conversation(
terminal_surface_id,
false,
false,
false,
ctx,
);
let task_id = history
.conversation(&conversation_id)
.unwrap()
.get_root_task_id()
.clone();
history
.update_conversation_for_new_request_input(
RequestInput {
conversation_id,
input_messages: HashMap::from([(task_id, vec![])]),
working_directory: None,
model_id: LLMId::from("test-model"),
coding_model_id: LLMId::from("test-coding-model"),
cli_agent_model_id: LLMId::from("test-cli-agent-model"),
computer_use_model_id: LLMId::from("test-computer-use-model"),
shared_session_response_initiator: None,
request_start_ts: Local::now(),
supported_tools_override: None,
},
stream_id.clone(),
terminal_surface_id,
ctx,
)
.unwrap();
conversation_id
});
let stream = ctx.add_model(|_| ResponseStream::new_for_test(stream_id.clone()));
view.ai_controller().update(ctx, |controller, ctx| {
controller.register_mock_stream_for_test(stream_id, conversation_id, stream, ctx);
// The long-running command finished while the agent was still
// streaming, cancelling the in-flight stream optimistically.
controller.cancel_conversation_progress(
conversation_id,
CancellationReason::CommandFinishedDuringInlineAgentView,
ctx,
);
});
conversation_id
});
BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| {
assert_eq!(
history.conversation(&conversation_id).map(|c| c.status()),
Some(&crate::ai::agent::conversation::ConversationStatus::Success)
);
});
});
}