Skip to content

Commit 48dd7b5

Browse files
Render delegated patch approval details (#19709)
## Why Fixes #19632. When a delegated agent requests approval for an in-progress file change, the parent TUI handles that request from an inactive thread. The app server already sent the `FileChange` item with the proposed diff, but the inactive-thread approval path was not recovering and rendering it the same way as the active-thread path. The result was an inconsistent approval prompt: main-thread edits show a normal patch preview history item before the approval modal, while delegated edits did not show that preview in the transcript flow. ## What Changed - Recover buffered or historical `FileChange` item changes when building inactive-thread file-change approval requests. - Reuse the app-server file-change conversion helper for both live transcript rendering and inactive-thread approvals. - Render recovered delegated patches as a normal patch preview history cell before the approval modal. - Keep apply-patch approval modals focused on the decision prompt and optional metadata; they do not render a synthetic command line or embed the diff body. ## Manual Repro And Verification I manually reproduced the issue using a file under `~/Desktop` so the write would require approval. Before the fix: 1. Ask the main thread: `Use apply_patch, not shell redirection or Python, to create ~/Desktop/bug1.txt with three short lines.` 2. Observe the expected TUI shape: the transcript shows a normal patch preview such as `• Added ~/Desktop/bug1.txt (+N -0)` above the approval modal, and the modal contains only the approval prompt/options without a synthetic command line. 3. Ask for the delegated path: `Spawn a worker. Have it use apply_patch, not shell redirection or Python, to create ~/Desktop/bug1.txt with four short lines.` 4. Observe the delegated approval is inconsistent: the parent view does not render the proposed patch as the normal transcript preview before the modal, so the diff context is missing from the stream or appears inside the modal instead of in the history flow. After the fix: 1. Repeat the delegated worker prompt with `apply_patch`. 2. Confirm the parent view renders the same normal patch preview history cell (`• Added ~/Desktop/bug1.txt (+N -0)` plus the diff) immediately before the approval modal. 3. Confirm the approval modal remains focused on the decision prompt. For delegated approvals it may show the worker thread label, but it should not show a `$ apply_patch` command line or embed the diff body in the modal.
1 parent 0e2300c commit 48dd7b5

9 files changed

Lines changed: 253 additions & 116 deletions

File tree

codex-rs/tui/src/app/tests.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ use codex_app_server_protocol::AdditionalPermissionProfile;
3030
use codex_app_server_protocol::AgentMessageDeltaNotification;
3131
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
3232
use codex_app_server_protocol::ConfigWarningNotification;
33+
use codex_app_server_protocol::FileChangeRequestApprovalParams;
34+
use codex_app_server_protocol::FileUpdateChange;
35+
use codex_app_server_protocol::ItemStartedNotification;
3336
use codex_app_server_protocol::JSONRPCErrorError;
3437
use codex_app_server_protocol::McpServerStartupState;
3538
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
@@ -38,6 +41,7 @@ use codex_app_server_protocol::NetworkApprovalProtocol as AppServerNetworkApprov
3841
use codex_app_server_protocol::NetworkPolicyAmendment as AppServerNetworkPolicyAmendment;
3942
use codex_app_server_protocol::NetworkPolicyRuleAction as AppServerNetworkPolicyRuleAction;
4043
use codex_app_server_protocol::NonSteerableTurnKind as AppServerNonSteerableTurnKind;
44+
use codex_app_server_protocol::PatchChangeKind;
4145
use codex_app_server_protocol::PermissionsRequestApprovalParams;
4246
use codex_app_server_protocol::RequestId as AppServerRequestId;
4347
use codex_app_server_protocol::ServerNotification;
@@ -70,6 +74,7 @@ use codex_protocol::models::PermissionProfile;
7074
use codex_protocol::protocol::AskForApproval;
7175
use codex_protocol::protocol::Event;
7276
use codex_protocol::protocol::EventMsg;
77+
use codex_protocol::protocol::FileChange;
7378
use codex_protocol::protocol::NetworkApprovalContext;
7479
use codex_protocol::protocol::NetworkApprovalProtocol;
7580
use codex_protocol::protocol::RolloutItem;
@@ -2522,6 +2527,75 @@ async fn inactive_thread_exec_approval_splits_shell_wrapped_command() {
25222527
);
25232528
}
25242529

