Handle orphan exec ends without clobbering active exploring cell (#12313)

Summary
- distinguish exec end handling targets (active tracking, active orphan
history, new cell) so unified exec responses don’t clobber unrelated
exploring cells
- ensure orphan ends flush existing exploring history when complete,
insert standalone history entries, and keep active cells correct
- add regression tests plus a snapshot covering the new behavior and
expose the ExecCell completion result for verification

Fix for https://github.com/openai/codex/issues/12278

---------

Co-authored-by: Josh McKinney <joshka@openai.com>
This commit is contained in:
jif-oai
2026-02-22 14:26:58 +00:00
committed by GitHub
parent 4666a6e631
commit 0a0caa9df2
4 changed files with 266 additions and 45 deletions

View File

@@ -2357,7 +2357,25 @@ impl ChatWidget {
elapsed
}
/// Finalizes an exec call while preserving the active exec cell grouping contract.
///
/// Exec begin/end events usually pair through `running_commands`, but unified exec can emit an
/// end event for a call that was never materialized as the current active `ExecCell` (for
/// example, when another exploring group is still active). In that case we render the end as a
/// standalone history entry instead of replacing or flushing the unrelated active exploring
/// cell. If this method treated every unknown end as "complete the active cell", the UI could
/// merge unrelated commands and hide still-running exploring work.
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
enum ExecEndTarget {
// Normal case: the active exec cell already tracks this call id.
ActiveTracked,
// We have an active exec group, but it does not contain this call id. Render the end
// as a standalone finalized history cell so the active group remains intact.
OrphanHistoryWhileActiveExec,
// No active exec cell can safely own this end; build a new cell from the end payload.
NewCell,
}
let running = self.running_commands.remove(&ev.call_id);
if self.suppressed_exec_calls.remove(&ev.call_id) {
return;
@@ -2368,49 +2386,96 @@ impl ChatWidget {
};
let is_unified_exec_interaction =
matches!(source, ExecCommandSource::UnifiedExecInteraction);
let needs_new = self
.active_cell
.as_ref()
.map(|cell| cell.as_any().downcast_ref::<ExecCell>().is_none())
.unwrap_or(true);
if needs_new {
self.flush_active_cell();
self.active_cell = Some(Box::new(new_active_exec_command(
ev.call_id.clone(),
command,
parsed,
source,
ev.interaction_input.clone(),
self.config.animations,
)));
}
if let Some(cell) = self
.active_cell
.as_mut()
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
{
let output = if is_unified_exec_interaction {
CommandOutput {
exit_code: ev.exit_code,
formatted_output: String::new(),
aggregated_output: String::new(),
let end_target = match self.active_cell.as_ref() {
Some(cell) => match cell.as_any().downcast_ref::<ExecCell>() {
Some(exec_cell)
if exec_cell
.iter_calls()
.any(|call| call.call_id == ev.call_id) =>
{
ExecEndTarget::ActiveTracked
}
} else {
CommandOutput {
exit_code: ev.exit_code,
formatted_output: ev.formatted_output.clone(),
aggregated_output: ev.aggregated_output.clone(),
Some(exec_cell) if exec_cell.is_active() => {
ExecEndTarget::OrphanHistoryWhileActiveExec
}
};
cell.complete_call(&ev.call_id, output, ev.duration);
if cell.should_flush() {
self.flush_active_cell();
} else {
self.bump_active_cell_revision();
Some(_) | None => ExecEndTarget::NewCell,
},
None => ExecEndTarget::NewCell,
};
// Unified exec interaction rows intentionally hide command output text in the exec cell and
// instead render the interaction-specific content elsewhere in the UI.
let output = if is_unified_exec_interaction {
CommandOutput {
exit_code: ev.exit_code,
formatted_output: String::new(),
aggregated_output: String::new(),
}
} else {
CommandOutput {
exit_code: ev.exit_code,
formatted_output: ev.formatted_output.clone(),
aggregated_output: ev.aggregated_output.clone(),
}
};
match end_target {
ExecEndTarget::ActiveTracked => {
if let Some(cell) = self
.active_cell
.as_mut()
.and_then(|c| c.as_any_mut().downcast_mut::<ExecCell>())
{
let completed = cell.complete_call(&ev.call_id, output, ev.duration);
debug_assert!(completed, "active exec cell should contain {}", ev.call_id);
if cell.should_flush() {
self.flush_active_cell();
} else {
self.bump_active_cell_revision();
self.request_redraw();
}
}
}
ExecEndTarget::OrphanHistoryWhileActiveExec => {
let mut orphan = new_active_exec_command(
ev.call_id.clone(),
command,
parsed,
source,
ev.interaction_input.clone(),
self.config.animations,
);
let completed = orphan.complete_call(&ev.call_id, output, ev.duration);
debug_assert!(
completed,
"new orphan exec cell should contain {}",
ev.call_id
);
self.needs_final_message_separator = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(orphan)));
self.request_redraw();
}
ExecEndTarget::NewCell => {
self.flush_active_cell();
let mut cell = new_active_exec_command(
ev.call_id.clone(),
command,
parsed,
source,
ev.interaction_input.clone(),
self.config.animations,
);
let completed = cell.complete_call(&ev.call_id, output, ev.duration);
debug_assert!(completed, "new exec cell should contain {}", ev.call_id);
if cell.should_flush() {
self.add_to_history(cell);
} else {
self.active_cell = Some(Box::new(cell));
self.bump_active_cell_revision();
self.request_redraw();
}
}
}
// Mark that actual work was done (command executed)
self.had_work_activity = true;