2410 lines
85 KiB
Rust
2410 lines
85 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxyui::{App, EntityId};
|
|
use instant::Instant;
|
|
use parking_lot::Mutex;
|
|
use persistence::model::{AgentConversationData, ConversationUsageMetadata};
|
|
use galaxy_cli::agent::Harness;
|
|
use galaxyui::{App, EntityId, ModelHandle, SingletonEntity};
|
|
|
|
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<Utc>,
|
|
) -> 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<Option<ConversationUpdateKind>>;
|
|
|
|
/// 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<AgentConversationsModel>,
|
|
) -> Arc<CapturedConversationUpdate> {
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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<AmbientAgentTaskId>,
|
|
) -> 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(),
|
|
},
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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,
|
|
},
|
|
);
|
|
|
|
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<Option<Harness>>,
|
|
) -> 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<String> {
|
|
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);
|
|
}
|