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()
.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 => {}
}
}
+10 -7
View File
@@ -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
+36 -9
View File
@@ -119,9 +119,11 @@ pub struct BlocklistAIStatusBar {
/// the warping indicator while the active block has a recorded LRC snapshot.
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>,
/// 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<SpawnedFutureHandle>,
@@ -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>) {
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;
-1
View File
@@ -879,7 +879,6 @@ impl View for AIBlock {
}
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.
if self.is_hidden(app) {
return ConstrainedBox::new(Empty::new().finish())
@@ -255,27 +255,6 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
| 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::<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_output_for_static_prompt_suggestions =
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={}",
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
+13
View File
@@ -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)