pub mod helper; pub mod transaction; use std::collections::{HashMap, HashSet}; use std::fmt::Display; use std::ops::Deref; use ai::skills::SkillPathOrigin; use field_mask::{FieldMaskError, FieldMaskOperation}; use helper::{MessageExt, SubagentExt, ToolCallExt}; use itertools::Itertools; use prost_types::FieldMask; use serde::{Deserialize, Serialize}; use uuid::Uuid; use warp_multi_agent_api::message::tool_call::subagent::Metadata; use warp_multi_agent_api::message::Message; use warp_multi_agent_api::{self as api}; use super::api::convert_conversation::convert_tool_call_result_to_input; use super::api::{ user_inputs_from_messages, ConversionParams, ConvertAPIMessageToClientOutputMessage, }; use super::comment::CodeReview; use super::conversation::{context_in_exchanges, update_todo_list_from_todo_op}; use super::{ AIAgentContext, AIAgentExchange, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessage, AIAgentOutputStatus, MaybeAIAgentOutputMessage, MessageId, MessageToAIAgentOutputMessageError, Shared, }; use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion}; use crate::terminal::model::block::BlockId; use crate::AIAgentTodoList; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct TaskId(String); impl TaskId { pub fn new(id: String) -> Self { TaskId(id) } } impl From for String { fn from(id: TaskId) -> Self { id.0 } } impl Deref for TaskId { type Target = str; fn deref(&self) -> &Self::Target { &self.0 } } impl Display for TaskId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } #[derive(Debug, thiserror::Error)] pub enum UpdateTaskError { #[error("Task never initialized with CreateTask client action.")] TaskNotInitialized, #[error("Message not found")] MessageNotFound, #[error("Field mask operation failed: {0:#}")] FieldMask(#[from] FieldMaskError), #[error("Exchange not found.")] ExchangeNotFound, #[error("Attempted to update already-finished output.")] OutputAlreadyFinished, #[error("Attempted to update output that was never initialized.")] OutputNeverInitialized, #[error("Failed to convert API message to client type: {0}")] ConversionError(#[from] MessageToAIAgentOutputMessageError), } #[derive(Debug, thiserror::Error)] pub enum ExtractMessagesError { #[error("Task never initialized with CreateTask client action.")] TaskNotInitialized, #[error("First message not found: {0}")] FirstMessageNotFound(String), #[error("Last message not found: {0}")] LastMessageNotFound(String), #[error("Invalid range: first message appears after last message")] InvalidRange, #[error("Checksum mismatch: expected {expected} messages, found {actual}")] ChecksumMismatch { expected: u32, actual: u32 }, } #[derive(Debug, thiserror::Error)] pub enum UpgradeOptimisticTaskError { #[error("Attempted to upgrade optimistic root task with parent.")] RootWithUnexpectedParent, #[error("Attempted to upgrade optimistic CLI subagent task with no parent.")] CLISubagentMissingParent, #[error( "Attempted to upgrade optimistic CLI subagent task for subtask with no CLI subagent call." )] CLISubagentMissingSubagentCall, #[error("Attempted to upgrade task with server data.")] UnexpectedUpgrade, } #[derive(Debug, Clone)] pub(super) struct SubagentParams { pub(super) tool_call_id: String, pub(super) call: api::message::tool_call::Subagent, } #[derive(Debug, Clone)] struct ServerTask { source: api::Task, subagent_params: Option, } mod optimistic { use crate::terminal::model::block::BlockId; #[derive(Debug, Clone)] pub(super) struct CLIAgentSubtask { pub(super) block_id: BlockId, } #[derive(Debug, Clone)] pub(super) enum Task { Root, #[allow(dead_code)] // Used in the server-mode path; Bedrock direct creates Server tasks directly CLIAgent(CLIAgentSubtask), } impl Task { pub(super) fn is_root(&self) -> bool { matches!(self, Task::Root) } pub(super) fn is_cli_subagent(&self) -> bool { matches!(self, Task::CLIAgent(..)) } } } #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] enum TaskImpl { Server(ServerTask), Optimistic(optimistic::Task), } impl TaskImpl { fn server_data(&self) -> Option<&ServerTask> { match &self { TaskImpl::Server(data) => Some(data), TaskImpl::Optimistic(_) => None, } } } #[derive(Debug, Clone)] pub struct Task { id: TaskId, data: TaskImpl, /// List of `AIAgentExchange`s corresponding to messages contained in this task. exchanges: Vec, } #[derive(Clone, Copy)] pub(super) struct TaskMessageContext<'a> { pub(super) current_todo_list: Option<&'a AIAgentTodoList>, pub(super) active_code_review: Option<&'a CodeReview>, pub(super) skill_path_origin: &'a SkillPathOrigin, } impl Task { pub(super) fn new_optimistic_root() -> Self { Self { id: TaskId::new(Uuid::new_v4().to_string()), data: TaskImpl::Optimistic(optimistic::Task::Root), exchanges: vec![], } } pub(super) fn new_optimistic_cli_agent_subtask( block_id: BlockId, parent_task_id: Option, ) -> Self { let task_id = Uuid::new_v4().to_string(); Self { id: TaskId::new(task_id.clone()), // Use a Server task with a source and CLI subagent_params so that: // 1. add_messages can immediately append response messages without // needing a CreateTask upgrade from the server (Bedrock direct path // has no server to emit CreateTask). // 2. cli_subagent_block_id() returns the correct block ID so the // CLISubagentController can set up the monitoring view. // 3. is_cli_subagent() returns true so the task is filtered from the // main blocklist (responses only show in the CLI subagent panel). data: TaskImpl::Server(ServerTask { source: api::Task { id: task_id.clone(), description: String::new(), dependencies: parent_task_id.map(|parent_id| api::task::Dependencies { parent_task_id: parent_id, }), messages: vec![], summary: String::new(), server_data: String::new(), }, subagent_params: Some(SubagentParams { tool_call_id: String::new(), call: api::message::tool_call::Subagent { task_id, payload: String::new(), metadata: Some(Metadata::Cli( api::message::tool_call::subagent::CliSubagent { command_id: block_id.as_str().to_owned(), }, )), }, }), }), exchanges: vec![], } } #[allow(clippy::unwrap_in_result)] pub(super) fn into_server_created_task( mut self, task: api::Task, parent_task: Option<&api::Task>, current_todo_list: Option<&AIAgentTodoList>, active_code_review: Option<&CodeReview>, skill_path_origin: &SkillPathOrigin, ) -> Result { match self.data { TaskImpl::Optimistic(optimistic::Task::Root) => { if parent_task.is_some() { return Err(UpgradeOptimisticTaskError::RootWithUnexpectedParent); } self.id = TaskId::new(task.id.clone()); self.data = TaskImpl::Server(ServerTask { source: task, subagent_params: None, }) } TaskImpl::Optimistic(optimistic::Task::CLIAgent(_)) => { let Some(parent_task) = parent_task else { return Err(UpgradeOptimisticTaskError::CLISubagentMissingParent); }; let Some((subagent_call, subagent_tool_call_id)) = parent_task.messages.iter().find_map(|message| { let tool_call = message.tool_call()?; let subagent_call = tool_call.subagent()?; (subagent_call.task_id == task.id && subagent_call.is_cli()) .then(|| (subagent_call.clone(), tool_call.tool_call_id.clone())) }) else { return Err(UpgradeOptimisticTaskError::CLISubagentMissingSubagentCall); }; self.id = TaskId::new(task.id.clone()); self.data = TaskImpl::Server(ServerTask { source: task, subagent_params: Some(SubagentParams { call: subagent_call, tool_call_id: subagent_tool_call_id, }), }) } TaskImpl::Server(_) => return Err(UpgradeOptimisticTaskError::UnexpectedUpgrade), }; let messages = self.source().expect("exists").messages.clone(); if let Some(exchange_id) = self.exchanges.last().map(|exchange| exchange.id) { if let Err(e) = self.update_exchange_from_messages( messages, exchange_id, TaskMessageContext { current_todo_list, active_code_review, skill_path_origin, }, false, ) { log::error!( "Failed to update last exchange from messages upon converting to a server created task: {e:?}" ); } } Ok(self) } pub(super) fn new_restored_root( task: api::Task, restored_exchanges: impl Iterator, ) -> Self { let mut restored_exchanges = restored_exchanges.collect_vec(); restored_exchanges.sort_by_key(|exchange| exchange.start_time); Self { id: TaskId(task.id.clone()), data: TaskImpl::Server(ServerTask { source: task, subagent_params: None, }), exchanges: restored_exchanges, } } pub(super) fn new_subtask( subtask: api::Task, parent_task: &api::Task, existing_exchange: &AIAgentExchange, current_todo_list: Option<&AIAgentTodoList>, active_code_review: Option<&CodeReview>, skill_path_origin: &SkillPathOrigin, should_convert_input_messages: bool, ) -> Self { let subagent_call_and_id = parent_task.messages.iter().find_map(|message| { let tool_call = message.tool_call()?; let subagent_call = tool_call.subagent()?; (subagent_call.task_id == subtask.id) .then(|| (subagent_call.clone(), tool_call.tool_call_id.clone())) }); let mut new_exchange = AIAgentExchange { id: AIAgentExchangeId::new(), input: vec![], output_status: AIAgentOutputStatus::Streaming { output: None }, added_message_ids: Default::default(), start_time: chrono::Local::now(), finish_time: None, time_to_first_token_ms: None, working_directory: existing_exchange.working_directory.clone(), model_id: existing_exchange.model_id.clone(), coding_model_id: existing_exchange.coding_model_id.clone(), cli_agent_model_id: existing_exchange.cli_agent_model_id.clone(), computer_use_model_id: existing_exchange.computer_use_model_id.clone(), request_cost: None, response_initiator: existing_exchange.response_initiator.clone(), }; new_exchange .init_output( existing_exchange .output_status .output() .expect("exists") .get() .server_output_id .clone() .expect("has output id"), ) .expect("Exchange output is in streaming state."); let messages_clone = subtask.messages.clone(); let new_exchange_id = new_exchange.id; let mut me = Self { id: TaskId(subtask.id.clone()), exchanges: vec![new_exchange], data: TaskImpl::Server(ServerTask { source: subtask, subagent_params: subagent_call_and_id .map(|(call, tool_call_id)| SubagentParams { call, tool_call_id }), }), }; me.update_exchange_from_messages( messages_clone, new_exchange_id, TaskMessageContext { current_todo_list, active_code_review, skill_path_origin, }, should_convert_input_messages, ) .expect("Exchange exists and output is in 'streaming' state."); me } pub(super) fn new_restored_subtask( subtask: api::Task, parent_task: &api::Task, restored_exchanges: Vec, ) -> Self { let subagent_call_and_id = parent_task.messages.iter().find_map(|message| { let tool_call = message.tool_call()?; let subagent_call = tool_call.subagent()?; (subagent_call.task_id == subtask.id) .then(|| (subagent_call.clone(), tool_call.tool_call_id.clone())) }); Self { id: TaskId(subtask.id.clone()), exchanges: restored_exchanges, data: TaskImpl::Server(ServerTask { source: subtask, subagent_params: subagent_call_and_id .map(|(call, tool_call_id)| SubagentParams { call, tool_call_id }), }), } } /// Creates a new subtask from an api::Task and the parent task for moved messages. /// /// This is used by `MoveMessagesToNewTask` to create a task for holding moved /// messages. The parent_task should already contain the replacement messages /// (including the subagent call referencing this subtask) so that we can look /// up the subagent_params. pub(super) fn new_moved_messages_subtask(subtask: api::Task, parent_task: &api::Task) -> Self { let subagent_call_and_id = parent_task.messages.iter().find_map(|message| { let tool_call = message.tool_call()?; let subagent_call = tool_call.subagent()?; (subagent_call.task_id == subtask.id) .then(|| (subagent_call.clone(), tool_call.tool_call_id.clone())) }); Self { id: TaskId(subtask.id.clone()), exchanges: vec![], data: TaskImpl::Server(ServerTask { source: subtask, subagent_params: subagent_call_and_id .map(|(call, tool_call_id)| SubagentParams { call, tool_call_id }), }), } } pub(super) fn subagent_params(&self) -> Option<&SubagentParams> { self.data .server_data() .and_then(|data| data.subagent_params.as_ref()) } pub fn cli_subagent_block_id(&self) -> Option { match &self.data { TaskImpl::Server(server_data) => server_data .subagent_params .as_ref() .map(|params| ¶ms.call) .and_then(|call| match &call.metadata { Some(Metadata::Cli(call)) => Some(call.command_id.clone().into()), Some(Metadata::Research(_)) => None, Some(Metadata::Advice(_)) => None, Some(Metadata::ComputerUse(_)) => None, Some(Metadata::Summarization(_)) => None, Some(Metadata::ConversationSearch(_)) => None, Some(Metadata::WarpDocumentationSearch(_)) => None, None => None, }), TaskImpl::Optimistic(optimistic::Task::CLIAgent(subtask)) => { Some(subtask.block_id.clone()) } TaskImpl::Optimistic(optimistic::Task::Root) => None, } } pub(super) fn append_new_exchange( &mut self, existing_exchange: &AIAgentExchange, ) -> AIAgentExchangeId { let mut new_exchange = AIAgentExchange { id: AIAgentExchangeId::new(), input: vec![], output_status: AIAgentOutputStatus::Streaming { output: None }, added_message_ids: Default::default(), start_time: chrono::Local::now(), finish_time: None, time_to_first_token_ms: None, working_directory: existing_exchange.working_directory.clone(), model_id: existing_exchange.model_id.clone(), coding_model_id: existing_exchange.coding_model_id.clone(), cli_agent_model_id: existing_exchange.cli_agent_model_id.clone(), computer_use_model_id: existing_exchange.computer_use_model_id.clone(), request_cost: None, response_initiator: existing_exchange.response_initiator.clone(), }; new_exchange .init_output( existing_exchange .output_status .output() .expect("exists") .get() .server_output_id .clone() .expect("has output id"), ) .expect("Output is initialized as streaming."); let new_exchange_id = new_exchange.id; self.exchanges.push(new_exchange); new_exchange_id } pub fn id(&self) -> &TaskId { &self.id } pub fn parent_id(&self) -> Option { self.source() .and_then(|source| source.dependencies.as_ref()) .map(|dependencies| TaskId(dependencies.parent_task_id.clone())) } pub fn is_root_task(&self) -> bool { match &self.data { TaskImpl::Server(server_data) => server_data .source .dependencies .as_ref() .is_none_or(|deps| deps.parent_task_id.is_empty()), TaskImpl::Optimistic(task) => task.is_root(), } } pub fn is_cli_subagent(&self) -> bool { match &self.data { TaskImpl::Server(server_data) => server_data .subagent_params .as_ref() .is_some_and(|params| params.call.is_cli()), TaskImpl::Optimistic(task) => task.is_cli_subagent(), } } pub fn is_advice_subagent(&self) -> bool { match &self.data { TaskImpl::Server(server_data) => server_data .subagent_params .as_ref() .is_some_and(|params| params.call.is_advice()), TaskImpl::Optimistic(_) => false, } } pub fn is_computer_use_subagent(&self) -> bool { match &self.data { TaskImpl::Server(server_data) => server_data .subagent_params .as_ref() .is_some_and(|params| params.call.is_computer_use()), TaskImpl::Optimistic(_) => false, } } pub fn is_conversation_search_subagent(&self) -> bool { match &self.data { TaskImpl::Server(server_data) => server_data .subagent_params .as_ref() .is_some_and(|params| params.call.is_conversation_search()), TaskImpl::Optimistic(_) => false, } } pub fn is_warp_documentation_search_subagent(&self) -> bool { match &self.data { TaskImpl::Server(server_data) => server_data .subagent_params .as_ref() .is_some_and(|params| params.call.is_warp_documentation_search()), TaskImpl::Optimistic(_) => false, } } pub fn description(&self) -> &str { self.source() .map(|source| source.description.as_str()) .unwrap_or("") } pub fn exchanges(&self) -> impl Iterator { self.exchanges.iter() } pub fn exchange(&self, exchange_id: AIAgentExchangeId) -> Option<&AIAgentExchange> { self.exchanges .iter() .find(|exchange| exchange.id == exchange_id) } pub(super) fn exchange_mut( &mut self, exchange_id: AIAgentExchangeId, ) -> Option<&mut AIAgentExchange> { self.exchanges .iter_mut() .find(|exchange| exchange.id == exchange_id) } pub fn last_exchange(&self) -> Option<&AIAgentExchange> { self.exchanges.last() } pub fn exchanges_len(&self) -> usize { self.exchanges.len() } pub fn exchanges_reversed(&self) -> impl Iterator { self.exchanges.iter().rev() } pub fn source(&self) -> Option<&api::Task> { self.try_get_source().ok() } pub(super) fn source_for_persistence(&self) -> Option { match &self.data { TaskImpl::Server(server_data) => Some(server_data.source.clone()), // Optimistic root tasks have a client-generated UUID and no // server-side identity yet. Persisting a stub `api::Task` for them // produces an orphan row in `agent_tasks` that survives the later // server-side upgrade and breaks restore by competing with the // real server root for parentless-task selection. See QUALITY-774. TaskImpl::Optimistic(optimistic::Task::Root) => None, TaskImpl::Optimistic(optimistic::Task::CLIAgent(_)) => None, } } pub fn messages(&self) -> impl Iterator { self.source() .into_iter() .flat_map(|source| source.messages.iter()) } /// Returns all the `AIAgentContext` objects attached messages in this conversation. pub fn all_contexts(&self) -> impl Iterator { context_in_exchanges(self.exchanges()) } pub fn initial_working_directory(&self) -> Option { self.source() .and_then(Self::api_task_initial_working_directory) } pub fn api_task_initial_working_directory(task: &api::Task) -> Option { task.messages .iter() .find_map(|message| { message.message.as_ref().and_then(|content| { let context = match content { Message::UserQuery(user_query) => user_query.context.as_ref(), Message::ToolCallResult(tool_call_result) => { tool_call_result.context.as_ref() } Message::SystemQuery(system_query) => system_query.context.as_ref(), _ => None, }; context .and_then(|ctx| ctx.directory.as_ref()) .map(|dir| dir.pwd.clone()) }) }) .filter(|pwd| !pwd.is_empty()) } pub(super) fn update_description(&mut self, description: String) { let Ok(source) = self.try_get_source_mut() else { return; }; source.description = description; } pub(super) fn update_task_server_data(&mut self, new_server_data: String) { let Ok(source) = self.try_get_source_mut() else { return; }; source.server_data = new_server_data; } pub(super) fn append_exchange(&mut self, exchange: AIAgentExchange) { self.exchanges.push(exchange); } fn try_get_source(&self) -> Result<&api::Task, UpdateTaskError> { let TaskImpl::Server(ServerTask { source, .. }) = &self.data else { return Err(UpdateTaskError::TaskNotInitialized); }; Ok(source) } fn try_get_source_mut(&mut self) -> Result<&mut api::Task, UpdateTaskError> { let TaskImpl::Server(ServerTask { source, .. }) = &mut self.data else { return Err(UpdateTaskError::TaskNotInitialized); }; Ok(source) } pub(super) fn add_messages( &mut self, messages: Vec, exchange_id: AIAgentExchangeId, message_context: TaskMessageContext<'_>, should_convert_input_messages: bool, ) -> Result<(), UpdateTaskError> { if self.source().is_none() { return Err(UpdateTaskError::TaskNotInitialized); } self.update_exchange_from_messages( messages.clone(), exchange_id, message_context, should_convert_input_messages, )?; self.try_get_source_mut()?.messages.extend(messages); Ok(()) } pub(super) fn upsert_message( &mut self, message: api::Message, exchange_id: AIAgentExchangeId, message_context: TaskMessageContext<'_>, mask: FieldMask, should_convert_input_messages: bool, ) -> Result<&api::Message, UpdateTaskError> { let Some((idx, existing_message)) = self .try_get_source()? .messages .iter() .enumerate() .find(|(_, m)| message.id == m.id) else { self.add_messages( vec![message.clone()], exchange_id, message_context, should_convert_input_messages, )?; return self .try_get_source()? .messages .last() .ok_or(UpdateTaskError::MessageNotFound); }; let updated_message = FieldMaskOperation::update(&api::MESSAGE_DESCRIPTOR, existing_message, &message, mask) .apply() .map_err(UpdateTaskError::from)?; let id = self.id.clone(); let exchange_to_update = self .exchange_mut(exchange_id) .ok_or(UpdateTaskError::ExchangeNotFound)?; exchange_to_update.upsert_output_for_message( &updated_message, ConversionParams { task_id: &id, current_todo_list: message_context.current_todo_list, active_code_review: message_context.active_code_review, skill_path_origin: message_context.skill_path_origin, }, )?; // Task message updates can carry tool call result updates with them, // so we need to convert any tool call results and update the exchange accordingly // (this is necessary for session sharing, where the tool call input has not already been // optimistically inserted into the exchange) if should_convert_input_messages { if let Some(tool_call_result) = message.tool_call_result() { let mut document_versions: HashMap = HashMap::new(); if let Some(input) = convert_tool_call_result_to_input( &id, tool_call_result, &HashMap::new(), &mut document_versions, ) { if let Some(action_result) = input.action_result() { if let Some(existing_result) = exchange_to_update.input.iter_mut().find(|existing_input| { existing_input .action_result() .is_some_and(|existing_result| { existing_result.id == action_result.id }) }) { *existing_result = input; } } else { exchange_to_update.input.push(input) } } } } let source = self.try_get_source_mut()?; source.messages[idx] = updated_message; Ok(&source.messages[idx]) } pub(super) fn append_to_message_content( &mut self, message: api::Message, exchange_id: AIAgentExchangeId, message_context: TaskMessageContext<'_>, mask: FieldMask, ) -> Result<&api::Message, UpdateTaskError> { let Some((idx, existing_message)) = self .try_get_source()? .messages .iter() .enumerate() .find(|(_, m)| message.id == m.id) else { // Self-healing: if the message doesn't exist yet (e.g. the initial // AddMessagesToTask was dropped or arrived out of order), treat this // append as an implicit add so streaming doesn't break. log::warn!( "[bedrock] append_to_message_content: message_id={} not found, treating as implicit add", message.id ); self.add_messages(vec![message], exchange_id, message_context, false)?; return self .try_get_source()? .messages .last() .ok_or(UpdateTaskError::MessageNotFound); }; let updated_message = FieldMaskOperation::append(&api::MESSAGE_DESCRIPTOR, existing_message, &message, mask) .apply() .map_err(UpdateTaskError::from)?; let text_len = updated_message .message .as_ref() .map(|m| match m { api::message::Message::AgentOutput(o) => o.text.len(), _ => 0, }) .unwrap_or(0); log::info!( "[bedrock-debug] append_to_message_content: accumulated text_len={}", text_len ); let id = self.id.clone(); let exchange_to_update = self .exchange_mut(exchange_id) .ok_or(UpdateTaskError::ExchangeNotFound)?; exchange_to_update.upsert_output_for_message( &updated_message, ConversionParams { task_id: &id, current_todo_list: message_context.current_todo_list, active_code_review: message_context.active_code_review, skill_path_origin: message_context.skill_path_origin, }, )?; let source = self.try_get_source_mut()?; source.messages[idx] = updated_message; Ok(&source.messages[idx]) } pub(super) fn remove_exchange( &mut self, exchange_id: AIAgentExchangeId, ) -> Option { if let Some(index) = self .exchanges .iter() .position(|exchange| exchange.id == exchange_id) { Some(self.exchanges.remove(index)) } else { None } } /// Truncates all exchanges starting from the given exchange ID (inclusive). pub(super) fn truncate_exchanges_from(&mut self, from_exchange_id: AIAgentExchangeId) { if let Some(index) = self .exchanges .iter() .position(|exchange| exchange.id == from_exchange_id) { self.exchanges.truncate(index); } } /// Assigns fresh exchange IDs to all exchanges in this task. /// Used when forking conversations to avoid ID collisions with persisted blocks. pub(super) fn reassign_exchange_ids(&mut self) { for exchange in &mut self.exchanges { exchange.id = AIAgentExchangeId::new(); } } /// Removes messages with the given IDs from the task source. pub(super) fn remove_messages(&mut self, message_ids: &HashSet) { match self.try_get_source_mut() { Ok(source) => { source .messages .retain(|m| !message_ids.contains(&MessageId::new(m.id.clone()))); } Err(e) => { log::warn!("Failed to get mutable source for removing messages: {e:?}"); } } } /// Splices a range of messages into the task, returning the replaced messages. /// /// This finds the range from `first_message_id` to `last_message_id` (inclusive), /// validates that the range contains `expected_message_count` messages, removes /// those messages, inserts `replacement_messages` at the same position, and /// returns the extracted messages. /// /// Note: This only modifies the task's proto message list. It does NOT modify /// the exchange's client representation, so the UI remains unchanged during a /// live session. pub(super) fn splice_messages( &mut self, first_message_id: &str, last_message_id: &str, expected_message_count: u32, replacement_messages: Vec, ) -> Result, ExtractMessagesError> { let source = match self.try_get_source_mut() { Ok(s) => s, Err(_) => return Err(ExtractMessagesError::TaskNotInitialized), }; // Find the index of the first message. let first_idx = source .messages .iter() .position(|m| m.id == first_message_id) .ok_or_else(|| { ExtractMessagesError::FirstMessageNotFound(first_message_id.to_string()) })?; // Find the index of the last message. let last_idx = source .messages .iter() .position(|m| m.id == last_message_id) .ok_or_else(|| { ExtractMessagesError::LastMessageNotFound(last_message_id.to_string()) })?; // Validate that first comes before or equals last. if first_idx > last_idx { return Err(ExtractMessagesError::InvalidRange); } // Calculate the actual message count in the range (inclusive). let actual_count = (last_idx - first_idx + 1) as u32; if actual_count != expected_message_count { return Err(ExtractMessagesError::ChecksumMismatch { expected: expected_message_count, actual: actual_count, }); } // Drain the messages from the range. let extracted: Vec = source.messages.drain(first_idx..=last_idx).collect(); // Insert the replacement messages at the same position. source .messages .splice(first_idx..first_idx, replacement_messages); Ok(extracted) } fn update_exchange_from_messages( &mut self, messages: Vec, exchange_id: AIAgentExchangeId, message_context: TaskMessageContext<'_>, should_convert_input_messages: bool, ) -> Result<(), UpdateTaskError> { let exchange = self .exchange_mut(exchange_id) .ok_or(UpdateTaskError::ExchangeNotFound)?; exchange .added_message_ids .extend(messages.iter().map(|m| MessageId::new(m.id.clone()))); if should_convert_input_messages { let user_inputs = user_inputs_from_messages(&messages); for input in user_inputs.into_iter() { // If the input is an ActionResult with an action ID that already exists, // replace the existing one (to handle updates to long-running commands). if let Some(action_result) = input.action_result() { if let Some(existing_result) = exchange.input.iter_mut().find(|existing_input| { existing_input .action_result() .is_some_and(|existing_result| { existing_result.id == action_result.id }) }) { *existing_result = input; continue; } } exchange.input.push(input); } } let output = exchange.get_streaming_output()?; let output_messages: Result, MessageToAIAgentOutputMessageError> = messages .into_iter() .filter_map(|m| { match m.to_client_output_message(ConversionParams { task_id: &self.id, current_todo_list: message_context.current_todo_list, active_code_review: message_context.active_code_review, skill_path_origin: message_context.skill_path_origin, }) { Ok(MaybeAIAgentOutputMessage::Message(m)) => Some(Ok(m)), Ok(MaybeAIAgentOutputMessage::NoClientRepresentation) => None, Err(e) => Some(Err(e)), } }) .collect(); output.get_mut().messages.extend(output_messages?); Ok(()) } } /// Derives todo lists from tasks by replaying UpdateTodos operations in message order. pub fn derive_todo_lists_from_root_task(root_task: &Task) -> Vec { let mut todo_lists = Vec::new(); // Sort messages by their index in the task (messages are already in order within each task) // For simplicity, we'll iterate through messages and apply UpdateTodos operations for message in root_task.messages() { if let Some(api::message::Message::UpdateTodos(update)) = &message.message { if let Some(operation) = &update.operation { update_todo_list_from_todo_op(&mut todo_lists, operation.clone()); } } } todo_lists } impl AIAgentExchange { /// Upserts the output for a specific message. /// Note: this means updates will insert a new entry after previously added entries. fn upsert_output_for_message( &self, task_message: &api::Message, conversion_params: super::api::ConversionParams<'_>, ) -> Result<(), UpdateTaskError> { if let AIAgentOutputStatus::Streaming { output: Some(output), } = &self.output_status { let mut output = output.get_mut(); let message_idx = output .messages .iter() .position(|m| m.id.0 == task_message.id); let proto_text = task_message .message .as_ref() .map(|m| match m { api::message::Message::AgentOutput(o) => { format!("AgentOutput(text_len={})", o.text.len()) } api::message::Message::ToolCall(t) => { format!("ToolCall(id={})", t.tool_call_id) } other => format!("{:?}", std::mem::discriminant(other)), }) .unwrap_or_else(|| "None".to_string()); log::info!( "[bedrock-debug] upsert_output_for_message: id={}, proto_type={}", task_message.id, proto_text ); match task_message .clone() .to_client_output_message(conversion_params)? { MaybeAIAgentOutputMessage::Message(m) => { log::info!( "[bedrock-debug] upsert_output_for_message: client_message_type={:?}", std::mem::discriminant(&m.message) ); output.extend_citations(m.citations.clone()); if let Some(message_idx) = message_idx { output.messages[message_idx] = m; } else { output.messages.push(m); } } MaybeAIAgentOutputMessage::NoClientRepresentation => { log::warn!( "[bedrock-debug] upsert_output_for_message: NoClientRepresentation for msg_id={}", task_message.id ); } } } Ok(()) } /// Retrieves the output if it is currently being streamed. fn get_streaming_output(&self) -> Result, UpdateTaskError> { match &self.output_status { AIAgentOutputStatus::Streaming { output: Some(output), } => Ok(output.get_owned()), AIAgentOutputStatus::Streaming { output: None } => { Err(UpdateTaskError::OutputNeverInitialized) } _ => Err(UpdateTaskError::OutputAlreadyFinished), } } } #[cfg(test)] #[path = "task_tests.rs"] mod tests;