diff --git a/app/src/ai/agent/task.rs b/app/src/ai/agent/task.rs index 5ce6cb48..c401e094 100644 --- a/app/src/ai/agent/task.rs +++ b/app/src/ai/agent/task.rs @@ -828,19 +828,6 @@ impl Task { .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) @@ -1069,34 +1056,11 @@ impl AIAgentExchange { .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; @@ -1104,11 +1068,7 @@ impl AIAgentExchange { output.messages.push(m); } } - MaybeAIAgentOutputMessage::NoClientRepresentation => { - log::warn!( - "[bedrock-debug] upsert_output_for_message: NoClientRepresentation for msg_id={}", task_message.id - ); - } + MaybeAIAgentOutputMessage::NoClientRepresentation => {} } } diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 22dba730..f8d98991 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -1317,23 +1317,26 @@ impl BlocklistAIActionModel { .or_default() .push(action_result); - ctx.emit(BlocklistAIActionEvent::FinishedAction { - action_id, - conversation_id, - cancellation_reason, - }); if self .running_actions .get(&conversation_id) .is_some_and(|running| !running.is_empty()) { // Wait until the entire phase drains before scheduling subsequent actions or deciding - // whether to send a follow-up request. + // whether to send a follow-up request. In particular, don't emit `FinishedAction` yet: + // the controller treats that event as the phase-complete signal and could otherwise + // drain only a prefix of parallel tool results into the next LLM request. return; } - // The phase is fully drained — sort results back into original tool-call order. + // The phase is fully drained — sort results back into original tool-call order before + // notifying the controller that it may send the follow-up request. self.sort_finished_results(conversation_id); + ctx.emit(BlocklistAIActionEvent::FinishedAction { + action_id, + conversation_id, + cancellation_reason, + }); if self .pending_actions diff --git a/app/src/ai/blocklist/block/status_bar.rs b/app/src/ai/blocklist/block/status_bar.rs index 413d94d2..3227609d 100644 --- a/app/src/ai/blocklist/block/status_bar.rs +++ b/app/src/ai/blocklist/block/status_bar.rs @@ -119,9 +119,11 @@ pub struct BlocklistAIStatusBar { /// the warping indicator while the active block has a recorded LRC snapshot. last_read_refresh_handle: Option, - /// The time the warping indicator started showing for the current exchange. + /// The time the warping indicator started showing for the current conversation run. + /// Preserved across model follow-up exchanges triggered by tool calls. warping_start_time: Option, - /// The random loading message chosen for the current exchange, stable across re-renders. + /// The random loading message chosen for the current conversation run, stable across + /// re-renders and tool-call follow-up exchanges. warping_message: Option<&'static str>, /// Handle for the periodic timer that updates the warping elapsed timer UI. warping_timer_handle: Option, @@ -197,7 +199,18 @@ impl BlocklistAIStatusBar { ctx.notify(); } } - BlocklistAIHistoryEvent::UpdatedConversationStatus { .. } => { + BlocklistAIHistoryEvent::UpdatedConversationStatus { + conversation_id, + new_status, + .. + } => { + let is_active_conversation = me + .active_exchange_model + .as_ref() + .is_some_and(|model| model.conversation_id(ctx) == Some(*conversation_id)); + if is_active_conversation && !new_status.is_in_progress() { + me.stop_warping_timer(); + } ctx.notify(); } BlocklistAIHistoryEvent::SetActiveConversation { @@ -541,11 +554,20 @@ impl BlocklistAIStatusBar { AIBlockOutputStatus::Pending | AIBlockOutputStatus::Failed { .. } => (), } - // Stop the warping timer when the exchange is no longer streaming/pending. - // Placed after the match so `model` (which borrows self.active_exchange_model) - // is no longer used, avoiding borrow conflicts with &mut self. + // An exchange finishing does not necessarily mean the logical run is complete: tool calls + // can execute and append a follow-up exchange. Conversation status is the authoritative + // lifecycle signal, so preserve the timer and phrase while it remains in progress. + // Placed after the match so `model` (which borrows self.active_exchange_model) is no longer + // used, avoiding borrow conflicts with &mut self. if is_finished { - self.stop_warping_timer(); + let conversation_is_in_progress = self + .active_exchange_model + .as_ref() + .and_then(|model| model.conversation(ctx)) + .is_some_and(|conversation| conversation.status().is_in_progress()); + if !conversation_is_in_progress { + self.stop_warping_timer(); + } } ctx.notify(); @@ -692,9 +714,14 @@ impl BlocklistAIStatusBar { } /// Starts a 1-second periodic timer that keeps the warping elapsed-time indicator fresh. + /// + /// A tool call can append several exchanges during one logical agent run. Keep the original + /// phrase and start time across those exchanges so the status does not thrash and the elapsed + /// time represents the complete run. fn start_warping_timer(&mut self, ctx: &mut ViewContext) { - self.warping_start_time = Some(Instant::now()); - self.warping_message = Some(random_load_output_message()); + self.warping_start_time.get_or_insert_with(Instant::now); + self.warping_message + .get_or_insert_with(random_load_output_message); // Don't start a new timer if one is already running if self.warping_timer_handle.is_some() { return; diff --git a/app/src/ai/blocklist/block/view_impl.rs b/app/src/ai/blocklist/block/view_impl.rs index 0566c3d9..52804d71 100644 --- a/app/src/ai/blocklist/block/view_impl.rs +++ b/app/src/ai/blocklist/block/view_impl.rs @@ -879,7 +879,6 @@ impl View for AIBlock { } fn render(&self, app: &AppContext) -> Box { - log::info!("[bedrock-debug] AIBlock::render() called"); // When the AI block is hidden, we don't need to render anything. if self.is_hidden(app) { return ConstrainedBox::new(Empty::new().finish()) diff --git a/app/src/ai/blocklist/block/view_impl/output.rs b/app/src/ai/blocklist/block/view_impl/output.rs index 1ed41007..12cb35cc 100644 --- a/app/src/ai/blocklist/block/view_impl/output.rs +++ b/app/src/ai/blocklist/block/view_impl/output.rs @@ -255,27 +255,6 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box { | AIBlockOutputStatus::Failed { .. } => { if let Some(output) = status.output_to_render() { let output = output.get(); - let total_text_len: usize = output - .messages - .iter() - .map(|m| match &m.message { - AIAgentOutputMessageType::Text(t) => t - .sections - .iter() - .map(|s| match s { - AIAgentTextSection::PlainText { text } => text.text().len(), - AIAgentTextSection::Code { code, .. } => code.len(), - _ => 0, - }) - .sum::(), - _ => 0, - }) - .sum(); - log::info!( - "[bedrock-debug] render output: messages={}, total_text_len={}", - output.messages.len(), - total_text_len - ); let is_complete = matches!(status, AIBlockOutputStatus::Complete { .. }); let is_output_for_static_prompt_suggestions = props.model.contains_static_prompt_suggestion_input(app); diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index eda5ab79..bdaba69d 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -3977,7 +3977,6 @@ impl BlocklistAIController { "[bedrock-debug] AfterStreamFinished: NO actions to queue, was_passive={}, is_any_unfinished={}", was_passive_request, is_any_exchange_unfinished ); - // If this is a child conversation (has a parent) and the // stream ended with EndTurn and no actions, the child agent // is done. Mark it as Success so the StartAgentExecutor @@ -4002,17 +4001,34 @@ impl BlocklistAIController { }); } - // Crosscheck Work experiment: if the main agent finished - // with no actions (i.e., it produced a final response), and - // this is not a child or passive conversation, trigger a - // reviewer sub-agent to critique the output. - if !is_child && !was_passive_request { + // Crosscheck Work experiment: remember that the main agent produced a final + // response. Start the reviewer only after stream cleanup below so fast reviewer + // feedback cannot race the stale in-flight response-stream entry. + let should_trigger_crosscheck = !is_child && !was_passive_request; + + // Remove the completed stream before starting the reviewer. A fast reviewer can + // otherwise return feedback while this stream is still considered in flight, + // causing `send_request_input` to reject and silently drop the correction turn. + if cancellation.is_none() { + self.in_flight_response_streams.cleanup_stream(&stream_id); + + // Now that the stream is cleaned up, re-check for pending + // orchestration events that couldn't be drained earlier. + self.handle_pending_events_ready(conversation_id, ctx); + } + + if should_trigger_crosscheck { self.maybe_trigger_crosscheck(conversation_id, ctx); } } - // Cancelled streams will handle pending_response_stream updates synchronously. - if cancellation.is_none() { + // Cancelled streams handle pending-response-stream updates synchronously. The + // no-action crosscheck path above also cleans up early before starting its reviewer. + if cancellation.is_none() + && self + .in_flight_response_streams + .has_active_stream_for_conversation(conversation_id, ctx) + { self.in_flight_response_streams.cleanup_stream(&stream_id); // Now that the stream is cleaned up, re-check for pending diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index f7077c4e..979ddcac 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -6439,6 +6439,19 @@ impl TerminalView { self.maybe_send_agent_mode_desktop_notification(conversation_id, ctx); + // Once the complete agent run is terminal (including all tool-call follow-ups), + // restore the input caret if focus is still within this terminal view. Do not steal + // focus from another pane or window if the user moved on while the agent was busy. + if matches!( + new_status, + ConversationStatus::Success + | ConversationStatus::Error + | ConversationStatus::Cancelled + ) && ctx.is_self_or_child_focused() + { + self.redetermine_global_focus(ctx); + } + // Show AI credits modal for cloud-mode out-of-credits failures. if FeatureFlag::CloudMode.is_enabled() && self.is_ambient_agent_session(ctx)