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
+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