first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{App, EntityId, ModelHandle};
|
||||
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryEvent;
|
||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
use galaxyui::{App, EntityId, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::AgentNotificationsModel;
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent_management::notifications::{
|
||||
NotificationCategory, NotificationFilter, NotificationOrigin, NotificationSourceAgent,
|
||||
};
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryEvent;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::{report_if_error, BlocklistAIHistoryModel};
|
||||
|
||||
fn setup_app(
|
||||
app: &mut App,
|
||||
@@ -16,7 +21,11 @@ fn setup_app(
|
||||
ModelHandle<BlocklistAIHistoryModel>,
|
||||
ModelHandle<AgentNotificationsModel>,
|
||||
) {
|
||||
initialize_settings_for_tests(app);
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
||||
// Registered after the history model since it subscribes to history events; the
|
||||
// notifications model reads it to suppress completion notifications when a prompt is queued.
|
||||
app.add_singleton_model(crate::ai::blocklist::QueuedQueryModel::new);
|
||||
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
|
||||
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
|
||||
let notifications = app.add_singleton_model(AgentNotificationsModel::new);
|
||||
@@ -51,7 +60,7 @@ fn artifact_event_accumulates_into_pending() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
artifact: make_pr_artifact("https://github.com/org/repo/pull/42", "feature-branch"),
|
||||
});
|
||||
@@ -76,14 +85,14 @@ fn multiple_artifacts_accumulated_across_turns() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
artifact: make_plan_artifact("doc-1", "My Plan"),
|
||||
});
|
||||
});
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "main"),
|
||||
});
|
||||
@@ -98,6 +107,46 @@ fn multiple_artifacts_accumulated_across_turns() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_notification_tracks_unread_activity_when_in_app_notifications_are_hidden() {
|
||||
App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
||||
let (_history, notifications) = setup_app(&mut app);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
report_if_error!(settings.show_agent_notifications.set_value(false, ctx));
|
||||
});
|
||||
|
||||
let conversation_id = AIConversationId::new();
|
||||
let terminal_view_id = EntityId::new();
|
||||
notifications.update(&mut app, |model, ctx| {
|
||||
model.add_notification(
|
||||
"Agent task".to_owned(),
|
||||
"Task completed.".to_owned(),
|
||||
NotificationCategory::Complete,
|
||||
NotificationSourceAgent::Oz { is_ambient: false },
|
||||
NotificationOrigin::Conversation(conversation_id),
|
||||
terminal_view_id,
|
||||
vec![],
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
1
|
||||
);
|
||||
assert!(model
|
||||
.notifications()
|
||||
.has_unread_for_terminal_view(terminal_view_id));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_drains_pending_artifacts() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -109,7 +158,7 @@ fn flush_drains_pending_artifacts() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
|
||||
});
|
||||
@@ -153,7 +202,7 @@ fn deletion_cleans_up_pending_artifacts() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
|
||||
});
|
||||
@@ -161,9 +210,10 @@ fn deletion_cleans_up_pending_artifacts() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::DeletedConversation {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id,
|
||||
conversation_title: None,
|
||||
run_id: None,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,14 +235,14 @@ fn separate_conversations_have_independent_pending_artifacts() {
|
||||
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id: conv_a,
|
||||
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-a"),
|
||||
});
|
||||
});
|
||||
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
||||
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
||||
terminal_view_id,
|
||||
terminal_surface_id: terminal_view_id,
|
||||
conversation_id: conv_b,
|
||||
artifact: make_plan_artifact("doc-b", "Plan B"),
|
||||
});
|
||||
@@ -209,3 +259,178 @@ fn separate_conversations_have_independent_pending_artifacts() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// should_trigger_notification: pure-function tests pinning which statuses
|
||||
// fire user-facing notifications. Terminal-error and blocked surface;
|
||||
// in-progress, waiting-for-events, and user-cancelled do not.
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_true_for_success() {
|
||||
assert!(ConversationStatus::Success.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_true_for_blocked() {
|
||||
assert!(ConversationStatus::Blocked {
|
||||
blocked_action: "approve diff".to_owned(),
|
||||
}
|
||||
.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_true_for_error() {
|
||||
assert!(ConversationStatus::Error.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_false_for_in_progress() {
|
||||
assert!(!ConversationStatus::InProgress.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_false_for_waiting_for_events() {
|
||||
assert!(!ConversationStatus::WaitingForEvents.should_trigger_notification());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trigger_notification_returns_false_for_cancelled() {
|
||||
assert!(!ConversationStatus::Cancelled.should_trigger_notification());
|
||||
}
|
||||
|
||||
// Mailbox suppression for non-terminal status updates. In App::test the
|
||||
// `is_conversation_open` gate always returns false, so the
|
||||
// WaitingForEvents and InProgress arms both clear stale notifications
|
||||
// regardless of status; this still pins the user-visible contract that
|
||||
// no stale "Task completed" toast survives a non-terminal transition.
|
||||
|
||||
/// Disables `show_agent_notifications` so subsequent `add_notification`
|
||||
/// calls skip the `send_telemetry_from_ctx!` branch — the test app does
|
||||
/// not register a `TelemetryContextProvider` singleton and the macro
|
||||
/// would otherwise panic.
|
||||
fn disable_telemetry_path(app: &mut App) {
|
||||
AISettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings.show_agent_notifications.set_value(false, ctx));
|
||||
});
|
||||
}
|
||||
|
||||
/// Pre-populates a `Complete` notification for `conversation_id` so that a
|
||||
/// subsequent non-terminal status update has something to clear.
|
||||
fn seed_stale_notification(
|
||||
notifications: &ModelHandle<AgentNotificationsModel>,
|
||||
app: &mut App,
|
||||
conversation_id: AIConversationId,
|
||||
terminal_view_id: EntityId,
|
||||
) {
|
||||
notifications.update(app, |model, ctx| {
|
||||
model.add_notification(
|
||||
"Agent task".to_owned(),
|
||||
"Task completed.".to_owned(),
|
||||
NotificationCategory::Complete,
|
||||
NotificationSourceAgent::Oz { is_ambient: false },
|
||||
NotificationOrigin::Conversation(conversation_id),
|
||||
terminal_view_id,
|
||||
vec![],
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_for_events_clears_stale_notification_and_adds_none() {
|
||||
App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
||||
let (history, notifications) = setup_app(&mut app);
|
||||
disable_telemetry_path(&mut app);
|
||||
|
||||
let conversation = AIConversation::new(false, false);
|
||||
let conversation_id = conversation.id();
|
||||
let terminal_view_id = EntityId::new();
|
||||
history.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
|
||||
});
|
||||
|
||||
seed_stale_notification(¬ifications, &mut app, conversation_id, terminal_view_id);
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
1,
|
||||
"precondition: one stale notification queued"
|
||||
);
|
||||
});
|
||||
|
||||
history.update(&mut app, |model, ctx| {
|
||||
let conv = model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation was just restored");
|
||||
conv.update_status(ConversationStatus::WaitingForEvents, terminal_view_id, ctx);
|
||||
});
|
||||
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
0,
|
||||
"WaitingForEvents must clear stale notifications and add no new toast"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_progress_resume_clears_stale_notification_and_adds_none() {
|
||||
App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
||||
let (history, notifications) = setup_app(&mut app);
|
||||
disable_telemetry_path(&mut app);
|
||||
|
||||
let conversation = AIConversation::new(false, false);
|
||||
let conversation_id = conversation.id();
|
||||
let terminal_view_id = EntityId::new();
|
||||
history.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
|
||||
});
|
||||
|
||||
// First move the conversation into WaitingForEvents, then back into
|
||||
// InProgress. The second transition is the resume signal that
|
||||
// PRODUCT.md (18) requires not to fire a notification.
|
||||
history.update(&mut app, |model, ctx| {
|
||||
let conv = model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation was just restored");
|
||||
conv.update_status(ConversationStatus::WaitingForEvents, terminal_view_id, ctx);
|
||||
});
|
||||
|
||||
seed_stale_notification(¬ifications, &mut app, conversation_id, terminal_view_id);
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
1,
|
||||
"precondition: one stale notification queued before the resume transition"
|
||||
);
|
||||
});
|
||||
|
||||
history.update(&mut app, |model, ctx| {
|
||||
let conv = model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation still exists");
|
||||
conv.update_status(ConversationStatus::InProgress, terminal_view_id, ctx);
|
||||
});
|
||||
|
||||
notifications.read(&app, |model, _| {
|
||||
assert_eq!(
|
||||
model
|
||||
.notifications()
|
||||
.filtered_count(NotificationFilter::All),
|
||||
0,
|
||||
"WaitingForEvents → InProgress resume must not fire a notification \
|
||||
(covers PRODUCT.md (18))"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user