use std::collections::hash_map::Entry; use std::collections::{HashMap, HashSet}; #[cfg(feature = "local_fs")] use std::sync::{Arc, Mutex}; use ai::skills::SkillPathOrigin; use anyhow::anyhow; use chrono::{DateTime, Local, NaiveDateTime}; #[cfg(feature = "local_fs")] use diesel::SqliteConnection; use galaxy_core::features::FeatureFlag; use itertools::Itertools as _; use serde::{Deserialize, Serialize}; use uuid::Uuid; use warp_cli::agent::Harness; use warp_multi_agent_api::client_action::{Action, StartNewConversation}; use warp_multi_agent_api::response_event::stream_finished::{ ConversationUsageMetadata, TokenUsage, }; use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity}; use super::controller::response_stream::ResponseStreamId; use super::persistence::{PersistedAIInput, PersistedAIInputType}; use super::RequestInput; use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::{ AIConversation, AIConversationId, ConversationStatus, ServerAIConversationMetadata, UpdateConversationError, }; use crate::ai::agent::task::helper::{MessageExt, ToolCallExt}; use crate::ai::agent::task::TaskId; use crate::ai::agent::{ AIAgentActionId, AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, CancellationReason, FinishedAIAgentOutput, MessageId, RenderableAIError, RequestCost, Suggestions, }; use crate::ai::artifacts::Artifact; use crate::ai::document::ai_document_model::AIDocumentModel; use crate::input_suggestions::HistoryOrder; use crate::persistence::model::{AgentConversation, AgentConversationData}; use crate::persistence::ModelEvent; #[cfg(feature = "local_fs")] use crate::persistence::{database_file_path_for_scope, establish_ro_connection, PersistenceScope}; use crate::server::server_api::ServerApiProvider; use crate::terminal::model::block::BlockId; use crate::terminal::view::blocklist_filter; use crate::ui_components::icons::Icon; use crate::GlobalResourceHandlesProvider; mod conversation_loader; pub use conversation_loader::{ convert_persisted_conversation_to_ai_conversation_with_metadata, load_conversation_from_server, CLIAgentConversation, CloudConversationData, }; /// Mirrors [`crate::persistence::agent::MAX_PERSISTED_CONVERSATION_COUNT`]. /// Moot at steady state because the disk-side prune already keeps the /// persisted set within this window; kept as defense-in-depth if rows ever /// arrive from another source (cross-machine import, prune bypass). pub(super) const MAX_HISTORICAL_CONVERSATIONS: usize = 200; /// Metadata for conversations /// When created from local DB, has_local_data=true and server_metadata=None. /// When fetched from server, has_local_data=false and server_metadata=Some(...). #[derive(Debug, Clone)] pub struct AIConversationMetadata { pub id: AIConversationId, pub title: String, pub initial_query: String, pub last_modified_at: NaiveDateTime, pub initial_working_directory: Option, pub credits_spent: Option, pub server_conversation_token: Option, /// Whether the full conversation data exists in the local database. /// false = must be fetched from server /// true = exists in local DB and can be fetched from there, even if it also exists in server pub has_local_data: bool, /// Whether this conversation exists in the cloud (has been synced). /// This is used to determine if the conversation can be shared. pub has_cloud_data: bool, /// Artifacts (plans, PRs) created during this conversation. pub artifacts: Vec, /// Full server metadata for cloud conversations, including permissions. /// Used by the sharing dialog to display permissions when the full conversation isn't loaded. pub server_conversation_metadata: Option, } impl From<&AIConversation> for AIConversationMetadata { fn from(conversation: &AIConversation) -> Self { let title = conversation.title().unwrap_or_default().to_string(); let initial_query: String = conversation.initial_query().unwrap_or_default(); let server_conversation_token = conversation.server_conversation_token().cloned(); let has_cloud_data = conversation.server_metadata().is_some() || server_conversation_token.is_some(); let last_modified_at = conversation .latest_exchange() .map(|exchange| exchange.start_time.naive_utc()) .unwrap_or_else(|| chrono::Utc::now().naive_utc()); Self { id: conversation.id(), title, initial_query, last_modified_at, initial_working_directory: conversation.initial_working_directory(), credits_spent: Some(conversation.credits_spent()), server_conversation_token, has_local_data: true, has_cloud_data, artifacts: conversation.artifacts().to_vec(), server_conversation_metadata: conversation.server_metadata().cloned(), } } } impl AIConversationMetadata { /// Create metadata from server-fetched GraphQL data. /// This is used when loading conversations from the cloud. pub fn from_server_metadata( conversation_id: AIConversationId, server_conversation_metadata: ServerAIConversationMetadata, ) -> Self { let title = server_conversation_metadata.title.clone(); let last_modified_at = server_conversation_metadata .metadata .metadata_last_updated_ts .utc() .naive_utc(); let credits_spent = Some( server_conversation_metadata.usage.credits_spent + server_conversation_metadata.usage.platform_credits_spent, ); let server_conversation_token = Some( server_conversation_metadata .server_conversation_token .clone(), ); let initial_working_directory = server_conversation_metadata.working_directory.clone(); let artifacts = server_conversation_metadata.artifacts.clone(); Self { id: conversation_id, title, // Server doesn't currently provide initial query in metadata // This is used to allow searching by initial query in command palette. initial_query: String::new(), last_modified_at, initial_working_directory, credits_spent, server_conversation_token, has_local_data: false, has_cloud_data: true, // Server metadata implies cloud data exists artifacts, server_conversation_metadata: Some(server_conversation_metadata), } } /// Whether this conversation is owned by an ambient agent run rather than /// being a direct user conversation. pub fn is_ambient_agent_conversation(&self) -> bool { self.server_conversation_metadata .as_ref() .is_some_and(|m| m.ambient_agent_task_id.is_some()) } } #[derive(Debug, thiserror::Error)] pub enum UpdateHistoryError { #[error("Failed to update conversation: {0:?}")] Conversation(#[from] UpdateConversationError), #[error("Failed to find conversation with ID {0:?}")] ConversationNotFound(AIConversationId), } #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub(crate) enum BeginConversationRenameError { #[error("conversation not found")] ConversationNotFound, #[error("conversation has no server token")] MissingServerConversationToken, #[error("conversation is not ready to rename")] ConversationNotReady, #[error("conversation rename already in progress")] RenameInProgress, } #[derive(Debug, Clone)] struct InFlightConversationRename { attempted_title: String, previous_root_task_description: String, previous_server_metadata_title: Option, previous_cached_metadata_title: Option, } /// Responsible for managing the history of user and AI exchanges. #[derive(Default)] pub struct BlocklistAIHistoryModel { /// Live conversations for each terminal surface. /// /// "Live" conversations are still visible/selectable for that terminal surface. Clearing /// a terminal surface moves those IDs out of this map, but closing a GUI view does not /// immediately remove its entry so the conversations can be restored. live_conversation_ids_for_terminal_surface: HashMap>, /// Conversations that were once live for a terminal surface, but were cleared from the blocklist. /// /// This is used to preserve queries for up-arrow history after clearing the blocklist. cleared_conversation_ids_for_terminal_surface: HashMap>, /// A [`HashMap`] mapping a [`AIConversationId`] to the [`AIConversation`] itself. /// Conversations may or may not be live in any open session. They will exist in this map if they /// have ever been loaded into memory. conversations_by_id: HashMap, /// The active conversation ID for a given terminal surface. /// /// The active conversation is the terminal surface's current or most recent progress /// target, such as a response stream or follow-up action flow. Selection /// lives in the surface's context/controller models. active_conversation_for_terminal_surface: HashMap, /// The time at which each terminal surface was created. Note that this /// has no bearing on when any [`AIConversation`]s take place for that terminal surface. terminal_surface_created_at: HashMap>, /// A set of terminal surfaces that are shared ambient agent sessions. ambient_agent_terminal_surface_ids: HashSet, /// A set of terminal surfaces that are read-only conversation transcript viewers. /// This is view/UI state (not conversation state) and is used to filter transcript viewer /// conversations out of local history and navigation. conversation_transcript_viewer_terminal_surface_ids: HashSet, /// AI queries that were read from the SQLite DB. These exchanges do not contain as much /// information as the other exchanges we store because they are only used for display in /// history. persisted_queries: Vec, /// Metadata for both local and ambient agent conversations. /// Does not include the actual content of the conversations. all_conversations_metadata: HashMap, /// Reverse index from server-side agent identifier to local conversation ID. /// /// Keyed by `run_id` for current orchestration. Older conversation data may /// still contain `server_conversation_token`-backed identifiers, but new /// runtime lookups use run IDs. agent_id_to_conversation_id: HashMap, /// Reverse index from [`ServerConversationToken`] to local [`AIConversationId`]. /// /// Maintained alongside every mutation of `conversations_by_id` and /// `all_conversations_metadata` that involves a token. Used to make /// `find_conversation_id_by_server_token` O(1); it is called once per /// ambient-agent task on every conversation-list refresh. server_token_to_conversation_id: HashMap, /// Index from parent conversation ID to child conversation IDs. /// Populated at startup from the local DB and kept in sync at runtime /// via `set_parent_for_conversation` and `restore_conversations`. children_by_parent: HashMap>, /// In-flight optimistic conversation rename state keyed by conversation. in_flight_conversation_renames: HashMap, #[cfg(feature = "local_fs")] db_connection: Option>>, } impl BlocklistAIHistoryModel { pub(crate) fn new( persisted_queries: Vec, multi_agent_conversations: &[AgentConversation], ) -> Self { #[cfg(feature = "local_fs")] let db_connection = database_file_path_for_scope(&PersistenceScope::App) .to_str() .and_then(|db_url| { establish_ro_connection(db_url) .ok() .map(|conn| Arc::new(Mutex::new(conn))) }); let mut model = Self { persisted_queries, #[cfg(feature = "local_fs")] db_connection, ..Self::default() }; // Initialize historical conversations from local DB model.initialize_historical_conversations(multi_agent_conversations); model } #[cfg(test)] pub(crate) fn new_for_test() -> Self { Self::default() } /// Returns a flattened and ordered (oldest first) list of live conversations for a terminal surface. /// This works for terminal surfaces that have been closed. pub fn all_live_conversations_for_terminal_surface( &self, terminal_surface_id: EntityId, ) -> impl Iterator { self.live_conversation_ids_for_terminal_surface .get(&terminal_surface_id) .into_iter() .flat_map(|conversation_ids| { conversation_ids .iter() .filter_map(|conversation_id| self.conversation(conversation_id)) }) } /// Returns a flattened and ordered (oldest first) list of exchanges from a terminal surface's live conversations. /// This works for terminal surfaces that have been closed. pub fn all_live_root_task_exchanges_for_terminal_surface( &self, terminal_surface_id: EntityId, ) -> impl Iterator { self.live_conversation_ids_for_terminal_surface .get(&terminal_surface_id) .into_iter() .flat_map(|conversation_ids| { conversation_ids.iter().flat_map(|conversation_id| { self.conversations_by_id .get(conversation_id) .map(|conversation| conversation.root_task_exchanges()) }) }) .flatten() } /// Returns a flattened and ordered (oldest first) list of exchanges from conversations /// that were cleared for a terminal surface, but are no longer live/visible. pub fn all_cleared_root_task_exchanges_for_terminal_surface( &self, terminal_surface_id: EntityId, ) -> impl Iterator { self.cleared_conversation_ids_for_terminal_surface .get(&terminal_surface_id) .into_iter() .flat_map(|conversation_ids| { conversation_ids.iter().flat_map(|conversation_id| { self.conversations_by_id .get(conversation_id) .map(|conversation| conversation.root_task_exchanges()) }) }) .flatten() } /// Returns a list of all conversations that have been cleared across all terminal surfaces. pub fn all_cleared_conversations(&self) -> Vec<(EntityId, &AIConversation)> { self.cleared_conversation_ids_for_terminal_surface .iter() .flat_map(|(terminal_surface_id, conversation_ids)| { conversation_ids.iter().filter_map(|conversation_id| { self.conversations_by_id .get(conversation_id) .map(|conversation| (*terminal_surface_id, conversation)) }) }) .collect::>() } /// Returns all live conversations paired with their terminal surface IDs. /// This includes terminal surfaces that have been closed. pub fn all_live_conversations(&self) -> Vec<(EntityId, &AIConversation)> { self.live_conversation_ids_for_terminal_surface .iter() .flat_map(|(terminal_surface_id, conversation_ids)| { conversation_ids.iter().filter_map(|conversation_id| { self.conversations_by_id .get(conversation_id) .map(|conversation| (*terminal_surface_id, conversation)) }) }) .collect::>() } /// Returns a conversation by ID by reading from memory. The conversation may not be available if: /// * The ID is invalid /// * The conversation has never been read into memory from db. Use load_conversation_from_db to handle reading from db. pub fn conversation(&self, conversation_id: &AIConversationId) -> Option<&AIConversation> { self.conversations_by_id.get(conversation_id) } pub fn conversation_mut( &mut self, conversation_id: &AIConversationId, ) -> Option<&mut AIConversation> { self.conversations_by_id.get_mut(conversation_id) } /// Returns all child conversations whose `parent_conversation_id` matches /// the given parent ID, using the `children_by_parent` index. pub fn child_conversations_of(&self, parent_id: AIConversationId) -> Vec<&AIConversation> { self.child_conversation_ids_of(&parent_id) .iter() .filter_map(|id| self.conversations_by_id.get(id)) .collect() } fn resolved_parent_conversation_id_from_refs( &self, parent_conversation_id: Option, parent_agent_id: Option<&str>, ) -> Option { parent_conversation_id.or_else(|| { parent_agent_id.and_then(|agent_id| self.conversation_id_for_agent_id(agent_id)) }) } fn resolved_parent_conversation_id_from_persisted_data( &self, conversation_data: &AgentConversationData, ) -> Option { let parent_conversation_id = conversation_data .parent_conversation_id .as_deref() .and_then(|id| AIConversationId::try_from(id.to_owned()).ok()); self.resolved_parent_conversation_id_from_refs( parent_conversation_id, conversation_data.parent_agent_id.as_deref(), ) } pub fn resolved_parent_conversation_id_for_conversation( &self, conversation: &AIConversation, ) -> Option { self.resolved_parent_conversation_id_from_refs( conversation.parent_conversation_id(), conversation.parent_agent_id(), ) } fn index_child_conversation( &mut self, child_id: AIConversationId, parent_id: AIConversationId, ) { let children = self.children_by_parent.entry(parent_id).or_default(); if !children.contains(&child_id) { children.push(child_id); } } /// Creates a new child agent conversation. pub fn start_new_child_conversation( &mut self, terminal_surface_id: EntityId, name: String, parent_conversation_id: AIConversationId, orchestration_harness: Option, ctx: &mut ModelContext, ) -> AIConversationId { let parent_agent_id = self .conversation(&parent_conversation_id) .and_then(|c| c.orchestration_agent_id()); if parent_agent_id.is_none() { log::warn!( "No agent identifier for parent conversation {parent_conversation_id:?}; \ child agent will not be linked to parent on the server." ); } let auto_execute = true; // Child auto-executes by default. let conversation_id = self.start_new_conversation(terminal_surface_id, auto_execute, false, false, ctx); { let conversation = self .conversation_mut(&conversation_id) .expect("Child conversation exists — was just created."); if let Some(id) = parent_agent_id { conversation.set_parent_agent_id(id); } conversation.set_agent_name(name); if let Some(harness) = orchestration_harness { conversation.set_orchestration_harness(harness); } } self.set_parent_for_conversation(conversation_id, parent_conversation_id); self.persist_conversation_state(conversation_id, ctx); conversation_id } /// Sets the parent conversation ID on a child conversation and updates /// the `children_by_parent` index. All parent-child relationships should /// be established through this method so the index stays in sync. pub fn set_parent_for_conversation( &mut self, child_id: AIConversationId, parent_id: AIConversationId, ) { if let Some(conversation) = self.conversations_by_id.get_mut(&child_id) { conversation.set_parent_conversation_id(parent_id); } self.index_child_conversation(child_id, parent_id); } /// Returns the child conversation IDs for a parent from the startup index. /// Unlike `child_conversations_of`, this works before children are loaded /// into `conversations_by_id`. pub fn child_conversation_ids_of(&self, parent_id: &AIConversationId) -> &[AIConversationId] { self.children_by_parent .get(parent_id) .map(|v| v.as_slice()) .unwrap_or_default() } fn persist_conversation_state( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) { let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else { return; }; conversation.write_updated_conversation_state(ctx); } fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) { let Some(conversation) = self.conversations_by_id.get(&conversation_id) else { return; }; let Some(metadata) = self.all_conversations_metadata.get_mut(&conversation_id) else { return; }; metadata.server_conversation_token = conversation.server_conversation_token().cloned(); if metadata.server_conversation_token.is_some() { metadata.has_cloud_data = true; } if let Some(server_metadata) = conversation.server_metadata() { metadata.server_conversation_metadata = Some(server_metadata.clone()); metadata.has_cloud_data = true; } } /// Starts an optimistic local rename and records rollback state. pub(crate) fn begin_conversation_rename( &mut self, conversation_id: AIConversationId, title: String, ctx: &mut ModelContext, ) -> Result { if self .in_flight_conversation_renames .contains_key(&conversation_id) { return Err(BeginConversationRenameError::RenameInProgress); } let conversation = self .conversations_by_id .get(&conversation_id) .ok_or(BeginConversationRenameError::ConversationNotFound)?; let server_conversation_token = conversation .server_conversation_token() .ok_or(BeginConversationRenameError::MissingServerConversationToken)? .as_str() .to_owned(); let root_task = conversation .get_root_task() .ok_or(BeginConversationRenameError::ConversationNotReady)?; if root_task.source().is_none() { return Err(BeginConversationRenameError::ConversationNotReady); } let previous_root_task_description = root_task.description().to_owned(); let previous_server_metadata_title = conversation .server_metadata() .map(|metadata| metadata.title.clone()); let previous_cached_metadata_title = self .all_conversations_metadata .get(&conversation_id) .map(|metadata| metadata.title.clone()); self.in_flight_conversation_renames.insert( conversation_id, InFlightConversationRename { attempted_title: title.clone(), previous_root_task_description, previous_server_metadata_title, previous_cached_metadata_title, }, ); self.apply_conversation_title(conversation_id, title, ctx); Ok(server_conversation_token) } /// Completes an in-flight rename and applies any server-normalized title. pub(crate) fn complete_conversation_rename( &mut self, conversation_id: AIConversationId, title: String, ctx: &mut ModelContext, ) { let Some(rename) = self.in_flight_conversation_renames.remove(&conversation_id) else { log::warn!( "complete_conversation_rename called for conversation {conversation_id:?} with no in-flight rename" ); return; }; if rename.attempted_title != title { self.apply_conversation_title(conversation_id, title, ctx); } } /// Reverts an in-flight rename to the captured previous title snapshot. pub(crate) fn fail_conversation_rename( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) { let Some(rename) = self.in_flight_conversation_renames.remove(&conversation_id) else { log::warn!( "fail_conversation_rename called for conversation {conversation_id:?} with no in-flight rename" ); return; }; let terminal_surface_id = self.terminal_surface_id_for_conversation(&conversation_id); let mut updated = false; if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { conversation.restore_conversation_title( rename.previous_root_task_description, rename.previous_server_metadata_title, ctx, ); updated = true; } else { log::warn!( "fail_conversation_rename called for missing conversation {conversation_id:?}" ); } let title = if let Some(previous_title) = rename.previous_cached_metadata_title { if let Some(metadata) = self.all_conversations_metadata.get_mut(&conversation_id) { metadata.title = previous_title.clone(); if let Some(server_metadata) = metadata.server_conversation_metadata.as_mut() { server_metadata.title = previous_title.clone(); } updated = true; } previous_title } else { self.conversations_by_id .get(&conversation_id) .and_then(AIConversation::title) .unwrap_or_default() }; if !updated { return; } ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationTitle { terminal_surface_id, conversation_id, title, }); } /// Applies a conversation title locally and notifies title observers. pub(crate) fn apply_conversation_title( &mut self, conversation_id: AIConversationId, title: String, ctx: &mut ModelContext, ) { let terminal_surface_id = self.terminal_surface_id_for_conversation(&conversation_id); let mut updated = false; if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { conversation.update_conversation_title(title.clone(), ctx); updated = true; } else { log::warn!( "apply_conversation_title called for missing conversation {conversation_id:?}" ); } if let Some(metadata) = self.all_conversations_metadata.get_mut(&conversation_id) { metadata.title = title.clone(); if let Some(server_metadata) = metadata.server_conversation_metadata.as_mut() { server_metadata.title = title.clone(); } updated = true; } if !updated { return; } ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationTitle { terminal_surface_id, conversation_id, title, }); } pub fn mark_conversation_as_remote_child( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) { { let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else { return; }; conversation.mark_as_remote_child(); } self.persist_conversation_state(conversation_id, ctx); } /// Updates the persisted `last_event_sequence` for a conversation and /// writes the updated conversation state to SQLite. Used by the /// orchestration event poller after draining an event batch to keep the /// cursor durable across restarts. pub fn update_event_sequence( &mut self, conversation_id: AIConversationId, sequence: i64, ctx: &mut ModelContext, ) { { let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else { return; }; conversation.set_last_event_sequence(sequence); } self.persist_conversation_state(conversation_id, ctx); } /// Updates the persisted `pinned` state for a conversation and writes /// the change to SQLite. Used by the orchestration pin singleton to /// keep the per-conversation source of truth in sync with toggles. pub fn set_conversation_pinned( &mut self, conversation_id: AIConversationId, pinned: bool, ctx: &mut ModelContext, ) { let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else { log::warn!( "set_conversation_pinned called for conversation {conversation_id:?} that is \ not loaded; pin state change to {pinned} will not be persisted." ); return; }; if conversation.is_pinned() == pinned { return; } conversation.set_pinned(pinned); conversation.write_updated_conversation_state(ctx); } /// Sets a live conversation's server token, updates the reverse index, and /// synchronizes any cached metadata entry for the same conversation. /// /// Returns whether the token changed. pub fn set_server_conversation_token_for_conversation( &mut self, conversation_id: AIConversationId, token: String, ) -> bool { let new_token = ServerConversationToken::new(token.clone()); { let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else { return false; }; let old_token = conversation.server_conversation_token().cloned(); if old_token.as_ref() == Some(&new_token) { return false; } // Drop the old entry only if it still points at the given // conversation_id, so we don't wrongly remove an entry that's // been remapped. if let Some(old_token) = old_token { if let Entry::Occupied(entry) = self.server_token_to_conversation_id.entry(old_token) { if *entry.get() == conversation_id { entry.remove(); } } } conversation.set_server_conversation_token(token); } self.server_token_to_conversation_id .insert(new_token, conversation_id); self.update_cached_metadata_for_conversation(conversation_id); true } /// Sets a live conversation's server token, updates the reverse index, /// synchronizes cached metadata, persists the rebound token to SQLite, /// and emits refresh events for live consumers. pub fn set_server_conversation_token_for_conversation_and_persist( &mut self, conversation_id: AIConversationId, token: String, ctx: &mut ModelContext, ) { if !self.set_server_conversation_token_for_conversation(conversation_id, token) { return; } self.persist_conversation_state(conversation_id, ctx); let terminal_surface_id = self.terminal_surface_id_for_conversation(&conversation_id); ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationMetadata { terminal_surface_id, conversation_id, }); if let Some(terminal_surface_id) = terminal_surface_id { ctx.emit(BlocklistAIHistoryEvent::ConversationServerTokenAssigned { conversation_id, terminal_surface_id, }); } } /// Sets server metadata for a conversation and emits the ConversationMetadataUpdated event. /// This helper ensures we don't forget to emit the event when updating metadata. /// Updates in-memory conversations, or historical metadata if the conversation isn't loaded. pub fn set_server_metadata_for_conversation( &mut self, conversation_id: AIConversationId, metadata: ServerAIConversationMetadata, ctx: &mut ModelContext, ) { let terminal_surface_id; // Update in-memory conversation if it exists if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { conversation.set_server_metadata(metadata); terminal_surface_id = self.terminal_surface_id_for_conversation(&conversation_id); } else if let Some(conversation_metadata) = self.all_conversations_metadata.get_mut(&conversation_id) { // Conversation not in memory - update historical metadata instead // This is needed because we might update permissions from share dialog in // conversation list view when we only have metadata. conversation_metadata.server_conversation_metadata = Some(metadata); terminal_surface_id = None; } else { // Conversation not found anywhere return; } // Emit event so sharing dialog and other listeners can refresh. ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationMetadata { terminal_surface_id, conversation_id, }); } /// Returns the ID of the conversation that processed or is processing the response stream. /// /// A given response stream may only correspond to a single conversation at any given time, /// though the conversation to which it corresponds may change if a new conversation is started /// in the middle of the response, as is the case when the new conversation suggestion is /// accepted. pub fn conversation_for_response_stream( &self, response_stream_id: &ResponseStreamId, ) -> Option { self.conversations_by_id .iter() .find_map(|(conversation_id, conversation)| { if conversation.is_processing_response_stream(response_stream_id) { Some(*conversation_id) } else { None } }) } pub fn conversation_status( &self, conversation_id: &AIConversationId, ) -> Option<&ConversationStatus> { self.conversation(conversation_id) .map(|conversation| conversation.status()) } /// Returns the terminal surface ID for the given conversation, if any. pub fn terminal_surface_id_for_conversation( &self, conversation_id: &AIConversationId, ) -> Option { self.live_conversation_ids_for_terminal_surface .iter() .find(|(_, conversation_ids)| conversation_ids.contains(conversation_id)) .map(|(terminal_surface_id, _)| *terminal_surface_id) } /// Returns the conversation ID from the terminal surface's history corresponding to the action, if any. pub fn conversation_id_for_action( &self, action_id: &AIAgentActionId, terminal_surface_id: EntityId, ) -> Option { self.live_conversation_ids_for_terminal_surface .get(&terminal_surface_id)? .iter() .rev() .find(|conversation_id| { self.conversations_by_id .get(conversation_id) .is_some_and(|conversation| conversation.contains_action(action_id)) }) .copied() } pub fn existing_suggestions_for_conversation( &self, conversation_id: AIConversationId, ) -> Option<&Suggestions> { self.conversations_by_id .get(&conversation_id) .and_then(|c| c.existing_suggestions()) } /// The active conversation is the one we're currently or have most recently streamed outputs for. /// If you want to get the conversation the next query will follow up in / what is selected in the input selector, /// use `context_model.selected_conversation` instead. pub fn active_conversation(&self, terminal_surface_id: EntityId) -> Option<&AIConversation> { self.active_conversation_id(terminal_surface_id) .and_then(|id| self.conversation(&id)) } /// True if this conversation was started from a passive entrypoint, AND the user has made no follow ups. pub fn is_entirely_passive_conversation(&self, conversation_id: &AIConversationId) -> bool { self.conversation(conversation_id) .is_some_and(|conversation| conversation.is_entirely_passive()) } pub fn is_exchange_hidden( &self, conversation_id: AIConversationId, exchange_id: AIAgentExchangeId, ) -> bool { self.conversations_by_id .get(&conversation_id) .is_some_and(|c| c.is_exchange_hidden(exchange_id)) } /// Add a new [`AIAgentExchange`] to the [`AIConversation`] with the given [`AIConversationId`]. /// Emits an event with the new exchange. pub(super) fn update_conversation_for_new_request_input( &mut self, request_input: RequestInput, stream_id: ResponseStreamId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) -> Result<(), UpdateHistoryError> { let conversation = self .conversations_by_id .get_mut(&request_input.conversation_id) .ok_or(UpdateHistoryError::ConversationNotFound( request_input.conversation_id, ))?; conversation.update_for_new_request_input( request_input, stream_id, terminal_surface_id, ctx, )?; Ok(()) } pub fn restore_conversations( &mut self, terminal_surface_id: EntityId, conversations: Vec, ctx: &mut ModelContext, ) { self.terminal_surface_created_at .insert(terminal_surface_id, Local::now()); let mut conversation_ids = Vec::new(); for conversation in conversations.into_iter() { let conversation_id = conversation.id(); conversation_ids.push(conversation_id); let live_conversation_ids = self .live_conversation_ids_for_terminal_surface .entry(terminal_surface_id) .or_default(); if !live_conversation_ids.contains(&conversation_id) { live_conversation_ids.push(conversation_id); } if let Some(key) = agent_id_key(&conversation) { self.agent_id_to_conversation_id .insert(key, conversation_id); } if let Some(token) = conversation.server_conversation_token() { self.server_token_to_conversation_id .insert(token.clone(), conversation_id); } // Maintain the parent→child index for child agent conversations. if let Some(parent_id) = self.resolved_parent_conversation_id_for_conversation(&conversation) { self.index_child_conversation(conversation_id, parent_id); } let new_status = conversation.status().clone(); self.conversations_by_id .insert(conversation_id, conversation); // Emit UpdatedConversationStatus for restored conversations so that // the workspace can set tab indicators appropriately ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationStatus { conversation_id, terminal_surface_id, update: ConversationStatusUpdate::Restored, new_status, }); } // Emit event so consumers can populate their associated view references. ctx.emit(BlocklistAIHistoryEvent::RestoredConversations { terminal_surface_id, conversation_ids, }); } /// Sets the active conversation ID for a terminal surface and moves the conversation /// from any other terminal surface that currently contains it. /// /// For automatic follow-ups and request-stream bookkeeping, use /// [`Self::mark_active_conversation_id`] instead. pub fn set_active_conversation_id( &mut self, conversation_id: AIConversationId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) { if !self .live_conversation_ids_for_terminal_surface .get(&terminal_surface_id) .is_some_and(|conversation_ids| conversation_ids.contains(&conversation_id)) { log::error!( "Attempted to set active conversation ID for a terminal surface that does not contain that conversation." ); return; } // Track previous terminal surfaces we removed the conversation from so we can // emit terminal-surface-transfer events outside of the borrow of // `live_conversation_ids_for_terminal_surface`. The conversation rendering // model assumes a single canonical terminal surface per conversation, so each // previous terminal surface needs a chance to drop its now-stale rendered AI // blocks. let mut previous_terminal_surfaces: Vec = Vec::new(); for (other_terminal_surface, other_terminal_surface_live_conversation_ids) in self .live_conversation_ids_for_terminal_surface .iter_mut() .filter(|(other_terminal_surface_id, _)| { **other_terminal_surface_id != terminal_surface_id }) { let previous_len = other_terminal_surface_live_conversation_ids.len(); other_terminal_surface_live_conversation_ids.retain(|id| *id != conversation_id); if other_terminal_surface_live_conversation_ids.len() != previous_len { previous_terminal_surfaces.push(*other_terminal_surface); } if self .active_conversation_for_terminal_surface .get(other_terminal_surface) .is_some_and(|id| *id == conversation_id) { self.active_conversation_for_terminal_surface .remove(other_terminal_surface); ctx.emit(BlocklistAIHistoryEvent::ClearedActiveConversation { conversation_id, terminal_surface_id: *other_terminal_surface, }); } } for previous_terminal_surface_id in previous_terminal_surfaces { ctx.emit( BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { conversation_id, previous_terminal_surface_id, new_terminal_surface_id: terminal_surface_id, }, ); } self.active_conversation_for_terminal_surface .insert(terminal_surface_id, conversation_id); ctx.emit(BlocklistAIHistoryEvent::SetActiveConversation { conversation_id, terminal_surface_id, }); } /// Marks a conversation as active for a terminal surface without removing it from other terminal surfaces. /// /// This is the non-transferring counterpart to [`Self::set_active_conversation_id`]. /// Use this during automatic follow-ups and request sending where the /// conversation already belongs to this terminal surface and we only need to update /// the "most recently streamed" pointer. pub fn mark_active_conversation_id( &mut self, conversation_id: AIConversationId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) { if !self .live_conversation_ids_for_terminal_surface .get(&terminal_surface_id) .is_some_and(|conversation_ids| conversation_ids.contains(&conversation_id)) { log::warn!( "mark_active_conversation_id: conversation {conversation_id:?} is not in \ terminal surface {terminal_surface_id:?} live list, skipping" ); return; } self.active_conversation_for_terminal_surface .insert(terminal_surface_id, conversation_id); ctx.emit(BlocklistAIHistoryEvent::SetActiveConversation { conversation_id, terminal_surface_id, }); } /// Starts a new conversation in the given terminal surface's history, effectively marking the /// existing conversation (if any) as completed. /// /// Returns the ID of the created conversation. /// /// Conversation completion is inferred if the conversation in question is _not_ the last /// element in the `conversations` vector. pub fn start_new_conversation( &mut self, terminal_surface_id: EntityId, is_autoexecute_override: bool, is_viewing_shared_session: bool, is_cli_agent_transcript: bool, ctx: &mut ModelContext, ) -> AIConversationId { let mut new_conversation = AIConversation::new(is_viewing_shared_session, is_cli_agent_transcript); if is_autoexecute_override { new_conversation.toggle_autoexecute_override(); } let new_conversation_id = new_conversation.id(); self.live_conversation_ids_for_terminal_surface .entry(terminal_surface_id) .or_default() .push(new_conversation_id); self.conversations_by_id .insert(new_conversation_id, new_conversation); ctx.emit(BlocklistAIHistoryEvent::StartedNewConversation { new_conversation_id, terminal_surface_id, }); new_conversation_id } pub fn create_cli_subagent_task_for_conversation( &mut self, block_id: BlockId, conversation_id: AIConversationId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) -> Result { let conversation = self .conversations_by_id .get_mut(&conversation_id) .ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?; Ok(conversation.create_optimistic_cli_subagent_task(&block_id, terminal_surface_id, ctx)) } pub fn update_conversation_status( &mut self, terminal_surface_id: EntityId, conversation_id: AIConversationId, status: ConversationStatus, ctx: &mut ModelContext, ) { self.update_conversation_status_with_error( terminal_surface_id, conversation_id, status, None, ctx, ); } pub fn update_conversation_status_with_error( &mut self, terminal_surface_id: EntityId, conversation_id: AIConversationId, status: ConversationStatus, error: Option, ctx: &mut ModelContext, ) { if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { conversation.update_status_with_error(status, error, terminal_surface_id, ctx); } } pub fn on_forked_conversation( &mut self, conversation_id: AIConversationId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) { // When a conversation is forked and restored for a new terminal surface, // we want to emit UpdatedStreamingExchange events for every exchange // to ensure that all of the existing exchanges are persisted correctly. if let Some(conversation) = self.conversations_by_id.get(&conversation_id) { for exchange in conversation.all_exchanges().into_iter() { let is_hidden = conversation.is_exchange_hidden(exchange.id); ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange { exchange_id: exchange.id, terminal_surface_id, conversation_id, is_hidden, }); } } } pub fn initialize_output_for_response_stream( &mut self, stream_id: &ResponseStreamId, conversation_id: AIConversationId, terminal_surface_id: EntityId, init_event: warp_multi_agent_api::response_event::StreamInit, ctx: &mut ModelContext, ) { let mut should_emit_server_token_assigned = false; let mut should_persist = false; if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { let had_token_before = conversation.server_conversation_token().is_some(); if let Err(e) = conversation.initialize_output_for_response_stream( stream_id, init_event, terminal_surface_id, ctx, ) { log::warn!("Failed to update conversation with updated streamed output: {e}"); } if let Some(key) = agent_id_key(conversation) { self.agent_id_to_conversation_id .insert(key, conversation_id); } if let Some(token) = conversation.server_conversation_token() { self.server_token_to_conversation_id .insert(token.clone(), conversation_id); } should_emit_server_token_assigned = !had_token_before && conversation.server_conversation_token().is_some(); should_persist = true; } if should_persist { self.persist_conversation_state(conversation_id, ctx); } if should_emit_server_token_assigned { ctx.emit(BlocklistAIHistoryEvent::ConversationServerTokenAssigned { conversation_id, terminal_surface_id, }); } } /// Assigns a `run_id` to a conversation that was spawned as a remote child /// agent. Updates the `agent_id_to_conversation_id` index and emits /// `ConversationServerTokenAssigned` so the `StartAgentExecutor` can /// complete the pending `start_agent` tool call. pub fn assign_run_id_for_conversation( &mut self, conversation_id: AIConversationId, run_id: String, task_id: Option, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) { let (agent_key, server_token) = { let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else { log::warn!( "assign_run_id_for_conversation: conversation {conversation_id:?} not found" ); return; }; conversation.set_run_id(run_id); if let Some(task_id) = task_id { conversation.set_task_id(task_id); } ( agent_id_key(conversation), conversation.server_conversation_token().cloned(), ) }; if let Some(key) = agent_key { self.agent_id_to_conversation_id .insert(key, conversation_id); } if let Some(token) = server_token { self.server_token_to_conversation_id .insert(token, conversation_id); } self.persist_conversation_state(conversation_id, ctx); ctx.emit(BlocklistAIHistoryEvent::ConversationServerTokenAssigned { conversation_id, terminal_surface_id, }); } /// Resolves a server-side agent identifier to a local conversation ID. /// The identifier may be a server conversation token (v1) or a run_id (v2). pub fn conversation_id_for_agent_id(&self, agent_id: &str) -> Option { self.agent_id_to_conversation_id .get(agent_id) .copied() .or_else(|| { self.server_token_to_conversation_id .get(&ServerConversationToken::new(agent_id.to_owned())) .copied() }) } /// Creates a new conversation and transfers relevant exchanges from /// the existing conversation to the new one. If successful, returns the new conversation id. fn handle_conversation_split( &mut self, old_conversation_id: AIConversationId, response_stream_id: &ResponseStreamId, start_from_message_id: MessageId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) -> Result { let exchange_ids_to_transfer: Vec = self .conversation(&old_conversation_id) .ok_or(UpdateHistoryError::ConversationNotFound( old_conversation_id, ))? .all_exchanges() .into_iter() .skip_while(|e| !e.added_message_ids.contains(&start_from_message_id)) .map(|e| e.id) .collect(); if exchange_ids_to_transfer.is_empty() { log::warn!("Starting a new conversation: message id not found"); return Err(UpdateHistoryError::Conversation( UpdateConversationError::ExchangeNotFound, )); } log::info!( "Starting a new conversation: transferring {} exchanges to new conversation", exchange_ids_to_transfer.len() ); let new_conversation_id = self.start_new_conversation(terminal_surface_id, false, false, false, ctx); for exchange_id in exchange_ids_to_transfer { let old_conversation = self .conversations_by_id .get_mut(&old_conversation_id) .ok_or(UpdateHistoryError::ConversationNotFound( old_conversation_id, ))?; let exchange = old_conversation.remove_exchange(exchange_id)?; let new_conversation = self .conversations_by_id .get_mut(&new_conversation_id) .ok_or(UpdateHistoryError::ConversationNotFound( new_conversation_id, ))?; new_conversation.append_reassigned_exchange( response_stream_id, exchange, terminal_surface_id, ctx, )?; } // Mark the old conversation as complete since we're starting a new one let old_conversation = self .conversations_by_id .get_mut(&old_conversation_id) .ok_or(UpdateHistoryError::ConversationNotFound( old_conversation_id, ))?; old_conversation.mark_completed_after_successful_split( response_stream_id, terminal_surface_id, ctx, )?; self.set_active_conversation_id(new_conversation_id, terminal_surface_id, ctx); ctx.emit(BlocklistAIHistoryEvent::SplitConversation { terminal_surface_id, old_conversation_id, new_conversation_id, }); Ok(new_conversation_id) } /// Forks an existing conversation by creating a new conversation /// and copying the existing conversation's tasks into the new conversation. /// /// The `prefix` parameter specifies the prefix added to the root task description /// (e.g., `FORK_PREFIX` for forks, `PRE_REWIND_PREFIX` for pre-rewind backups). /// /// When `preserve_task_ids` is true, the forked conversation reuses the source's task ids /// instead of minting new ones. Used by local-to-cloud handoff so the local /// fork's task store matches the cloud-side fork. The cloud agent's /// `ClientAction`s reference those task ids; if we minted new ones locally /// they would fail to resolve. pub fn fork_conversation( &mut self, source_conversation: &AIConversation, prefix: &str, preserve_task_ids: bool, title_override: Option<&str>, app: &AppContext, ) -> Result { let tasks: Vec = source_conversation .all_tasks() .filter_map(|t| t.source().cloned()) .collect(); let updated_tasks_with_new_ids = update_forked_task_properties(tasks, prefix, preserve_task_ids, title_override); let Some(sqlite_sender) = GlobalResourceHandlesProvider::as_ref(app) .get() .model_event_sender .clone() else { return Err(anyhow!("No sqlite sender available.")); }; // We preserve reverted action IDs. Orphaned IDs (for actions not in fork) are harmless. // The reverted states are only copied to the new conversation if the revert happened before the user clicked fork, // but regardless of when the revert happened relative to the fork point. // // Example: // 1. Agent edit action // 2. Agent edit action // 3. User reverts edit from 1 // 4. **User clicks fork** // 5. User reverts edit from 2 // // In this example, the forked conversation will always show edit 1 as reverted and edit 2 as not reverted, // regardless of if the fork point is between 2 and 3 or 3 and 4. This is because we preserve all prior reverts, // either if they game before or after the fork point. However, once forked, we don't copy later reverts. let reverted_action_ids = if source_conversation.reverted_action_ids().is_empty() { None } else { Some( source_conversation .reverted_action_ids() .clone() .into_iter() .map_into() .collect(), ) }; let conversation_data = AgentConversationData { server_conversation_token: None, conversation_usage_metadata: Some(source_conversation.usage_metadata()), reverted_action_ids, forked_from_server_conversation_token: source_conversation .server_conversation_token() .map(|t| t.as_str().to_string()), // We reset artifacts on fork artifacts_json: None, // Forked conversation loses its parentage parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: Some(source_conversation.autoexecute_override().into()), last_event_sequence: None, pinned: false, progressive_summary: source_conversation .progressive_summary() .map(|s| s.to_string()), messages_summarized_up_to: source_conversation.messages_summarized_up_to(), }; let forked_conversation_id = AIConversationId::new(); if let Err(e) = sqlite_sender.send(ModelEvent::UpdateMultiAgentConversation { conversation_id: forked_conversation_id.to_string(), updated_tasks: updated_tasks_with_new_ids.clone(), conversation_data: conversation_data.clone(), }) { return Err(anyhow!("Failed to persist forked conversation: {e:?}.")); } // Insert this conversation into the history model memory so we don't need to read from DB to restore this forked conversation // (otherwise, we can run into a race condition where the conversation is not found in the DB because we haven't finished writing to the db). let forked_conversation = self.insert_forked_conversation_from_tasks( forked_conversation_id, updated_tasks_with_new_ids.clone(), conversation_data.clone(), )?; Ok(forked_conversation) } /// Forks an existing conversation at a specific exchange boundary. When `exact_exchange` /// is true, the fork includes all messages up to and including the selected exchange. /// Otherwise, it extends through the full response (every message after the user's query /// until the next root-task user query). /// /// The `prefix` parameter specifies the prefix added to the root task description /// (e.g., `FORK_PREFIX` for forks, `PRE_REWIND_PREFIX` for pre-rewind backups). pub fn fork_conversation_at_exchange( &mut self, source_conversation: &AIConversation, from_exchange_id: AIAgentExchangeId, fork_from_exact_exchange: bool, prefix: &str, title_override: Option<&str>, app: &AppContext, ) -> Result { let conversation = source_conversation; let exchanges_by_task: Vec<(TaskId, Vec<&AIAgentExchange>)> = conversation.all_exchanges_by_task(); let root_task_id = conversation.get_root_task_id().clone(); let mut message_ids_to_retain_by_task: HashMap> = HashMap::new(); let mut found_from_exchange_id = false; 'outer: for (task_id, task_exchanges) in exchanges_by_task.into_iter() { for exchange in task_exchanges { // In the non-exact case, we continue past the selected exchange until we reach // the next user query (effectively forking from the selected 'response'). if found_from_exchange_id && task_id == root_task_id && exchange.has_user_query() { break 'outer; } let message_ids_to_retain = message_ids_to_retain_by_task .entry(task_id.clone()) .or_default(); message_ids_to_retain.extend(exchange.added_message_ids.iter().cloned()); if exchange.id == from_exchange_id { if fork_from_exact_exchange { break 'outer; } found_from_exchange_id = true; } } } if message_ids_to_retain_by_task.is_empty() { return Err(anyhow!( "No messages found for block in conversation {}.", conversation.id() )); } // Build truncated tasks by retaining only messages whose IDs are in // `allowed_message_ids`. Tasks whose message list becomes empty and // which are non-root tasks are dropped. let truncated_tasks: Vec = conversation .all_tasks() .filter_map(|t| { if let Some(message_ids_to_retain) = message_ids_to_retain_by_task.get(t.id()) { let mut truncated_task = t.source().cloned()?; truncated_task .messages .retain(|m| message_ids_to_retain.contains(&MessageId::new(m.id.clone()))); (!truncated_task.messages.is_empty()).then_some(truncated_task) } else { None } }) .collect(); if truncated_tasks.is_empty() { return Err(anyhow!( "Truncated tasks for forked conversation at block are empty for conversation {}.", conversation.id() )); } let updated_tasks_with_new_ids = update_forked_task_properties(truncated_tasks, prefix, false, title_override); let Some(sqlite_sender) = GlobalResourceHandlesProvider::as_ref(app) .get() .model_event_sender .clone() else { return Err(anyhow!("No sqlite sender available.")); }; // We preserve reverted action IDs. Orphaned IDs (for actions not in fork) are harmless. // The reverted states are only copied to the new conversation if the revert happened before the user clicked fork, // but regardless of when the revert happened relative to the fork point. // // Example: // 1. Agent edit action // 2. Agent edit action // 3. User reverts edit from 1 // 4. **User clicks fork** // 5. User reverts edit from 2 // // In this example, the forked conversation will always show edit 1 as reverted and edit 2 as not reverted, // regardless of if the fork point is between 2 and 3 or 3 and 4. This is because we preserve all prior reverts, // either if they game before or after the fork point. However, once forked, we don't copy later reverts. let reverted_action_ids = if conversation.reverted_action_ids().is_empty() { None } else { Some( conversation .reverted_action_ids() .clone() .into_iter() .map_into() .collect(), ) }; // Start forked conversations without usage metadata for now; this can // be recomputed based on the retained exchanges in a follow-up. let conversation_data = AgentConversationData { server_conversation_token: None, conversation_usage_metadata: None, reverted_action_ids, forked_from_server_conversation_token: conversation .server_conversation_token() .map(|t| t.as_str().to_string()), // We reset artifacts on fork artifacts_json: None, // Forked conversation loses its parentage. parent_agent_id: None, agent_name: None, orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: Some(conversation.autoexecute_override().into()), last_event_sequence: None, pinned: false, progressive_summary: conversation.progressive_summary().map(|s| s.to_string()), messages_summarized_up_to: conversation.messages_summarized_up_to(), }; let forked_conversation_id = AIConversationId::new(); if let Err(e) = sqlite_sender.send(ModelEvent::UpdateMultiAgentConversation { conversation_id: forked_conversation_id.to_string(), updated_tasks: updated_tasks_with_new_ids.clone(), conversation_data: conversation_data.clone(), }) { return Err(anyhow!( "Failed to persist forked conversation at block: {e:?}." )); } let forked_conversation = self.insert_forked_conversation_from_tasks( forked_conversation_id, updated_tasks_with_new_ids, conversation_data, )?; Ok(forked_conversation) } pub fn apply_client_actions( &mut self, response_stream_id: &ResponseStreamId, client_actions: Vec, conversation_id: AIConversationId, terminal_surface_id: EntityId, skill_path_origin: &SkillPathOrigin, ctx: &mut ModelContext, ) -> Result<(), UpdateHistoryError> { let mut current_conversation_id = conversation_id; for client_action in client_actions { match client_action.action { Some(Action::StartNewConversation(StartNewConversation { start_from_message_id, })) => { let new_conversation_id = self.handle_conversation_split( current_conversation_id, response_stream_id, MessageId::new(start_from_message_id), terminal_surface_id, ctx, )?; current_conversation_id = new_conversation_id; } Some(action) => { let conversation = self .conversations_by_id .get_mut(¤t_conversation_id) .ok_or(UpdateHistoryError::ConversationNotFound( current_conversation_id, ))?; conversation.apply_client_action( response_stream_id, terminal_surface_id, action, skill_path_origin, ctx, )?; } None => { log::warn!("Received empty client action"); } } } Ok(()) } pub fn update_conversation_cost_and_usage_for_request( &mut self, conversation_id: AIConversationId, request_cost: Option, token_usage: Vec, usage_metadata: Option, was_user_initiated_request: bool, ctx: &mut ModelContext, ) { // Track whether this update changes any state derived by // `BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated` // subscribers (e.g. the orchestration credit rollup). We emit the // event only when there's actual data to react to. let emits_usage_event = request_cost.is_some() || usage_metadata.is_some(); if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { if let Err(e) = conversation.update_cost_and_usage_for_request( request_cost, token_usage, usage_metadata, was_user_initiated_request, ctx, ) { log::warn!( "Failed to update request cost for conversation {conversation_id}: {e:#}" ); } if emits_usage_event { ctx.emit(BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { conversation_id, }); } } else { log::warn!( "Failed to update request cost because conversation {conversation_id} was not found" ); } } pub fn mark_response_stream_completed_successfully( &mut self, stream_id: &ResponseStreamId, conversation_id: AIConversationId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) { let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else { return; }; if let Err(e) = conversation.mark_request_completed(stream_id, terminal_surface_id, ctx) { log::warn!("Failed to mark exchange as completed: {e}"); } // If this conversation doesn't have server metadata yet, and it has a server conversation token, // fetch the metadata from the server. let should_fetch_metadata = FeatureFlag::CloudConversations.is_enabled() && conversation.server_metadata().is_none() && conversation.server_conversation_token().is_some(); if should_fetch_metadata { let server_token = conversation .server_conversation_token() .unwrap() .as_str() .to_string(); let server_api = ServerApiProvider::as_ref(ctx).get_ai_client(); ctx.spawn( async move { server_api .list_ai_conversation_metadata(Some(vec![server_token])) .await }, move |model, result, ctx| match result { Ok(mut metadata_list) if !metadata_list.is_empty() => { if let Some(metadata) = metadata_list.pop() { model.set_server_metadata_for_conversation( conversation_id, metadata, ctx, ); } } Ok(_) => { log::warn!("No metadata returned for conversation {}", conversation_id); } Err(e) => { log::warn!( "Failed to fetch metadata for conversation {}: {e:#}", conversation_id ); } }, ); } } pub fn set_exchange_time_to_first_token( &mut self, conversation_id: AIConversationId, exchange_id: AIAgentExchangeId, time_to_first_token_ms: i64, ) { if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { if let Ok(exchange) = conversation.get_exchange_to_update(exchange_id) { exchange.time_to_first_token_ms = Some(time_to_first_token_ms); } } } pub fn mark_response_stream_cancelled( &mut self, stream_id: &ResponseStreamId, conversation_id: AIConversationId, terminal_surface_id: EntityId, reason: CancellationReason, ctx: &mut ModelContext, ) { if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { if reason.is_reverted() { if let Err(e) = conversation.mark_request_cancelled_due_to_revert(terminal_surface_id, ctx) { log::warn!("Failed to mark exchange as cancelled: {e}"); } } else if let Err(e) = conversation.mark_request_cancelled(stream_id, terminal_surface_id, reason, ctx) { log::warn!("Failed to mark exchange as cancelled: {e}"); } } AIDocumentModel::handle(ctx).update(ctx, |model, ctx| { model.clear_streaming_documents_for_conversation(&conversation_id, ctx); }); } /// Marks the stream's exchanges as finished with `error`. pub fn mark_response_stream_completed_with_error( &mut self, error: RenderableAIError, recovery_pending: bool, stream_id: &ResponseStreamId, conversation_id: AIConversationId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) { if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { if let Err(e) = conversation.mark_request_completed_with_error( stream_id, error.clone(), recovery_pending, terminal_surface_id, ctx, ) { log::warn!("Failed to mark exchange as completed with error: {e}"); } } } /// Handle clearing the blocklist for a terminal surface. /// The terminal surface will also cancel the active stream on processing the event emitted here. pub(crate) fn clear_conversations_for_terminal_surface( &mut self, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) { // Cancel the active stream when we clear conversations for this terminal surface. let active_conversation_id = self .active_conversation_for_terminal_surface .remove(&terminal_surface_id); let mut cleared_conversation_ids: Vec = Vec::new(); if let Some(ids) = self .live_conversation_ids_for_terminal_surface .remove(&terminal_surface_id) { cleared_conversation_ids.extend(ids.iter().copied()); self.cleared_conversation_ids_for_terminal_surface .entry(terminal_surface_id) .and_modify(|existing| existing.extend(ids.clone())) .or_insert(ids); } ctx.emit( BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { terminal_surface_id, active_conversation_id, cleared_conversation_ids, }, ); } /// Handle removing a conversation from the history model, blocklist and in-memory. pub fn remove_conversation( &mut self, conversation_id: AIConversationId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) { self.remove_conversation_from_memory(conversation_id, Some(terminal_surface_id), ctx); } /// Permanently delete a conversation. pub fn delete_conversation( &mut self, conversation_id: AIConversationId, terminal_surface_id: Option, ctx: &mut ModelContext, ) { let conversation_title = self .conversations_by_id .get(&conversation_id) .and_then(|c| c.title().map(|t| t.to_string())); // Capture the run_id BEFORE the in-memory record is dropped so it // can be forwarded on the DeletedConversation event. let run_id = self .conversations_by_id .get(&conversation_id) .and_then(|c| c.run_id()); self.remove_conversation_from_memory(conversation_id, terminal_surface_id, ctx); // Delete persisted conversation from sqlite. let model_event_sender = GlobalResourceHandlesProvider::as_ref(ctx) .get() .model_event_sender .clone(); let conversation_id_string = conversation_id.to_string(); ctx.spawn( async move { if let Some(sender) = model_event_sender { if let Err(e) = sender.send(ModelEvent::DeleteAIConversation { conversation_id: conversation_id_string.clone(), }) { log::error!("Error sending DeleteAIConversation event: {e:?}"); } if let Err(e) = sender.send(ModelEvent::DeleteMultiAgentConversations { conversation_ids: vec![conversation_id_string], }) { log::error!("Error sending DeleteMultiAgentConversations event: {e:?}"); } } }, |_, _, _| {}, ); // Only emit the event if we have a terminal_surface_id, since the event is // filtered by terminal_surface_id in handlers. if let Some(terminal_surface_id) = terminal_surface_id { ctx.emit(BlocklistAIHistoryEvent::DeletedConversation { terminal_surface_id, conversation_id, conversation_title, run_id, }); } } /// Remove a conversation from all in-memory storage. fn remove_conversation_from_memory( &mut self, conversation_id: AIConversationId, terminal_surface_id: Option, ctx: &mut ModelContext, ) { // Capture the run_id BEFORE the in-memory record is dropped so the // RemoveConversation event can carry it (event subscribers can no // longer look it up via `conversation()` after this function returns). let run_id = self .conversations_by_id .get(&conversation_id) .and_then(|c| c.run_id()); // Clean up reverse indices before removing the conversation. Guard // token-index removals with an equality check: the live conversation's // token and the metadata's token can diverge after a rebind, and we // must not clobber an entry already owned by another conversation. if let Some(conversation) = self.conversations_by_id.get(&conversation_id) { if let Some(key) = agent_id_key(conversation) { self.agent_id_to_conversation_id.remove(&key); } if let Some(token) = conversation.server_conversation_token() { if self.server_token_to_conversation_id.get(token) == Some(&conversation_id) { self.server_token_to_conversation_id.remove(token); } } } // Also clean up the token index entry that might have been installed // via the metadata path (no live conversation present). if let Some(metadata) = self.all_conversations_metadata.get(&conversation_id) { if let Some(token) = &metadata.server_conversation_token { if self.server_token_to_conversation_id.get(token) == Some(&conversation_id) { self.server_token_to_conversation_id.remove(token); } } } self.all_conversations_metadata.remove(&conversation_id); self.conversations_by_id.remove(&conversation_id); if let Some(terminal_surface_id) = terminal_surface_id { if self .active_conversation_for_terminal_surface .get(&terminal_surface_id) .is_some_and(|id| *id == conversation_id) { self.active_conversation_for_terminal_surface .remove(&terminal_surface_id); } if let Some(vec) = self .live_conversation_ids_for_terminal_surface .get_mut(&terminal_surface_id) { vec.retain(|&id| id != conversation_id); } if let Some(vec) = self .cleared_conversation_ids_for_terminal_surface .get_mut(&terminal_surface_id) { vec.retain(|&id| id != conversation_id); } ctx.emit(BlocklistAIHistoryEvent::RemoveConversation { terminal_surface_id, conversation_id, run_id, }); } } /// Returns true if the conversation is live for any terminal surface. pub fn is_conversation_live(&self, conversation_id: AIConversationId) -> bool { self.live_conversation_ids_for_terminal_surface .values() .any(|conversation_ids| conversation_ids.contains(&conversation_id)) } pub fn mark_terminal_surface_as_ambient_agent_session_view( &mut self, terminal_surface_id: EntityId, ) { self.ambient_agent_terminal_surface_ids .insert(terminal_surface_id); } pub fn mark_terminal_surface_as_conversation_transcript_viewer( &mut self, terminal_surface_id: EntityId, ) { self.conversation_transcript_viewer_terminal_surface_ids .insert(terminal_surface_id); } pub fn is_terminal_surface_conversation_transcript_viewer( &self, terminal_surface_id: EntityId, ) -> bool { self.conversation_transcript_viewer_terminal_surface_ids .contains(&terminal_surface_id) } /// Returns [`AIQueryHistory`]s from all sources: live conversations, cleared conversations, /// and persisted queries from conversations not loaded in memory. /// /// When `terminal_surface_id` is provided, queries from that terminal surface are categorized as /// `CurrentSession` and all others as `DifferentSession`. When `None`, all queries are /// categorized as `DifferentSession`. /// /// Ambient agent sessions are always excluded. pub(crate) fn all_ai_queries( &self, terminal_surface_id: Option, ) -> impl Iterator + '_ { // Collect all conversation IDs that are already in memory (live or cleared) // and build query vectors in the same loops let mut loaded_conversation_ids: HashSet = HashSet::new(); let mut live_queries_vec = Vec::new(); for (conversation_terminal_surface_id, conversation_ids) in self.live_conversation_ids_for_terminal_surface.iter() { loaded_conversation_ids.extend(conversation_ids); // Skip shared ambient agent sessions if self .ambient_agent_terminal_surface_ids .contains(conversation_terminal_surface_id) { continue; } let history_order = if terminal_surface_id.is_some_and(|id| id == *conversation_terminal_surface_id) { HistoryOrder::CurrentSession } else { HistoryOrder::DifferentSession }; for conversation_id in conversation_ids { if let Some(conversation) = self.conversations_by_id.get(conversation_id) { // For child agent conversations, skip the first exchange — it // contains the synthetic orchestrator prompt, not user input. // TODO(QUALITY-636): Replace positional skip with an // `is_agent_initiated` field on the MAA UserQuery proto // message so the flag survives server restoration. let skip_count = if conversation.is_child_agent_conversation() { 1 } else { 0 }; for exchange in conversation.root_task_exchanges().skip(skip_count) { if let Some(query) = ai_exchange_to_query_history(exchange, history_order) { live_queries_vec.push(query); } } } } } let mut cleared_queries_vec = Vec::new(); for (conversation_terminal_surface_id, conversation_ids) in self.cleared_conversation_ids_for_terminal_surface.iter() { loaded_conversation_ids.extend(conversation_ids); let history_order = if terminal_surface_id.is_some_and(|id| id == *conversation_terminal_surface_id) { HistoryOrder::CurrentSession } else { HistoryOrder::DifferentSession }; for conversation_id in conversation_ids { if let Some(conversation) = self.conversations_by_id.get(conversation_id) { let skip_count = if conversation.is_child_agent_conversation() { 1 } else { 0 }; for exchange in conversation.root_task_exchanges().skip(skip_count) { if let Some(query) = ai_exchange_to_query_history(exchange, history_order) { cleared_queries_vec.push(query); } } } } } // Add persisted queries from conversations not loaded in memory let persisted_queries_vec: Vec<_> = self .persisted_queries .iter() .filter(|persisted| !loaded_conversation_ids.contains(&persisted.conversation_id)) .filter_map(|persisted| { persisted_ai_input_to_query_history(persisted, HistoryOrder::DifferentSession) }) .collect(); persisted_queries_vec .into_iter() .chain(cleared_queries_vec) .chain(live_queries_vec) } /// Returns the active conversation ID for a terminal surface, if one exists. /// The active conversation is the one we're currently or have most recently streamed outputs for. /// If you want to check what conversation the next query will follow up in / what is selected in the input selector, /// use `context_model.selected_conversation_id` instead. pub(crate) fn active_conversation_id( &self, terminal_surface_id: EntityId, ) -> Option { let active_conversation_id = self .active_conversation_for_terminal_surface .get(&terminal_surface_id) .copied()?; let conversation_ids_for_terminal_surface = self .live_conversation_ids_for_terminal_surface .get(&terminal_surface_id)?; if !conversation_ids_for_terminal_surface.contains(&active_conversation_id) { log::warn!( "The active conversation ID {active_conversation_id:?} was not found in the list of conversation IDs for terminal surface {terminal_surface_id:?}. Conversation IDs: {conversation_ids_for_terminal_surface:?}" ); return None; } Some(active_conversation_id) } /// Returns the last conversation ID created for a terminal surface, if one exists. #[cfg_attr(target_family = "wasm", allow(unused))] pub(crate) fn last_conversation_id( &self, terminal_surface_id: EntityId, ) -> Option { self.live_conversation_ids_for_terminal_surface .get(&terminal_surface_id)? .last() .copied() } /// Set the hidden status of the exchange with the given ID. pub fn set_exchange_hidden_status( &mut self, terminal_surface_id: EntityId, conversation_id: AIConversationId, exchange_id: AIAgentExchangeId, is_hidden: bool, ctx: &mut ModelContext, ) { let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else { return; }; conversation.set_is_exchange_hidden(exchange_id, is_hidden, terminal_surface_id, ctx); } pub fn set_viewing_shared_session_for_conversation( &mut self, conversation_id: AIConversationId, is_viewing_shared_session: bool, ) { if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { conversation.set_is_viewing_shared_session(is_viewing_shared_session); } } pub fn set_has_code_review_opened_to_true(&mut self, conversation_id: AIConversationId) { if let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) { conversation.mark_code_review_as_opened(); } } pub fn toggle_autoexecute_override( &mut self, conversation_id: &AIConversationId, terminal_surface_id: EntityId, ctx: &mut ModelContext, ) { let Some(conversation) = self.conversations_by_id.get_mut(conversation_id) else { return; }; conversation.toggle_autoexecute_override(); conversation.write_updated_conversation_state(ctx); ctx.emit(BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { terminal_surface_id, }); } /// Truncates a conversation from the given exchange ID, removing all exchanges /// from that exchange onwards (inclusive). This is a lossy operation. /// /// Returns the set of exchange IDs that were removed. pub fn truncate_conversation_from_exchange( &mut self, conversation_id: AIConversationId, from_exchange_id: AIAgentExchangeId, ctx: &mut ModelContext, ) -> Result, UpdateHistoryError> { let conversation = self .conversations_by_id .get_mut(&conversation_id) .ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?; let removed_exchange_ids = conversation.truncate_from_exchange(from_exchange_id, ctx)?; Ok(removed_exchange_ids) } /// Returns the latest exchange across all conversations for a terminal surface. /// This is useful for determining if a specific exchange is the most recent one. /// Excludes passive code generation exchanges from consideration. pub fn latest_exchange_across_all_conversations( &self, terminal_surface_id: EntityId, ) -> Option<&AIAgentExchange> { self.all_live_root_task_exchanges_for_terminal_surface(terminal_surface_id) .filter(|exchange| !exchange.has_passive_request()) .max_by_key(|exchange| exchange.start_time) } /// Returns the conversation ID that contains the given exchange ID, if any. /// Searches through all conversations for a terminal surface. pub fn conversation_id_for_exchange( &self, exchange_id: AIAgentExchangeId, terminal_surface_id: EntityId, ) -> Option { self.live_conversation_ids_for_terminal_surface .get(&terminal_surface_id)? .iter() .find(|conversation_id| { self.conversations_by_id .get(conversation_id) .is_some_and(|conversation| { conversation.exchange_with_id(exchange_id).is_some() }) }) .copied() } /// Returns local conversation metadata, /// (excluding conversations from ambient agent runs). pub fn get_local_conversations_metadata( &self, ) -> impl Iterator { self.all_conversations_metadata .values() .filter(|m| !m.is_ambient_agent_conversation()) } /// Returns conversation metadata for a specific conversation ID. pub fn get_conversation_metadata( &self, conversation_id: &AIConversationId, ) -> Option<&AIConversationMetadata> { self.all_conversations_metadata.get(conversation_id) } /// Returns whether a conversation can be shared. /// /// A conversation can be shared if we have server metadata available /// (either from a loaded conversation or from conversation metadata). pub fn can_conversation_be_shared(&self, conversation_id: &AIConversationId) -> bool { self.get_server_conversation_metadata(conversation_id) .is_some() } /// Returns the server conversation metadata, used by the sharing dialog. /// /// This checks: /// 1. If the conversation is loaded in memory, returns from its server metadata /// 2. Otherwise, falls back to data stored in conversation metadata pub fn get_server_conversation_metadata( &self, conversation_id: &AIConversationId, ) -> Option<&ServerAIConversationMetadata> { // Check if conversation exists in memory and has server metadata if let Some(conversation) = self.conversation(conversation_id) { if let Some(m) = conversation.server_metadata() { return Some(m); } } // Fall back to conversation metadata if let Some(metadata) = self.get_conversation_metadata(conversation_id) { return metadata.server_conversation_metadata.as_ref(); } None } pub fn get_server_conversation_metadata_by_server_token( &self, server_token: &ServerConversationToken, ) -> Option<&ServerAIConversationMetadata> { self.find_conversation_id_by_server_token(server_token) .and_then(|conversation_id| self.get_server_conversation_metadata(&conversation_id)) .or_else(|| { self.all_conversations_metadata .values() .find(|metadata| { metadata.server_conversation_token.as_ref() == Some(server_token) }) .and_then(|metadata| metadata.server_conversation_metadata.as_ref()) }) } /// Finds an AIConversationId by its server conversation token. /// /// O(1) lookup via `server_token_to_conversation_id`, which is maintained /// alongside every mutation of `conversations_by_id` / /// `all_conversations_metadata` that involves a token. Used to look up /// conversations for ambient agent tasks, which store the server token /// but not the AIConversationId. pub fn find_conversation_id_by_server_token( &self, server_token: &ServerConversationToken, ) -> Option { if let Some(id) = self.server_token_to_conversation_id.get(server_token) { return Some(*id); } // A token miss is the expected outcome whenever a task references a // conversation this client hasn't loaded (shared-session tasks from // other users, pre-sync state). log::debug!( "No conversation found for server token: {}", server_token.as_str() ); None } /// Returns the canonical local conversation ID for a server token, creating /// and caching one if this client has not seen the token before. pub fn get_or_set_canonical_conversation_id_for_server_token( &mut self, server_token: &ServerConversationToken, ) -> AIConversationId { if let Some(conversation_id) = self.server_token_to_conversation_id.get(server_token) { return *conversation_id; } let conversation_id = self .conversations_by_id .iter() .find_map(|(conversation_id, conversation)| { (conversation.server_conversation_token() == Some(server_token)) .then_some(*conversation_id) }) .or_else(|| { self.all_conversations_metadata .iter() .find_map(|(conversation_id, metadata)| { (metadata.server_conversation_token.as_ref() == Some(server_token)) .then_some(*conversation_id) }) }) .unwrap_or_else(AIConversationId::new); self.server_token_to_conversation_id .insert(server_token.clone(), conversation_id); conversation_id } /// Mark conversations as historical /// Historical conversations consist of non-live conversations that were read from the disk or server on startup, /// and conversations (recorded here) that were live this session but have now been cleared. pub fn mark_conversations_historical_for_terminal_surface( &mut self, terminal_surface_id: EntityId, ) { if self.is_terminal_surface_conversation_transcript_viewer(terminal_surface_id) { // We don't mark conversation transcript viewer conversations as historical, // as they are stored separately and should not be persisted/displayed as regular user conversations. return; } // There's a slight concern here that the conversations we're preserving might not have persisted successfully // because of some unexpected error. Attempting to then restore these conversations would lead to unexpected behavior. // In the future it might be worthwhile to check that these conversations exist in the database before marking them as historical, // but for now this is an edge case that we don't need to worry about too much. let conversations_to_mark_historical: Vec = self .all_live_conversations_for_terminal_surface(terminal_surface_id) .filter_map(|conversation| { let conversation_id = conversation.id(); if !self.conversations_by_id.contains_key(&conversation_id) || conversation.should_exclude_from_navigation() || !blocklist_filter::conversation_would_render_in_blocklist(conversation) { return None; } Some(conversation.into()) }) .collect(); for metadata in conversations_to_mark_historical { if let Some(token) = &metadata.server_conversation_token { self.server_token_to_conversation_id .insert(token.clone(), metadata.id); } self.all_conversations_metadata .insert(metadata.id, metadata); } } /// Inserts a conversation into memory by reconstructing exchanges from tasks. /// We use this when forking a conversation to ensure that the forked conversation /// is immediately available in memory before we try to restore it in a new tab. pub fn insert_forked_conversation_from_tasks( &mut self, conversation_id: AIConversationId, tasks: Vec, conversation_data: AgentConversationData, ) -> anyhow::Result { let mut conversation = AIConversation::new_restored(conversation_id, tasks, Some(conversation_data))?; // Assign fresh exchange IDs so persisted blocks do not collide. conversation.reassign_exchange_ids(); if let Some(token) = conversation.server_conversation_token() { self.server_token_to_conversation_id .insert(token.clone(), conversation_id); } self.conversations_by_id .insert(conversation_id, conversation.clone()); // This is harmless if we're opening the conversation immediately, but ensures it's in the conversation list right away if we fork in the background. let metadata = AIConversationMetadata::from(&conversation); if let Some(token) = &metadata.server_conversation_token { self.server_token_to_conversation_id .insert(token.clone(), conversation_id); } self.all_conversations_metadata .insert(conversation_id, metadata); Ok(conversation) } /// Rebuilds a remote-child placeholder conversation identified by /// `local_placeholder_id` from the cloud `tasks` + `cloud_conversation`, /// keeping the placeholder's local id and orchestration linkage /// (parent ids, agent_name, run_id, is_remote_child, pinned) /// authoritative. Cloud supplies the transcript and server-side metadata. /// /// Narrowly scoped to the remote-child placeholder hydration path /// (`pane_group::hydrate_remote_child_transcript_in_place`). Returns /// `Err` when the placeholder isn't loaded so the caller can fall back /// instead of silently producing a detached conversation. pub fn hydrate_remote_child_placeholder_with_cloud_transcript( &mut self, local_placeholder_id: AIConversationId, tasks: Vec, cloud_conversation: AIConversation, ) -> anyhow::Result { let placeholder = self .conversations_by_id .get(&local_placeholder_id) .cloned() .ok_or_else(|| { anyhow!( "hydrate_remote_child_placeholder_with_cloud_transcript: \ local placeholder {local_placeholder_id} not found in conversations_by_id; \ refusing to construct a detached merged conversation" ) })?; let merged_conversation_data = merged_remote_child_placeholder_conversation_data(&placeholder, &cloud_conversation); let mut merged = AIConversation::new_restored( local_placeholder_id, tasks, Some(merged_conversation_data), )?; merged.reassign_exchange_ids(); if let Some(metadata) = cloud_conversation.server_metadata() { merged.set_server_metadata(metadata.clone()); } if let Some(token) = merged.server_conversation_token() { self.server_token_to_conversation_id .insert(token.clone(), local_placeholder_id); } self.conversations_by_id .insert(local_placeholder_id, merged.clone()); if let Some(parent_id) = self.resolved_parent_conversation_id_for_conversation(&merged) { self.index_child_conversation(local_placeholder_id, parent_id); } Ok(merged) } /// Clears all stored conversation-related data in memory. /// This is used when logging out to ensure no AI history persists across users. pub(crate) fn reset(&mut self) { self.live_conversation_ids_for_terminal_surface.clear(); self.cleared_conversation_ids_for_terminal_surface.clear(); self.conversations_by_id.clear(); self.active_conversation_for_terminal_surface.clear(); self.ambient_agent_terminal_surface_ids.clear(); self.conversation_transcript_viewer_terminal_surface_ids .clear(); self.persisted_queries.clear(); self.all_conversations_metadata.clear(); self.agent_id_to_conversation_id.clear(); self.server_token_to_conversation_id.clear(); self.children_by_parent.clear(); } } /// Builds the `AgentConversationData` for a remote-child placeholder /// hydrated from a cloud transcript. /// /// **Placeholder authoritative** (local orchestration linkage that the cloud /// transcript cannot reconstruct): /// - `parent_conversation_id`, `is_remote_child`, `pinned` /// /// **Placeholder-preferred, cloud fallback** (local value wins when present, /// cloud's value is used otherwise so we don't lose data on a stale /// placeholder): /// - `parent_agent_id`, `agent_name`, `orchestration_harness_type`, `run_id` /// /// **Cloud authoritative** (server-side state the placeholder doesn't know): /// - `server_conversation_token`, `conversation_usage_metadata`, /// `forked_from_server_conversation_token`, `artifacts_json`, /// `last_event_sequence` /// /// **Reset on merge** (rebuild-from-cloud invariants): /// - `reverted_action_ids = None`, `root_task_is_optimistic = None`, /// `autoexecute_override = None` fn merged_remote_child_placeholder_conversation_data( placeholder: &AIConversation, cloud_conversation: &AIConversation, ) -> AgentConversationData { AgentConversationData { // Cloud authoritative. server_conversation_token: cloud_conversation .server_conversation_token() .map(|t| t.as_str().to_string()), conversation_usage_metadata: Some(cloud_conversation.usage_metadata()), forked_from_server_conversation_token: cloud_conversation .forked_from_server_conversation_token() .map(|t| t.as_str().to_string()), artifacts_json: serde_json::to_string(cloud_conversation.artifacts()).ok(), last_event_sequence: cloud_conversation.last_event_sequence(), // Placeholder-preferred, cloud fallback. parent_agent_id: placeholder .parent_agent_id() .map(ToString::to_string) .or_else(|| { cloud_conversation .parent_agent_id() .map(ToString::to_string) }), agent_name: placeholder .agent_name() .map(ToString::to_string) .or_else(|| cloud_conversation.agent_name().map(ToString::to_string)), orchestration_harness_type: placeholder .orchestration_harness_type() .map(ToString::to_string) .or_else(|| { cloud_conversation .orchestration_harness_type() .map(ToString::to_string) }), run_id: placeholder.run_id().or_else(|| cloud_conversation.run_id()), // Placeholder authoritative. parent_conversation_id: placeholder .parent_conversation_id() .map(|id| id.to_string()), is_remote_child: placeholder.is_remote_child(), pinned: placeholder.is_pinned(), // Reset on merge. reverted_action_ids: None, root_task_is_optimistic: None, autoexecute_override: None, progressive_summary: cloud_conversation .progressive_summary() .map(|s| s.to_string()), messages_summarized_up_to: cloud_conversation.messages_summarized_up_to(), } } /// Returns the key to use in `agent_id_to_conversation_id` for the given /// conversation. fn agent_id_key(conversation: &AIConversation) -> Option { conversation.orchestration_agent_id() } fn agent_id_key_from_persisted_data(conversation_data: &AgentConversationData) -> Option<&str> { conversation_data.run_id.as_deref() } /// Whether an `UpdatedConversationStatus` event represents a restoration /// (the conversation was re-loaded for a terminal surface; the underlying /// `ConversationStatus` did not change) or a real status set, in which case /// the previous status is included. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConversationStatusUpdate { Restored, Changed { prev_status: ConversationStatus }, } #[derive(Clone, Debug)] pub enum BlocklistAIHistoryEvent { /// A new conversation was started. StartedNewConversation { new_conversation_id: AIConversationId, terminal_surface_id: EntityId, }, CreatedSubtask { conversation_id: AIConversationId, terminal_surface_id: EntityId, task_id: TaskId, }, /// Emitted when the optimistically created task is "upgraded" to a server-backed task upon /// receiving a CreateTask client action. UpgradedTask { optimistic_id: TaskId, server_id: TaskId, terminal_surface_id: EntityId, }, AppendedExchange { exchange_id: AIAgentExchangeId, task_id: TaskId, terminal_surface_id: EntityId, conversation_id: AIConversationId, is_hidden: bool, // Populated if this exchange is appended as a result of an in-flight API request. response_stream_id: Option, }, ReassignedExchange { exchange_id: AIAgentExchangeId, terminal_surface_id: EntityId, new_task_id: TaskId, new_conversation_id: AIConversationId, }, /// Includes the terminal surface's [`EntityId`] so we can disambiguate the source of the event /// because this [`BlocklistAIHistoryModel`] is global. #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] UpdatedStreamingExchange { exchange_id: AIAgentExchangeId, terminal_surface_id: EntityId, conversation_id: AIConversationId, is_hidden: bool, }, UpdatedConversationStatus { conversation_id: AIConversationId, terminal_surface_id: EntityId, /// Distinguishes a restoration from a real status set. update: ConversationStatusUpdate, /// The conversation's status after this update. new_status: ConversationStatus, }, /// The active conversation was set to another conversation in the history. SetActiveConversation { conversation_id: AIConversationId, terminal_surface_id: EntityId, }, /// `conversation_id` is no longer marked as active for the given terminal surface. ClearedActiveConversation { conversation_id: AIConversationId, terminal_surface_id: EntityId, }, ClearedConversationsForTerminalSurface { terminal_surface_id: EntityId, active_conversation_id: Option, /// All conversation ids that were live in `terminal_surface_id` before the clear. /// Subscribers (e.g. `QueuedQueryModel`) use this to drop per-conversation state. cleared_conversation_ids: Vec, }, UpdatedTodoList { terminal_surface_id: EntityId, }, UpdatedAutoexecuteOverride { terminal_surface_id: EntityId, }, /// Emitted when a conversation is split into two (on suggest starting new conversation) SplitConversation { terminal_surface_id: EntityId, old_conversation_id: AIConversationId, new_conversation_id: AIConversationId, }, /// This is emitted when an ephemeral/abandoned conversation is cleaned up /// (e.g. empty conversations the user never used, rejected passive code suggestions). /// `run_id` carries the conversation's last known server run identifier /// (captured before the in-memory record was dropped) so subscribers can /// still act on it without a history-model lookup. RemoveConversation { terminal_surface_id: EntityId, conversation_id: AIConversationId, run_id: Option, }, /// This is emitted when a user explicitly deletes an existing conversation. /// `run_id` is captured before the in-memory record was dropped — see /// the note on [`Self::RemoveConversation`]. DeletedConversation { terminal_surface_id: EntityId, conversation_id: AIConversationId, conversation_title: Option, run_id: Option, }, /// Emitted when conversations are restored for a terminal surface. RestoredConversations { terminal_surface_id: EntityId, conversation_ids: Vec, }, /// Emitted when conversation metadata is updated. /// `terminal_surface_id` is None when updating historical-only conversations. UpdatedConversationMetadata { terminal_surface_id: Option, conversation_id: AIConversationId, }, /// Emitted when a conversation title changes. UpdatedConversationTitle { terminal_surface_id: Option, conversation_id: AIConversationId, title: String, }, /// Emitted when conversation artifacts are updated (plans, PRs, etc.) UpdatedConversationArtifacts { terminal_surface_id: EntityId, conversation_id: AIConversationId, artifact: Artifact, }, /// Emitted when a conversation first receives its server-assigned conversation token /// (during StreamInit). Used by the StartAgentExecutor to resolve pending StartAgent /// actions for child agent conversations. ConversationServerTokenAssigned { conversation_id: AIConversationId, terminal_surface_id: EntityId, }, /// Emitted when a conversation moves between terminal surfaces — i.e. when /// `set_active_conversation_id` removes the conversation from the live /// list of one or more `previous_terminal_surface_id`s. The previous terminal surfaces /// must drop any rendered AI blocks for this conversation so the new /// terminal surface is the sole renderer; otherwise we end up with a transcript /// split across panes (some blocks in the old view, new exchanges in the /// new view). The `terminal_surface_id()` accessor returns the previous /// terminal surface so existing per-view event filters do the right thing. ConversationTransferredBetweenTerminalSurfaces { conversation_id: AIConversationId, previous_terminal_surface_id: EntityId, new_terminal_surface_id: EntityId, }, /// Links an executor-minted request to a freshly-created /// conversation. NewConversationRequestComplete { request_id: crate::ai::blocklist::StartAgentRequestId, conversation_id: AIConversationId, }, /// Emitted when a conversation's orchestration config is updated /// (live wire snapshot, user edit, or restore-hydration). /// Consumers that perform UI side effects should gate on `!from_restore`. OrchestrationConfigUpdated { conversation_id: AIConversationId, from_restore: bool, }, /// Emitted when a conversation's `conversation_usage_metadata` is updated /// (for example after a `StreamFinished` event). Subscribers that derive /// data from cross-conversation usage — e.g. the orchestration credit /// rollup in the agent-mode footer — can listen for this to re-render /// when a descendant's credits change. ConversationUsageMetadataUpdated { conversation_id: AIConversationId, }, /// Emitted when a sharer-owned conversation establishes a local /// shared session. LocalSharedSessionEstablished { conversation_id: AIConversationId, session_id: session_sharing_protocol::common::SessionId, }, } impl BlocklistAIHistoryEvent { /// Returns the terminal surface ID associated with this event, if any. /// Returns `None` for events that apply globally (e.g., historical conversation metadata updates). pub fn terminal_surface_id(&self) -> Option { match self { BlocklistAIHistoryEvent::StartedNewConversation { terminal_surface_id, .. } | BlocklistAIHistoryEvent::AppendedExchange { terminal_surface_id, .. } | BlocklistAIHistoryEvent::UpdatedStreamingExchange { terminal_surface_id, .. } | BlocklistAIHistoryEvent::UpdatedConversationStatus { terminal_surface_id, .. } | BlocklistAIHistoryEvent::SetActiveConversation { terminal_surface_id, .. } | BlocklistAIHistoryEvent::ClearedActiveConversation { terminal_surface_id, .. } | BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { terminal_surface_id, .. } | BlocklistAIHistoryEvent::ReassignedExchange { terminal_surface_id, .. } | BlocklistAIHistoryEvent::UpdatedTodoList { terminal_surface_id, .. } | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { terminal_surface_id, .. } | BlocklistAIHistoryEvent::SplitConversation { terminal_surface_id, .. } | BlocklistAIHistoryEvent::RemoveConversation { terminal_surface_id, .. } | BlocklistAIHistoryEvent::DeletedConversation { terminal_surface_id, .. } | BlocklistAIHistoryEvent::CreatedSubtask { terminal_surface_id, .. } | BlocklistAIHistoryEvent::RestoredConversations { terminal_surface_id, .. } | BlocklistAIHistoryEvent::UpgradedTask { terminal_surface_id, .. } | BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { previous_terminal_surface_id: terminal_surface_id, .. } | BlocklistAIHistoryEvent::UpdatedConversationArtifacts { terminal_surface_id, .. } | BlocklistAIHistoryEvent::ConversationServerTokenAssigned { terminal_surface_id, .. } => Some(*terminal_surface_id), // UpdatedConversationMetadata can have None when updating historical-only conversations BlocklistAIHistoryEvent::UpdatedConversationMetadata { terminal_surface_id, .. } | BlocklistAIHistoryEvent::UpdatedConversationTitle { terminal_surface_id, .. } => *terminal_surface_id, // NewConversationRequestComplete is executor-scoped and has no // terminal_surface_id. BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } => None, // OrchestrationConfigUpdated is conversation-scoped and has no // terminal_surface_id. BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } => None, // ConversationUsageMetadataUpdated is conversation-scoped and // has no terminal_surface_id. Cross-pane consumers (e.g. the // orchestrator footer reading descendant credits) can't be // disambiguated by a single terminal surface pane. BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => None, // Conversation-scoped; subscribers resolve the owning view via conversation_id. BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => None, } } } impl BlocklistAIHistoryModel { /// Emits [`BlocklistAIHistoryEvent::NewConversationRequestComplete`]. pub fn record_new_conversation_request_complete( &mut self, request_id: crate::ai::blocklist::StartAgentRequestId, conversation_id: AIConversationId, ctx: &mut ModelContext, ) { ctx.emit(BlocklistAIHistoryEvent::NewConversationRequestComplete { request_id, conversation_id, }); } } impl Entity for BlocklistAIHistoryModel { type Event = BlocklistAIHistoryEvent; } impl SingletonEntity for BlocklistAIHistoryModel {} /// Helper struct for showing AI history to the user. Guarantees that there is a user query and /// contains less data than [`AIAgentExchange`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AIQueryHistory { /// The input originating from the user. pub query_text: String, /// The time the input was sent. pub start_time: DateTime, /// The status of the output streaming from the AI API. pub output_status: AIQueryHistoryOutputStatus, /// The working directory when the AI query was submitted. pub working_directory: Option, /// The ordering category for this query in history. pub history_order: HistoryOrder, } impl AIQueryHistory { /// Creates a new [`AIQueryHistory`] for testing. #[cfg(test)] pub(crate) fn new_for_test( query_text: &str, start_time: DateTime, history_order: HistoryOrder, ) -> Self { Self { query_text: query_text.to_owned(), start_time, output_status: AIQueryHistoryOutputStatus::Pending, working_directory: None, history_order, } } } fn ai_exchange_to_query_history( value: &AIAgentExchange, history_order: HistoryOrder, ) -> Option { let query = value.input.iter().find_map(AIAgentInput::display_query)?; Some(AIQueryHistory { query_text: query, start_time: value.start_time, output_status: AIQueryHistoryOutputStatus::from(&value.output_status), working_directory: value.working_directory.clone(), history_order, }) } fn persisted_ai_input_to_query_history( value: &PersistedAIInput, history_order: HistoryOrder, ) -> Option { // Extract the query text from the first Query input let query_text = value .inputs .iter() .map(|input| match input { PersistedAIInputType::Query { text, .. } => Some(text.clone()), }) .next() .flatten()?; Some(AIQueryHistory { query_text, start_time: value.start_ts, output_status: value.output_status.clone(), working_directory: value.working_directory.clone(), history_order, }) } /// Status of output streaming from the AI API. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum AIQueryHistoryOutputStatus { /// We are waiting to or are currently streaming output. Pending, /// The user manually cancelled output streaming. Cancelled, /// Output streaming failed. Failed, /// Output streaming completed successfully. Completed, } impl AIQueryHistoryOutputStatus { /// Returns a string representation of the output status. pub(crate) fn display_text(&self) -> &'static str { match self { AIQueryHistoryOutputStatus::Completed => "Completed successfully", AIQueryHistoryOutputStatus::Pending => "Pending", AIQueryHistoryOutputStatus::Cancelled => "Cancelled by user", AIQueryHistoryOutputStatus::Failed => "Failed", } } pub(crate) fn icon(&self) -> Icon { match self { AIQueryHistoryOutputStatus::Completed => Icon::Check, AIQueryHistoryOutputStatus::Pending => Icon::Loading, AIQueryHistoryOutputStatus::Cancelled => Icon::SlashCircle, AIQueryHistoryOutputStatus::Failed => Icon::AlertTriangle, } } } impl From<&AIAgentOutputStatus> for AIQueryHistoryOutputStatus { fn from(status: &AIAgentOutputStatus) -> Self { match status { AIAgentOutputStatus::Streaming { .. } => Self::Pending, AIAgentOutputStatus::Finished { finished_output, .. } => match finished_output { FinishedAIAgentOutput::Cancelled { .. } => Self::Cancelled, FinishedAIAgentOutput::Error { .. } => Self::Failed, FinishedAIAgentOutput::Success { .. } => Self::Completed, }, } } } /// Updates the given tasks, which are presumed to be clones of tasks from a source conversation to be /// used to back a fork or copy of the source conversation. /// /// When `preserve_task_ids` is false, reassigns new task IDs to each forked task to ensure task IDs /// remain globally unique. When true, leaves task IDs as-is so the local fork's task store matches /// an externally-known set of task ids whose ClientActions must resolve in the local fork. /// /// Always prepends the given prefix to the root task's description. fn update_forked_task_properties( tasks: Vec, prefix: &str, preserve_task_ids: bool, title_override: Option<&str>, ) -> Vec { let root_description = |current: &str| match title_override { Some(title) => title.to_owned(), None => format!("{prefix}{current}"), }; if preserve_task_ids { return tasks .into_iter() .map(|mut t| { let is_root = t .dependencies .as_ref() .map(|deps| deps.parent_task_id.is_empty()) .unwrap_or(true); if is_root { t.description = root_description(&t.description); } t }) .collect(); } let mut old_to_new_task_ids = HashMap::new(); fn get_new_task_id(new_ids: &mut HashMap, old_task_id: &str) -> String { new_ids .entry(old_task_id.to_owned()) .or_insert_with(|| Uuid::new_v4().to_string()) .clone() } tasks .into_iter() .map(|mut t| { let new_id = get_new_task_id(&mut old_to_new_task_ids, &t.id); // Update task id to avoid duplicate tasks across conversations and ensure // all messages reference the new task id. t.id = new_id.clone(); for message in &mut t.messages { message.task_id = new_id.clone(); if let Some(subagent) = message.tool_call_mut().and_then(|tc| tc.subagent_mut()) { subagent.task_id = get_new_task_id(&mut old_to_new_task_ids, &subagent.task_id).clone(); } } if let Some(deps) = t .dependencies .as_mut() .filter(|deps| !deps.parent_task_id.is_empty()) { deps.parent_task_id = get_new_task_id(&mut old_to_new_task_ids, &deps.parent_task_id).clone(); } else { t.description = root_description(&t.description); } t }) .collect() } /// The default prefix used when forking a conversation. pub const FORK_PREFIX: &str = "(Fork) "; /// The prefix used when saving a conversation before a rewind operation. pub const PRE_REWIND_PREFIX: &str = "(Pre-Rewind) "; #[cfg(test)] #[path = "history_model_tests.rs"] mod tests;