Fix agent run completion and follow-up handling

Ensure parallel tool results finish before follow-ups, preserve warping
status across exchanges, clean up streams before crosscheck reviews, and
restore terminal focus when conversations complete. Remove temporary
debug
logging.
This commit is contained in:
Ryan Ward
2026-07-31 16:33:08 -05:00
parent 7f4891ec7c
commit d9cf0d8ae3
7 changed files with 84 additions and 87 deletions
+1 -41
View File
@@ -828,19 +828,6 @@ impl Task {
.apply() .apply()
.map_err(UpdateTaskError::from)?; .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 id = self.id.clone();
let exchange_to_update = self let exchange_to_update = self
.exchange_mut(exchange_id) .exchange_mut(exchange_id)
@@ -1069,34 +1056,11 @@ impl AIAgentExchange {
.iter() .iter()
.position(|m| m.id.0 == task_message.id); .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 match task_message
.clone() .clone()
.to_client_output_message(conversion_params)? .to_client_output_message(conversion_params)?
{ {
MaybeAIAgentOutputMessage::Message(m) => { 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()); output.extend_citations(m.citations.clone());
if let Some(message_idx) = message_idx { if let Some(message_idx) = message_idx {
output.messages[message_idx] = m; output.messages[message_idx] = m;
@@ -1104,11 +1068,7 @@ impl AIAgentExchange {
output.messages.push(m); output.messages.push(m);
} }
} }
MaybeAIAgentOutputMessage::NoClientRepresentation => { MaybeAIAgentOutputMessage::NoClientRepresentation => {}
log::warn!(
"[bedrock-debug] upsert_output_for_message: NoClientRepresentation for msg_id={}", task_message.id
);
}
} }
} }
+10 -7
View File
@@ -1317,23 +1317,26 @@ impl BlocklistAIActionModel {
.or_default() .or_default()
.push(action_result); .push(action_result);
ctx.emit(BlocklistAIActionEvent::FinishedAction {
action_id,
conversation_id,
cancellation_reason,
});
if self if self
.running_actions .running_actions
.get(&conversation_id) .get(&conversation_id)
.is_some_and(|running| !running.is_empty()) .is_some_and(|running| !running.is_empty())
{ {
// Wait until the entire phase drains before scheduling subsequent actions or deciding // 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; 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); self.sort_finished_results(conversation_id);
ctx.emit(BlocklistAIActionEvent::FinishedAction {
action_id,
conversation_id,
cancellation_reason,
});
if self if self
.pending_actions .pending_actions
+36 -9
View File
@@ -119,9 +119,11 @@ pub struct BlocklistAIStatusBar {
/// the warping indicator while the active block has a recorded LRC snapshot. /// the warping indicator while the active block has a recorded LRC snapshot.
last_read_refresh_handle: Option<SpawnedFutureHandle>, last_read_refresh_handle: Option<SpawnedFutureHandle>,
/// 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<Instant>, warping_start_time: Option<Instant>,
/// 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>, warping_message: Option<&'static str>,
/// Handle for the periodic timer that updates the warping elapsed timer UI. /// Handle for the periodic timer that updates the warping elapsed timer UI.
warping_timer_handle: Option<SpawnedFutureHandle>, warping_timer_handle: Option<SpawnedFutureHandle>,
@@ -197,7 +199,18 @@ impl BlocklistAIStatusBar {
ctx.notify(); 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(); ctx.notify();
} }
BlocklistAIHistoryEvent::SetActiveConversation { BlocklistAIHistoryEvent::SetActiveConversation {
@@ -541,11 +554,20 @@ impl BlocklistAIStatusBar {
AIBlockOutputStatus::Pending | AIBlockOutputStatus::Failed { .. } => (), AIBlockOutputStatus::Pending | AIBlockOutputStatus::Failed { .. } => (),
} }
// Stop the warping timer when the exchange is no longer streaming/pending. // An exchange finishing does not necessarily mean the logical run is complete: tool calls
// Placed after the match so `model` (which borrows self.active_exchange_model) // can execute and append a follow-up exchange. Conversation status is the authoritative
// is no longer used, avoiding borrow conflicts with &mut self. // 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 { 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(); ctx.notify();
@@ -692,9 +714,14 @@ impl BlocklistAIStatusBar {
} }
/// Starts a 1-second periodic timer that keeps the warping elapsed-time indicator fresh. /// 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>) { fn start_warping_timer(&mut self, ctx: &mut ViewContext<Self>) {
self.warping_start_time = Some(Instant::now()); self.warping_start_time.get_or_insert_with(Instant::now);
self.warping_message = Some(random_load_output_message()); self.warping_message
.get_or_insert_with(random_load_output_message);
// Don't start a new timer if one is already running // Don't start a new timer if one is already running
if self.warping_timer_handle.is_some() { if self.warping_timer_handle.is_some() {
return; return;
-1
View File
@@ -879,7 +879,6 @@ impl View for AIBlock {
} }
fn render(&self, app: &AppContext) -> Box<dyn Element> { fn render(&self, app: &AppContext) -> Box<dyn Element> {
log::info!("[bedrock-debug] AIBlock::render() called");
// When the AI block is hidden, we don't need to render anything. // When the AI block is hidden, we don't need to render anything.
if self.is_hidden(app) { if self.is_hidden(app) {
return ConstrainedBox::new(Empty::new().finish()) return ConstrainedBox::new(Empty::new().finish())
@@ -255,27 +255,6 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
| AIBlockOutputStatus::Failed { .. } => { | AIBlockOutputStatus::Failed { .. } => {
if let Some(output) = status.output_to_render() { if let Some(output) = status.output_to_render() {
let output = output.get(); 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::<usize>(),
_ => 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_complete = matches!(status, AIBlockOutputStatus::Complete { .. });
let is_output_for_static_prompt_suggestions = let is_output_for_static_prompt_suggestions =
props.model.contains_static_prompt_suggestion_input(app); props.model.contains_static_prompt_suggestion_input(app);
+24 -8
View File
@@ -3977,7 +3977,6 @@ impl BlocklistAIController {
"[bedrock-debug] AfterStreamFinished: NO actions to queue, was_passive={}, is_any_unfinished={}", "[bedrock-debug] AfterStreamFinished: NO actions to queue, was_passive={}, is_any_unfinished={}",
was_passive_request, is_any_exchange_unfinished was_passive_request, is_any_exchange_unfinished
); );
// If this is a child conversation (has a parent) and the // If this is a child conversation (has a parent) and the
// stream ended with EndTurn and no actions, the child agent // stream ended with EndTurn and no actions, the child agent
// is done. Mark it as Success so the StartAgentExecutor // is done. Mark it as Success so the StartAgentExecutor
@@ -4002,17 +4001,34 @@ impl BlocklistAIController {
}); });
} }
// Crosscheck Work experiment: if the main agent finished // Crosscheck Work experiment: remember that the main agent produced a final
// with no actions (i.e., it produced a final response), and // response. Start the reviewer only after stream cleanup below so fast reviewer
// this is not a child or passive conversation, trigger a // feedback cannot race the stale in-flight response-stream entry.
// reviewer sub-agent to critique the output. let should_trigger_crosscheck = !is_child && !was_passive_request;
if !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); self.maybe_trigger_crosscheck(conversation_id, ctx);
} }
} }
// Cancelled streams will handle pending_response_stream updates synchronously. // Cancelled streams handle pending-response-stream updates synchronously. The
if cancellation.is_none() { // 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); self.in_flight_response_streams.cleanup_stream(&stream_id);
// Now that the stream is cleaned up, re-check for pending // Now that the stream is cleaned up, re-check for pending
+13
View File
@@ -6439,6 +6439,19 @@ impl TerminalView {
self.maybe_send_agent_mode_desktop_notification(conversation_id, ctx); 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. // Show AI credits modal for cloud-mode out-of-credits failures.
if FeatureFlag::CloudMode.is_enabled() if FeatureFlag::CloudMode.is_enabled()
&& self.is_ambient_agent_session(ctx) && self.is_ambient_agent_session(ctx)