use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use chrono::{DateTime, Duration, Utc}; use galaxy_cli::agent::Harness; use galaxy_core::features::FeatureFlag; use galaxyui::{App, EntityId, ModelHandle, SingletonEntity}; use instant::Instant; use parking_lot::Mutex; use persistence::model::{AgentConversationData, ConversationUsageMetadata}; use super::entry::{ AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationProvenance, }; use super::{ record_earliest_rtc_task_refresh_timestamp, AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter, ConversationMetadata, ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter, RtcTaskRefreshThrottleState, StatusFilter, TaskFetchError, TaskFetchState, MAX_PERSONAL_TASKS, MAX_TEAM_TASKS, }; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::{ AIAgentHarness, AIConversation, AIConversationId, ConversationStatus, ServerAIConversationMetadata, }; use crate::ai::ambient_agents::task::{HarnessConfig, TaskPrincipalInfo, TaskStatusMessage}; use crate::ai::ambient_agents::{ AgentConfigSnapshot, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState, }; use crate::ai::artifacts::Artifact; use crate::ai::blocklist::history_model::{ BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatusUpdate, }; use crate::ai::conversation_navigation::ConversationNavigationData; use crate::auth::AuthStateProvider; use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions}; use crate::server::ids::ServerId; use crate::server::server_api::presigned_upload::HttpStatusError; use crate::test_util::ai_agent_tasks::{create_api_task, create_message}; use crate::test_util::settings::initialize_history_persistence_for_tests; use crate::workspace::WorkspaceAction; /// Creates a test task with specified creator UID and updated_at time fn create_test_task( task_id: &str, creator_uid: &str, updated_at: DateTime, ) -> AmbientAgentTask { AmbientAgentTask { task_id: task_id.parse().unwrap(), parent_run_id: None, title: format!("Task {task_id}"), state: AmbientAgentTaskState::Succeeded, prompt: "test".to_string(), created_at: updated_at, started_at: Some(updated_at), updated_at, run_time: Some("PT1S".parse().unwrap()), status_message: None, source: None, session_id: None, session_link: None, creator: Some(TaskPrincipalInfo { creator_type: "USER".to_string(), uid: creator_uid.to_string(), display_name: Some(format!("User {creator_uid}")), }), executor: None, conversation_id: None, request_usage: None, agent_config_snapshot: None, artifacts: vec![], is_sandbox_running: false, last_event_sequence: None, children: vec![], } } type CapturedConversationUpdate = Mutex>; /// Test-only handler that mirrors the production view subscription: extracts the /// `ConversationUpdated` payload and stashes it on a shared cell that test cases assert /// against. fn handle_agent_conversation_model_event( captured: &CapturedConversationUpdate, event: &AgentConversationsModelEvent, ) { if let AgentConversationsModelEvent::ConversationUpdated { kind } = event { *captured.lock() = Some(*kind); } } /// Subscribes a [`handle_agent_conversation_model_event`] capture cell to `model` and /// returns the cell so individual cases can assert on the most recent emission without /// re-implementing the subscription bookkeeping. fn subscribe_to_conversation_updated( app: &mut App, model: &ModelHandle, ) -> Arc { let captured = Arc::new(Mutex::new(None)); let captured_clone = captured.clone(); app.update(|ctx| { ctx.subscribe_to_model(model, move |_, event, _| { handle_agent_conversation_model_event(&captured_clone, event); }); }); captured } #[test] fn test_restored_conversation_emits_restored_kind() { App::test((), |mut app| async move { let _interactive_management_guard = FeatureFlag::InteractiveConversationManagementView.override_enabled(true); let agent_model = app.add_singleton_model(|_| create_test_model()); let captured = subscribe_to_conversation_updated(&mut app, &agent_model); agent_model.update(&mut app, |model, ctx| { model.handle_history_event( &BlocklistAIHistoryEvent::UpdatedConversationStatus { conversation_id: AIConversationId::new(), terminal_surface_id: EntityId::new(), update: ConversationStatusUpdate::Restored, new_status: ConversationStatus::Success, }, ctx, ); }); let captured = *captured.lock(); assert_eq!(captured, Some(ConversationUpdateKind::Restored)); }); } #[test] fn test_status_transition_emits_status_set_with_filter_buckets() { App::test((), |mut app| async move { let _interactive_management_guard = FeatureFlag::InteractiveConversationManagementView.override_enabled(true); let agent_model = app.add_singleton_model(|_| create_test_model()); let captured = subscribe_to_conversation_updated(&mut app, &agent_model); agent_model.update(&mut app, |model, ctx| { model.handle_history_event( &BlocklistAIHistoryEvent::UpdatedConversationStatus { conversation_id: AIConversationId::new(), terminal_surface_id: EntityId::new(), update: ConversationStatusUpdate::Changed { prev_status: ConversationStatus::InProgress, }, new_status: ConversationStatus::Success, }, ctx, ); }); let captured = *captured.lock(); assert_eq!( captured, Some(ConversationUpdateKind::StatusSet { prev_filter: StatusFilter::Working, new_filter: StatusFilter::Done, }), ); }); } #[test] fn test_same_bucket_re_emission_emits_status_set_with_equal_filters() { App::test((), |mut app| async move { let _interactive_management_guard = FeatureFlag::InteractiveConversationManagementView.override_enabled(true); let agent_model = app.add_singleton_model(|_| create_test_model()); let captured = subscribe_to_conversation_updated(&mut app, &agent_model); agent_model.update(&mut app, |model, ctx| { model.handle_history_event( &BlocklistAIHistoryEvent::UpdatedConversationStatus { conversation_id: AIConversationId::new(), terminal_surface_id: EntityId::new(), update: ConversationStatusUpdate::Changed { prev_status: ConversationStatus::InProgress, }, new_status: ConversationStatus::InProgress, }, ctx, ); }); let captured = *captured.lock(); assert_eq!( captured, Some(ConversationUpdateKind::StatusSet { prev_filter: StatusFilter::Working, new_filter: StatusFilter::Working, }), ); }); } #[test] fn test_title_update_refreshes_shadowing_task_title() { App::test((), |mut app| async move { let _interactive_management_guard = FeatureFlag::InteractiveConversationManagementView.override_enabled(true); initialize_history_persistence_for_tests(&mut app); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(|_| ActiveAgentViewsModel::new()); let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); let agent_model = app.add_singleton_model(|_| create_test_model()); let captured = subscribe_to_conversation_updated(&mut app, &agent_model); let terminal_view_id = EntityId::new(); let conversation_id = AIConversationId::new(); let server_token = "rename-token"; let task_id = make_uuid(3900); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: Some(server_token.to_string()), conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); history_model.update(&mut app, |model, ctx| { model.restore_conversations(terminal_view_id, vec![conversation], ctx); model.apply_conversation_title( conversation_id, "Renamed conversation".to_string(), ctx, ); }); agent_model.update(&mut app, |model, _| { let mut task = create_test_task(&task_id, "user-a", Utc::now()); task.title = "Old task title".to_string(); task.conversation_id = Some(server_token.to_string()); model.tasks.insert(task.task_id, task); }); agent_model.update(&mut app, |model, ctx| { model.handle_history_event( &BlocklistAIHistoryEvent::UpdatedConversationTitle { terminal_surface_id: Some(terminal_view_id), conversation_id, title: "Renamed conversation".to_string(), }, ctx, ); }); assert_eq!(*captured.lock(), Some(ConversationUpdateKind::TitleChanged)); agent_model.read(&app, |model, ctx| { let task_id: AmbientAgentTaskId = task_id.parse().unwrap(); assert_eq!( model.get_task_data(&task_id).map(|task| task.title), Some("Renamed conversation".to_string()), ); let entry = model .get_entry_by_id(&AgentConversationEntryId::AmbientRun(task_id), ctx) .expect("task-backed entry should exist"); assert_eq!(entry.display.title, "Renamed conversation"); }); }); } #[test] fn test_display_status_uses_setup_task_states() { App::test((), |mut app| async move { let now = Utc::now(); let test_cases = [ ( AmbientAgentTaskState::Queued, AgentRunDisplayStatus::TaskQueued, ), ( AmbientAgentTaskState::Pending, AgentRunDisplayStatus::TaskPending, ), ( AmbientAgentTaskState::Claimed, AgentRunDisplayStatus::TaskClaimed, ), ]; app.update(|ctx| { for (index, (task_state, expected_status)) in test_cases.into_iter().enumerate() { let mut task = create_test_task(&make_uuid(index + 4000), "user-a", now); task.state = task_state; assert_eq!( AgentRunDisplayStatus::from_task(&task, ctx), expected_status ); } }); }); } #[test] fn test_display_status_uses_matching_conversation_for_in_progress_task() { App::test((), |mut app| async move { let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); let now = Utc::now(); let conversation_id = AIConversationId::new(); let terminal_view_id = EntityId::new(); let task_id = make_uuid(4003); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); history_model.update(&mut app, |model, ctx| { model.restore_conversations(terminal_view_id, vec![conversation], ctx); model.update_conversation_status( terminal_view_id, conversation_id, ConversationStatus::Success, ctx, ); }); let mut task = create_test_task(&task_id, "user-a", now); task.state = AmbientAgentTaskState::InProgress; app.update(|ctx| { let display_status = AgentRunDisplayStatus::from_task(&task, ctx); assert_eq!(display_status, AgentRunDisplayStatus::ConversationSucceeded); assert_eq!(display_status.status_filter(), StatusFilter::Done); assert!(!display_status.is_cancellable()); assert!(!display_status.is_working()); }); }); } #[test] fn test_display_status_uses_active_execution_over_previous_conversation_status() { App::test((), |mut app| async move { let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); let now = Utc::now(); let conversation_id = AIConversationId::new(); let terminal_view_id = EntityId::new(); let task_id = make_uuid(4005); let session_id = make_uuid(4006); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); history_model.update(&mut app, |model, ctx| { model.restore_conversations(terminal_view_id, vec![conversation], ctx); model.update_conversation_status( terminal_view_id, conversation_id, ConversationStatus::Success, ctx, ); }); let mut task = create_test_task(&task_id, "user-a", now); task.state = AmbientAgentTaskState::InProgress; task.session_id = Some(session_id.clone()); task.session_link = Some("https://example.com/session/followup".to_string()); task.is_sandbox_running = true; app.update(|ctx| { assert!(task.has_active_execution()); assert_eq!( task.active_execution_session_id(), Some(session_id.as_str()) ); let display_status = AgentRunDisplayStatus::from_task(&task, ctx); assert_eq!(display_status, AgentRunDisplayStatus::TaskInProgress); assert_eq!(display_status.status_filter(), StatusFilter::Working); assert!(display_status.is_cancellable()); assert!(display_status.is_working()); }); }); } #[test] fn test_display_status_updates_when_blocked_conversation_resumes() { App::test((), |mut app| async move { let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); let now = Utc::now(); let conversation_id = AIConversationId::new(); let terminal_view_id = EntityId::new(); let task_id = make_uuid(4006); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); history_model.update(&mut app, |model, ctx| { model.restore_conversations(terminal_view_id, vec![conversation], ctx); model.update_conversation_status( terminal_view_id, conversation_id, ConversationStatus::Blocked { blocked_action: "waiting for approval".to_string(), }, ctx, ); }); let mut task = create_test_task(&task_id, "user-a", now); task.state = AmbientAgentTaskState::InProgress; app.update(|ctx| { let display_status = AgentRunDisplayStatus::from_task(&task, ctx); assert!(matches!( display_status, AgentRunDisplayStatus::ConversationBlocked { .. } )); assert_eq!(display_status.status_filter(), StatusFilter::Failed); assert!(!display_status.is_cancellable()); }); history_model.update(&mut app, |model, ctx| { model.update_conversation_status( terminal_view_id, conversation_id, ConversationStatus::InProgress, ctx, ); }); app.update(|ctx| { let display_status = AgentRunDisplayStatus::from_task(&task, ctx); assert_eq!( display_status, AgentRunDisplayStatus::ConversationInProgress ); assert_eq!(display_status.status_filter(), StatusFilter::Working); assert!(display_status.is_cancellable()); assert!(display_status.is_working()); }); }); } #[test] fn test_display_status_terminal_task_state_overrides_matching_conversation() { App::test((), |mut app| async move { let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); let now = Utc::now(); let conversation_id = AIConversationId::new(); let terminal_view_id = EntityId::new(); let task_id = make_uuid(4004); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); history_model.update(&mut app, |model, ctx| { model.restore_conversations(terminal_view_id, vec![conversation], ctx); model.update_conversation_status( terminal_view_id, conversation_id, ConversationStatus::Error, ctx, ); }); let mut task = create_test_task(&task_id, "user-a", now); task.state = AmbientAgentTaskState::Succeeded; app.update(|ctx| { assert_eq!( AgentRunDisplayStatus::from_task(&task, ctx), AgentRunDisplayStatus::TaskSucceeded ); }); }); } #[test] fn test_status_filter_uses_display_status_for_task_backed_conversations() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let history_model = BlocklistAIHistoryModel::handle(&app); let now = Utc::now(); let conversation_id = AIConversationId::new(); let terminal_view_id = EntityId::new(); let task_id = make_uuid(4005); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); history_model.update(&mut app, |model, ctx| { model.restore_conversations(terminal_view_id, vec![conversation], ctx); model.update_conversation_status( terminal_view_id, conversation_id, ConversationStatus::Success, ctx, ); }); let mut model = create_test_model(); let mut task = create_test_task(&task_id, "user-a", now); task.state = AmbientAgentTaskState::InProgress; model.tasks.insert(task.task_id, task.clone()); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); app.update(|ctx| { let done_items = model.get_entries( &AgentManagementFilters { owners: OwnerFilter::All, status: StatusFilter::Done, ..Default::default() }, ctx, ); assert_eq!(done_items.len(), 1); assert_eq!( done_items.first().map(|entry| entry.id), Some(AgentConversationEntryId::AmbientRun(task.task_id)) ); let working_items = model.get_entries( &AgentManagementFilters { owners: OwnerFilter::All, status: StatusFilter::Working, ..Default::default() }, ctx, ); assert!(working_items.is_empty()); }); }); } /// Helper to generate a unique UUID for task IDs fn make_uuid(index: usize) -> String { format!("550e8400-e29b-41d4-a716-{:012}", index) } fn create_test_model() -> AgentConversationsModel { AgentConversationsModel { tasks: HashMap::new(), conversations: HashMap::new(), in_flight_poll_abort_handle: None, next_poll_abort_handle: None, active_data_consumers_per_window: HashMap::new(), has_finished_initial_load: false, task_fetch_state: Default::default(), rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(), dirty_since: None, } } #[test] fn rtc_task_refresh_pending_timestamp_records_first_timestamp() { let timestamp = Utc::now(); let mut pending_timestamp = None; record_earliest_rtc_task_refresh_timestamp(&mut pending_timestamp, timestamp); assert_eq!(pending_timestamp, Some(timestamp)); } #[test] fn rtc_task_refresh_pending_timestamp_keeps_earliest_timestamp() { let earliest_timestamp = Utc::now(); let later_timestamp = earliest_timestamp + Duration::seconds(3); let mut pending_timestamp = Some(earliest_timestamp); record_earliest_rtc_task_refresh_timestamp(&mut pending_timestamp, later_timestamp); assert_eq!(pending_timestamp, Some(earliest_timestamp)); } #[test] fn rtc_task_refresh_pending_timestamp_replaces_later_timestamp() { let earliest_timestamp = Utc::now(); let later_timestamp = earliest_timestamp + Duration::seconds(3); let mut pending_timestamp = Some(later_timestamp); record_earliest_rtc_task_refresh_timestamp(&mut pending_timestamp, earliest_timestamp); assert_eq!(pending_timestamp, Some(earliest_timestamp)); } fn create_test_conversation_metadata( conversation_id: AIConversationId, title: &str, ) -> ConversationMetadata { ConversationMetadata { nav_data: ConversationNavigationData { id: conversation_id, title: title.to_string(), initial_query: None, last_updated: chrono::Local::now(), terminal_view_id: None, window_id: None, pane_view_locator: None, initial_working_directory: None, latest_working_directory: None, is_selected: false, is_in_active_pane: false, is_closed: false, server_conversation_token: None, }, } } fn create_restored_conversation( conversation_id: AIConversationId, root_task_id: &str, conversation_data: AgentConversationData, ) -> AIConversation { let task = create_api_task( root_task_id, vec![create_message( &format!("{root_task_id}-message"), root_task_id, )], ); AIConversation::new_restored(conversation_id, vec![task], Some(conversation_data)) .expect("restored conversation should build") } fn all_owner_filters() -> AgentManagementFilters { AgentManagementFilters { owners: OwnerFilter::All, ..Default::default() } } fn add_entry_projection_test_models(app: &mut App) { app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); app.add_singleton_model(|_| ActiveAgentViewsModel::new()); } fn mock_server_metadata() -> ServerMetadata { ServerMetadata { uid: ServerId::default(), revision: Revision::now(), metadata_last_updated_ts: Utc::now().into(), trashed_ts: None, folder_id: None, is_welcome_object: false, creator_uid: None, last_editor_uid: None, current_editor_uid: None, } } fn mock_server_permissions() -> ServerPermissions { ServerPermissions { space: Owner::mock_current_user(), guests: Vec::new(), anyone_link_sharing: None, permissions_last_updated_ts: Utc::now().into(), } } fn create_server_conversation_metadata( title: &str, server_token: &str, ambient_agent_task_id: Option, ) -> ServerAIConversationMetadata { ServerAIConversationMetadata { title: title.to_string(), working_directory: None, harness: AIAgentHarness::Oz, usage: ConversationUsageMetadata { was_summarized: false, context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), context_window_segments: Vec::new(), ..Default::default() }, metadata: mock_server_metadata(), creator: None, permissions: mock_server_permissions(), ambient_agent_task_id, server_conversation_token: ServerConversationToken::new(server_token.to_string()), artifacts: Vec::new(), } } #[test] fn test_get_entries_includes_task_only_entry() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let mut model = create_test_model(); let task = create_test_task(&make_uuid(8100), "user-a", now); let mut task = task; task.run_time = Some("PT2M".parse().unwrap()); model.tasks.insert(task.task_id, task.clone()); app.update(|ctx| { let entries = model.get_entries(&all_owner_filters(), ctx); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.id, AgentConversationEntryId::AmbientRun(task.task_id)); assert_eq!(entry.identity.ambient_agent_task_id, Some(task.task_id)); assert_eq!(entry.identity.local_conversation_id, None); assert_eq!(entry.provenance, AgentConversationProvenance::AmbientRun); assert_eq!(entry.display.run_time.as_deref(), Some("2.00 min")); assert!(entry.backing.has_ambient_run); assert!(!entry.backing.has_loaded_conversation); }); }); } #[test] fn test_get_entries_includes_local_only_entry() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let conversation_id = AIConversationId::new(); let mut model = create_test_model(); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Local conversation"), ); app.update(|ctx| { let entries = model.get_entries(&all_owner_filters(), ctx); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry.id, AgentConversationEntryId::Conversation(conversation_id) ); assert_eq!(entry.identity.local_conversation_id, Some(conversation_id)); assert_eq!(entry.identity.ambient_agent_task_id, None); assert_eq!( entry.provenance, AgentConversationProvenance::LocalInteractive ); assert_eq!(entry.display.title, "Local conversation"); }); }); } #[test] fn test_get_entries_includes_cloud_metadata_only_entry() { App::test((), |mut app| async move { let token = "cloud-token-only"; add_entry_projection_test_models(&mut app); BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, _| { model.merge_cloud_conversation_metadata(vec![create_server_conversation_metadata( "Cloud conversation", token, None, )]); }); let model = create_test_model(); app.update(|ctx| { let entries = model.get_entries(&all_owner_filters(), ctx); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!( entry .identity .server_conversation_token .as_ref() .map(|t| t.as_str()), Some(token) ); assert_eq!( entry.provenance, AgentConversationProvenance::CloudSyncedConversation ); assert!(entry.backing.has_cloud_data); assert!(!entry.backing.has_loaded_conversation); assert!(!entry.backing.has_local_persisted_data); }); }); } #[test] fn test_get_entries_merges_task_and_local_conversation_by_run_id() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let conversation_id = AIConversationId::new(); let task_id = make_uuid(8101); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| { model.restore_conversations(EntityId::new(), vec![conversation], ctx); }); let mut model = create_test_model(); let task = create_test_task(&task_id, "user-a", now); model.tasks.insert(task.task_id, task.clone()); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); app.update(|ctx| { let entries = model.get_entries(&all_owner_filters(), ctx); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.id, AgentConversationEntryId::AmbientRun(task.task_id)); assert_eq!(entry.identity.local_conversation_id, Some(conversation_id)); assert_eq!(entry.identity.ambient_agent_task_id, Some(task.task_id)); assert!(entry.backing.has_loaded_conversation); }); }); } #[test] fn test_get_entries_merges_task_and_local_conversation_by_server_token() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let conversation_id = AIConversationId::new(); let server_token = "entry-server-token"; let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: Some(server_token.to_string()), conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| { model.restore_conversations(EntityId::new(), vec![conversation], ctx); }); let mut model = create_test_model(); let mut task = create_test_task(&make_uuid(8102), "user-a", now); task.conversation_id = Some(server_token.to_string()); model.tasks.insert(task.task_id, task.clone()); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); app.update(|ctx| { let entries = model.get_entries(&all_owner_filters(), ctx); assert_eq!(entries.len(), 1); let entry = &entries[0]; assert_eq!(entry.id, AgentConversationEntryId::AmbientRun(task.task_id)); assert_eq!(entry.identity.local_conversation_id, Some(conversation_id)); assert_eq!( entry .identity .server_conversation_token .as_ref() .map(|t| t.as_str()), Some(server_token) ); }); }); } #[test] fn test_get_entries_keeps_unrelated_task_and_conversation_entries() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let conversation_id = AIConversationId::new(); let mut model = create_test_model(); let mut task = create_test_task(&make_uuid(8103), "user-a", now); task.conversation_id = Some("different-token".to_string()); model.tasks.insert(task.task_id, task.clone()); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); app.update(|ctx| { let entries = model.get_entries(&all_owner_filters(), ctx); assert_eq!(entries.len(), 2); assert!(entries .iter() .any(|entry| entry.id == AgentConversationEntryId::AmbientRun(task.task_id))); assert!(entries.iter().any(|entry| { entry.id == AgentConversationEntryId::Conversation(conversation_id) })); }); }); } #[test] fn test_resolve_open_action_prefers_active_ambient_terminal() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let task = create_test_task(&make_uuid(8200), "user-a", now); let task_id = task.task_id; let terminal_view_id = EntityId::new(); app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task); model }); ActiveAgentViewsModel::handle(&app).update(&mut app, |model, ctx| { model.register_ambient_session(terminal_view_id, task_id, ctx); }); app.update(|ctx| { let action = AgentConversationsModel::resolve_open_action( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), None, ctx, ); assert!(matches!( action, Some(WorkspaceAction::FocusTerminalViewInWorkspace { terminal_view_id: id }) if id == terminal_view_id )); }); }); } #[test] fn test_resolve_open_action_opens_active_ambient_session() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let session_id = make_uuid(8201); let mut task = create_test_task(&make_uuid(8202), "user-a", now); task.state = AmbientAgentTaskState::InProgress; task.session_id = Some(session_id.clone()); task.session_link = Some("https://example.com/session".to_string()); task.is_sandbox_running = true; let task_id = task.task_id; app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task); model }); app.update(|ctx| { let action = AgentConversationsModel::resolve_open_action( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), None, ctx, ); assert!(matches!( action, Some(WorkspaceAction::OpenOrAttachAmbientAgentConversation { session_id: resolved_session_id, task_id: resolved_task_id, }) if resolved_session_id.to_string() == session_id && resolved_task_id == task_id )); }); }); } #[test] fn test_resolve_open_action_opens_active_ambient_session_from_link() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let session_id = make_uuid(8205); let mut task = create_test_task(&make_uuid(8206), "user-a", now); task.state = AmbientAgentTaskState::InProgress; task.session_link = Some(format!("https://example.com/session/{session_id}")); task.is_sandbox_running = true; let task_id = task.task_id; app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task); model }); app.update(|ctx| { let action = AgentConversationsModel::resolve_open_action( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), None, ctx, ); assert!(matches!( action, Some(WorkspaceAction::OpenOrAttachAmbientAgentConversation { session_id: resolved_session_id, task_id: resolved_task_id, }) if resolved_session_id.to_string() == session_id && resolved_task_id == task_id )); }); }); } #[test] fn test_resolve_open_action_returns_none_for_active_unattachable_session() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let conversation_id = AIConversationId::new(); let task_id = make_uuid(8203); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| { model.restore_conversations(EntityId::new(), vec![conversation], ctx); }); let mut task = create_test_task(&task_id, "user-a", now); task.state = AmbientAgentTaskState::InProgress; task.session_id = Some("not-a-session-id".to_string()); task.session_link = Some("https://example.com/session".to_string()); task.is_sandbox_running = true; let task_id = task.task_id; app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); model }); app.update(|ctx| { let action = AgentConversationsModel::resolve_open_action( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), None, ctx, ); assert!(action.is_none()); }); }); } #[test] fn test_resolve_open_action_handles_server_token_subject_without_entry() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); app.add_singleton_model(|_| create_test_model()); let server_token = ServerConversationToken::new("server-token-subject".to_string()); app.update(|ctx| { let action = AgentConversationsModel::resolve_open_action( AgentConversationNavigationSubject::ServerToken(server_token.clone()), None, ctx, ); assert!(matches!( action, Some(WorkspaceAction::OpenConversationTranscriptViewer { conversation_id, ambient_agent_task_id: None, }) if conversation_id == server_token )); }); }); } #[test] fn test_resolve_open_action_opens_completed_cloud_task_by_server_token() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let token = "completed-cloud-task-token"; let mut task = create_test_task(&make_uuid(8204), "user-a", Utc::now()); task.state = AmbientAgentTaskState::Succeeded; task.conversation_id = Some(token.to_string()); task.session_id = None; task.session_link = None; let task_id = task.task_id; app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task); model }); app.update(|ctx| { let action = AgentConversationsModel::resolve_open_action( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), None, ctx, ); assert!(matches!( action, Some(WorkspaceAction::OpenConversationTranscriptViewer { conversation_id, ambient_agent_task_id: Some(resolved_task_id), }) if conversation_id.as_str() == token && resolved_task_id == task_id )); }); }); } #[test] fn test_resolve_open_action_opens_metadata_only_cloud_conversation_by_server_token() { App::test((), |mut app| async move { let token = "metadata-only-token"; add_entry_projection_test_models(&mut app); BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, _| { model.merge_cloud_conversation_metadata(vec![create_server_conversation_metadata( "Cloud conversation", token, None, )]); }); app.add_singleton_model(|_| create_test_model()); app.update(|ctx| { let entries = AgentConversationsModel::as_ref(ctx).get_entries(&all_owner_filters(), ctx); let entry = entries .iter() .find(|entry| { entry .identity .server_conversation_token .as_ref() .is_some_and(|server_token| server_token.as_str() == token) }) .expect("metadata-only cloud entry should exist"); assert!(entry.backing.has_cloud_data); assert!(!entry.backing.has_loaded_conversation); assert!(!entry.backing.has_local_persisted_data); let action = AgentConversationsModel::resolve_open_action( AgentConversationNavigationSubject::Entry(entry.id), None, ctx, ); assert!(matches!( action, Some(WorkspaceAction::OpenConversationTranscriptViewer { conversation_id, ambient_agent_task_id: None, }) if conversation_id.as_str() == token )); }); }); } #[test] fn test_resolve_copy_link_prefers_active_session_link() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let session_link = "https://example.com/session/active"; let mut task = create_test_task(&make_uuid(8300), "user-a", now); task.state = AmbientAgentTaskState::InProgress; task.session_id = Some(make_uuid(8301)); task.session_link = Some(session_link.to_string()); task.conversation_id = Some("session-backed-token".to_string()); task.is_sandbox_running = true; let task_id = task.task_id; app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task); model }); app.update(|ctx| { let link = AgentConversationsModel::resolve_copy_link( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), ctx, ); assert_eq!(link.as_deref(), Some(session_link)); }); }); } #[test] fn test_resolve_copy_link_uses_cloud_conversation_link_for_inactive_task() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let token = "inactive-task-token"; let mut task = create_test_task(&make_uuid(8302), "user-a", Utc::now()); task.conversation_id = Some(token.to_string()); let task_id = task.task_id; app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task); model }); app.update(|ctx| { let link = AgentConversationsModel::resolve_copy_link( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), ctx, ); assert_eq!( link, Some(ServerConversationToken::new(token.to_string()).conversation_link()) ); let entry = AgentConversationsModel::as_ref(ctx) .get_entry_by_id(&AgentConversationEntryId::AmbientRun(task_id), ctx) .expect("task entry should exist"); assert!(entry.capabilities.can_copy_link); }); }); } #[test] fn test_resolve_copy_link_returns_none_for_local_only_unsynced_conversation() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let conversation_id = AIConversationId::new(); app.add_singleton_model(|_| { let mut model = create_test_model(); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Local only"), ); model }); app.update(|ctx| { let link = AgentConversationsModel::resolve_copy_link( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation( conversation_id, )), ctx, ); assert_eq!(link, None); let entry = AgentConversationsModel::as_ref(ctx) .get_entry_by_id( &AgentConversationEntryId::Conversation(conversation_id), ctx, ) .expect("conversation entry should exist"); assert!(!entry.capabilities.can_copy_link); }); }); } #[test] fn test_server_token_assignment_updates_copy_link_resolution() { App::test((), |mut app| async move { let _interactive_management_guard = FeatureFlag::InteractiveConversationManagementView.override_enabled(true); add_entry_projection_test_models(&mut app); let conversation_id = AIConversationId::new(); let terminal_view_id = EntityId::new(); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| { model.restore_conversations(terminal_view_id, vec![conversation], ctx); }); let agent_model = app.add_singleton_model(|_| { let mut model = create_test_model(); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); model }); let saw_conversation_updated = Arc::new(AtomicBool::new(false)); app.update(|ctx| { let saw_conversation_updated = saw_conversation_updated.clone(); ctx.subscribe_to_model(&agent_model, move |_, event, _| { if matches!( event, AgentConversationsModelEvent::ConversationUpdated { .. } ) { saw_conversation_updated.store(true, Ordering::SeqCst); } }); let link = AgentConversationsModel::resolve_copy_link( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation( conversation_id, )), ctx, ); assert_eq!(link, None); }); let token = "assigned-token-after-entry-build"; BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, _| { model .set_server_conversation_token_for_conversation(conversation_id, token.to_string()); }); agent_model.update(&mut app, |model, ctx| { model.handle_history_event( &BlocklistAIHistoryEvent::ConversationServerTokenAssigned { conversation_id, terminal_surface_id: terminal_view_id, }, ctx, ); }); app.update(|ctx| { assert!(saw_conversation_updated.load(Ordering::SeqCst)); let link = AgentConversationsModel::resolve_copy_link( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation( conversation_id, )), ctx, ); assert_eq!( link, Some(ServerConversationToken::new(token.to_string()).conversation_link()) ); }); }); } #[test] fn test_resolve_open_action_reopens_ambient_session_after_terminal_unregister() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let session_id = make_uuid(8205); let mut task = create_test_task(&make_uuid(8206), "user-a", now); task.state = AmbientAgentTaskState::InProgress; task.session_id = Some(session_id.clone()); task.session_link = Some("https://example.com/session".to_string()); task.is_sandbox_running = true; let task_id = task.task_id; let terminal_view_id = EntityId::new(); app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task); model }); ActiveAgentViewsModel::handle(&app).update(&mut app, |model, ctx| { model.register_ambient_session(terminal_view_id, task_id, ctx); }); app.update(|ctx| { let action = AgentConversationsModel::resolve_open_action( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), None, ctx, ); assert!(matches!( action, Some(WorkspaceAction::OpenOrAttachAmbientAgentConversation { session_id: resolved_session_id, task_id: resolved_task_id, }) if resolved_session_id.to_string() == session_id && resolved_task_id == task_id )); }); ActiveAgentViewsModel::handle(&app).update(&mut app, |model, ctx| { model.unregister_ambient_session(terminal_view_id, ctx); }); app.update(|ctx| { let action = AgentConversationsModel::resolve_open_action( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), None, ctx, ); assert!(matches!( action, Some(WorkspaceAction::OpenOrAttachAmbientAgentConversation { session_id: resolved_session_id, task_id: resolved_task_id, }) if resolved_session_id.to_string() == session_id && resolved_task_id == task_id )); }); }); } #[test] fn test_resolve_copy_link_uses_attached_synced_conversation_for_task_without_token() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let conversation_id = AIConversationId::new(); let token = "attached-conversation-token"; let task_id = make_uuid(8303); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: Some(token.to_string()), conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| { model.restore_conversations(EntityId::new(), vec![conversation], ctx); }); let mut task = create_test_task(&task_id, "user-a", Utc::now()); task.conversation_id = None; let task_id = task.task_id; app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); model }); app.update(|ctx| { let link = AgentConversationsModel::resolve_copy_link( AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( task_id, )), ctx, ); assert_eq!( link, Some(ServerConversationToken::new(token.to_string()).conversation_link()) ); let entry = AgentConversationsModel::as_ref(ctx) .get_entry_by_id(&AgentConversationEntryId::AmbientRun(task_id), ctx) .expect("task entry should exist"); assert!(entry.capabilities.can_copy_link); assert_eq!(entry.identity.local_conversation_id, Some(conversation_id)); }); }); } #[test] fn test_eviction_protects_personal_from_team_overflow() { // Add 50 old personal tasks + 600 new team tasks // After eviction: all 50 personal remain, only 300 team remain let current_user = "user-personal"; let team_user = "user-team"; let now = Utc::now(); let mut model = create_test_model(); // Add 50 old personal tasks for i in 0..50 { let task = create_test_task(&make_uuid(i), current_user, now - Duration::days(30)); model.tasks.insert(task.task_id, task); } // Add 600 new team tasks for i in 50..650 { let task = create_test_task(&make_uuid(i), team_user, now - Duration::hours(i as i64)); model.tasks.insert(task.task_id, task); } model.enforce_task_cap(current_user); // Count personal vs team let personal_count = model .tasks .values() .filter(|t| t.creator.as_ref().is_some_and(|c| c.uid == current_user)) .count(); let team_count = model.tasks.len() - personal_count; // All 50 personal tasks should remain assert_eq!(personal_count, 50, "all personal tasks should remain"); // Team tasks should be capped at MAX_TEAM_TASKS assert_eq!(team_count, MAX_TEAM_TASKS, "team tasks should be capped"); } #[test] fn test_eviction_caps_each_group_independently() { // Add 250 personal + 350 team // After eviction: 200 personal + 300 team let current_user = "user-personal"; let team_user = "user-team"; let now = Utc::now(); let mut model = create_test_model(); // Add 250 personal tasks for i in 0..250 { let task = create_test_task(&make_uuid(i), current_user, now - Duration::hours(i as i64)); model.tasks.insert(task.task_id, task); } // Add 350 team tasks for i in 250..600 { let task = create_test_task(&make_uuid(i), team_user, now - Duration::hours(i as i64)); model.tasks.insert(task.task_id, task); } model.enforce_task_cap(current_user); // Count personal vs team let personal_count = model .tasks .values() .filter(|t| t.creator.as_ref().is_some_and(|c| c.uid == current_user)) .count(); let team_count = model.tasks.len() - personal_count; // Personal capped at MAX_PERSONAL_TASKS assert_eq!( personal_count, MAX_PERSONAL_TASKS, "personal tasks should be capped" ); // Team capped at MAX_TEAM_TASKS assert_eq!(team_count, MAX_TEAM_TASKS, "team tasks should be capped"); } #[test] fn test_eviction_removes_oldest_within_group() { let current_user = "user-personal"; let now = Utc::now(); let mut model = create_test_model(); // Add 250 personal tasks with different timestamps // Newer tasks have lower index (i.e., index 0 is newest) for i in 0..250 { let task = create_test_task(&make_uuid(i), current_user, now - Duration::hours(i as i64)); model.tasks.insert(task.task_id, task); } // Add 350 team tasks (to trigger eviction) let team_user = "user-team"; for i in 250..600 { let task = create_test_task(&make_uuid(i), team_user, now - Duration::hours(i as i64)); model.tasks.insert(task.task_id, task); } model.enforce_task_cap(current_user); // The 200 newest personal tasks should remain (indices 0-199) for i in 0..MAX_PERSONAL_TASKS { let task_id: AmbientAgentTaskId = make_uuid(i).parse().unwrap(); assert!( model.tasks.contains_key(&task_id), "newest personal task {i} should remain" ); } // The oldest personal tasks should be evicted (indices 200-249) for i in MAX_PERSONAL_TASKS..250 { let task_id: AmbientAgentTaskId = make_uuid(i).parse().unwrap(); assert!( !model.tasks.contains_key(&task_id), "oldest personal task {i} should be evicted" ); } } #[test] fn test_eviction_noop_when_under_cap() { let current_user = "user-personal"; let team_user = "user-team"; let now = Utc::now(); let mut model = create_test_model(); // Add 100 personal + 100 team (well under cap) for i in 0..100 { let task = create_test_task(&make_uuid(i), current_user, now - Duration::hours(i as i64)); model.tasks.insert(task.task_id, task); } for i in 100..200 { let task = create_test_task(&make_uuid(i), team_user, now - Duration::hours(i as i64)); model.tasks.insert(task.task_id, task); } let original_count = model.tasks.len(); model.enforce_task_cap(current_user); // No tasks should be evicted assert_eq!( model.tasks.len(), original_count, "no tasks should be evicted when under cap" ); } #[test] fn test_environment_none_filter_includes_conversations() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let mut model = create_test_model(); let task_no_env = create_test_task(&make_uuid(1), "user-a", now); model.tasks.insert(task_no_env.task_id, task_no_env.clone()); let mut task_with_env = create_test_task(&make_uuid(2), "user-b", now); task_with_env.agent_config_snapshot = Some(AgentConfigSnapshot { environment_id: Some("env_123".to_string()), ..Default::default() }); model .tasks .insert(task_with_env.task_id, task_with_env.clone()); let conversation_id = AIConversationId::new(); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Test conversation"), ); let filters = AgentManagementFilters { owners: OwnerFilter::All, environment: EnvironmentFilter::NoEnvironment, ..Default::default() }; app.update(|ctx| { let entries = model.get_entries(&filters, ctx); assert!(entries .iter() .any(|entry| entry.id == AgentConversationEntryId::Conversation(conversation_id))); assert!( entries .iter() .any(|entry| entry.id == AgentConversationEntryId::AmbientRun(task_no_env.task_id)) ); assert!(!entries.iter().any( |entry| entry.id == AgentConversationEntryId::AmbientRun(task_with_env.task_id) )); }); }) } #[test] fn test_file_artifact_filter_matches_only_items_with_file_artifacts() { let artifacts_with_file = vec![Artifact::File { artifact_uid: "artifact-file-1".to_string(), filepath: "outputs/report.txt".to_string(), filename: "report.txt".to_string(), mime_type: "text/plain".to_string(), description: Some("Daily summary".to_string()), size_bytes: Some(42), }]; let artifacts_with_pr = vec![Artifact::PullRequest { url: "https://github.com/org/repo/pull/1".to_string(), branch: "main".to_string(), repo: Some("repo".to_string()), number: Some(1), }]; assert!(super::artifacts_match_filter( &artifacts_with_file, &ArtifactFilter::File, )); assert!(!super::artifacts_match_filter( &artifacts_with_pr, &ArtifactFilter::File, )); assert!(super::artifacts_match_filter( &artifacts_with_file, &ArtifactFilter::All, )); } #[test] fn test_task_status_maps_blocked_state_to_blocked() { App::test((), |mut app| async move { let now = Utc::now(); let mut task = create_test_task(&make_uuid(999), "user-a", now); task.state = AmbientAgentTaskState::Blocked; task.status_message = Some(TaskStatusMessage { message: "Needs clarification".to_string(), error_code: None, }); app.update(|ctx| { let status = AgentRunDisplayStatus::from_task(&task, ctx).to_conversation_status(); match status { ConversationStatus::Blocked { blocked_action } => { assert_eq!(blocked_action, "Needs clarification"); } other => panic!("expected blocked status, got {other:?}"), } }); }); } #[test] fn test_get_entries_prefers_task_when_task_id_matches_conversation_run_id() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let history_model = BlocklistAIHistoryModel::handle(&app); let now = Utc::now(); let conversation_id = AIConversationId::new(); let task_id = make_uuid(3000); let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); history_model.update(&mut app, |model, ctx| { model.restore_conversations(EntityId::new(), vec![conversation], ctx); }); let mut model = create_test_model(); let mut task = create_test_task(&task_id, "user-a", now); task.conversation_id = None; model.tasks.insert(task.task_id, task.clone()); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); app.update(|ctx| { let entries = model.get_entries(&all_owner_filters(), ctx); assert_eq!(entries.len(), 1); assert_eq!( entries[0].id, AgentConversationEntryId::AmbientRun(task.task_id) ); assert_eq!( entries[0].identity.local_conversation_id, Some(conversation_id) ); }); }); } #[test] fn test_get_entries_prefers_task_when_server_token_matches() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let history_model = BlocklistAIHistoryModel::handle(&app); let now = Utc::now(); let conversation_id = AIConversationId::new(); let server_token = "server-token-123"; let conversation = create_restored_conversation( conversation_id, "root-task", AgentConversationData { server_conversation_token: Some(server_token.to_string()), conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, last_event_sequence: None, pinned: false, ..Default::default() }, ); history_model.update(&mut app, |model, ctx| { model.restore_conversations(EntityId::new(), vec![conversation], ctx); }); let mut model = create_test_model(); let mut task = create_test_task(&make_uuid(3001), "user-a", now); task.conversation_id = Some(server_token.to_string()); model.tasks.insert(task.task_id, task.clone()); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); app.update(|ctx| { let entries = model.get_entries(&all_owner_filters(), ctx); assert_eq!(entries.len(), 1); assert_eq!( entries[0].id, AgentConversationEntryId::AmbientRun(task.task_id) ); assert_eq!( entries[0].identity.local_conversation_id, Some(conversation_id) ); }); }); } #[test] fn test_get_entries_keeps_unrelated_tasks_and_conversations() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let now = Utc::now(); let conversation_id = AIConversationId::new(); let mut model = create_test_model(); let mut task = create_test_task(&make_uuid(3002), "user-a", now); task.conversation_id = Some("different-token".to_string()); model.tasks.insert(task.task_id, task.clone()); model.conversations.insert( conversation_id, create_test_conversation_metadata(conversation_id, "Conversation"), ); app.update(|ctx| { let entries = model.get_entries(&all_owner_filters(), ctx); assert_eq!(entries.len(), 2); assert!(entries .iter() .any(|entry| entry.id == AgentConversationEntryId::AmbientRun(task.task_id))); assert!(entries.iter().any(|entry| { entry.id == AgentConversationEntryId::Conversation(conversation_id) })); }); }); } fn task_with_harness( index: usize, creator_uid: &str, harness: Option>, ) -> AmbientAgentTask { let mut task = create_test_task(&make_uuid(index), creator_uid, Utc::now()); task.agent_config_snapshot = harness.map(|harness| AgentConfigSnapshot { harness: harness.map(HarnessConfig::from_harness_type), ..Default::default() }); task } #[test] fn test_harness_filter_matches_only_selected_harness() { App::test((), |mut app| async move { add_entry_projection_test_models(&mut app); let mut model = create_test_model(); let task_claude = task_with_harness(5100, "user-a", Some(Some(Harness::Claude))); let task_gemini = task_with_harness(5101, "user-a", Some(Some(Harness::Gemini))); let task_oz_default = task_with_harness(5102, "user-a", Some(None)); let task_no_snapshot = task_with_harness(5103, "user-a", None); for task in [ &task_claude, &task_gemini, &task_oz_default, &task_no_snapshot, ] { model.tasks.insert(task.task_id, task.clone()); } let conv_id = AIConversationId::new(); model.conversations.insert( conv_id, create_test_conversation_metadata(conv_id, "Local conv"), ); app.update(|ctx| { let items_for = |filter: HarnessFilter| -> Vec { model .get_entries( &AgentManagementFilters { owners: OwnerFilter::All, harness: filter, ..Default::default() }, ctx, ) .into_iter() .map(|entry| match entry.id { AgentConversationEntryId::AmbientRun(task_id) => format!("task:{task_id}"), AgentConversationEntryId::Conversation(conversation_id) => { format!("conversation:{conversation_id}") } }) .collect() }; assert_eq!(items_for(HarnessFilter::All).len(), 5); let claude_items = items_for(HarnessFilter::Specific(Harness::Claude)); assert_eq!(claude_items, vec![format!("task:{}", task_claude.task_id)]); let gemini_items = items_for(HarnessFilter::Specific(Harness::Gemini)); assert_eq!(gemini_items, vec![format!("task:{}", task_gemini.task_id)]); let oz_items = items_for(HarnessFilter::Specific(Harness::Oz)); assert_eq!( oz_items.len(), 2, "expected 2 Warp Agent matches, got {oz_items:?}" ); assert!(oz_items.contains(&format!("task:{}", task_oz_default.task_id))); assert!(oz_items.contains(&format!("conversation:{conv_id}"))); assert!( !oz_items.contains(&format!("task:{}", task_no_snapshot.task_id)), "stub task with no snapshot should not match the Warp Agent filter" ); }); }); } #[test] fn test_harness_filter_is_filtering_and_reset() { // Default is All → not filtering, and after toggling reset_all_but_owner returns to default. let mut filters = AgentManagementFilters::default(); assert!(!filters.is_filtering()); filters.harness = HarnessFilter::Specific(Harness::Claude); assert!( filters.is_filtering(), "harness != All should report filtering" ); filters.reset_all_but_owner(); assert_eq!(filters.harness, HarnessFilter::default()); assert!(!filters.is_filtering()); } #[test] fn test_task_fetch_error_extracts_access_denied_http_status() { for status in [401, 403] { let error = anyhow::Error::new(HttpStatusError { status, body: String::new(), }) .context("run metadata unavailable"); let fetch_error = TaskFetchError::from_error(&error); assert_eq!(fetch_error.message(), "run metadata unavailable"); assert!( fetch_error.is_access_denied(), "expected status {status} to be access denied" ); } for error in [ anyhow::Error::new(HttpStatusError { status: 404, body: String::new(), }) .context("permission denied text alone should not decide the UI"), anyhow::anyhow!("API error 403: forbidden"), ] { assert!(!TaskFetchError::from_error(&error).is_access_denied()); } } #[test] fn test_get_or_async_fetch_task_data_returns_cached_task_without_fetching() { // If the task is already in `tasks`, return it directly and don't touch the fetch-state // map — even if a stale `PermanentlyFailedAt` entry exists (which shouldn't normally happen, // but proves the success path takes precedence). App::test((), |mut app| async move { let now = Utc::now(); let task = create_test_task(&make_uuid(7000), "user-a", now); let task_id = task.task_id; let model_handle = app.add_singleton_model(|_| { let mut model = create_test_model(); model.tasks.insert(task_id, task.clone()); // Sentinel: even if a permanent-failure entry is present, the cached task wins. model.task_fetch_state.insert( task_id, TaskFetchState::PermanentlyFailed { at: Instant::now(), error: TaskFetchError { message: "test".to_string(), status: None, }, }, ); model }); let result = model_handle.update(&mut app, |model, ctx| { model.get_or_async_fetch_task_data(&task_id, ctx) }); assert!(result.is_some(), "cached task should be returned"); model_handle.update(&mut app, |model, _| { // The cached-hit fast path doesn't touch `task_fetch_state`, so the sentinel // entry is left as-is and (importantly) no `InFlight` entry was added. assert!(matches!( model.task_fetch_state.get(&task_id), Some(TaskFetchState::PermanentlyFailed { .. }) )); }); }); } #[test] fn test_get_or_async_fetch_task_data_skips_when_permanently_failed() { // A task id marked as `PermanentlyFailed` within its cooldown (e.g. very recent 403) must // not spawn a new fetch. App::test((), |mut app| async move { let task_id: AmbientAgentTaskId = make_uuid(7001).parse().unwrap(); let model_handle = app.add_singleton_model(|_| { let mut model = create_test_model(); model.task_fetch_state.insert( task_id, TaskFetchState::PermanentlyFailed { at: Instant::now(), error: TaskFetchError { message: "403 Forbidden".to_string(), status: Some(403), }, }, ); model }); let result = model_handle.update(&mut app, |model, ctx| { model.get_or_async_fetch_task_data(&task_id, ctx) }); assert!(result.is_none()); model_handle.update(&mut app, |model, _| { // The state is unchanged -- still permanently failed, no in-flight upgrade. assert!(matches!( model.task_fetch_state.get(&task_id), Some(TaskFetchState::PermanentlyFailed { .. }) )); }); }); } #[test] fn test_get_or_async_fetch_task_data_skips_when_in_flight() { // A task id already marked as `InFlight` must not spawn a duplicate fetch. App::test((), |mut app| async move { let task_id: AmbientAgentTaskId = make_uuid(7002).parse().unwrap(); let model_handle = app.add_singleton_model(|_| { let mut model = create_test_model(); model .task_fetch_state .insert(task_id, TaskFetchState::InFlight); model }); let result = model_handle.update(&mut app, |model, ctx| { model.get_or_async_fetch_task_data(&task_id, ctx) }); assert!(result.is_none()); model_handle.update(&mut app, |model, _| { // Still exactly the one in-flight entry we pre-seeded. assert_eq!(model.task_fetch_state.len(), 1); assert!(matches!( model.task_fetch_state.get(&task_id), Some(TaskFetchState::InFlight) )); }); }); } #[test] fn test_get_or_async_fetch_task_data_skips_within_transient_cooldown() { // A recent transient failure (timestamp younger than the cooldown) must short-circuit. App::test((), |mut app| async move { let task_id: AmbientAgentTaskId = make_uuid(7003).parse().unwrap(); let model_handle = app.add_singleton_model(|_| { let mut model = create_test_model(); model.task_fetch_state.insert( task_id, TaskFetchState::TransientlyFailed { at: Instant::now(), error: TaskFetchError { message: "500 Internal Server Error".to_string(), status: Some(500), }, }, ); model }); let result = model_handle.update(&mut app, |model, ctx| { model.get_or_async_fetch_task_data(&task_id, ctx) }); assert!(result.is_none()); model_handle.update(&mut app, |model, _| { // The transient entry is preserved (no upgrade to in-flight). assert!(matches!( model.task_fetch_state.get(&task_id), Some(TaskFetchState::TransientlyFailed { .. }) )); }); }); } #[test] fn test_agent_management_filters_serde_backwards_compat() { // Persisted state from older clients has no `harness` key → deserializes to All. let legacy = r#"{ "owners": "PersonalOnly", "status": "All", "source": "All", "created_on": "All", "creator": "All", "artifact": "All" }"#; let decoded: AgentManagementFilters = serde_json::from_str(legacy).expect("legacy payload without harness must deserialize"); assert_eq!(decoded.harness, HarnessFilter::All); // Round trip a Specific(Claude) value. let original = AgentManagementFilters { harness: HarnessFilter::Specific(Harness::Claude), ..Default::default() }; let encoded = serde_json::to_string(&original).unwrap(); assert!( encoded.contains("\"harness\":\"claude\""), "expected serialized form to contain \"harness\":\"claude\", got {encoded}" ); let decoded: AgentManagementFilters = serde_json::from_str(&encoded).unwrap(); assert_eq!(decoded, original); // Unknown harness strings deserialize to All (forward compat). let forward = r#"{ "owners": "PersonalOnly", "status": "All", "source": "All", "created_on": "All", "creator": "All", "artifact": "All", "harness": "some-future-harness" }"#; let decoded: AgentManagementFilters = serde_json::from_str(forward).unwrap(); assert_eq!(decoded.harness, HarnessFilter::All); }