Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 50 additions & 9 deletions app/src/ai/agent_conversations_model.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#[allow(dead_code)]
pub mod entry;
mod query;

use std::collections::{HashMap, HashSet};
use std::time::Duration;
Expand All @@ -11,8 +12,10 @@ pub use entry::{
AgentConversationProvenance,
};
use futures::stream::AbortHandle;
use fuzzy_match::FuzzyMatchResult;
use instant::Instant;
use itertools::Itertools;
pub use query::query_conversation_entries;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use warp_cli::agent::Harness;
use warp_core::execution_mode::AppExecutionMode;
Expand Down Expand Up @@ -122,6 +125,14 @@ enum TaskFetchState {
TransientlyFailed { at: Instant, error: TaskFetchError },
}

/// Availability state for cloud conversation metadata.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum CloudConversationMetadataLoadState {
#[default]
Available,
Failed,
}

/// Tracks the cooldown window for RTC-triggered task-list refreshes. Pending events keep
/// the earliest timestamp in the burst because `updated_after` is a lower bound; using the
/// latest timestamp could skip tasks that changed earlier in the same window.
Expand Down Expand Up @@ -302,6 +313,12 @@ pub trait AgentConversationListPolicy: 'static {
) -> AgentConversationListEntryState;
}

/// A normalized conversation entry paired with optional title-match metadata.
pub struct AgentConversationQueryResult {
pub entry: AgentConversationEntry,
pub title_match: Option<FuzzyMatchResult>,
}

