437 lines
16 KiB
Rust
437 lines
16 KiB
Rust
use settings::Setting as _;
|
|
use galaxy_core::features::FeatureFlag;
|
|
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,
|
|
) -> (
|
|
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);
|
|
(history, notifications)
|
|
}
|
|
|
|
fn make_pr_artifact(url: &str, branch: &str) -> Artifact {
|
|
Artifact::PullRequest {
|
|
url: url.to_string(),
|
|
branch: branch.to_string(),
|
|
repo: None,
|
|
number: None,
|
|
}
|
|
}
|
|
|
|
fn make_plan_artifact(doc_uid: &str, title: &str) -> Artifact {
|
|
Artifact::Plan {
|
|
document_uid: doc_uid.to_string(),
|
|
notebook_uid: None,
|
|
title: Some(title.to_string()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn artifact_event_accumulates_into_pending() {
|
|
App::test((), |mut app| async move {
|
|
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
|
let (history, notifications) = setup_app(&mut app);
|
|
|
|
let conversation_id = AIConversationId::new();
|
|
let terminal_view_id = EntityId::new();
|
|
|
|
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
|
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
|
terminal_surface_id: terminal_view_id,
|
|
conversation_id,
|
|
artifact: make_pr_artifact("https://github.com/org/repo/pull/42", "feature-branch"),
|
|
});
|
|
});
|
|
|
|
notifications.read(&app, |model, _| {
|
|
let pending = model.pending_artifacts.get(&conversation_id).unwrap();
|
|
assert_eq!(pending.len(), 1);
|
|
assert!(matches!(&pending[0], Artifact::PullRequest { branch, .. } if branch == "feature-branch"));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_artifacts_accumulated_across_turns() {
|
|
App::test((), |mut app| async move {
|
|
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
|
let (history, notifications) = setup_app(&mut app);
|
|
|
|
let conversation_id = AIConversationId::new();
|
|
let terminal_view_id = EntityId::new();
|
|
|
|
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
|
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
|
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_surface_id: terminal_view_id,
|
|
conversation_id,
|
|
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "main"),
|
|
});
|
|
});
|
|
|
|
notifications.read(&app, |model, _| {
|
|
let pending = model.pending_artifacts.get(&conversation_id).unwrap();
|
|
assert_eq!(pending.len(), 2);
|
|
assert!(matches!(&pending[0], Artifact::Plan { title: Some(t), .. } if t == "My Plan"));
|
|
assert!(matches!(&pending[1], Artifact::PullRequest { .. }));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[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 {
|
|
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
|
let (history, notifications) = setup_app(&mut app);
|
|
|
|
let conversation_id = AIConversationId::new();
|
|
let terminal_view_id = EntityId::new();
|
|
|
|
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
|
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
|
terminal_surface_id: terminal_view_id,
|
|
conversation_id,
|
|
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
|
|
});
|
|
});
|
|
|
|
notifications.update(&mut app, |model, _| {
|
|
let artifacts = model.flush_pending_artifacts(conversation_id);
|
|
assert_eq!(artifacts.len(), 1);
|
|
assert!(matches!(&artifacts[0], Artifact::PullRequest { branch, .. } if branch == "branch-1"));
|
|
});
|
|
|
|
notifications.read(&app, |model, _| {
|
|
assert!(!model.pending_artifacts.contains_key(&conversation_id));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn flush_returns_empty_vec_when_no_artifacts() {
|
|
App::test((), |mut app| async move {
|
|
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
|
let (_history, notifications) = setup_app(&mut app);
|
|
|
|
let conversation_id = AIConversationId::new();
|
|
|
|
notifications.update(&mut app, |model, _| {
|
|
let artifacts = model.flush_pending_artifacts(conversation_id);
|
|
assert!(artifacts.is_empty());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn deletion_cleans_up_pending_artifacts() {
|
|
App::test((), |mut app| async move {
|
|
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
|
let (history, notifications) = setup_app(&mut app);
|
|
|
|
let conversation_id = AIConversationId::new();
|
|
let terminal_view_id = EntityId::new();
|
|
|
|
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
|
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
|
terminal_surface_id: terminal_view_id,
|
|
conversation_id,
|
|
artifact: make_pr_artifact("https://github.com/org/repo/pull/1", "branch-1"),
|
|
});
|
|
});
|
|
|
|
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
|
ctx.emit(BlocklistAIHistoryEvent::DeletedConversation {
|
|
terminal_surface_id: terminal_view_id,
|
|
conversation_id,
|
|
conversation_title: None,
|
|
run_id: None,
|
|
});
|
|
});
|
|
|
|
notifications.read(&app, |model, _| {
|
|
assert!(!model.pending_artifacts.contains_key(&conversation_id));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn separate_conversations_have_independent_pending_artifacts() {
|
|
App::test((), |mut app| async move {
|
|
let _guard = FeatureFlag::HOANotifications.override_enabled(true);
|
|
let (history, notifications) = setup_app(&mut app);
|
|
|
|
let conv_a = AIConversationId::new();
|
|
let conv_b = AIConversationId::new();
|
|
let terminal_view_id = EntityId::new();
|
|
|
|
history.update(&mut app, |_: &mut BlocklistAIHistoryModel, ctx| {
|
|
ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationArtifacts {
|
|
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_surface_id: terminal_view_id,
|
|
conversation_id: conv_b,
|
|
artifact: make_plan_artifact("doc-b", "Plan B"),
|
|
});
|
|
});
|
|
|
|
notifications.update(&mut app, |model, _| {
|
|
let a = model.flush_pending_artifacts(conv_a);
|
|
assert_eq!(a.len(), 1);
|
|
assert!(matches!(&a[0], Artifact::PullRequest { branch, .. } if branch == "branch-a"));
|
|
|
|
let b = model.flush_pending_artifacts(conv_b);
|
|
assert_eq!(b.len(), 1);
|
|
assert!(matches!(&b[0], Artifact::Plan { title: Some(t), .. } if t == "Plan B"));
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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))"
|
|
);
|
|
});
|
|
});
|
|
}
|