diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index 59c9b84eb..36d5b6e37 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -1,3 +1,8 @@ +#[allow(dead_code)] +pub mod entry; + +pub use entry::AgentConversationEntry; + use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; @@ -273,7 +278,7 @@ impl AgentRunDisplayStatus { return Self::from_task_state(task); } let history_model = BlocklistAIHistoryModel::as_ref(app); - AgentConversationsModel::conversation_id_shadowed_by_task(task, history_model) + entry::conversation_id_shadowed_by_task(task, history_model) .and_then(|conversation_id| history_model.conversation(&conversation_id)) .map(|conversation| Self::from_conversation_status(conversation.status())) .unwrap_or_else(|| Self::from_task_state(task)) @@ -1418,32 +1423,67 @@ impl AgentConversationsModel { } } - /// Returns the local conversation ID represented by the given task, if this task and a - /// conversation entry both point at the same underlying local run. - /// - /// We first match using the orchestration agent ID (task ID / run ID under v2), and fall back - /// to the server conversation token for cases where the task only carries conversation identity - /// through `conversation_id`. - fn conversation_id_shadowed_by_task( - task: &AmbientAgentTask, - history_model: &BlocklistAIHistoryModel, - ) -> Option { - history_model - .conversation_id_for_agent_id(&task.run_id().to_string()) - .or_else(|| { - task.conversation_id().and_then(|conversation_id| { - history_model.find_conversation_id_by_server_token( - &ServerConversationToken::new(conversation_id.to_string()), - ) - }) - }) - } - fn conversation_ids_shadowed_by_tasks(&self, app: &AppContext) -> HashSet { let history_model = BlocklistAIHistoryModel::as_ref(app); self.tasks .values() - .filter_map(|task| Self::conversation_id_shadowed_by_task(task, history_model)) + .filter_map(|task| entry::conversation_id_shadowed_by_task(task, history_model)) + .collect() + } + + /// Returns normalized, owned entries for agent management/navigation surfaces. + /// + /// This projection keeps task, local conversation, and cloud metadata identity together while + /// leaving the current `ConversationOrTask` call sites unchanged. + #[allow(dead_code)] + pub fn get_entries( + &self, + filters: &AgentManagementFilters, + app: &AppContext, + ) -> Vec { + let history_model = BlocklistAIHistoryModel::as_ref(app); + let mut entries = Vec::new(); + let mut attached_conversation_ids = HashSet::new(); + let mut emitted_conversation_ids = HashSet::new(); + + for task in self.tasks.values() { + let entry = entry::entry_for_task(task, history_model, app); + if let Some(conversation_id) = entry.identity.local_conversation_id { + attached_conversation_ids.insert(conversation_id); + } + entries.push(entry); + } + + for metadata in self.conversations.values() { + let conversation_id = metadata.nav_data.id; + if attached_conversation_ids.contains(&conversation_id) { + continue; + } + let entry = entry::entry_for_conversation(metadata, history_model, app); + emitted_conversation_ids.insert(conversation_id); + entries.push(entry); + } + + for metadata in history_model.get_local_conversations_metadata() { + if attached_conversation_ids.contains(&metadata.id) + || emitted_conversation_ids.contains(&metadata.id) + { + continue; + } + let nav_data = + ConversationNavigationData::from_historical_conversation_metadata(metadata); + entries.push(entry::entry_for_historical_metadata( + metadata, + nav_data, + history_model, + app, + )); + } + + entries + .into_iter() + .filter(|entry| entry.matches_filters(filters, app)) + .sorted_by(|a, b| b.display.last_updated.cmp(&a.display.last_updated)) .collect() } diff --git a/app/src/ai/agent_conversations_model/entry.rs b/app/src/ai/agent_conversations_model/entry.rs new file mode 100644 index 000000000..e18bb6e1c --- /dev/null +++ b/app/src/ai/agent_conversations_model/entry.rs @@ -0,0 +1,417 @@ +use crate::ai::agent::api::ServerConversationToken; +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::ambient_agents::{AgentSource, AmbientAgentTask, AmbientAgentTaskId}; +use crate::ai::artifacts::Artifact; +use crate::ai::blocklist::history_model::{AIConversationMetadata, BlocklistAIHistoryModel}; +use crate::ai::conversation_navigation::ConversationNavigationData; +use crate::auth::AuthStateProvider; +use chrono::{DateTime, Utc}; +use session_sharing_protocol::common::SessionId; +use warp_cli::agent::Harness; +use warpui::{AppContext, SingletonEntity}; + +use super::{ + artifacts_match_filter, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter, + ConversationMetadata, ConversationOrTask, CreatedOnFilter, CreatorFilter, EnvironmentFilter, + HarnessFilter, OwnerFilter, SourceFilter, StatusFilter, +}; + +/// Stable projection identity used by list and navigation surfaces. +/// +/// Task-backed rows use the ambient run ID even when they are attached to a local +/// conversation, so task-specific affordances do not disappear when local data is present. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum AgentConversationEntryId { + AmbientRun(AmbientAgentTaskId), + Conversation(AIConversationId), +} + +/// Normalized row data for agent conversation list, management, and navigation surfaces. +/// +/// The entry keeps local conversation identity, ambient run identity, cloud token identity, +/// display fields, and available actions together so callers do not recompute navigation +/// policy from stale partial sources. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentConversationEntry { + pub id: AgentConversationEntryId, + pub identity: AgentConversationIdentity, + pub provenance: AgentConversationProvenance, + pub display: AgentConversationDisplayData, + pub backing: AgentConversationBackingData, + pub capabilities: AgentConversationCapabilities, +} + +/// Cross-system identifiers that may refer to the same underlying conversation/run. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentConversationIdentity { + pub local_conversation_id: Option, + pub ambient_agent_task_id: Option, + pub server_conversation_token: Option, + pub session_id: Option, +} + +/// Display-only fields for rendering a conversation entry without consulting source models. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentConversationDisplayData { + pub title: String, + pub initial_query: Option, + pub created_at: DateTime, + pub last_updated: DateTime, + pub status: AgentRunDisplayStatus, + pub creator: AgentConversationCreator, + pub request_usage: Option, + pub run_time: Option, + pub source: Option, + pub working_directory: Option, + pub environment_id: Option, + pub harness: Option, + pub artifacts: Vec, +} + +/// Creator information normalized across local conversations and ambient runs. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AgentConversationCreator { + pub name: Option, + pub uid: Option, +} + +/// Source category that explains why an entry exists and which backing systems can refresh it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentConversationProvenance { + LocalInteractive, + AmbientRun, + CloudSyncedConversation, +} + +/// Availability flags for the source data that contributed to an entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentConversationBackingData { + pub has_loaded_conversation: bool, + pub has_local_persisted_data: bool, + pub has_cloud_data: bool, + pub has_ambient_run: bool, +} + +/// Actions that should be exposed for an entry after applying current navigation policy. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentConversationCapabilities { + pub can_open: bool, + pub can_copy_link: bool, + pub can_share: bool, + pub can_delete: bool, + pub can_fork_locally: bool, + pub can_cancel: bool, +} + +impl AgentConversationEntry { + pub(super) fn matches_filters( + &self, + filters: &AgentManagementFilters, + app: &AppContext, + ) -> bool { + self.matches_owner_and_creator(&filters.owners, &filters.creator, app) + && self.matches_status(&filters.status) + && self.matches_source(&filters.source) + && self.matches_created_on(&filters.created_on) + && self.matches_artifact(&filters.artifact) + && self.matches_environment(&filters.environment) + && self.matches_harness(&filters.harness) + } + + fn matches_owner_and_creator( + &self, + owner_filter: &OwnerFilter, + creator_filter: &CreatorFilter, + app: &AppContext, + ) -> bool { + let current_user_id = AuthStateProvider::as_ref(app) + .get() + .user_id() + .map(|uid| uid.as_string()); + + let passes_owner = match owner_filter { + OwnerFilter::All => true, + OwnerFilter::PersonalOnly => { + if self.backing.has_ambient_run { + self.display.creator.uid == current_user_id + } else { + true + } + } + }; + + if !passes_owner || matches!(owner_filter, OwnerFilter::PersonalOnly) { + return passes_owner; + } + + match creator_filter { + CreatorFilter::All => true, + CreatorFilter::Specific { name, .. } => { + self.display.creator.name.as_ref() == Some(name) + } + } + } + + fn matches_status(&self, status_filter: &StatusFilter) -> bool { + match status_filter { + StatusFilter::All => true, + StatusFilter::Working | StatusFilter::Done | StatusFilter::Failed => { + self.display.status.status_filter() == *status_filter + } + } + } + + fn matches_source(&self, source_filter: &SourceFilter) -> bool { + match source_filter { + SourceFilter::All => true, + SourceFilter::Specific(source) => self.display.source.as_ref() == Some(source), + } + } + + fn matches_created_on(&self, created_on_filter: &CreatedOnFilter) -> bool { + let now = Utc::now(); + let created_cutoff = match created_on_filter { + CreatedOnFilter::All => None, + CreatedOnFilter::Last24Hours => Some(now - chrono::Duration::hours(24)), + CreatedOnFilter::Past3Days => Some(now - chrono::Duration::days(3)), + CreatedOnFilter::LastWeek => Some(now - chrono::Duration::days(7)), + }; + match created_cutoff { + Some(cutoff) => self.display.created_at >= cutoff, + None => true, + } + } + + fn matches_artifact(&self, artifact_filter: &ArtifactFilter) -> bool { + artifacts_match_filter(&self.display.artifacts, artifact_filter) + } + + fn matches_environment(&self, environment_filter: &EnvironmentFilter) -> bool { + match environment_filter { + EnvironmentFilter::All => true, + EnvironmentFilter::NoEnvironment => self.display.environment_id.is_none(), + EnvironmentFilter::Specific(id) => self.display.environment_id.as_ref() == Some(id), + } + } + + fn matches_harness(&self, harness_filter: &HarnessFilter) -> bool { + match harness_filter { + HarnessFilter::All => true, + HarnessFilter::Specific(harness) => self.display.harness == Some(*harness), + } + } +} + +/// Returns the local conversation ID represented by the given task, if this task and a +/// conversation entry both point at the same underlying local run. +/// +/// We first match using the orchestration agent ID (task ID / run ID under v2), and fall back +/// to the server conversation token for cases where the task only carries conversation identity +/// through `conversation_id`. +pub(super) fn conversation_id_shadowed_by_task( + task: &AmbientAgentTask, + history_model: &BlocklistAIHistoryModel, +) -> Option { + history_model + .conversation_id_for_agent_id(&task.run_id().to_string()) + .or_else(|| { + task.conversation_id().and_then(|conversation_id| { + history_model.find_conversation_id_by_server_token(&ServerConversationToken::new( + conversation_id.to_string(), + )) + }) + }) +} + +pub(super) fn entry_for_task( + task: &AmbientAgentTask, + history_model: &BlocklistAIHistoryModel, + app: &AppContext, +) -> AgentConversationEntry { + let item = ConversationOrTask::Task(task); + let local_conversation_id = conversation_id_shadowed_by_task(task, history_model); + let conversation_metadata = + local_conversation_id.and_then(|id| history_model.get_conversation_metadata(&id)); + let server_conversation_token = task + .conversation_id() + .map(|id| ServerConversationToken::new(id.to_string())) + .or_else(|| { + local_conversation_id.and_then(|conversation_id| { + server_conversation_token_for_conversation(conversation_id, None, history_model) + }) + }); + let status = item.display_status(app); + + AgentConversationEntry { + id: AgentConversationEntryId::AmbientRun(task.task_id), + identity: AgentConversationIdentity { + local_conversation_id, + ambient_agent_task_id: Some(task.task_id), + server_conversation_token, + session_id: item.session_id(), + }, + provenance: AgentConversationProvenance::AmbientRun, + display: AgentConversationDisplayData { + title: item.title(app), + initial_query: Some(task.prompt.clone()), + created_at: item.created_at(), + last_updated: item.last_updated(), + status: status.clone(), + creator: AgentConversationCreator { + name: item.creator_name(app), + uid: item.creator_uid(app), + }, + request_usage: item.request_usage(app), + run_time: item.run_time(), + source: item.source().cloned(), + working_directory: None, + environment_id: item.environment_id().map(ToString::to_string), + harness: item.harness(app), + artifacts: item.artifacts(app), + }, + backing: AgentConversationBackingData { + has_loaded_conversation: local_conversation_id + .is_some_and(|id| history_model.conversation(&id).is_some()), + has_local_persisted_data: conversation_metadata + .is_some_and(|metadata| metadata.has_local_data), + has_cloud_data: conversation_metadata.is_some_and(|metadata| metadata.has_cloud_data) + || task.conversation_id().is_some(), + has_ambient_run: true, + }, + capabilities: AgentConversationCapabilities { + can_open: item.get_open_action(None, app).is_some(), + can_copy_link: item.session_or_conversation_link(app).is_some(), + can_share: task.conversation_id().is_some() + || local_conversation_id + .is_some_and(|id| history_model.can_conversation_be_shared(&id)), + can_delete: false, + can_fork_locally: local_conversation_id.is_some(), + can_cancel: status.is_cancellable(), + }, + } +} + +pub(super) fn entry_for_conversation( + metadata: &ConversationMetadata, + history_model: &BlocklistAIHistoryModel, + app: &AppContext, +) -> AgentConversationEntry { + let conversation_metadata = history_model.get_conversation_metadata(&metadata.nav_data.id); + entry_for_conversation_parts( + metadata.nav_data.clone(), + conversation_metadata, + history_model, + app, + ) +} + +pub(super) fn entry_for_historical_metadata( + metadata: &AIConversationMetadata, + nav_data: ConversationNavigationData, + history_model: &BlocklistAIHistoryModel, + app: &AppContext, +) -> AgentConversationEntry { + entry_for_conversation_parts(nav_data, Some(metadata), history_model, app) +} + +fn entry_for_conversation_parts( + nav_data: ConversationNavigationData, + conversation_metadata: Option<&AIConversationMetadata>, + history_model: &BlocklistAIHistoryModel, + app: &AppContext, +) -> AgentConversationEntry { + let metadata = ConversationMetadata { nav_data }; + let item = ConversationOrTask::Conversation(&metadata); + let conversation_id = metadata.nav_data.id; + let status = item.display_status(app); + let has_loaded_conversation = history_model.conversation(&conversation_id).is_some(); + let has_local_persisted_data = conversation_metadata + .is_some_and(|metadata| metadata.has_local_data) + || has_loaded_conversation; + let has_cloud_data = conversation_metadata.is_some_and(|metadata| metadata.has_cloud_data) + || server_conversation_token_for_conversation( + conversation_id, + Some(&metadata.nav_data), + history_model, + ) + .is_some(); + let provenance = if has_cloud_data { + AgentConversationProvenance::CloudSyncedConversation + } else { + AgentConversationProvenance::LocalInteractive + }; + + AgentConversationEntry { + id: AgentConversationEntryId::Conversation(conversation_id), + identity: AgentConversationIdentity { + local_conversation_id: Some(conversation_id), + ambient_agent_task_id: conversation_metadata + .and_then(|metadata| metadata.server_conversation_metadata.as_ref()) + .and_then(|metadata| metadata.ambient_agent_task_id), + server_conversation_token: server_conversation_token_for_conversation( + conversation_id, + Some(&metadata.nav_data), + history_model, + ), + session_id: None, + }, + provenance, + display: AgentConversationDisplayData { + title: item.title(app), + initial_query: metadata.nav_data.initial_query.clone(), + created_at: item.created_at(), + last_updated: item.last_updated(), + status: status.clone(), + creator: AgentConversationCreator { + name: item.creator_name(app), + uid: item.creator_uid(app), + }, + request_usage: item.request_usage(app), + run_time: item.run_time(), + source: item.source().cloned(), + working_directory: metadata + .nav_data + .latest_working_directory + .clone() + .or_else(|| metadata.nav_data.initial_working_directory.clone()), + environment_id: None, + harness: conversation_metadata + .and_then(|metadata| metadata.server_conversation_metadata.as_ref()) + .map(|metadata| Harness::from(metadata.harness)) + .or_else(|| item.harness(app)), + artifacts: item.artifacts(app), + }, + backing: AgentConversationBackingData { + has_loaded_conversation, + has_local_persisted_data, + has_cloud_data, + has_ambient_run: conversation_metadata + .is_some_and(AIConversationMetadata::is_ambient_agent_conversation), + }, + capabilities: AgentConversationCapabilities { + can_open: item.get_open_action(None, app).is_some(), + can_copy_link: item.session_or_conversation_link(app).is_some(), + can_share: history_model.can_conversation_be_shared(&conversation_id), + can_delete: has_local_persisted_data, + can_fork_locally: has_local_persisted_data, + can_cancel: status.is_cancellable(), + }, + } +} + +fn server_conversation_token_for_conversation( + conversation_id: AIConversationId, + nav_data: Option<&ConversationNavigationData>, + history_model: &BlocklistAIHistoryModel, +) -> Option { + history_model + .conversation(&conversation_id) + .and_then(|conversation| conversation.server_conversation_token()) + .cloned() + .or_else(|| { + history_model + .get_conversation_metadata(&conversation_id) + .and_then(|metadata| metadata.server_conversation_token.clone()) + }) + .or_else(|| nav_data.and_then(|nav_data| nav_data.server_conversation_token.clone())) +} diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index ec16df0ed..45d66e201 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -1,12 +1,17 @@ use chrono::{DateTime, Duration, Utc}; use instant::Instant; use parking_lot::Mutex; -use persistence::model::AgentConversationData; +use persistence::model::{AgentConversationData, ConversationUsageMetadata}; use std::{collections::HashMap, sync::Arc}; use warp_core::features::FeatureFlag; -use warpui::{App, EntityId, ModelHandle}; +use warpui::{App, EntityId, ModelHandle, SingletonEntity}; -use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; +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::{TaskCreatorInfo, TaskStatusMessage}; use crate::ai::ambient_agents::AgentConfigSnapshot; use crate::ai::ambient_agents::AmbientAgentTaskId; @@ -17,8 +22,11 @@ use crate::ai::blocklist::history_model::{ }; 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::test_util::ai_agent_tasks::{create_api_task, create_message}; +use super::entry::{AgentConversationEntryId, AgentConversationProvenance}; use super::{ AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter, ConversationMetadata, ConversationOrTask, @@ -606,6 +614,298 @@ fn all_owner_filters() -> AgentManagementFilters { } } +fn add_entry_projection_test_models(app: &mut App) { + app.add_singleton_model(|_| AuthStateProvider::new_for_test()); + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + app.add_singleton_model(|_| ActiveAgentViewsModel::new()); +} + +fn mock_server_metadata() -> ServerMetadata { + ServerMetadata { + uid: ServerId::default(), + revision: Revision::now(), + metadata_last_updated_ts: Utc::now().into(), + trashed_ts: None, + folder_id: None, + is_welcome_object: false, + creator_uid: None, + last_editor_uid: None, + current_editor_uid: None, + } +} + +fn mock_server_permissions() -> ServerPermissions { + ServerPermissions { + space: Owner::mock_current_user(), + guests: Vec::new(), + anyone_link_sharing: None, + permissions_last_updated_ts: Utc::now().into(), + } +} + +fn create_server_conversation_metadata( + title: &str, + server_token: &str, + ambient_agent_task_id: Option, +) -> ServerAIConversationMetadata { + ServerAIConversationMetadata { + title: title.to_string(), + working_directory: None, + harness: AIAgentHarness::Oz, + usage: ConversationUsageMetadata { + was_summarized: false, + context_window_usage: 0.0, + credits_spent: 0.0, + credits_spent_for_last_block: None, + token_usage: vec![], + tool_usage_metadata: Default::default(), + }, + metadata: mock_server_metadata(), + 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); + 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!(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 { + let _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true); + 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, + parent_conversation_id: None, + is_remote_child: false, + run_id: Some(task_id.clone()), + autoexecute_override: None, + last_event_sequence: None, + }, + ); + + 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, + parent_conversation_id: None, + is_remote_child: false, + run_id: None, + autoexecute_override: None, + last_event_sequence: None, + }, + ); + + 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_eviction_protects_personal_from_team_overflow() { // Add 50 old personal tasks + 600 new team tasks diff --git a/specs/APP-4382/INCREMENT-1-entry-schema-and-builder.md b/specs/APP-4382/INCREMENT-1-entry-schema-and-builder.md new file mode 100644 index 000000000..abffd1482 --- /dev/null +++ b/specs/APP-4382/INCREMENT-1-entry-schema-and-builder.md @@ -0,0 +1,46 @@ +# Increment 1: entry schema and merge builder +## Context +This increment adds the normalized projection API behind existing UI code. `AgentConversationsModel` already stores tasks and conversations separately in `app/src/ai/agent_conversations_model.rs (844-1901)`, and `ConversationOrTask` currently provides display helpers over borrowed raw data in `app/src/ai/agent_conversations_model.rs (399-813)`. The migration should add the new API without removing or migrating those call sites yet. +The builder must understand metadata-only conversations. `AIConversationMetadata` can represent local persisted conversations or cloud-only metadata in `app/src/ai/blocklist/history_model.rs (49-157)`. `merge_cloud_conversation_metadata` creates a local `AIConversationId` for new cloud-only metadata and records the server token in `server_token_to_conversation_id` in `app/src/ai/blocklist/history_model/conversation_loader.rs (334-453)`. +## Proposed changes +Add a module near `agent_conversations_model.rs`, for example `app/src/ai/agent_conversations_model/entry.rs` if splitting the file is practical, or keep the first pass in `agent_conversations_model.rs` to reduce churn. Define: +- `AgentConversationEntry` +- `AgentConversationEntryId` +- `AgentConversationIdentity` +- `AgentConversationDisplayData` +- `AgentConversationProvenance` +- `AgentConversationBackingData` +- `AgentConversationCapabilities` +Add an internal builder that consumes current model state and `AppContext`. The builder should: +1. build a map from server token to local conversation id from history metadata and loaded conversations; +2. build a map from run id to local conversation id using `conversation_id_for_agent_id`; +3. create one entry for each `AmbientAgentTask`, keyed by `AgentConversationEntryId::AmbientRun(task.run_id())`; +4. attach a matching local conversation id by run id first, then server token; +5. attach server token from the task, matching conversation metadata, or loaded conversation; +6. create `Conversation` entries for metadata/local conversations not already attached to an ambient run. +The display-data derivation should reuse existing `ConversationOrTask` behavior where possible during this increment, but the new type should not expose `ConversationOrTask` publicly. It is acceptable for the builder to use temporary private helpers to avoid duplicating all display logic in the first PR. +Capabilities should be conservative in this increment. `can_open` should mean the entry has at least one of: open ambient run id, local conversation id, or server token. `can_share`, `can_delete`, and `can_fork_locally` can mirror current behavior but should remain data booleans, not actions. +Add a new read API: +```rust +pub fn get_entries( + &self, + filters: &AgentManagementFilters, + app: &AppContext, +) -> Vec +``` +Returning a `Vec` is acceptable for the first pass because entries are owned snapshots and list sizes are small. Keep the existing `get_tasks_and_conversations` API for current UI. +## Testing and validation +Add unit tests in `app/src/ai/agent_conversations_model_tests.rs` for builder identity and dedupe behavior: +- task-only entry uses `AmbientRun` id and `AmbientRun` provenance; +- local-only entry uses `Conversation` id and `LocalInteractive` provenance; +- cloud metadata-only entry uses `Conversation` id and `CloudSyncedConversation` provenance with `has_cloud_data = true` and `has_loaded_conversation = false`; +- task plus local conversation matched by run id produces one `AmbientRun` entry with both ids attached; +- task plus local conversation matched by server token produces one `AmbientRun` entry with both ids attached; +- unrelated task and conversation produce two entries; +- child-agent cloud metadata skipped today by metadata merge remains skipped from entries. +Run the focused test module after implementation. This increment should not change rendered UI, so existing conversation list and management view behavior should remain unchanged. +## Risks and mitigations +### Duplicates from incomplete indexing +Build token and run-id indices before constructing entries. Keep the “ambient run owns row identity” rule centralized in one builder function. +### Too much display duplication +It is fine to temporarily delegate to private helpers based on `ConversationOrTask` as long as the public API is normalized. Increment 4 should remove the old helper path. diff --git a/specs/APP-4382/INCREMENT-2-navigation-and-conversation-list.md b/specs/APP-4382/INCREMENT-2-navigation-and-conversation-list.md new file mode 100644 index 000000000..3af9cf7a1 --- /dev/null +++ b/specs/APP-4382/INCREMENT-2-navigation-and-conversation-list.md @@ -0,0 +1,50 @@ +# Increment 2: navigation resolver and conversation list migration +## Context +The conversation list currently caches `ConversationOrTaskId`s in `ConversationListViewModel` and filters out task rows whose `get_session_status()` is not available in `app/src/workspace/view/conversation_list/view_model.rs (31-183)`. Rendering checks `conversation.get_open_action(None, app)` for cursor/clickability in `app/src/workspace/view/conversation_list/item.rs (377-407)`, and activation recomputes the action in `app/src/workspace/view/conversation_list/view.rs (535-548)`. +This can fail when a visible task row shadows a local conversation row but lacks fresh session/conversation fields. Increment 2 replaces that path with normalized entries and a dynamic navigation resolver. +## Proposed changes +Add an entry navigation resolver near `AgentConversationsModel`, for example: +```rust +pub enum AgentConversationNavigationSubject { + Entry(AgentConversationEntryId), + ServerToken(ServerConversationToken), +} + +pub fn resolve_open_action( + subject: AgentConversationNavigationSubject, + restore_layout: Option, + app: &AppContext, +) -> Option +``` +The resolver should re-read current state at click time. For `Entry`, resolve the latest `AgentConversationEntry` or equivalent identity refs from `AgentConversationsModel`. For `ServerToken`, resolve through `BlocklistAIHistoryModel::find_conversation_id_by_server_token` and fall back to transcript loading where the caller supports it. +Default resolver order for listed entries: +1. if an ambient run id is attached and `ActiveAgentViewsModel` has an open ambient session, focus/open that tab; +2. if a local conversation id is attached and currently open, focus it; +3. if the ambient run has an active execution with parseable `session_id`, open ambient shared session; +4. if a local conversation id is attached, restore/navigate to the local conversation with the requested layout; +5. if a server token is attached, open the cloud transcript viewer; +6. otherwise return `None`. +The resolver may initially return existing `WorkspaceAction` variants. If focusing an already-open ambient session still relies on `WorkspaceAction::OpenAmbientAgentSession` plus workspace fallback, keep that behavior but ensure the resolver prefers the open ambient identity before transcript fallback. +Migrate `ConversationListViewModel` to cache `AgentConversationEntryId` and `ConversationEntry { id, highlight_indices }`. It should source entries from `AgentConversationsModel::get_entries` with the same personal/all status defaults currently used by the conversation list. Do not filter out completed cloud entries simply because `get_session_status()` is unavailable; filter based on normalized `capabilities.can_open`. +Migrate `render_item` props to take `AgentConversationEntry` or a lightweight view data struct instead of `ConversationOrTask`. The leading icon can continue to use existing helper behavior if Increment 1 exposes enough display data; otherwise add a normalized icon helper that consumes `AgentConversationEntry`. +Migrate click/Enter to call `resolve_open_action(Entry(entry.id), None, ctx)`. +## Testing and validation +Add unit tests for the resolver: +- task row with matching local conversation but missing task `conversation_id` restores local conversation; +- task row with `session_link` but no parseable `session_id` does not claim session-open capability and falls back to local/server token when available; +- active ambient session is preferred over transcript opening; +- active local conversation is preferred over restoring into a new tab; +- server-token-only navigation can open transcript when no local id is known. +Add conversation-list view-model tests if existing harness support is sufficient: +- cloud metadata-only entries appear when openable by token; +- stale/unavailable session status does not hide a restorable local conversation attached to a task; +- search still matches titles from normalized display data. +Manual validation: +- open a local cloud-mode conversation that also has a task row and verify clicking the list item focuses/restores the local conversation even if the task has no active session; +- open a live cloud task and verify clicking the list item focuses/joins the live session; +- open a completed cloud run and verify clicking opens/restores transcript/local conversation consistently. +## Risks and mitigations +### Workspace action gaps +The existing workspace actions may not express “focus open ambient session by task id” directly. If needed, add a small focused action or keep using `OpenAmbientAgentSession` with the workspace’s `find_tab_with_ambient_agent_conversation` fallback. +### UI state churn +Changing list item IDs can reset hover/selection state. Use `AgentConversationEntryId` as a stable key and preserve row state maps by that key. diff --git a/specs/APP-4382/INCREMENT-3-agent-management-and-details.md b/specs/APP-4382/INCREMENT-3-agent-management-and-details.md new file mode 100644 index 000000000..b72322f42 --- /dev/null +++ b/specs/APP-4382/INCREMENT-3-agent-management-and-details.md @@ -0,0 +1,45 @@ +# Increment 3: Agent Management and details migration +## Context +Agent Management currently calls `get_tasks_and_conversations` and then maps each `ConversationOrTask` into card state, artifacts, action buttons, copy links, and details-panel data in `app/src/ai/agent_management/view.rs (938-1357)`. Cards use `get_open_action(Some(NewTab), app)` for clickability in `app/src/ai/agent_management/view.rs (1710-1755)` and dispatch it from `AgentManagementViewAction::OpenSession` in `app/src/ai/agent_management/view.rs (2324-2379)`. +This surface also owns filtering by status, source, environment, harness, creator, created date, and artifacts. Those filters should move to normalized entries so they are based on the same merged identity/display data that drives navigation. +## Proposed changes +Replace the management list’s `ManagementCardItemId` task/conversation split with `AgentConversationEntryId`. Card construction should use `AgentConversationEntry` fields directly: +- title from `display.title`; +- status from `display.status`; +- source from `display.source`; +- environment from `display.environment_id`; +- harness from `display.harness`; +- artifacts from `display.artifacts`; +- creator from `display.creator`; +- request usage and runtime from `display`. +Move filtering to the entry builder or add an entry-specific filter function. Prefer one filtering path that can be shared by the conversation list and Agent Management, with owner-specific behavior parameterized by `AgentManagementFilters`. +Replace card click and details-panel open actions with `resolve_open_action(Entry(entry.id), Some(RestoreConversationLayout::NewTab), ctx)`. +Replace copy-link derivation with a sibling resolver: +```rust +pub fn resolve_copy_link( + subject: AgentConversationNavigationSubject, + app: &AppContext, +) -> Option +``` +This should share identity resolution with `resolve_open_action` and avoid a separate `link_preference()` policy. Link policy can prefer a live session URL for active executions and otherwise use the server conversation token when available. +Update details panel construction so both task-backed and conversation-backed entries go through one normalized path. Keep raw task-only fields where they are truly run-specific, but they should come from attached run identity rather than from a separate card variant. +## Testing and validation +Add unit tests for entry filtering: +- stale raw `InProgress` task with terminal matching conversation filters into Done/Failed according to `AgentRunDisplayStatus`; +- environment filter includes local/cloud conversation entries as “None” and filters task environments correctly; +- harness filter handles task harness, local Oz conversation, and metadata-only cloud conversation; +- artifact filter works for artifacts sourced from task, loaded conversation, and metadata. +Add tests for copy-link resolver: +- active joinable session returns session link; +- non-active cloud-backed entry returns conversation link; +- local-only unsynced conversation returns no link; +- task with no token but attached local synced conversation returns conversation link. +Manual validation: +- Agent Management card click and details “Open” use the same destination as the conversation list for the same logical run; +- copy-link button and card click no longer disagree for completed cloud runs; +- details panel displays one coherent entry when task and local conversation both exist. +## Risks and mitigations +### Details panel field regressions +Some fields are genuinely task/run-specific. Keep `AgentConversationIdentity.ambient_run_id` available and fetch raw task data only for fields not represented in `display`. +### Filter behavior changes +Normalized filtering may intentionally move stale task rows between status buckets. Preserve current behavior only where it matches the derived display status policy. diff --git a/specs/APP-4382/INCREMENT-4-cleanup-and-hardening.md b/specs/APP-4382/INCREMENT-4-cleanup-and-hardening.md new file mode 100644 index 000000000..555c544ba --- /dev/null +++ b/specs/APP-4382/INCREMENT-4-cleanup-and-hardening.md @@ -0,0 +1,43 @@ +# Increment 4: cleanup and hardening +## Context +After conversation list and Agent Management migrate to `AgentConversationEntry`, `ConversationOrTask` should no longer be the public model consumed by list/navigation/details surfaces. The old `link_preference()` helper in `app/src/ai/agent_conversations_model.rs (624-660)` should stop being a source of truth for open and copy-link behavior. +This increment removes transitional APIs, hardens event invalidation, and adds regression coverage around the originally inconsistent cases. +## Proposed changes +Remove or narrow public access to: +- `ConversationOrTask` +- `ConversationOrTaskId` usage in list/navigation surfaces +- `ConversationOrTask::get_open_action` +- `ConversationOrTask::session_or_conversation_link` +- `ConversationOrTask::link_preference` +If some internal helpers remain useful, make them private to the entry builder and rename them so they cannot be mistaken for the public entry API. +Audit all `get_open_action`, `session_or_conversation_link`, and `get_session_status` call sites. Remaining navigation should go through `resolve_open_action`, and remaining copy-link behavior should go through `resolve_copy_link`. +Review event handling in `AgentConversationsModel`: +- task updates should invalidate/re-emit entry updates; +- conversation status updates should refresh derived status and capabilities; +- server token assignment should update merged identity; +- cloud metadata merge should update entries; +- active view open/close/focus should update active/open capabilities without requiring stale nav data. +If existing `AgentConversationsModelEvent` variants are too ambiguous, add a normalized event such as: +```rust +pub enum AgentConversationsModelEvent { + EntriesChanged, + EntryDisplayDataChanged { id: AgentConversationEntryId }, + EntryArtifactsChanged { id: AgentConversationEntryId }, +} +``` +Only do this if it reduces caller complexity; avoid event churn if all migrated consumers can simply rebuild from `get_entries`. +Update comments and docs in the model to describe the new ownership boundary: raw task/conversation caches are source data, while `AgentConversationEntry` is the UI/navigation projection. +## Testing and validation +Add regression tests named around the fixed behaviors: +- task shadows local conversation but missing task token still opens via local conversation; +- stale active execution no longer forces session open when no parseable session id exists; +- completed cloud run with token remains openable even without session link; +- copy-link and open resolver use consistent source priority; +- metadata-only cloud conversation can be opened by server token without a loaded `AIConversation`; +- server token assignment after an entry is first built updates identity/copy-link behavior. +Run all focused tests touched during increments 1-3 plus a targeted compile/check. Before review, run repository-required formatting and linting commands. +## Risks and mitigations +### Hidden call sites +Use grep for removed method names and old ID types. Keep this increment small and mechanical where possible. +### Event overengineering +Prefer simple rebuild-on-event behavior until performance requires finer invalidation. The entry list is small enough that correctness is more important than micro-optimizing derived state. diff --git a/specs/APP-4382/TECH.md b/specs/APP-4382/TECH.md new file mode 100644 index 000000000..8a4a65455 --- /dev/null +++ b/specs/APP-4382/TECH.md @@ -0,0 +1,93 @@ +# Agent conversation entry normalization migration +## Context +Linear: https://linear.app/warpdotdev/issue/APP-4382/normalize-agent-conversation-entries-for-list-and-navigation-surfaces +`AgentConversationsModel` currently exposes `ConversationOrTask`, a wrapper over either `AmbientAgentTask` or `ConversationMetadata`, to list, details, filtering, and navigation surfaces in `app/src/ai/agent_conversations_model.rs (399-813)`. The wrapper centralizes several display helpers, but still encodes important behavior as “task vs conversation” decisions. The most problematic example is `link_preference()` and `get_open_action()` in `app/src/ai/agent_conversations_model.rs (624-813)`: task rows choose between session, conversation transcript, or no action from cached task fields, while local conversation rows restore/navigate from `ConversationNavigationData`. +The model also intentionally hides local conversation rows when a task appears to represent the same logical run in `app/src/ai/agent_conversations_model.rs (1382-1497)`. That shadowing is useful for preserving cloud-run affordances, but it means the visible task row can discard better local-conversation navigation data. A task row with a stale or incomplete `session_id`, `session_link`, `conversation_id`, or `is_sandbox_running` can therefore be unopenable even when the hidden conversation representation is restorable. +The underlying sources are already separate. `BlocklistAIHistoryModel` owns loaded `AIConversation` contents in `conversations_by_id` and lightweight historical/cloud metadata in `all_conversations_metadata` in `app/src/ai/blocklist/history_model.rs (49-214)`. Cloud metadata can exist without a loaded conversation via `AIConversationMetadata` and `merge_cloud_conversation_metadata` in `app/src/ai/blocklist/history_model/conversation_loader.rs (334-453)`, while `load_conversation_data` loads content only on demand in `app/src/ai/blocklist/history_model/conversation_loader.rs (145-236)`. `ActiveAgentViewsModel` separately tracks which local conversations and ambient sessions are currently open in `app/src/ai/active_agent_views_model.rs (45-531)`. +This migration introduces a projection-layer `AgentConversationEntry` for list/navigation/detail surfaces. It should not replace `AIConversation`, `BlocklistAIHistoryModel`, or transcript content APIs. Its job is to merge provenance and lightweight display data into one logical row so UI surfaces no longer choose between task and conversation representations. +## Proposed changes +### Target model boundary +Add a normalized entry API to `AgentConversationsModel` while keeping raw task and conversation storage as implementation details: +```rust +pub struct AgentConversationEntry { + pub id: AgentConversationEntryId, + pub identity: AgentConversationIdentity, + pub display: AgentConversationDisplayData, + pub provenance: AgentConversationProvenance, + pub backing: AgentConversationBackingData, + pub capabilities: AgentConversationCapabilities, +} +``` +`AgentConversationEntry` is a projection. It carries owned IDs and display snapshots, not borrowed references to `AmbientAgentTask` or `AIConversation`. Callers that need transcript contents still go through `BlocklistAIHistoryModel` and loader APIs. +Use local/client identities for listed entries: +```rust +pub enum AgentConversationEntryId { + AmbientRun(AmbientAgentTaskId), + Conversation(AIConversationId), +} +``` +Do not use `ServerConversationToken` as a normal list identity. Server tokens are global fetch/navigation handles; cloud-only metadata rows already receive a local `AIConversationId` when metadata is merged. +The entry identity should preserve every known reference: +```rust +pub struct AgentConversationIdentity { + pub local_conversation_id: Option, + pub ambient_run_id: Option, + pub server_conversation_token: Option, + pub parent_conversation_id: Option, + pub parent_run_id: Option, +} +``` +Keep provenance semantic and move loadability into backing: +```rust +pub enum AgentConversationProvenance { + LocalInteractive, + AmbientRun, + CloudSyncedConversation, +} + +pub struct AgentConversationBackingData { + pub has_loaded_conversation: bool, + pub has_local_persisted_data: bool, + pub has_cloud_data: bool, + pub has_ambient_run: bool, +} +``` +`AgentConversationDisplayData` should centralize list/detail fields currently read from `ConversationOrTask`: title, initial query, created/updated timestamps, `AgentRunDisplayStatus`, creator, working directory, source, environment, harness, request usage, run time, and artifacts. The display status should continue to use the derived `AgentRunDisplayStatus` algorithm instead of raw `AmbientAgentTaskState` or raw `ConversationStatus`. +`AgentConversationCapabilities` should expose eligibility booleans for UI affordances such as open, copy link, share, delete, fork locally, continue locally, and cancel. It must not store the final `WorkspaceAction`. Open actions should be resolved dynamically from entry identity at click time. +### Increment sequence +Increment 1, `INCREMENT-1-entry-schema-and-builder.md`, adds the entry schema, merge builder, and tests without migrating UI. It introduces `AgentConversationsModel::get_entries(...)` or equivalent behind existing code paths. +Increment 2, `INCREMENT-2-navigation-and-conversation-list.md`, adds a dynamic navigation resolver and migrates the conversation list to normalized entries. This is the first user-visible behavior fix for task rows that shadow restorable conversations. +Increment 3, `INCREMENT-3-agent-management-and-details.md`, migrates Agent Management cards, filters, action buttons, and details panel data to normalized entries. +Increment 4, `INCREMENT-4-cleanup-and-hardening.md`, removes or narrows `ConversationOrTask`, deletes `link_preference()` as a source of truth, and adds regression coverage for previously inconsistent entrypoints. +## End-to-end flow +1. `AgentConversationsModel` keeps its existing task and conversation caches. +2. The entry builder indexes tasks by stable run id, conversations by local id, and metadata by server token. +3. For each ambient run, the builder creates one `AmbientRun` entry and attaches matching local conversation/server-token data when available. +4. For each local/cloud metadata conversation not already attached to a run, the builder creates one `Conversation` entry. +5. List/detail surfaces render from `AgentConversationEntry`. +6. On click, the navigation resolver re-reads current task, history, active-view, and workspace state before producing a `WorkspaceAction`. +## Testing and validation +The migration should be implemented as stacked PRs. Each increment spec lists its focused test coverage. Across the full migration, add tests for: +- local conversation only +- cloud metadata only with `has_local_data = false` +- task only with active joinable session +- task only with terminal state and server token +- task plus local conversation by run id +- task plus local conversation by server token +- task with stale in-progress state but terminal local conversation status +- task with `session_link` but no parseable `session_id` +- existing open local conversation focus +- existing open ambient session focus +- copied link and open action using the same resolved identity data +Before opening PRs, run the focused `agent_conversations_model` tests for each increment and a targeted app check. Follow repo PR workflow before review. +## Risks and mitigations +### Merge identity regressions +Wrong merge rules can duplicate rows or hide rows. Mitigate by writing the merge tests before migrating UI and keeping merge precedence explicit: ambient run owns the UI identity when present, but local conversation/server token refs remain attached. +### Recreating stale navigation in a new type +If entries cache `WorkspaceAction`, they will reproduce the current bug. Store identities and capabilities only; resolve final actions at click time. +### Overreaching into transcript content +`AgentConversationEntry` should not expose exchanges, block data, or content mutation methods. Keep transcript operations in `BlocklistAIHistoryModel` and loader paths. +### Incomplete event invalidation +Entries depend on task updates, history events, metadata updates, and active view changes. Initially prefer rebuilding entries on any relevant `AgentConversationsModelEvent` instead of maintaining fine-grained derived caches. +## Parallelization +Increment 1 should be implemented first and mostly sequentially because later increments depend on the entry schema. After that, Increment 2 and Increment 3 can be implemented on separate stacked branches if the entry API is stable. Increment 4 should wait until both UI migrations land.