impl AgentManagementFilters {
pub fn reset_all_but_owner(&mut self) {
self.status = StatusFilter::default();
Expand Down Expand Up @@ -578,6 +595,8 @@ pub struct AgentConversationsModel {
active_data_consumers_per_window: HashMap<WindowId, HashSet<EntityId>>,
/// Whether we have finished the initial task load
has_finished_initial_load: bool,
/// Availability state for cloud conversation metadata.
cloud_conversation_metadata_load_state: CloudConversationMetadataLoadState,
/// Per-task fetch state for `get_or_async_fetch_task_data`. See [`TaskFetchState`] for
/// the meaning of each variant. Tasks that have been successfully fetched live in `tasks`
/// and are absent from this map.
Expand Down Expand Up @@ -633,6 +652,8 @@ impl AgentConversationsModel {
next_poll_abort_handle: None,
active_data_consumers_per_window: HashMap::new(),
has_finished_initial_load: true,
cloud_conversation_metadata_load_state:
CloudConversationMetadataLoadState::Available,
task_fetch_state: HashMap::new(),
rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(),
dirty_since: None,
Expand Down Expand Up @@ -672,6 +693,7 @@ impl AgentConversationsModel {
next_poll_abort_handle: None,
active_data_consumers_per_window: HashMap::new(),
has_finished_initial_load: false,
cloud_conversation_metadata_load_state: CloudConversationMetadataLoadState::Available,
task_fetch_state: HashMap::new(),
rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(),
dirty_since: None,
Expand All @@ -692,6 +714,12 @@ impl AgentConversationsModel {
!self.has_finished_initial_load
}

/// Returns whether cloud conversation metadata failed to load.
#[cfg_attr(not(feature = "tui"), allow(dead_code))]
pub(crate) fn cloud_conversation_metadata_load_failed(&self) -> bool {
self.cloud_conversation_metadata_load_state == CloudConversationMetadataLoadState::Failed
}

fn handle_network_status_changed(
&mut self,
_: ModelHandle<NetworkStatus>,
Expand Down Expand Up @@ -941,13 +969,14 @@ impl AgentConversationsModel {
};

// Handle conversation metadata result
let mut conversation_metadata = match conversation_metadata_result {
Ok(metadata) => metadata,
Err(e) => {
log::warn!("Failed to fetch conversation metadata: {e:?}");
vec![]
}
};
let (mut conversation_metadata, cloud_metadata_loaded) =
match conversation_metadata_result {
Ok(metadata) => (metadata, true),
Err(e) => {
log::warn!("Failed to fetch conversation metadata: {e:?}");
(vec![], false)
}
};

// Collect all conversation IDs from tasks
let task_conversation_ids: HashSet<String> = tasks
Expand Down Expand Up @@ -991,13 +1020,23 @@ impl AgentConversationsModel {
}

// Always return success - we handle failures individually above
Ok((tasks, conversation_metadata))
Ok((tasks, conversation_metadata, cloud_metadata_loaded))
}
},
OUT_OF_BAND_REQUEST_RETRY_STRATEGY,
|model, result, ctx| {
if let RequestState::RequestSucceeded((tasks, conversation_metadata)) = result {
if let RequestState::RequestSucceeded((
tasks,
conversation_metadata,
cloud_metadata_loaded,
)) = result
{
model.has_finished_initial_load = true;
model.cloud_conversation_metadata_load_state = if cloud_metadata_loaded {
CloudConversationMetadataLoadState::Available
} else {
CloudConversationMetadataLoadState::Failed
};

// Update tasks if we got any
if !tasks.is_empty() {
Expand Down Expand Up @@ -1025,6 +1064,8 @@ impl AgentConversationsModel {
ctx.emit(AgentConversationsModelEvent::ConversationsLoaded);
} else if let RequestState::RequestFailed(e) = result {
model.has_finished_initial_load = true;
model.cloud_conversation_metadata_load_state =
CloudConversationMetadataLoadState::Failed;
model.update_polling_state(ctx);
report_error!(e);
}
Expand Down
49 changes: 49 additions & 0 deletions app/src/ai/agent_conversations_model/query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use fuzzy_match::match_indices_case_insensitive;

use super::{AgentConversationEntry, AgentConversationQueryResult};

pub(super) const DEFAULT_RESULT_COUNT: usize = 50;
pub(super) const MAX_SEARCH_RESULTS: usize = 500;
const MINIMUM_FUZZY_SCORE: i64 = 25;

/// Applies the shared conversation-menu recency and fuzzy-ranking policy.
pub fn query_conversation_entries(
mut entries: Vec<AgentConversationEntry>,
query: &str,
) -> Vec<AgentConversationQueryResult> {
let query = query.trim().to_lowercase();
if query.is_empty() {
entries.sort_by(|a, b| b.display.last_updated.cmp(&a.display.last_updated));
entries.truncate(DEFAULT_RESULT_COUNT);
entries.reverse();
return entries
.into_iter()
.map(|entry| AgentConversationQueryResult {
entry,
title_match: None,
})
.collect();
}

let mut matches = entries
.into_iter()
.filter_map(|entry| {
let title_match = match_indices_case_insensitive(&entry.display.title, &query)?;
(title_match.score >= MINIMUM_FUZZY_SCORE).then_some(AgentConversationQueryResult {
entry,
title_match: Some(title_match),
})
})
.collect::<Vec<_>>();
matches.sort_by_key(|result| {
let score = result
.title_match
.as_ref()
.map_or(i64::MIN, |title_match| title_match.score);
(score, result.entry.display.last_updated.timestamp_millis())
});
if matches.len() > MAX_SEARCH_RESULTS {
matches.drain(..matches.len() - MAX_SEARCH_RESULTS);
}
matches
}
113 changes: 111 additions & 2 deletions app/src/ai/agent_conversations_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ use warpui::{App, EntityId, ModelHandle, SingletonEntity};
use super::entry::{
AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationProvenance,
};
use super::query::{DEFAULT_RESULT_COUNT, MAX_SEARCH_RESULTS};
use super::{
record_earliest_rtc_task_refresh_timestamp, AgentConversationsModel,
AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter,
query_conversation_entries, record_earliest_rtc_task_refresh_timestamp,
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters,
AgentRunDisplayStatus, ArtifactFilter, CloudConversationMetadataLoadState,
ConversationMetadata, ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter,
RtcTaskRefreshThrottleState, StatusFilter, TaskFetchError, TaskFetchState, MAX_PERSONAL_TASKS,
MAX_TEAM_TASKS,
Expand Down Expand Up @@ -664,12 +666,119 @@ fn create_test_model() -> AgentConversationsModel {
next_poll_abort_handle: None,
active_data_consumers_per_window: HashMap::new(),
has_finished_initial_load: false,
cloud_conversation_metadata_load_state: CloudConversationMetadataLoadState::Available,
task_fetch_state: Default::default(),
rtc_task_refresh_throttle_state: RtcTaskRefreshThrottleState::default(),
dirty_since: None,
}
}

#[test]
fn cloud_conversation_metadata_reports_failed_load() {
let mut model = create_test_model();
assert!(!model.cloud_conversation_metadata_load_failed());

model.cloud_conversation_metadata_load_state = CloudConversationMetadataLoadState::Failed;
assert!(model.cloud_conversation_metadata_load_failed());
}

#[test]
fn conversation_query_caps_recent_entries_and_places_newest_last() {
App::test((), |mut app| async move {
add_entry_projection_test_models(&mut app);
let now = Utc::now();
let mut model = create_test_model();
for index in 0..55 {
let task_id = make_uuid(9000 + index);
let mut task =
create_test_task(&task_id, "user-a", now - Duration::seconds(index as i64));
task.title = format!("Conversation {index}");
model.tasks.insert(task.task_id, task);
}

app.update(|ctx| {
let entries = model.get_entries(&all_owner_filters(), ctx);
let results = query_conversation_entries(entries, "");

assert_eq!(results.len(), DEFAULT_RESULT_COUNT);
assert_eq!(
results
.first()
.map(|result| result.entry.display.title.as_str()),
Some("Conversation 49")
);
assert_eq!(
results
.last()
.map(|result| result.entry.display.title.as_str()),
Some("Conversation 0")
);
assert!(!results
.iter()
.any(|result| result.entry.display.title == "Conversation 50"));
});
});
}

#[test]
fn conversation_query_filters_titles_and_caps_best_fuzzy_results() {
App::test((), |mut app| async move {
add_entry_projection_test_models(&mut app);
let now = Utc::now();
let mut model = create_test_model();
for index in 0..=MAX_SEARCH_RESULTS + 2 {
let task_id = make_uuid(9100 + index);
let mut task =
create_test_task(&task_id, "user-a", now - Duration::seconds(index as i64));
task.title = if index == 1 {
"Fix unit tests".to_owned()
} else {
format!("Deploy service {index}")
};
model.tasks.insert(task.task_id, task);
}

app.update(|ctx| {
let entries = model.get_entries(&all_owner_filters(), ctx);
let results = query_conversation_entries(entries, "deploy");

assert_eq!(results.len(), MAX_SEARCH_RESULTS);
assert!(results
.iter()
.all(|result| result.entry.display.title.contains("Deploy")));
assert!(results.windows(2).all(|window| {
window[0].title_match.as_ref().unwrap().score
<= window[1].title_match.as_ref().unwrap().score
}));
});
});
}

#[test]
fn conversation_query_orders_equal_fuzzy_scores_by_recency() {
App::test((), |mut app| async move {
add_entry_projection_test_models(&mut app);
let now = Utc::now();
let mut model = create_test_model();
for index in [0, 2, 1] {
let task_id = make_uuid(9700 + index);
let mut task =
create_test_task(&task_id, "user-a", now - Duration::seconds(index as i64));
task.title = "Deploy service".to_owned();
model.tasks.insert(task.task_id, task);
}

app.update(|ctx| {
let entries = model.get_entries(&all_owner_filters(), ctx);
let results = query_conversation_entries(entries, "deploy");

assert!(results.windows(2).all(|window| {
window[0].entry.display.last_updated <= window[1].entry.display.last_updated
}));
});
});
}

#[test]
fn rtc_task_refresh_pending_timestamp_records_first_timestamp() {
let timestamp = Utc::now();
Expand Down
Loading
Loading