Restore agent tool panes and require explicit session resume

Persist direct-provider tool calls, results, display details, and turn identities so saved conversations rebuild individual panes. Keep restored runs dormant until explicitly resumed and bind queued shell commands to their actual execution blocks.
This commit is contained in:
2026-09-20 17:11:41 -05:00
parent d7fbc09bcf
commit 35ff43de7f
21 changed files with 1011 additions and 88 deletions
+10
View File
@@ -2455,6 +2455,16 @@ impl BlocklistAIActionModel {
let permission_denied = self
.denied_permissions
.remove(&(conversation_id, action_result.id.clone()));
if execution_ref.is_some() {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
if let Some(conversation) = history.conversation_mut(&conversation_id) {
if let Err(error) = conversation.save_provider_tool_result(&action_result, ctx)
{
log::error!("Failed to save provider tool result: {error}");
}
}
});
}
let tool_result = domain_tool_result(&action_result, permission_denied);
if execution_ref.is_none() {
self.finished_tool_results
@@ -277,8 +277,16 @@ impl ShellCommandExecutor {
move |result, ctx| {
// Remove the senders from the maps.
if let Some(handle) = handle.upgrade(ctx) {
handle.update(ctx, |me, _| {
handle.update(ctx, |me, ctx| {
me.prune_closed_senders(&block_selector);
if matches!(
result,
ActionResult::BlockNotFound | ActionResult::Cancelled
) {
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
action_id: action_id.clone(),
});
}
});
}
@@ -659,11 +667,9 @@ impl ShellCommandExecutor {
};
// Cancelling the wait future alone would report cancellation while the process keeps
// running. Terminate the exact requested command before resolving the action as cancelled.
if requested_block_is_running {
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
action_id: id.clone(),
});
}
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
action_id: id.clone(),
});
if !requested_block_is_running {
self.block_finished_senders.remove(&selector);
self.force_refresh_senders.remove(&selector);
+18 -5
View File
@@ -295,6 +295,20 @@ impl CLISubagentController {
});
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| {
if let ModelEvent::AfterBlockStarted { block_id, .. } = event {
let identity = {
let model = me.terminal_model.lock();
model.block_list().block_with_id(block_id).and_then(|block| {
Some((
block.ai_conversation_id()?,
block.requested_command_action_id()?.clone(),
))
})
};
if let Some((conversation_id, action_id)) = identity {
me.track_requested_command(block_id, conversation_id, &action_id);
}
}
if let ModelEvent::BlockCompleted(block_completed_event) = event {
let terminal_model = me.terminal_model.lock();
let Some(block) = terminal_model
@@ -654,11 +668,10 @@ impl CLISubagentController {
.and_then(|state| state.last_snapshot_at)
}
/// Begins tracking an agent-requested command before its shell event is dispatched.
///
/// The placeholder lets command completion and action-result events arrive in either order
/// without losing the completion that a subsequently-created CLI monitor needs.
pub fn track_requested_command(
/// Tracks the block assigned when an agent-requested command actually starts in the shell.
/// This must not use the active block from when the command was queued: that block may still
/// belong to a previous command. The placeholder preserves completion before a monitor exists.
fn track_requested_command(
&mut self,
block_id: &BlockId,
conversation_id: AIConversationId,
+59 -2
View File
@@ -2558,7 +2558,23 @@ impl BlocklistAIController {
terminal_surface_id,
conversation_ids,
} if *terminal_surface_id == me.terminal_surface_id => {
me.schedule_restored_provider_runs(conversation_ids, ctx);
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
for conversation_id in conversation_ids {
if history
.conversation(conversation_id)
.is_some_and(|conversation| {
conversation.active_provider_run_json().is_some()
})
{
history.update_conversation_status(
me.terminal_surface_id,
*conversation_id,
ConversationStatus::Cancelled,
ctx,
);
}
}
});
}
BlocklistAIHistoryEvent::UpdatedConversationStatus { new_status, .. }
if new_status.is_done() =>
@@ -4124,6 +4140,13 @@ impl BlocklistAIController {
);
return false;
};
// A saved checkpoint is dormant until the user explicitly resumes. In particular,
// child completion or late terminal events must not wake a restored conversation.
if conversation.active_provider_run_json().is_some()
&& !self.active_provider_runs.contains_key(&conversation_id)
{
return false;
}
// WaitingForEvents is treated as Success here: pending events
// drain via the next outbound request and the server-side
// supersede emits the resume signal.
@@ -4463,6 +4486,12 @@ impl BlocklistAIController {
log::error!("Tried to resume non-existent conversation: {conversation_id:?}");
return;
};
if conversation.active_provider_run_json().is_some()
&& !self.active_provider_runs.contains_key(&conversation_id)
{
self.schedule_restored_provider_runs(&[conversation_id], ctx);
return;
}
let task_id = {
let terminal_model = self.terminal_model.lock();
let active_block = terminal_model.block_list().active_block();
@@ -5573,7 +5602,8 @@ impl BlocklistAIController {
self.restoring_provider_runs
.extend(conversation_ids.iter().copied());
// RestoredConversations is emitted before terminal views finish rebuilding their blocks.
// Resume only after the caller releases its view/model borrows. Merely loading a
// conversation never reaches this path; the user must explicitly request Resume.
let _ = ctx.spawn(async {}, move |me, _, ctx| {
for conversation_id in conversation_ids {
me.restore_active_provider_run(conversation_id, ctx);
@@ -7664,6 +7694,7 @@ impl BlocklistAIController {
else {
return;
};
let action_context = run.action_context.clone();
let tool_titles = batch
.calls
.iter()
@@ -7823,6 +7854,32 @@ impl BlocklistAIController {
return;
}
}
if let Some(pending) = batch
.calls
.iter()
.find(|pending| pending.call.id == action.id.to_string())
{
let saved = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let conversation = history
.conversation_mut(&conversation_id)
.ok_or_else(|| anyhow::anyhow!("missing provider conversation"))?;
conversation.save_provider_tool(
&stream_id,
&action_context,
&pending.call,
&action,
ctx,
)
});
if let Err(error) = saved {
self.fail_active_provider_run(
conversation_id,
format!("failed to save provider tool history: {error}"),
ctx,
);
return;
}
}
actions.push(action);
}
if let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) {
+54
View File
@@ -3890,3 +3890,57 @@ fn failed_child_without_an_exchange_reaches_terminal_error() {
});
});
}
#[test]
fn restored_provider_checkpoint_waits_for_explicit_resume() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
let conversation_id = terminal.update(&mut app, |terminal, ctx| {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
let conversation_id =
history.start_new_conversation(terminal.id(), false, false, false, ctx);
history
.persist_active_provider_run_json(
conversation_id,
Some("unparsed saved checkpoint".to_owned()),
ctx,
)
.unwrap();
ctx.emit(
crate::ai::blocklist::BlocklistAIHistoryEvent::RestoredConversations {
terminal_surface_id: terminal.id(),
conversation_ids: vec![conversation_id],
},
);
conversation_id
})
});
terminal.update(&mut app, |terminal, ctx| {
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.unwrap();
assert_eq!(conversation.status(), &ConversationStatus::Cancelled);
assert_eq!(
conversation.active_provider_run_json(),
Some("unparsed saved checkpoint")
);
terminal.ai_controller().update(ctx, |controller, ctx| {
assert!(!controller
.restoring_provider_runs
.contains(&conversation_id));
assert!(!controller
.active_provider_runs
.contains_key(&conversation_id));
assert!(!controller.conversation_ready_for_pending_events(conversation_id, ctx));
controller.resume_conversation(conversation_id, Vec::new(), ctx);
assert!(controller
.restoring_provider_runs
.contains(&conversation_id));
// A second click must not schedule another restoration.
controller.resume_conversation(conversation_id, Vec::new(), ctx);
assert_eq!(controller.restoring_provider_runs.len(), 1);
});
});
});
}