2530+
#[tokio::test]
2531+
async fn inactive_thread_file_change_approval_recovers_buffered_changes() {
2532+
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
2533+
let thread_id = ThreadId::new();
2534+
app.enqueue_thread_notification(
2535+
thread_id,
2536+
ServerNotification::ItemStarted(ItemStartedNotification {
2537+
thread_id: thread_id.to_string(),
2538+
turn_id: "turn-approval".to_string(),
2539+
item: ThreadItem::FileChange {
2540+
id: "patch-approval".to_string(),
2541+
changes: vec![FileUpdateChange {
2542+
path: "README.md".to_string(),
2543+
kind: PatchChangeKind::Add,
2544+
diff: "hello\n".to_string(),
2545+
}],
2546+
status: codex_app_server_protocol::PatchApplyStatus::InProgress,
2547+
},
2548+
}),
2549+
)
2550+
.await
2551+
.expect("enqueue file change item");
2552+
2553+
let request = ServerRequest::FileChangeRequestApproval {
2554+
request_id: AppServerRequestId::Integer(9),
2555+
params: FileChangeRequestApprovalParams {
2556+
thread_id: thread_id.to_string(),
2557+
turn_id: "turn-approval".to_string(),
2558+
item_id: "patch-approval".to_string(),
2559+
reason: Some("command failed; retry without sandbox?".to_string()),
2560+
grant_root: None,
2561+
},
2562+
};
2563+
2564+
let request = app
2565+
.interactive_request_for_thread_request(thread_id, &request)
2566+
.await
2567+
.expect("expected file change approval request");
2568+
2569+
let ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch {
2570+
changes, reason, ..
2571+
}) = &request
2572+
else {
2573+
panic!("expected apply-patch approval request");
2574+
};
2575+
assert_eq!(
2576+
changes,
2577+
&HashMap::from([(
2578+
PathBuf::from("README.md"),
2579+
FileChange::Add {
2580+
content: "hello\n".to_string(),
2581+
},
2582+
)])
2583+
);
2584+
assert_eq!(
2585+
reason,
2586+
&Some("command failed; retry without sandbox?".to_string())
2587+
);
2588+
2589+
app.push_thread_interactive_request(request);
2590+
let cell = match app_event_rx.try_recv() {
2591+
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
2592+
other => panic!("expected patch preview history cell, saw {other:?}"),
2593+
};
2594+
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
2595+
assert!(rendered.contains("• Added README.md (+1 -0)"));
2596+
assert!(rendered.contains("1 +hello"));
2597+
}
2598+
25252599
#[tokio::test]
25262600
async fn inactive_thread_permissions_approval_preserves_file_system_permissions() {
25272601
let app = make_test_app().await;

codex-rs/tui/src/app/thread_events.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,40 @@ impl ThreadEventStore {
157157
.collect()
158158
}
159159

160+
pub(super) fn file_change_changes(
161+
&self,
162+
turn_id: &str,
163+
item_id: &str,
164+
) -> Option<Vec<codex_app_server_protocol::FileUpdateChange>> {
165+
self.buffer
166+
.iter()
167+
.rev()
168+
.find_map(|event| match event {
169+
ThreadBufferedEvent::Notification(ServerNotification::ItemStarted(
170+
notification,
171+
)) if turn_id_matches(turn_id, &notification.turn_id) => {
172+
file_change_item_changes(&notification.item, item_id)
173+
}
174+
ThreadBufferedEvent::Notification(ServerNotification::ItemCompleted(
175+
notification,
176+
)) if turn_id_matches(turn_id, &notification.turn_id) => {
177+
file_change_item_changes(&notification.item, item_id)
178+
}
179+
ThreadBufferedEvent::Request(_)
180+
| ThreadBufferedEvent::Notification(_)
181+
| ThreadBufferedEvent::HistoryEntryResponse(_)
182+
| ThreadBufferedEvent::FeedbackSubmission(_) => None,
183+
})
184+
.or_else(|| {
185+
self.turns
186+
.iter()
187+
.rev()
188+
.filter(|turn| turn_id_matches(turn_id, &turn.id))
189+
.flat_map(|turn| turn.items.iter().rev())
190+
.find_map(|item| file_change_item_changes(item, item_id))
191+
})
192+
}
193+
160194
pub(super) fn apply_thread_rollback(&mut self, response: &ThreadRollbackResponse) {
161195
self.turns = response.thread.turns.clone();
162196
self.buffer.clear();
@@ -231,6 +265,20 @@ impl ThreadEventStore {
231265
}
232266
}
233267

268+
fn turn_id_matches(request_turn_id: &str, candidate_turn_id: &str) -> bool {
269+
request_turn_id.is_empty() || request_turn_id == candidate_turn_id
270+
}
271+
272+
fn file_change_item_changes(
273+
item: &ThreadItem,
274+
item_id: &str,
275+
) -> Option<Vec<codex_app_server_protocol::FileUpdateChange>> {
276+
match item {
277+
ThreadItem::FileChange { id, changes, .. } if id == item_id => Some(changes.clone()),
278+
_ => None,
279+
}
280+
}
281+
234282
#[derive(Debug)]
235283
pub(super) struct ThreadEventChannel {
236284
pub(super) sender: mpsc::Sender<ThreadBufferedEvent>,

codex-rs/tui/src/app/thread_routing.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,17 @@ impl App {
194194
store.session.as_ref().map(|session| session.cwd.clone())
195195
}
196196

197+
async fn thread_file_change_changes(
198+
&self,
199+
thread_id: ThreadId,
200+
turn_id: &str,
201+
item_id: &str,
202+
) -> Option<Vec<codex_app_server_protocol::FileUpdateChange>> {
203+
let channel = self.thread_event_channels.get(&thread_id)?;
204+
let store = channel.store.lock().await;
205+
store.file_change_changes(turn_id, item_id)
206+
}
207+
197208
pub(super) async fn interactive_request_for_thread_request(
198209
&self,
199210
thread_id: ThreadId,
@@ -264,7 +275,11 @@ impl App {
264275
.thread_cwd(thread_id)
265276
.await
266277
.unwrap_or_else(|| self.config.cwd.clone()),
267-
changes: HashMap::new(),
278+
changes: self
279+
.thread_file_change_changes(thread_id, &params.turn_id, &params.item_id)
280+
.await
281+
.map(crate::app_server_approval_conversions::file_update_changes_to_core)
282+
.unwrap_or_default(),
268283
}),
269284
),
270285
ServerRequest::McpServerElicitationRequest { request_id, params } => {
@@ -311,6 +326,7 @@ impl App {
311326
pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) {
312327
match request {
313328
ThreadInteractiveRequest::Approval(request) => {
329+
self.render_inactive_patch_preview(&request);
314330
self.chat_widget.push_approval_request(request);
315331
}
316332
ThreadInteractiveRequest::McpServerElicitation(request) => {
@@ -320,6 +336,23 @@ impl App {
320336
}
321337
}
322338

339+
fn render_inactive_patch_preview(&mut self, request: &ApprovalRequest) {
340+
let ApprovalRequest::ApplyPatch {
341+
thread_label,
342+
cwd,
343+
changes,
344+
..
345+
} = request
346+
else {
347+
return;
348+
};
349+
if thread_label.is_none() || changes.is_empty() {
350+
return;
351+
}
352+
self.chat_widget
353+
.add_to_history(history_cell::new_patch_event(changes.clone(), cwd));
354+
}
355+
323356
pub(super) async fn pending_inactive_thread_requests(&self) -> Vec<(ThreadId, ServerRequest)> {
324357
let channels: Vec<(ThreadId, Arc<Mutex<ThreadEventStore>>)> = self
325358
.thread_event_channels

codex-rs/tui/src/app_server_approval_conversions.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
use codex_app_server_protocol::AdditionalNetworkPermissions;
2+
use codex_app_server_protocol::FileUpdateChange;
23
use codex_app_server_protocol::GrantedPermissionProfile;
34
use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext;
5+
use codex_app_server_protocol::PatchChangeKind;
6+
use codex_protocol::protocol::FileChange;
47
use codex_protocol::protocol::NetworkApprovalContext;
58
use codex_protocol::protocol::NetworkApprovalProtocol;
69
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
10+
use std::collections::HashMap;
11+
use std::path::PathBuf;
712

813
pub(crate) fn network_approval_context_to_core(
914
value: AppServerNetworkApprovalContext,
@@ -38,21 +43,50 @@ pub(crate) fn granted_permission_profile_from_request(
3843
}
3944
}
4045

46+
pub(crate) fn file_update_changes_to_core(
47+
changes: Vec<FileUpdateChange>,
48+
) -> HashMap<PathBuf, FileChange> {
49+
changes
50+
.into_iter()
51+
.map(|change| {
52+
let path = PathBuf::from(change.path);
53+
let file_change = match change.kind {
54+
PatchChangeKind::Add => FileChange::Add {
55+
content: change.diff,
56+
},
57+
PatchChangeKind::Delete => FileChange::Delete {
58+
content: change.diff,
59+
},
60+
PatchChangeKind::Update { move_path } => FileChange::Update {
61+
unified_diff: change.diff,
62+
move_path,
63+
},
64+
};
65+
(path, file_change)
66+
})
67+
.collect()
68+
}
69+
4170
#[cfg(test)]
4271
mod tests {
72+
use super::file_update_changes_to_core;
4373
use super::granted_permission_profile_from_request;
4474
use super::network_approval_context_to_core;
75+
use codex_app_server_protocol::FileUpdateChange;
76+
use codex_app_server_protocol::PatchChangeKind;
4577
use codex_protocol::models::FileSystemPermissions;
4678
use codex_protocol::models::NetworkPermissions;
4779
use codex_protocol::permissions::FileSystemAccessMode;
4880
use codex_protocol::permissions::FileSystemPath;
4981
use codex_protocol::permissions::FileSystemSandboxEntry;
5082
use codex_protocol::permissions::FileSystemSpecialPath;
83+
use codex_protocol::protocol::FileChange;
5184
use codex_protocol::protocol::NetworkApprovalContext;
5285
use codex_protocol::protocol::NetworkApprovalProtocol;
5386
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
5487
use codex_utils_absolute_path::AbsolutePathBuf;
5588
use pretty_assertions::assert_eq;
89+
use std::collections::HashMap;
5690
use std::path::PathBuf;
5791

5892
fn absolute_path(path: &str) -> AbsolutePathBuf {
@@ -73,6 +107,23 @@ mod tests {
73107
);
74108
}
75109

110+
#[test]
111+
fn converts_file_update_changes_to_core() {
112+
assert_eq!(
113+
file_update_changes_to_core(vec![FileUpdateChange {
114+
path: "foo.txt".to_string(),
115+
kind: PatchChangeKind::Add,
116+
diff: "hello\n".to_string(),
117+
}]),
118+
HashMap::from([(
119+
PathBuf::from("foo.txt"),
120+
FileChange::Add {
121+
content: "hello\n".to_string(),
122+
},
123+
)])
124+
);
125+
}
126+
76127
#[test]
77128
fn converts_request_permissions_into_granted_permissions() {
78129
assert_eq!(

codex-rs/tui/src/bottom_pane/approval_overlay.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ use crate::bottom_pane::CancellationEvent;
99
use crate::bottom_pane::list_selection_view::ListSelectionView;
1010
use crate::bottom_pane::list_selection_view::SelectionItem;
1111
use crate::bottom_pane::list_selection_view::SelectionViewParams;
12-
use crate::diff_render::DiffSummary;
1312
use crate::exec_command::strip_bash_lc_and_escape;
1413
use crate::history_cell;
1514
use crate::key_hint;
@@ -633,8 +632,6 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
633632
ApprovalRequest::ApplyPatch {
634633
thread_label,
635634
reason,
636-
cwd,
637-
changes,
638635
..
639636
} => {
640637
let mut header: Vec<Box<dyn Renderable>> = Vec::new();
@@ -643,21 +640,21 @@ fn build_header(request: &ApprovalRequest) -> Box<dyn Renderable> {
643640
"Thread: ".into(),
644641
thread_label.clone().bold(),
645642
])));
646-
header.push(Box::new(Line::from("")));
647643
}
648644
if let Some(reason) = reason
649645
&& !reason.is_empty()
650646
{
647+
if !header.is_empty() {
648+
header.push(Box::new(Line::from("")));
649+
}
651650
header.push(Box::new(
652651
Paragraph::new(Line::from_iter([
653652
"Reason: ".into(),
654653
reason.clone().italic(),
655654
]))
656655
.wrap(Wrap { trim: false }),
657656
));
658-
header.push(Box::new(Line::from("")));
659657
}
660-
header.push(DiffSummary::new(changes.clone(), cwd.clone()).into());
661658
Box::new(ColumnRenderable::with(header))
662659
}
663660
ApprovalRequest::McpElicitation {
@@ -1556,6 +1553,32 @@ mod tests {
15561553
);
15571554
}
15581555

1556+
#[test]
1557+
fn apply_patch_prompt_with_thread_label_omits_command_line() {
1558+
let (tx, _rx) = unbounded_channel::<AppEvent>();
1559+
let tx = AppEventSender::new(tx);
1560+
let mut changes = HashMap::new();
1561+
changes.insert(
1562+
PathBuf::from("bug1.txt"),
1563+
FileChange::Add {
1564+
content: "one\ntwo\nthree\n".to_string(),
1565+
},
1566+
);
1567+
let request = ApprovalRequest::ApplyPatch {
1568+
thread_id: ThreadId::new(),
1569+
thread_label: Some("Banach [worker]".to_string()),
1570+
id: "test".to_string(),
1571+
reason: None,
1572+
cwd: absolute_path("/tmp"),
1573+
changes,
1574+
};
1575+
let view = ApprovalOverlay::new(request, tx, Features::with_defaults());
1576+
let rendered = render_overlay_lines(&view, /*width*/ 120);
1577+
assert!(rendered.contains("Thread: Banach [worker]"));
1578+
assert!(rendered.contains("o to open thread"));
1579+
assert!(!rendered.contains("$ apply_patch"));
1580+
}
1581+
15591582
#[test]
15601583
fn network_exec_prompt_title_includes_host() {
15611584
let (tx, _rx) = unbounded_channel::<AppEvent>();

0 commit comments

Comments
 (0)