Avoid cloning deferred TUI lifecycle payloads (#34347)

## What changed

- Move lifecycle and approval payloads into either the interrupt queue or the
  immediate handler instead of cloning them for both branches.
- Borrow MCP elicitation request metadata while constructing the corresponding
  form, and consume apply-patch changes after collecting notification paths.

## Testing

- Add a history replay test confirming deferred MCP start and completion events
  remain FIFO after streaming finishes.

GitOrigin-RevId: 84ed34f6ff173463a1da016dc140405392c0ba45
This commit is contained in:
Charlie Marsh authored and copyberry committed 2026-07-20 13:54:11 +00:00
1 parent 2244d11a1d
commit ada5a79ddf
7 files changed
+111 -52

No files matched your search

@@ -44,10 +44,10 @@ impl ChatWidget {
return;
}
}
let item2 = item.clone();
self.defer_or_handle(
|q| q.push_item_started(item),
|s| s.handle_command_execution_started_now(item2),
item,
InterruptManager::push_item_started,
Self::handle_command_execution_started_now,
);
}
@@ -155,10 +155,10 @@ impl ChatWidget {
return;
}
}
let item2 = item.clone();
self.defer_or_handle(
|q| q.push_item_completed(item),
|s| s.handle_command_execution_completed_now(item2),
item,
InterruptManager::push_item_completed,
Self::handle_command_execution_completed_now,
);
}
+7 -5
View File
@@ -396,19 +396,21 @@ impl ChatWidget {
self.interrupts = mgr;
}
/// Move a lifecycle payload into the interrupt queue or its immediate handler.
#[inline]
pub(super) fn defer_or_handle(
pub(super) fn defer_or_handle<T>(
&mut self,
push: impl FnOnce(&mut InterruptManager),
handle: impl FnOnce(&mut Self),
payload: T,
push: impl FnOnce(&mut InterruptManager, T),
handle: impl FnOnce(&mut Self, T),
) {
// Preserve deterministic FIFO across queued interrupts: once anything
// is queued due to an active write cycle, continue queueing until the
// queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin).
if self.stream_controller.is_some() || !self.interrupts.is_empty() {
push(&mut self.interrupts);
push(&mut self.interrupts, payload);
} else {
handle(self);
handle(self, payload);
}
}
@@ -1135,6 +1135,64 @@ async fn replayed_in_progress_mcp_tool_call_stays_active() {
assert!(!active.contains("MCP tool call completed without a result"));
}
#[tokio::test]
async fn deferred_mcp_lifecycle_events_keep_fifo_after_stream_finishes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let cwd = chat.config.cwd.to_path_buf();
chat.stream_controller = Some(crate::streaming::controller::StreamController::new(
/*width*/ Some(80),
cwd.as_path(),
chat.history_render_mode(),
));
chat.on_mcp_tool_call_started(AppServerThreadItem::McpToolCall {
id: "mcp-deferred".to_string(),
server: "copilot-bridge".to_string(),
tool: "copilot".to_string(),
status: codex_app_server_protocol::McpToolCallStatus::InProgress,
arguments: json!({"action": "wait"}),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: None,
duration_ms: None,
});
assert!(!chat.interrupts.is_empty());
chat.stream_controller = None;
chat.on_mcp_tool_call_completed(AppServerThreadItem::McpToolCall {
id: "mcp-deferred".to_string(),
server: "copilot-bridge".to_string(),
tool: "copilot".to_string(),
status: codex_app_server_protocol::McpToolCallStatus::Completed,
arguments: json!({"action": "wait"}),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(Box::new(codex_app_server_protocol::McpToolCallResult {
content: vec![json!({"type": "text", "text": "deferred result"})],
structured_content: None,
meta: None,
})),
error: None,
duration_ms: Some(5),
});
assert!(!chat.interrupts.is_empty());
assert!(drain_insert_history(&mut rx).is_empty());
chat.flush_interrupt_queue();
assert!(chat.interrupts.is_empty());
assert!(chat.transcript.active_cell.is_none());
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<String>();
assert!(rendered.contains("deferred result"), "{rendered}");
}
#[tokio::test]
async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -45,26 +45,26 @@ impl ChatWidget {
}
pub(super) fn on_file_change_completed(&mut self, item: ThreadItem) {
let item2 = item.clone();
self.defer_or_handle(
|q| q.push_item_completed(item),
|s| s.handle_file_change_completed_now(item2),
item,
InterruptManager::push_item_completed,
Self::handle_file_change_completed_now,
);
}
pub(super) fn on_mcp_tool_call_started(&mut self, item: ThreadItem) {
let item2 = item.clone();
self.defer_or_handle(
|q| q.push_item_started(item),
|s| s.handle_mcp_tool_call_started_now(item2),
item,
InterruptManager::push_item_started,
Self::handle_mcp_tool_call_started_now,
);
}
pub(super) fn on_mcp_tool_call_completed(&mut self, item: ThreadItem) {
let item2 = item.clone();
self.defer_or_handle(
|q| q.push_item_completed(item),
|s| s.handle_mcp_tool_call_completed_now(item2),
item,
InterruptManager::push_item_completed,
Self::handle_mcp_tool_call_completed_now,
);
}
+19 -19
View File
@@ -7,10 +7,10 @@ use super::*;
impl ChatWidget {
pub(super) fn on_exec_approval_request(&mut self, _id: String, ev: ExecApprovalRequestEvent) {
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_exec_approval(ev),
|s| s.handle_exec_approval_now(ev2),
ev,
InterruptManager::push_exec_approval,
Self::handle_exec_approval_now,
);
}
@@ -19,10 +19,10 @@ impl ChatWidget {
_id: String,
ev: ApplyPatchApprovalRequestEvent,
) {
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_apply_patch_approval(ev),
|s| s.handle_apply_patch_approval_now(ev2),
ev,
InterruptManager::push_apply_patch_approval,
Self::handle_apply_patch_approval_now,
);
}
@@ -256,27 +256,26 @@ impl ChatWidget {
request_id: AppServerRequestId,
params: McpServerElicitationRequestParams,
) {
let request_id2 = request_id.clone();
let params2 = params.clone();
self.defer_or_handle(
|q| q.push_elicitation(request_id, params),
|s| s.handle_elicitation_request_now(request_id2, params2),
(request_id, params),
|q, (request_id, params)| q.push_elicitation(request_id, params),
|s, (request_id, params)| s.handle_elicitation_request_now(request_id, params),
);
}
pub(super) fn on_request_user_input(&mut self, ev: ToolRequestUserInputParams) {
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_user_input(ev),
|s| s.handle_request_user_input_now(ev2),
ev,
InterruptManager::push_user_input,
Self::handle_request_user_input_now,
);
}
pub(super) fn on_request_permissions(&mut self, ev: RequestPermissionsEvent) {
let ev2 = ev.clone();
self.defer_or_handle(
|q| q.push_request_permissions(ev),
|s| s.handle_request_permissions_now(ev2),
ev,
InterruptManager::push_request_permissions,
Self::handle_request_permissions_now,
);
}
@@ -310,12 +309,13 @@ impl ChatWidget {
pub(crate) fn handle_apply_patch_approval_now(&mut self, ev: ApplyPatchApprovalRequestEvent) {
self.flush_answer_stream_with_separator();
let changed_paths = ev.changes.keys().cloned().collect();
let request = ApprovalRequest::ApplyPatch(ApplyPatchApprovalRequest {
thread_id: self.thread_id.unwrap_or_default(),
thread_label: None,
id: ev.call_id,
reason: ev.reason,
changes: ev.changes.clone(),
changes: ev.changes,
cwd: self.config.cwd.clone(),
});
self.bottom_pane
@@ -327,7 +327,7 @@ impl ChatWidget {
self.request_redraw();
self.notify(Notification::EditApprovalRequested {
cwd: self.config.cwd.to_path_buf(),
changes: ev.changes.keys().cloned().collect(),
changes: changed_paths,
});
}
@@ -354,7 +354,7 @@ impl ChatWidget {
} else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request(
thread_id,
request_id.clone(),
params.clone(),
&params,
) {
self.bottom_pane
.push_mcp_server_elicitation_request(request);