## Description Restore orchestration event delivery on the client after a Warp restart so that a parent conversation continues to receive lifecycle events and inbox messages from its children — including terminal events that arrived while Warp was not running. See the full design in `specs/replay-agent-events-on-restore/PRODUCT.md` and `specs/replay-agent-events-on-restore/TECH.md`. This is a re-land of warpdotdev/warp-internal#24999, which was reverted in warpdotdev/warp-internal#25055 due to a CI race. This version fixes the test compilation issue (missing fields in `AmbientAgentTask` struct literal in `conversation_ended_tombstone_view_tests.rs`). ### What - Persists the per-conversation event cursor across restarts. - Adds `last_event_sequence: Option<i64>` to `AgentConversationData` (SQLite) and `AIConversation`. - New `BlocklistAIHistoryModel::update_event_sequence` helper writes the cursor through `write_updated_conversation_state` after each event batch. - Also persists the cursor to the server (fire-and-forget) so driver / cloud restarts can resume without local SQLite state. - Restores `OrchestrationEventPoller.watched_run_ids` and re-establishes event delivery on `BlocklistAIHistoryEvent::RestoredConversations`. - New `on_restored_conversations` handler issues `GET /agent/runs/{run_id}` for each restored parent and uses the response inline `children` and `last_event_sequence` to populate watched run ids and merge the cursor (`max(SQLite, server)`). - Fetch failures retry with exponential backoff (1s, 2s, 5s, 10s capped) keyed off a per-conversation `restore_fetch_failures` counter; reset on success and on conversation removal. - Gated on `OrchestrationV2`. Shared-session viewers and conversations without children are skipped. - `Success` parents resume delivery immediately; `InProgress` parents defer to the existing `on_conversation_status_updated` path. - Restores V1 lifecycle subscriptions on restart by extending the existing `RestoredConversations` handler in `OrchestrationEventService` to re-register `lifecycle_subscription_routes` for restored child conversations whose parents are present locally. ### Why After a Warp restart, parent conversations were silently receiving no further events from children. The `event_cursor` in `OrchestrationEventPoller` was always initialized to 0, so even if delivery had resumed, every event since the start of the conversation would have replayed and produced duplicate messages. V1 lifecycle subscription routes were also not restored, so V1 parents missed child status transitions. ## Testing - Added unit tests in `app/src/ai/blocklist/orchestration_event_poller_tests.rs` covering: cursor merge from server vs SQLite, retry on `get_ambient_agent_task` failure, V2 gating, shared-session-viewer exclusion, cleanup on delete, and `last_event_sequence` round-trip through `AIConversation::new_restored`. - Added unit test coverage in `app/src/ai/blocklist/orchestration_events_tests.rs` for V1 lifecycle re-registration on restore. - Manual verification per `specs/replay-agent-events-on-restore/TECH.md`. ## Server API dependencies - [x] Does this change rely on a [new server API](https://www.notion.so/warpdev/How-to-add-a-new-full-stack-feature-8412cede405a4ec194b32bdd4b951035?pvs=4#04da1e6a493542d68b3e998c7d339640)? - [x] If so, is the use of this API restricted to client channels that rely on the staging server (e.g. WarpDev)? The companion server change adds: - `last_event_sequence` on `Task` (`ai_tasks` column), surfaced inline on `GET /agent/runs/:run_id`. - `PATCH /agent/runs/:run_id/event-sequence` for client cursor writes. - `children` inline on the `GET /agent/runs/:run_id` response. ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode Co-Authored-By: Oz <oz-agent@warp.dev> --------- Co-authored-by: Oz <oz-agent@warp.dev>
1214 lines
43 KiB
Rust
1214 lines
43 KiB
Rust
use chrono::{DateTime, Duration, Utc};
|
|
use instant::Instant;
|
|
use persistence::model::AgentConversationData;
|
|
use std::{
|
|
collections::HashMap,
|
|
sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc,
|
|
},
|
|
};
|
|
use warp_core::features::FeatureFlag;
|
|
use warpui::{App, EntityId};
|
|
|
|
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
|
use crate::ai::ambient_agents::task::{TaskCreatorInfo, TaskStatusMessage};
|
|
use crate::ai::ambient_agents::AgentConfigSnapshot;
|
|
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
|
use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskState};
|
|
use crate::ai::artifacts::Artifact;
|
|
use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
|
use crate::ai::conversation_navigation::ConversationNavigationData;
|
|
use crate::auth::AuthStateProvider;
|
|
use crate::test_util::ai_agent_tasks::{create_api_task, create_message};
|
|
|
|
use super::{
|
|
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters,
|
|
AgentRunDisplayStatus, ArtifactFilter, ConversationMetadata, ConversationOrTask,
|
|
EnvironmentFilter, HarnessFilter, OwnerFilter, StatusFilter, TaskFetchState,
|
|
MAX_PERSONAL_TASKS, MAX_TEAM_TASKS,
|
|
};
|
|
use crate::ai::ambient_agents::task::HarnessConfig;
|
|
use warp_cli::agent::Harness;
|
|
|
|
/// 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,
|
|
status_message: None,
|
|
source: None,
|
|
session_id: None,
|
|
session_link: None,
|
|
creator: Some(TaskCreatorInfo {
|
|
creator_type: "USER".to_string(),
|
|
uid: creator_uid.to_string(),
|
|
display_name: Some(format!("User {creator_uid}")),
|
|
}),
|
|
conversation_id: None,
|
|
request_usage: None,
|
|
agent_config_snapshot: None,
|
|
artifacts: vec![],
|
|
is_sandbox_running: false,
|
|
last_event_sequence: None,
|
|
children: vec![],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_conversation_status_update_emits_conversation_updated() {
|
|
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 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);
|
|
}
|
|
});
|
|
});
|
|
|
|
agent_model.update(&mut app, |model, ctx| {
|
|
model.handle_history_event(
|
|
&BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
|
conversation_id: AIConversationId::new(),
|
|
terminal_view_id: EntityId::new(),
|
|
is_restored: false,
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
assert!(saw_conversation_updated.load(Ordering::SeqCst));
|
|
});
|
|
}
|
|
|
|
#[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 _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true);
|
|
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,
|
|
parent_conversation_id: None,
|
|
run_id: Some(task_id.clone()),
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
},
|
|
);
|
|
|
|
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_updates_when_blocked_conversation_resumes() {
|
|
App::test((), |mut app| async move {
|
|
let _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true);
|
|
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,
|
|
parent_conversation_id: None,
|
|
run_id: Some(task_id.clone()),
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
},
|
|
);
|
|
|
|
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 _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true);
|
|
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,
|
|
parent_conversation_id: None,
|
|
run_id: Some(task_id.clone()),
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
},
|
|
);
|
|
|
|
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 {
|
|
let _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true);
|
|
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
|
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 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,
|
|
parent_conversation_id: None,
|
|
run_id: Some(task_id.clone()),
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
},
|
|
);
|
|
|
|
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: Vec<_> = model
|
|
.get_tasks_and_conversations(
|
|
&AgentManagementFilters {
|
|
owners: OwnerFilter::All,
|
|
status: StatusFilter::Done,
|
|
..Default::default()
|
|
},
|
|
ctx,
|
|
)
|
|
.collect();
|
|
assert_eq!(done_items.len(), 1);
|
|
assert!(matches!(
|
|
done_items.first(),
|
|
Some(ConversationOrTask::Task(_))
|
|
));
|
|
|
|
let working_items: Vec<_> = model
|
|
.get_tasks_and_conversations(
|
|
&AgentManagementFilters {
|
|
owners: OwnerFilter::All,
|
|
status: StatusFilter::Working,
|
|
..Default::default()
|
|
},
|
|
ctx,
|
|
)
|
|
.collect();
|
|
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,
|
|
manually_opened_task_ids: Default::default(),
|
|
task_fetch_state: Default::default(),
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
#[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 {
|
|
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
|
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
|
|
let now = Utc::now();
|
|
|
|
let mut model = create_test_model();
|
|
|
|
// Task with no environment.
|
|
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());
|
|
|
|
// Task with an environment (should be excluded when filtering for None).
|
|
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());
|
|
|
|
// Local conversation (environment_id is always None) should be included.
|
|
let conversation_id = AIConversationId::new();
|
|
model.conversations.insert(
|
|
conversation_id,
|
|
ConversationMetadata {
|
|
nav_data: ConversationNavigationData {
|
|
id: conversation_id,
|
|
title: "Test conversation".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,
|
|
},
|
|
},
|
|
);
|
|
|
|
let filters = AgentManagementFilters {
|
|
owners: OwnerFilter::All,
|
|
environment: EnvironmentFilter::NoEnvironment,
|
|
..Default::default()
|
|
};
|
|
|
|
app.update(|ctx| {
|
|
let mut saw_conversation = false;
|
|
let mut saw_task_no_env = false;
|
|
let mut saw_task_with_env = false;
|
|
|
|
for item in model.get_tasks_and_conversations(&filters, ctx) {
|
|
match item {
|
|
ConversationOrTask::Conversation(_) => saw_conversation = true,
|
|
ConversationOrTask::Task(task) if task.task_id == task_no_env.task_id => {
|
|
saw_task_no_env = true
|
|
}
|
|
ConversationOrTask::Task(task) if task.task_id == task_with_env.task_id => {
|
|
saw_task_with_env = true
|
|
}
|
|
ConversationOrTask::Task(_) => {}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
saw_conversation,
|
|
"expected Environment=None filter to include conversations"
|
|
);
|
|
assert!(
|
|
saw_task_no_env,
|
|
"expected Environment=None filter to include tasks without an environment"
|
|
);
|
|
assert!(
|
|
!saw_task_with_env,
|
|
"expected Environment=None filter to exclude tasks with an environment"
|
|
);
|
|
});
|
|
})
|
|
}
|
|
|
|
#[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(),
|
|
});
|
|
|
|
app.update(|ctx| {
|
|
let status = ConversationOrTask::Task(&task).status(ctx);
|
|
match status {
|
|
ConversationStatus::Blocked { blocked_action } => {
|
|
assert_eq!(blocked_action, "Needs clarification");
|
|
}
|
|
other => panic!("expected blocked status, got {other:?}"),
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_tasks_and_conversations_prefers_task_when_task_id_matches_conversation_run_id() {
|
|
App::test((), |mut app| async move {
|
|
let _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true);
|
|
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
|
|
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,
|
|
parent_conversation_id: None,
|
|
run_id: Some(task_id.clone()),
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
},
|
|
);
|
|
|
|
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 items: Vec<String> = model
|
|
.get_tasks_and_conversations(&all_owner_filters(), ctx)
|
|
.map(|item| match item {
|
|
ConversationOrTask::Task(task) => format!("task:{}", task.task_id),
|
|
ConversationOrTask::Conversation(conversation) => {
|
|
format!("conversation:{}", conversation.nav_data.id)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(items, vec![format!("task:{}", task.task_id)]);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_tasks_and_conversations_prefers_task_when_server_token_matches() {
|
|
App::test((), |mut app| async move {
|
|
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
|
|
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,
|
|
parent_conversation_id: None,
|
|
run_id: None,
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
},
|
|
);
|
|
|
|
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 items: Vec<String> = model
|
|
.get_tasks_and_conversations(&all_owner_filters(), ctx)
|
|
.map(|item| match item {
|
|
ConversationOrTask::Task(task) => format!("task:{}", task.task_id),
|
|
ConversationOrTask::Conversation(conversation) => {
|
|
format!("conversation:{}", conversation.nav_data.id)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(items, vec![format!("task:{}", task.task_id)]);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_tasks_and_conversations_keeps_unrelated_tasks_and_conversations() {
|
|
App::test((), |mut app| async move {
|
|
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
|
|
let now = Utc::now();
|
|
let conversation_id = AIConversationId::new();
|
|
|
|
let conversation = create_restored_conversation(
|
|
conversation_id,
|
|
"root-task",
|
|
AgentConversationData {
|
|
server_conversation_token: Some("server-token-123".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,
|
|
parent_conversation_id: None,
|
|
run_id: None,
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
},
|
|
);
|
|
|
|
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(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 items: Vec<String> = model
|
|
.get_tasks_and_conversations(&all_owner_filters(), ctx)
|
|
.map(|item| match item {
|
|
ConversationOrTask::Task(task) => format!("task:{}", task.task_id),
|
|
ConversationOrTask::Conversation(conversation) => {
|
|
format!("conversation:{}", conversation.nav_data.id)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(items.len(), 2);
|
|
assert!(items.contains(&format!("task:{}", task.task_id)));
|
|
assert!(items.contains(&format!("conversation:{conversation_id}")));
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Helper: build a task with the given harness on its config snapshot.
|
|
///
|
|
/// `harness` semantics:
|
|
/// - `None` → leaves `agent_config_snapshot = None` (stub task).
|
|
/// - `Some(None)` → `agent_config_snapshot = Some { harness: None }`.
|
|
/// - `Some(Some(h))` → `agent_config_snapshot = Some { harness: Some(h) }`.
|
|
fn task_with_harness(
|
|
task_id_index: usize,
|
|
creator_uid: &str,
|
|
harness: Option<Option<Harness>>,
|
|
) -> AmbientAgentTask {
|
|
let mut task = create_test_task(&make_uuid(task_id_index), creator_uid, Utc::now());
|
|
match harness {
|
|
None => task.agent_config_snapshot = None,
|
|
Some(None) => {
|
|
task.agent_config_snapshot = Some(AgentConfigSnapshot {
|
|
harness: None,
|
|
..Default::default()
|
|
});
|
|
}
|
|
Some(Some(h)) => {
|
|
task.agent_config_snapshot = Some(AgentConfigSnapshot {
|
|
harness: Some(HarnessConfig::from_harness_type(h)),
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
task
|
|
}
|
|
|
|
#[test]
|
|
fn test_harness_filter_matches_only_selected_harness() {
|
|
App::test((), |mut app| async move {
|
|
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
|
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
|
|
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)));
|
|
// Snapshot present but no harness set → Some(Oz), matches Warp Agent.
|
|
let task_oz_default = task_with_harness(5102, "user-a", Some(None));
|
|
// No snapshot at all → None, matches only `All`.
|
|
let task_no_snapshot = task_with_harness(5103, "user-a", None);
|
|
|
|
for t in [
|
|
&task_claude,
|
|
&task_gemini,
|
|
&task_oz_default,
|
|
&task_no_snapshot,
|
|
] {
|
|
model.tasks.insert(t.task_id, t.clone());
|
|
}
|
|
|
|
// Local conversation: effectively Warp Agent.
|
|
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_tasks_and_conversations(
|
|
&AgentManagementFilters {
|
|
owners: OwnerFilter::All,
|
|
harness: filter,
|
|
..Default::default()
|
|
},
|
|
ctx,
|
|
)
|
|
.map(|item| match item {
|
|
ConversationOrTask::Task(t) => format!("task:{}", t.task_id),
|
|
ConversationOrTask::Conversation(c) => {
|
|
format!("conversation:{}", c.nav_data.id)
|
|
}
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
// All → everything (incl. the unknown-harness stub task).
|
|
assert_eq!(items_for(HarnessFilter::All).len(), 5);
|
|
|
|
// Claude → only the claude task.
|
|
let claude_items = items_for(HarnessFilter::Specific(Harness::Claude));
|
|
assert_eq!(claude_items, vec![format!("task:{}", task_claude.task_id)]);
|
|
|
|
// Gemini → only the gemini task.
|
|
let gemini_items = items_for(HarnessFilter::Specific(Harness::Gemini));
|
|
assert_eq!(gemini_items, vec![format!("task:{}", task_gemini.task_id)]);
|
|
|
|
// Warp Agent / Oz → default-snapshot task and local conversation.
|
|
// The stub task with no snapshot resolves to `harness() == None` and
|
|
// is deliberately excluded from any specific-harness filter.
|
|
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_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::PermanentlyFailedAt(Instant::now()));
|
|
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::PermanentlyFailedAt(_))
|
|
));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_or_async_fetch_task_data_skips_when_permanently_failed() {
|
|
// A task id marked as `PermanentlyFailedAt` 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::PermanentlyFailedAt(Instant::now()));
|
|
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::PermanentlyFailedAt(_))
|
|
));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[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::TransientlyFailedAt(Instant::now()));
|
|
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::TransientlyFailedAt(_))
|
|
));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[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);
|
|
}
|