mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why This PR builds on [#22610](https://github.com/openai/codex/pull/22610) and is the app-server side of the migration from mutable per-turn `SandboxPolicy` replacement toward selecting immutable permission profiles by id plus mutable runtime workspace roots. Once permission profiles can carry their own immutable `workspace_roots`, app-server no longer needs to mutate the selected `PermissionProfile` just to represent thread-specific filesystem context. The mutable part now lives on the thread as explicit `runtimeWorkspaceRoots`, while `:workspace_roots` remains symbolic until the sandbox is realized for a turn. ## What Changed - Replaced the v2 permission-selection wrapper surface with plain profile ids for `thread/start`, `thread/resume`, `thread/fork`, and `turn/start`. - Removed the API surface for profile modifications (`PermissionProfileSelectionParams`, `PermissionProfileModificationParams`, `ActivePermissionProfileModification`). - Added experimental `runtimeWorkspaceRoots` fields to the thread lifecycle and turn-start APIs. - Threaded runtime workspace roots through core session/thread snapshots, turn overrides, app-server request handling, and command execution permission resolution. - Kept session permission state symbolic so later runtime root updates and cwd-only implicit-root retargeting rebind `:workspace_roots` correctly. - Updated the embedded clients just enough to send and restore the new thread state. - Refreshed the generated schema/TypeScript artifacts and the app-server README to match the new contract. ## Verification Targeted coverage for this layer lives in: - `codex-rs/app-server-protocol/src/protocol/v2/tests.rs` - `codex-rs/app-server/tests/suite/v2/thread_start.rs` - `codex-rs/app-server/tests/suite/v2/thread_resume.rs` - `codex-rs/app-server/tests/suite/v2/turn_start.rs` - `codex-rs/core/src/session/tests.rs` The key regression checks exercise that: - `runtimeWorkspaceRoots` resolve against the effective cwd on thread start. - Profile-declared workspace roots are excluded from the runtime workspace roots returned by app-server. - A turn-level runtime workspace-root update persists onto the thread and is returned by `thread/resume`. - A named permission profile selected on one turn remains symbolic so a later runtime-root-only turn update changes the actual sandbox writes. - A cwd-only turn update retargets the implicit runtime cwd root while preserving additional runtime roots. - The protocol fixtures and generated client artifacts stay in sync with the string-based permission selection contract. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22611). * #22612 * __->__ #22611
1692 lines
58 KiB
Rust
1692 lines
58 KiB
Rust
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[tokio::test]
|
|
async fn exec_approval_emits_proposed_command_and_decision_history() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Trigger an exec approval request with a short, single-line command
|
|
let ev = ExecApprovalRequestEvent {
|
|
call_id: "call-short".into(),
|
|
approval_id: Some("call-short".into()),
|
|
turn_id: "turn-short".into(),
|
|
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
|
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
|
reason: Some(
|
|
"this is a test reason such as one that would be produced by the model".into(),
|
|
),
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
available_decisions: None,
|
|
};
|
|
handle_exec_approval_request(&mut chat, "sub-short", ev);
|
|
|
|
let proposed_cells = drain_insert_history(&mut rx);
|
|
assert!(
|
|
proposed_cells.is_empty(),
|
|
"expected approval request to render via modal without emitting history cells"
|
|
);
|
|
|
|
// The approval modal should display the command snippet for user confirmation.
|
|
let area = Rect::new(0, 0, 80, chat.desired_height(/*width*/ 80));
|
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
|
chat.render(area, &mut buf);
|
|
assert_chatwidget_snapshot!("exec_approval_modal_exec", format!("{buf:?}"));
|
|
|
|
// Approve via keyboard and verify a concise decision history line is added
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
let decision = drain_insert_history(&mut rx)
|
|
.pop()
|
|
.expect("expected decision cell in history");
|
|
assert_chatwidget_snapshot!(
|
|
"exec_approval_history_decision_approved_short",
|
|
lines_to_single_string(&decision)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn app_server_exec_approval_request_splits_shell_wrapped_command() {
|
|
let script = r#"python3 -c 'print("Hello, world!")'"#;
|
|
let request = exec_approval_request_from_params(
|
|
AppServerCommandExecutionRequestApprovalParams {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
item_id: "item-1".to_string(),
|
|
started_at_ms: 0,
|
|
approval_id: Some("approval-1".to_string()),
|
|
reason: None,
|
|
network_approval_context: None,
|
|
command: Some(
|
|
shlex::try_join(["/bin/zsh", "-lc", script])
|
|
.expect("round-trippable shell wrapper"),
|
|
),
|
|
cwd: Some(test_path_buf("/tmp").abs()),
|
|
command_actions: None,
|
|
additional_permissions: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
available_decisions: None,
|
|
},
|
|
&test_path_buf("/tmp").abs(),
|
|
);
|
|
|
|
assert_eq!(
|
|
request.command,
|
|
vec![
|
|
"/bin/zsh".to_string(),
|
|
"-lc".to_string(),
|
|
script.to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_approval_uses_approval_id_when_present() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
handle_exec_approval_request(
|
|
&mut chat,
|
|
"sub-short",
|
|
ExecApprovalRequestEvent {
|
|
call_id: "call-parent".into(),
|
|
approval_id: Some("approval-subcommand".into()),
|
|
turn_id: "turn-short".into(),
|
|
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
|
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
|
reason: Some(
|
|
"this is a test reason such as one that would be produced by the model".into(),
|
|
),
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
available_decisions: None,
|
|
},
|
|
);
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
|
|
let mut found = false;
|
|
while let Ok(app_ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::ExecApproval { id, decision, .. },
|
|
..
|
|
} = app_ev
|
|
{
|
|
assert_eq!(id, "approval-subcommand");
|
|
assert_matches!(
|
|
decision,
|
|
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept
|
|
);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(found, "expected ExecApproval op to be sent");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Multiline command: modal should show full command, history records decision only
|
|
let ev_multi = ExecApprovalRequestEvent {
|
|
call_id: "call-multi".into(),
|
|
approval_id: Some("call-multi".into()),
|
|
turn_id: "turn-multi".into(),
|
|
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
|
|
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
|
reason: Some(
|
|
"this is a test reason such as one that would be produced by the model".into(),
|
|
),
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
available_decisions: None,
|
|
};
|
|
handle_exec_approval_request(&mut chat, "sub-multi", ev_multi);
|
|
let proposed_multi = drain_insert_history(&mut rx);
|
|
assert!(
|
|
proposed_multi.is_empty(),
|
|
"expected multiline approval request to render via modal without emitting history cells"
|
|
);
|
|
|
|
let area = Rect::new(0, 0, 80, chat.desired_height(/*width*/ 80));
|
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
|
chat.render(area, &mut buf);
|
|
let mut saw_first_line = false;
|
|
for y in 0..area.height {
|
|
let mut row = String::new();
|
|
for x in 0..area.width {
|
|
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
|
}
|
|
if row.contains("echo line1") {
|
|
saw_first_line = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
saw_first_line,
|
|
"expected modal to show first line of multiline snippet"
|
|
);
|
|
|
|
// Deny via keyboard; decision snippet should be single-line and elided with " ..."
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
let aborted_multi = drain_insert_history(&mut rx)
|
|
.pop()
|
|
.expect("expected aborted decision cell (multiline)");
|
|
assert_chatwidget_snapshot!(
|
|
"exec_approval_history_decision_aborted_multiline",
|
|
lines_to_single_string(&aborted_multi)
|
|
);
|
|
|
|
// Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ...
|
|
let long = format!("echo {}", "a".repeat(200));
|
|
let ev_long = ExecApprovalRequestEvent {
|
|
call_id: "call-long".into(),
|
|
approval_id: Some("call-long".into()),
|
|
turn_id: "turn-long".into(),
|
|
command: vec!["bash".into(), "-lc".into(), long],
|
|
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
|
reason: None,
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
available_decisions: None,
|
|
};
|
|
handle_exec_approval_request(&mut chat, "sub-long", ev_long);
|
|
let proposed_long = drain_insert_history(&mut rx);
|
|
assert!(
|
|
proposed_long.is_empty(),
|
|
"expected long approval request to avoid emitting history cells before decision"
|
|
);
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
|
let aborted_long = drain_insert_history(&mut rx)
|
|
.pop()
|
|
.expect("expected aborted decision cell (long)");
|
|
assert_chatwidget_snapshot!(
|
|
"exec_approval_history_decision_aborted_long",
|
|
lines_to_single_string(&aborted_long)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn preamble_keeps_working_status_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
|
|
// Regression sequence: a preamble line is committed to history before any exec/tool event.
|
|
// After commentary completes, the status row should be restored before subsequent work.
|
|
chat.on_task_started();
|
|
chat.on_agent_message_delta("Preamble line\n".to_string());
|
|
chat.on_commit_tick();
|
|
drain_insert_history(&mut rx);
|
|
complete_assistant_message(
|
|
&mut chat,
|
|
"msg-commentary-snapshot",
|
|
"Preamble line\n",
|
|
Some(MessagePhase::Commentary),
|
|
);
|
|
|
|
let height = chat.desired_height(/*width*/ 80);
|
|
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
|
|
.expect("create terminal");
|
|
terminal
|
|
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
|
.expect("draw preamble + status widget");
|
|
assert_chatwidget_snapshot!(
|
|
"preamble_keeps_working_status",
|
|
normalized_backend_snapshot(terminal.backend())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_begin_restores_status_indicator_after_preamble() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.on_task_started();
|
|
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
|
|
|
|
// Simulate a hidden status row during an active turn.
|
|
chat.bottom_pane.hide_status_indicator();
|
|
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
|
|
assert_eq!(chat.bottom_pane.is_task_running(), true);
|
|
|
|
begin_unified_exec_startup(&mut chat, "call-1", "proc-1", "sleep 2");
|
|
|
|
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_begin_restores_working_status_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.on_task_started();
|
|
chat.on_agent_message_delta("Preamble line\n".to_string());
|
|
chat.on_commit_tick();
|
|
drain_insert_history(&mut rx);
|
|
|
|
begin_unified_exec_startup(&mut chat, "call-1", "proc-1", "sleep 2");
|
|
|
|
let width: u16 = 80;
|
|
let height = chat.desired_height(width);
|
|
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
|
|
.expect("create terminal");
|
|
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
|
terminal
|
|
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
|
.expect("draw chatwidget");
|
|
assert_chatwidget_snapshot!(
|
|
"unified_exec_begin_restores_working_status",
|
|
normalized_backend_snapshot(terminal.backend())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_history_cell_shows_working_then_completed() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Begin command
|
|
let begin = begin_exec(&mut chat, "call-1", "echo done");
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet");
|
|
|
|
// End command successfully
|
|
end_exec(&mut chat, begin, "done", "", /*exit_code*/ 0);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
// Exec end now finalizes and flushes the exec cell immediately.
|
|
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
|
|
// Inspect the flushed exec cell rendering.
|
|
let lines = &cells[0];
|
|
let blob = lines_to_single_string(lines);
|
|
// New behavior: no glyph markers; ensure command is shown and no panic.
|
|
assert!(
|
|
blob.contains("• Ran"),
|
|
"expected summary header present: {blob:?}"
|
|
);
|
|
assert!(
|
|
blob.contains("echo done"),
|
|
"expected command text to be present: {blob:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_history_cell_shows_working_then_failed() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Begin command
|
|
let begin = begin_exec(&mut chat, "call-2", "false");
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet");
|
|
|
|
// End command with failure
|
|
end_exec(&mut chat, begin, "", "Bloop", /*exit_code*/ 2);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
// Exec end with failure should also flush immediately.
|
|
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
|
|
let lines = &cells[0];
|
|
let blob = lines_to_single_string(lines);
|
|
assert!(
|
|
blob.contains("• Ran false"),
|
|
"expected command and header text present: {blob:?}"
|
|
);
|
|
assert!(blob.to_lowercase().contains("bloop"), "expected error text");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_end_without_begin_uses_event_command() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let command = vec![
|
|
"bash".to_string(),
|
|
"-lc".to_string(),
|
|
"echo orphaned".to_string(),
|
|
];
|
|
let command_actions = codex_shell_command::parse_command::parse_command(&command)
|
|
.into_iter()
|
|
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd))
|
|
.collect();
|
|
let cwd = chat.config.cwd.clone();
|
|
handle_exec_end(
|
|
&mut chat,
|
|
AppServerThreadItem::CommandExecution {
|
|
id: "call-orphan".to_string(),
|
|
command: codex_shell_command::parse_command::shlex_join(&command),
|
|
cwd,
|
|
process_id: None,
|
|
source: ExecCommandSource::Agent,
|
|
status: AppServerCommandExecutionStatus::Completed,
|
|
command_actions,
|
|
aggregated_output: Some("done".to_string()),
|
|
exit_code: Some(0),
|
|
duration_ms: Some(5),
|
|
},
|
|
);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
|
|
let blob = lines_to_single_string(&cells[0]);
|
|
assert!(
|
|
blob.contains("• Ran echo orphaned"),
|
|
"expected command text to come from event: {blob:?}"
|
|
);
|
|
assert!(
|
|
!blob.contains("call-orphan"),
|
|
"call id should not be rendered when event has the command: {blob:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_end_without_begin_does_not_flush_unrelated_running_exploring_cell() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
|
|
begin_exec(&mut chat, "call-exploring", "cat /dev/null");
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
assert!(active_blob(&chat).contains("Read null"));
|
|
|
|
let orphan =
|
|
begin_unified_exec_startup(&mut chat, "call-orphan", "proc-1", "echo repro-marker");
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
end_exec(
|
|
&mut chat,
|
|
orphan,
|
|
"repro-marker\n",
|
|
"",
|
|
/*exit_code*/ 0,
|
|
);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 1, "only the orphan end should be inserted");
|
|
let orphan_blob = lines_to_single_string(&cells[0]);
|
|
assert!(
|
|
orphan_blob.contains("• Ran echo repro-marker"),
|
|
"expected orphan end to render a standalone entry: {orphan_blob:?}"
|
|
);
|
|
let active = active_blob(&chat);
|
|
assert!(
|
|
active.contains("• Exploring"),
|
|
"expected unrelated exploring call to remain active: {active:?}"
|
|
);
|
|
assert!(
|
|
active.contains("Read null"),
|
|
"expected active exploring command to remain visible: {active:?}"
|
|
);
|
|
assert!(
|
|
!active.contains("echo repro-marker"),
|
|
"orphaned end should not replace the active exploring cell: {active:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_end_without_begin_flushes_completed_unrelated_exploring_cell() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
|
|
let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la");
|
|
end_exec(&mut chat, begin_ls, "", "", /*exit_code*/ 0);
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
assert!(active_blob(&chat).contains("ls -la"));
|
|
|
|
let orphan = begin_unified_exec_startup(&mut chat, "call-after", "proc-1", "echo after");
|
|
end_exec(&mut chat, orphan, "after\n", "", /*exit_code*/ 0);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(
|
|
cells.len(),
|
|
2,
|
|
"completed exploring cell should flush before the orphan entry"
|
|
);
|
|
let first = lines_to_single_string(&cells[0]);
|
|
let second = lines_to_single_string(&cells[1]);
|
|
assert!(
|
|
first.contains("• Explored"),
|
|
"expected flushed exploring cell: {first:?}"
|
|
);
|
|
assert!(
|
|
first.contains("List ls -la"),
|
|
"expected flushed exploring cell: {first:?}"
|
|
);
|
|
assert!(
|
|
second.contains("• Ran echo after"),
|
|
"expected orphan end entry after flush: {second:?}"
|
|
);
|
|
assert!(
|
|
chat.transcript.active_cell.is_none(),
|
|
"both entries should be finalized"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn overlapping_exploring_exec_end_is_not_misclassified_as_orphan() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la");
|
|
let begin_cat = begin_exec(&mut chat, "call-cat", "cat foo.txt");
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
end_exec(&mut chat, begin_ls, "foo.txt\n", "", /*exit_code*/ 0);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert!(
|
|
cells.is_empty(),
|
|
"tracked end inside an exploring cell should not render as an orphan"
|
|
);
|
|
let active = active_blob(&chat);
|
|
assert!(
|
|
active.contains("List ls -la"),
|
|
"expected first command still grouped: {active:?}"
|
|
);
|
|
assert!(
|
|
active.contains("Read foo.txt"),
|
|
"expected second running command to stay in the same active cell: {active:?}"
|
|
);
|
|
assert!(
|
|
active.contains("• Exploring"),
|
|
"expected grouped exploring header to remain active: {active:?}"
|
|
);
|
|
|
|
end_exec(&mut chat, begin_cat, "hello\n", "", /*exit_code*/ 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_history_shows_unified_exec_startup_commands() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
|
|
let begin = begin_exec_with_source(
|
|
&mut chat,
|
|
"call-startup",
|
|
"echo unified exec startup",
|
|
ExecCommandSource::UnifiedExecStartup,
|
|
);
|
|
assert!(
|
|
drain_insert_history(&mut rx).is_empty(),
|
|
"exec begin should not flush until completion"
|
|
);
|
|
|
|
end_exec(
|
|
&mut chat,
|
|
begin,
|
|
"echo unified exec startup\n",
|
|
"",
|
|
/*exit_code*/ 0,
|
|
);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
|
|
let blob = lines_to_single_string(&cells[0]);
|
|
assert!(
|
|
blob.contains("• Ran echo unified exec startup"),
|
|
"expected startup command to render: {blob:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_history_shows_unified_exec_tool_calls() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
|
|
let begin = begin_exec_with_source(
|
|
&mut chat,
|
|
"call-startup",
|
|
"ls",
|
|
ExecCommandSource::UnifiedExecStartup,
|
|
);
|
|
end_exec(&mut chat, begin, "", "", /*exit_code*/ 0);
|
|
|
|
let blob = active_blob(&chat);
|
|
assert_eq!(blob, "• Explored\n └ List ls\n");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_unknown_end_with_active_exploring_cell_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
|
|
begin_exec(&mut chat, "call-exploring", "cat /dev/null");
|
|
let orphan =
|
|
begin_unified_exec_startup(&mut chat, "call-orphan", "proc-1", "echo repro-marker");
|
|
end_exec(
|
|
&mut chat,
|
|
orphan,
|
|
"repro-marker\n",
|
|
"",
|
|
/*exit_code*/ 0,
|
|
);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let history = cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<String>();
|
|
let active = active_blob(&chat);
|
|
let snapshot = format!("History:\n{history}\nActive:\n{active}");
|
|
assert_chatwidget_snapshot!(
|
|
"unified_exec_unknown_end_with_active_exploring_cell",
|
|
snapshot
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_end_after_task_complete_is_suppressed() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
|
|
let begin = begin_exec_with_source(
|
|
&mut chat,
|
|
"call-startup",
|
|
"echo unified exec startup",
|
|
ExecCommandSource::UnifiedExecStartup,
|
|
);
|
|
drain_insert_history(&mut rx);
|
|
|
|
chat.on_task_complete(
|
|
/*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
|
|
);
|
|
end_exec(&mut chat, begin, "", "", /*exit_code*/ 0);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert!(
|
|
cells.is_empty(),
|
|
"expected unified exec end after task complete to be suppressed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_interaction_after_task_complete_is_suppressed() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
chat.on_task_complete(
|
|
/*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
|
|
);
|
|
|
|
terminal_interaction(&mut chat, "call-1", "proc-1", "ls\n");
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert!(
|
|
cells.is_empty(),
|
|
"expected unified exec interaction after task complete to be suppressed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_wait_after_final_agent_message_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
|
|
begin_unified_exec_startup(&mut chat, "call-wait", "proc-1", "cargo test -p codex-core");
|
|
terminal_interaction(&mut chat, "call-wait-stdin", "proc-1", "");
|
|
|
|
complete_assistant_message(&mut chat, "msg-1", "Final response.", /*phase*/ None);
|
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let combined = cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<String>();
|
|
assert_chatwidget_snapshot!("unified_exec_wait_after_final_agent_message", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
|
|
begin_unified_exec_startup(
|
|
&mut chat,
|
|
"call-wait-stream",
|
|
"proc-1",
|
|
"cargo test -p codex-core",
|
|
);
|
|
terminal_interaction(&mut chat, "call-wait-stream-stdin", "proc-1", "");
|
|
|
|
handle_agent_message_delta(&mut chat, "Streaming response.");
|
|
handle_turn_completed(&mut chat, "turn-wait-1", /*duration_ms*/ None);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let combined = cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<String>();
|
|
assert_chatwidget_snapshot!("unified_exec_wait_before_streamed_agent_message", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn final_worked_for_uses_cumulative_turn_duration_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
|
|
let exec = begin_exec_with_source(
|
|
&mut chat,
|
|
"call-1",
|
|
"echo preparing",
|
|
ExecCommandSource::Agent,
|
|
);
|
|
end_exec(&mut chat, exec, "preparing\n", "", /*exit_code*/ 0);
|
|
|
|
complete_assistant_message(
|
|
&mut chat,
|
|
"msg-final",
|
|
"Final response.",
|
|
Some(MessagePhase::FinalAnswer),
|
|
);
|
|
handle_turn_completed(&mut chat, "turn-1", Some(125_000));
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let combined = cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<String>();
|
|
assert!(
|
|
combined.contains("Worked for 2m 05s"),
|
|
"expected final separator to use cumulative turn duration, got:\n{combined}"
|
|
);
|
|
assert_chatwidget_snapshot!("final_worked_for_uses_cumulative_turn_duration", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_wait_status_header_updates_on_late_command_display() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
chat.unified_exec_processes.push(UnifiedExecProcessSummary {
|
|
key: "proc-1".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
command_display: "sleep 5".to_string(),
|
|
recent_chunks: Vec::new(),
|
|
});
|
|
|
|
terminal_interaction(&mut chat, "call-1", "proc-1", "");
|
|
|
|
assert!(chat.transcript.active_cell.is_none());
|
|
assert_eq!(
|
|
chat.status_state.current_status.header,
|
|
"Waiting for background terminal"
|
|
);
|
|
let status = chat
|
|
.bottom_pane
|
|
.status_widget()
|
|
.expect("status indicator should be visible");
|
|
assert_eq!(status.header(), "Waiting for background terminal");
|
|
assert_eq!(status.details(), Some("sleep 5"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_waiting_multiple_empty_snapshots() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
begin_unified_exec_startup(&mut chat, "call-wait-1", "proc-1", "just fix");
|
|
|
|
terminal_interaction(&mut chat, "call-wait-1a", "proc-1", "");
|
|
terminal_interaction(&mut chat, "call-wait-1b", "proc-1", "");
|
|
assert_eq!(
|
|
chat.status_state.current_status.header,
|
|
"Waiting for background terminal"
|
|
);
|
|
let status = chat
|
|
.bottom_pane
|
|
.status_widget()
|
|
.expect("status indicator should be visible");
|
|
assert_eq!(status.header(), "Waiting for background terminal");
|
|
assert_eq!(status.details(), Some("just fix"));
|
|
|
|
handle_turn_completed(&mut chat, "turn-wait-3", /*duration_ms*/ None);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let combined = cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<String>();
|
|
assert_chatwidget_snapshot!("unified_exec_waiting_multiple_empty_after", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_wait_status_renders_command_in_single_details_row_snapshot() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
begin_unified_exec_startup(
|
|
&mut chat,
|
|
"call-wait-ui",
|
|
"proc-ui",
|
|
"cargo test -p codex-core -- --exact some::very::long::test::name",
|
|
);
|
|
|
|
terminal_interaction(&mut chat, "call-wait-ui-stdin", "proc-ui", "");
|
|
|
|
let rendered = render_bottom_popup(&chat, /*width*/ 48);
|
|
assert_chatwidget_snapshot!(
|
|
"unified_exec_wait_status_renders_command_in_single_details_row",
|
|
normalize_snapshot_paths(rendered)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_empty_then_non_empty_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
begin_unified_exec_startup(&mut chat, "call-wait-2", "proc-2", "just fix");
|
|
|
|
terminal_interaction(&mut chat, "call-wait-2a", "proc-2", "");
|
|
terminal_interaction(&mut chat, "call-wait-2b", "proc-2", "ls\n");
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
let combined = cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<String>();
|
|
assert_chatwidget_snapshot!("unified_exec_empty_then_non_empty_after", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unified_exec_non_empty_then_empty_snapshots() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
begin_unified_exec_startup(&mut chat, "call-wait-3", "proc-3", "just fix");
|
|
|
|
terminal_interaction(&mut chat, "call-wait-3a", "proc-3", "pwd\n");
|
|
terminal_interaction(&mut chat, "call-wait-3b", "proc-3", "");
|
|
assert_eq!(
|
|
chat.status_state.current_status.header,
|
|
"Waiting for background terminal"
|
|
);
|
|
let status = chat
|
|
.bottom_pane
|
|
.status_widget()
|
|
.expect("status indicator should be visible");
|
|
assert_eq!(status.header(), "Waiting for background terminal");
|
|
assert_eq!(status.details(), Some("just fix"));
|
|
let pre_cells = drain_insert_history(&mut rx);
|
|
let active_combined = pre_cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<String>();
|
|
assert_chatwidget_snapshot!("unified_exec_non_empty_then_empty_active", active_combined);
|
|
|
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
|
|
|
let post_cells = drain_insert_history(&mut rx);
|
|
let mut combined = pre_cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<String>();
|
|
let post = post_cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<String>();
|
|
if !combined.is_empty() && !post.is_empty() {
|
|
combined.push('\n');
|
|
}
|
|
combined.push_str(&post);
|
|
assert_chatwidget_snapshot!("unified_exec_non_empty_then_empty_after", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn view_image_tool_call_adds_history_cell() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let image_path = chat.config.cwd.join("example.png");
|
|
|
|
handle_view_image_tool_call(&mut chat, "call-image", image_path);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 1, "expected a single history cell");
|
|
let combined = lines_to_single_string(&cells[0]);
|
|
assert_chatwidget_snapshot!("local_image_attachment_history_snapshot", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_generation_call_adds_history_cell() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
handle_image_generation_end(
|
|
&mut chat,
|
|
"call-image-generation",
|
|
Some("A tiny blue square".into()),
|
|
Some(test_path_buf("/tmp/ig-1.png").abs()),
|
|
);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 1, "expected a single history cell");
|
|
let platform_file_url = url::Url::from_file_path(test_path_buf("/tmp/ig-1.png"))
|
|
.expect("test path should convert to file URL")
|
|
.to_string();
|
|
let combined =
|
|
lines_to_single_string(&cells[0]).replace(&platform_file_url, "file:///tmp/ig-1.png");
|
|
assert_chatwidget_snapshot!("image_generation_call_history_snapshot", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exec_history_extends_previous_when_consecutive() {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// 1) Start "ls -la" (List)
|
|
let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la");
|
|
assert_chatwidget_snapshot!("exploring_step1_start_ls", active_blob(&chat));
|
|
|
|
// 2) Finish "ls -la"
|
|
end_exec(&mut chat, begin_ls, "", "", /*exit_code*/ 0);
|
|
assert_chatwidget_snapshot!("exploring_step2_finish_ls", active_blob(&chat));
|
|
|
|
// 3) Start "cat foo.txt" (Read)
|
|
let begin_cat_foo = begin_exec(&mut chat, "call-cat-foo", "cat foo.txt");
|
|
assert_chatwidget_snapshot!("exploring_step3_start_cat_foo", active_blob(&chat));
|
|
|
|
// 4) Complete "cat foo.txt"
|
|
end_exec(
|
|
&mut chat,
|
|
begin_cat_foo,
|
|
"hello from foo",
|
|
"",
|
|
/*exit_code*/ 0,
|
|
);
|
|
assert_chatwidget_snapshot!("exploring_step4_finish_cat_foo", active_blob(&chat));
|
|
|
|
// 5) Start & complete "sed -n 100,200p foo.txt" (treated as Read of foo.txt)
|
|
let begin_sed_range = begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt");
|
|
end_exec(
|
|
&mut chat,
|
|
begin_sed_range,
|
|
"chunk",
|
|
"",
|
|
/*exit_code*/ 0,
|
|
);
|
|
assert_chatwidget_snapshot!("exploring_step5_finish_sed_range", active_blob(&chat));
|
|
|
|
// 6) Start & complete "cat bar.txt"
|
|
let begin_cat_bar = begin_exec(&mut chat, "call-cat-bar", "cat bar.txt");
|
|
end_exec(
|
|
&mut chat,
|
|
begin_cat_bar,
|
|
"hello from bar",
|
|
"",
|
|
/*exit_code*/ 0,
|
|
);
|
|
assert_chatwidget_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_shell_command_renders_output_not_exploring() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let begin_ls = begin_exec_with_source(
|
|
&mut chat,
|
|
"user-shell-ls",
|
|
"ls",
|
|
ExecCommandSource::UserShell,
|
|
);
|
|
end_exec(
|
|
&mut chat,
|
|
begin_ls,
|
|
"file1\nfile2\n",
|
|
"",
|
|
/*exit_code*/ 0,
|
|
);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(
|
|
cells.len(),
|
|
1,
|
|
"expected a single history cell for the user command"
|
|
);
|
|
let blob = lines_to_single_string(cells.first().unwrap());
|
|
assert_chatwidget_snapshot!("user_shell_ls_output", blob);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let thread_id = ThreadId::new();
|
|
let rollout_file = NamedTempFile::new().unwrap();
|
|
let configured = crate::session_state::ThreadSessionState {
|
|
thread_id,
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "test-model".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(rollout_file.path().to_path_buf()),
|
|
};
|
|
chat.handle_thread_session(configured);
|
|
drain_insert_history(&mut rx);
|
|
while op_rx.try_recv().is_ok() {}
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
|
|
chat.bottom_pane
|
|
.set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
match op_rx.try_recv() {
|
|
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
|
|
other => panic!("expected RunUserShellCommand op, got {other:?}"),
|
|
}
|
|
assert_matches!(
|
|
rx.try_recv(),
|
|
Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "!echo hi"
|
|
);
|
|
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn user_message_during_user_shell_command_is_queued_not_steered() {
|
|
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
let begin = begin_exec_with_source(
|
|
&mut chat,
|
|
"user-shell-sleep",
|
|
"sleep 10",
|
|
ExecCommandSource::UserShell,
|
|
);
|
|
|
|
assert!(chat.only_user_shell_commands_running());
|
|
chat.bottom_pane
|
|
.set_composer_text("hi".to_string(), Vec::new(), Vec::new());
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
|
assert_eq!(chat.queued_user_message_texts(), vec!["hi".to_string()]);
|
|
|
|
end_exec(&mut chat, begin, "", "", /*exit_code*/ 0);
|
|
complete_assistant_message(
|
|
&mut chat,
|
|
"msg-done",
|
|
"done",
|
|
Some(MessagePhase::FinalAnswer),
|
|
);
|
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
|
|
|
match next_submit_op(&mut op_rx) {
|
|
Op::UserTurn { items, .. } => assert_eq!(
|
|
items,
|
|
vec![UserInput::Text {
|
|
text: "hi".to_string(),
|
|
text_elements: Vec::new(),
|
|
}]
|
|
),
|
|
other => panic!("expected queued user message after shell completion, got {other:?}"),
|
|
}
|
|
assert!(chat.input_queue.queued_user_messages.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_slash_command_while_task_running_snapshot() {
|
|
// Build a chat widget and simulate an active task
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.bottom_pane.set_task_running(/*running*/ true);
|
|
|
|
// Dispatch a command that is unavailable while a task runs (e.g., /model)
|
|
chat.dispatch_command(SlashCommand::Model);
|
|
|
|
// Drain history and snapshot the rendered error line(s)
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert!(
|
|
!cells.is_empty(),
|
|
"expected an error message history cell to be emitted",
|
|
);
|
|
let blob = lines_to_single_string(cells.last().unwrap());
|
|
assert_chatwidget_snapshot!("disabled_slash_command_while_task_running_snapshot", blob);
|
|
}
|
|
|
|
//
|
|
// Snapshot test: command approval modal
|
|
//
|
|
// Synthesizes an exec approval request to trigger the approval modal
|
|
// and snapshots the visual output using the ratatui TestBackend.
|
|
#[tokio::test]
|
|
async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
|
|
// Build a chat widget with manual channels to avoid spawning the agent.
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
// Ensure policy allows surfacing approvals explicitly (not strictly required for direct event).
|
|
chat.config
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::OnRequest.to_core())?;
|
|
// Inject an exec approval request to display the approval modal.
|
|
let ev = ExecApprovalRequestEvent {
|
|
call_id: "call-approve-cmd".into(),
|
|
approval_id: Some("call-approve-cmd".into()),
|
|
turn_id: "turn-approve-cmd".into(),
|
|
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
|
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
|
reason: Some(
|
|
"this is a test reason such as one that would be produced by the model".into(),
|
|
),
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
|
|
command: vec!["echo".into(), "hello".into(), "world".into()],
|
|
}),
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
available_decisions: None,
|
|
};
|
|
handle_exec_approval_request(&mut chat, "sub-approve", ev);
|
|
// Render to a fixed-size test terminal and snapshot.
|
|
// Call desired_height first and use that exact height for rendering.
|
|
let width = 100;
|
|
let height = chat.desired_height(width);
|
|
let mut terminal =
|
|
crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height))
|
|
.expect("create terminal");
|
|
let viewport = Rect::new(0, 0, width, height);
|
|
terminal.set_viewport_area(viewport);
|
|
|
|
terminal
|
|
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
|
.expect("draw approval modal");
|
|
assert!(
|
|
terminal
|
|
.backend()
|
|
.vt100()
|
|
.screen()
|
|
.contents()
|
|
.contains("echo hello world")
|
|
);
|
|
assert_chatwidget_snapshot!(
|
|
"approval_modal_exec",
|
|
terminal.backend().vt100().screen().contents()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Snapshot test: command approval modal without a reason
|
|
// Ensures spacing looks correct when no reason text is provided.
|
|
#[tokio::test]
|
|
async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.config
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::OnRequest.to_core())?;
|
|
|
|
let ev = ExecApprovalRequestEvent {
|
|
call_id: "call-approve-cmd-noreason".into(),
|
|
approval_id: Some("call-approve-cmd-noreason".into()),
|
|
turn_id: "turn-approve-cmd-noreason".into(),
|
|
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
|
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
|
reason: None,
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
|
|
command: vec!["echo".into(), "hello".into(), "world".into()],
|
|
}),
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
available_decisions: None,
|
|
};
|
|
handle_exec_approval_request(&mut chat, "sub-approve-noreason", ev);
|
|
|
|
let width = 100;
|
|
let height = chat.desired_height(width);
|
|
let mut terminal =
|
|
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
|
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
|
terminal
|
|
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
|
.expect("draw approval modal (no reason)");
|
|
assert_chatwidget_snapshot!(
|
|
"approval_modal_exec_no_reason",
|
|
terminal.backend().vt100().screen().contents()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Snapshot test: approval modal with a proposed execpolicy prefix that is multi-line;
|
|
// we should not offer adding it to execpolicy.
|
|
#[tokio::test]
|
|
async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
|
|
-> anyhow::Result<()> {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.config
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::OnRequest.to_core())?;
|
|
|
|
let script = "python - <<'PY'\nprint('hello')\nPY".to_string();
|
|
let command = vec!["bash".into(), "-lc".into(), script];
|
|
let ev = ExecApprovalRequestEvent {
|
|
call_id: "call-approve-cmd-multiline-trunc".into(),
|
|
approval_id: Some("call-approve-cmd-multiline-trunc".into()),
|
|
turn_id: "turn-approve-cmd-multiline-trunc".into(),
|
|
command: command.clone(),
|
|
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
|
|
reason: None,
|
|
network_approval_context: None,
|
|
proposed_execpolicy_amendment: Some(ExecPolicyAmendment { command }),
|
|
proposed_network_policy_amendments: None,
|
|
additional_permissions: None,
|
|
available_decisions: None,
|
|
};
|
|
handle_exec_approval_request(&mut chat, "sub-approve-multiline-trunc", ev);
|
|
|
|
let width = 100;
|
|
let height = chat.desired_height(width);
|
|
let mut terminal =
|
|
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
|
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
|
terminal
|
|
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
|
.expect("draw approval modal (multiline prefix)");
|
|
let contents = terminal.backend().vt100().screen().contents();
|
|
assert!(!contents.contains("don't ask again"));
|
|
assert_chatwidget_snapshot!(
|
|
"approval_modal_exec_multiline_prefix_no_execpolicy",
|
|
contents
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// Snapshot test: patch approval modal
|
|
#[tokio::test]
|
|
async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.config
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::OnRequest.to_core())?;
|
|
|
|
// Build a small changeset and a reason/grant_root to exercise the prompt text.
|
|
let mut changes = HashMap::new();
|
|
changes.insert(
|
|
PathBuf::from("README.md"),
|
|
FileChange::Add {
|
|
content: "hello\nworld\n".into(),
|
|
},
|
|
);
|
|
let ev = ApplyPatchApprovalRequestEvent {
|
|
call_id: "call-approve-patch".into(),
|
|
turn_id: "turn-approve-patch".into(),
|
|
changes,
|
|
reason: Some("The model wants to apply changes".into()),
|
|
grant_root: Some(PathBuf::from("/tmp")),
|
|
};
|
|
handle_apply_patch_approval_request(&mut chat, "sub-approve-patch", ev);
|
|
|
|
// Render at the widget's desired height and snapshot.
|
|
let height = chat.desired_height(/*width*/ 80);
|
|
let mut terminal =
|
|
ratatui::Terminal::new(VT100Backend::new(/*width*/ 80, height)).expect("create terminal");
|
|
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
|
|
terminal
|
|
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
|
.expect("draw patch approval modal");
|
|
let contents = terminal.backend().vt100().screen().contents();
|
|
assert!(!contents.contains("$ apply_patch"));
|
|
assert_chatwidget_snapshot!("approval_modal_patch", contents);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupt_preserves_unified_exec_processes() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5");
|
|
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
|
|
assert_eq!(chat.unified_exec_processes.len(), 2);
|
|
|
|
handle_turn_interrupted(&mut chat, "turn-1");
|
|
|
|
assert_eq!(chat.unified_exec_processes.len(), 2);
|
|
|
|
chat.add_ps_output();
|
|
let cells = drain_insert_history(&mut rx);
|
|
let combined = cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
combined.contains("Background terminals"),
|
|
"expected /ps to remain available after interrupt; got {combined:?}"
|
|
);
|
|
assert!(
|
|
combined.contains("sleep 5") && combined.contains("sleep 6"),
|
|
"expected /ps to list running unified exec processes; got {combined:?}"
|
|
);
|
|
|
|
let _ = drain_insert_history(&mut rx);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn interrupt_preserves_unified_exec_wait_streak_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
|
|
let begin = begin_unified_exec_startup(&mut chat, "call-1", "process-1", "just fix");
|
|
terminal_interaction(&mut chat, "call-1a", "process-1", "");
|
|
|
|
handle_turn_interrupted(&mut chat, "turn-1");
|
|
|
|
end_exec(&mut chat, begin, "", "", /*exit_code*/ 0);
|
|
let cells = drain_insert_history(&mut rx);
|
|
let combined = cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let snapshot = format!("cells={}\n{combined}", cells.len());
|
|
assert_chatwidget_snapshot!("interrupt_preserves_unified_exec_wait_streak", snapshot);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn turn_complete_keeps_unified_exec_processes() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5");
|
|
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
|
|
assert_eq!(chat.unified_exec_processes.len(), 2);
|
|
|
|
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
|
|
|
|
assert_eq!(chat.unified_exec_processes.len(), 2);
|
|
|
|
chat.add_ps_output();
|
|
let cells = drain_insert_history(&mut rx);
|
|
let combined = cells
|
|
.iter()
|
|
.map(|lines| lines_to_single_string(lines))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
combined.contains("Background terminals"),
|
|
"expected /ps to remain available after turn complete; got {combined:?}"
|
|
);
|
|
assert!(
|
|
combined.contains("sleep 5") && combined.contains("sleep 6"),
|
|
"expected /ps to list running unified exec processes; got {combined:?}"
|
|
);
|
|
|
|
let _ = drain_insert_history(&mut rx);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_patch_events_emit_history_cells() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// 1) Approval request -> proposed patch summary cell
|
|
let mut changes = HashMap::new();
|
|
changes.insert(
|
|
PathBuf::from("foo.txt"),
|
|
FileChange::Add {
|
|
content: "hello\n".to_string(),
|
|
},
|
|
);
|
|
let ev = ApplyPatchApprovalRequestEvent {
|
|
call_id: "c1".into(),
|
|
turn_id: "turn-c1".into(),
|
|
changes,
|
|
reason: None,
|
|
grant_root: None,
|
|
};
|
|
handle_apply_patch_approval_request(&mut chat, "s1", ev);
|
|
assert!(
|
|
drain_insert_history(&mut rx).is_empty(),
|
|
"expected approval request to surface via modal without emitting history cells"
|
|
);
|
|
|
|
// 2) Begin apply -> per-file apply block cell (no global header)
|
|
let mut changes2 = HashMap::new();
|
|
changes2.insert(
|
|
PathBuf::from("foo.txt"),
|
|
FileChange::Add {
|
|
content: "hello\n".to_string(),
|
|
},
|
|
);
|
|
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", changes2);
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert!(!cells.is_empty(), "expected apply block cell to be sent");
|
|
let blob = lines_to_single_string(cells.last().unwrap());
|
|
assert!(
|
|
blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"),
|
|
"expected single-file header with filename (Added/Edited): {blob:?}"
|
|
);
|
|
|
|
// 3) End apply success -> success cell
|
|
let mut end_changes = HashMap::new();
|
|
end_changes.insert(
|
|
PathBuf::from("foo.txt"),
|
|
FileChange::Add {
|
|
content: "hello\n".to_string(),
|
|
},
|
|
);
|
|
handle_patch_apply_end(
|
|
&mut chat,
|
|
"c1",
|
|
"turn-c1",
|
|
end_changes,
|
|
AppServerPatchApplyStatus::Completed,
|
|
);
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert!(
|
|
cells.is_empty(),
|
|
"no success cell should be emitted anymore"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_patch_manual_approval_adjusts_header() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let mut proposed_changes = HashMap::new();
|
|
proposed_changes.insert(
|
|
PathBuf::from("foo.txt"),
|
|
FileChange::Add {
|
|
content: "hello\n".to_string(),
|
|
},
|
|
);
|
|
handle_apply_patch_approval_request(
|
|
&mut chat,
|
|
"s1",
|
|
ApplyPatchApprovalRequestEvent {
|
|
call_id: "c1".into(),
|
|
turn_id: "turn-c1".into(),
|
|
changes: proposed_changes,
|
|
reason: None,
|
|
grant_root: None,
|
|
},
|
|
);
|
|
drain_insert_history(&mut rx);
|
|
|
|
let mut apply_changes = HashMap::new();
|
|
apply_changes.insert(
|
|
PathBuf::from("foo.txt"),
|
|
FileChange::Add {
|
|
content: "hello\n".to_string(),
|
|
},
|
|
);
|
|
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes);
|
|
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert!(!cells.is_empty(), "expected apply block cell to be sent");
|
|
let blob = lines_to_single_string(cells.last().unwrap());
|
|
assert!(
|
|
blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"),
|
|
"expected apply summary header for foo.txt: {blob:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_patch_manual_flow_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let mut proposed_changes = HashMap::new();
|
|
proposed_changes.insert(
|
|
PathBuf::from("foo.txt"),
|
|
FileChange::Add {
|
|
content: "hello\n".to_string(),
|
|
},
|
|
);
|
|
handle_apply_patch_approval_request(
|
|
&mut chat,
|
|
"s1",
|
|
ApplyPatchApprovalRequestEvent {
|
|
call_id: "c1".into(),
|
|
turn_id: "turn-c1".into(),
|
|
changes: proposed_changes,
|
|
reason: Some("Manual review required".into()),
|
|
grant_root: None,
|
|
},
|
|
);
|
|
let history_before_apply = drain_insert_history(&mut rx);
|
|
assert!(
|
|
history_before_apply.is_empty(),
|
|
"expected approval modal to defer history emission"
|
|
);
|
|
|
|
let mut apply_changes = HashMap::new();
|
|
apply_changes.insert(
|
|
PathBuf::from("foo.txt"),
|
|
FileChange::Add {
|
|
content: "hello\n".to_string(),
|
|
},
|
|
);
|
|
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes);
|
|
let approved_lines = drain_insert_history(&mut rx)
|
|
.pop()
|
|
.expect("approved patch cell");
|
|
|
|
assert_chatwidget_snapshot!(
|
|
"apply_patch_manual_flow_history_approved",
|
|
lines_to_single_string(&approved_lines)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_patch_approval_sends_op_with_call_id() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
// Simulate receiving an approval request with a distinct event id and call id.
|
|
let mut changes = HashMap::new();
|
|
changes.insert(
|
|
PathBuf::from("file.rs"),
|
|
FileChange::Add {
|
|
content: "fn main(){}\n".into(),
|
|
},
|
|
);
|
|
let ev = ApplyPatchApprovalRequestEvent {
|
|
call_id: "call-999".into(),
|
|
turn_id: "turn-999".into(),
|
|
changes,
|
|
reason: None,
|
|
grant_root: None,
|
|
};
|
|
handle_apply_patch_approval_request(&mut chat, "sub-123", ev);
|
|
|
|
// Approve via key press 'y'
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
|
|
// Expect a thread-scoped PatchApproval op carrying the call id.
|
|
let mut found = false;
|
|
while let Ok(app_ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp {
|
|
op: Op::PatchApproval { id, decision },
|
|
..
|
|
} = app_ev
|
|
{
|
|
assert_eq!(id, "call-999");
|
|
assert_matches!(
|
|
decision,
|
|
codex_app_server_protocol::FileChangeApprovalDecision::Accept
|
|
);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(found, "expected PatchApproval op to be sent");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_patch_full_flow_integration_like() {
|
|
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// 1) Backend requests approval
|
|
let mut changes = HashMap::new();
|
|
changes.insert(
|
|
PathBuf::from("pkg.rs"),
|
|
FileChange::Add { content: "".into() },
|
|
);
|
|
handle_apply_patch_approval_request(
|
|
&mut chat,
|
|
"sub-xyz",
|
|
ApplyPatchApprovalRequestEvent {
|
|
call_id: "call-1".into(),
|
|
turn_id: "turn-call-1".into(),
|
|
changes,
|
|
reason: None,
|
|
grant_root: None,
|
|
},
|
|
);
|
|
|
|
// 2) User approves via 'y' and App receives a thread-scoped op
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
|
let mut maybe_op: Option<Op> = None;
|
|
while let Ok(app_ev) = rx.try_recv() {
|
|
if let AppEvent::SubmitThreadOp { op, .. } = app_ev {
|
|
maybe_op = Some(op);
|
|
break;
|
|
}
|
|
}
|
|
let op = maybe_op.expect("expected thread-scoped op after key press");
|
|
|
|
// 3) App forwards to widget.submit_op, which pushes onto codex_op_tx
|
|
chat.submit_op(op);
|
|
let forwarded = op_rx
|
|
.try_recv()
|
|
.expect("expected op forwarded to codex channel");
|
|
match forwarded {
|
|
Op::PatchApproval { id, decision } => {
|
|
assert_eq!(id, "call-1");
|
|
assert_matches!(
|
|
decision,
|
|
codex_app_server_protocol::FileChangeApprovalDecision::Accept
|
|
);
|
|
}
|
|
other => panic!("unexpected op forwarded: {other:?}"),
|
|
}
|
|
|
|
// 4) Simulate patch begin/end events from backend; ensure history cells are emitted
|
|
let mut changes2 = HashMap::new();
|
|
changes2.insert(
|
|
PathBuf::from("pkg.rs"),
|
|
FileChange::Add { content: "".into() },
|
|
);
|
|
handle_patch_apply_begin(&mut chat, "call-1", "turn-call-1", changes2);
|
|
let mut end_changes = HashMap::new();
|
|
end_changes.insert(
|
|
PathBuf::from("pkg.rs"),
|
|
FileChange::Add { content: "".into() },
|
|
);
|
|
handle_patch_apply_end(
|
|
&mut chat,
|
|
"call-1",
|
|
"turn-call-1",
|
|
end_changes,
|
|
AppServerPatchApplyStatus::Completed,
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> {
|
|
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
// Ensure approval policy is untrusted (OnRequest)
|
|
chat.config
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::OnRequest.to_core())?;
|
|
|
|
// Simulate a patch approval request from backend
|
|
let mut changes = HashMap::new();
|
|
changes.insert(
|
|
PathBuf::from("a.rs"),
|
|
FileChange::Add { content: "".into() },
|
|
);
|
|
handle_apply_patch_approval_request(
|
|
&mut chat,
|
|
"sub-1",
|
|
ApplyPatchApprovalRequestEvent {
|
|
call_id: "call-1".into(),
|
|
turn_id: "turn-call-1".into(),
|
|
changes,
|
|
reason: None,
|
|
grant_root: None,
|
|
},
|
|
);
|
|
|
|
// Render and ensure the approval modal title is present
|
|
let area = Rect::new(0, 0, 80, 12);
|
|
let mut buf = Buffer::empty(area);
|
|
chat.render(area, &mut buf);
|
|
|
|
let mut contains_title = false;
|
|
for y in 0..area.height {
|
|
let mut row = String::new();
|
|
for x in 0..area.width {
|
|
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
|
}
|
|
if row.contains("Would you like to make the following edits?") {
|
|
contains_title = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
contains_title,
|
|
"expected approval modal to be visible with title 'Would you like to make the following edits?'"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn apply_patch_request_omits_diff_summary_from_modal() -> anyhow::Result<()> {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
// Ensure we are in OnRequest so an approval is surfaced
|
|
chat.config
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::OnRequest.to_core())?;
|
|
|
|
// Simulate backend asking to apply a patch adding two lines to README.md
|
|
let mut changes = HashMap::new();
|
|
changes.insert(
|
|
PathBuf::from("README.md"),
|
|
FileChange::Add {
|
|
// Two lines (no trailing empty line counted)
|
|
content: "line one\nline two\n".into(),
|
|
},
|
|
);
|
|
handle_apply_patch_approval_request(
|
|
&mut chat,
|
|
"sub-apply",
|
|
ApplyPatchApprovalRequestEvent {
|
|
call_id: "call-apply".into(),
|
|
turn_id: "turn-apply".into(),
|
|
changes,
|
|
reason: None,
|
|
grant_root: None,
|
|
},
|
|
);
|
|
|
|
assert!(
|
|
drain_insert_history(&mut rx).is_empty(),
|
|
"expected approval request to render via modal instead of history"
|
|
);
|
|
|
|
let area = Rect::new(0, 0, 80, chat.desired_height(/*width*/ 80));
|
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
|
chat.render(area, &mut buf);
|
|
let mut contents = String::new();
|
|
for y in 0..area.height {
|
|
for x in 0..area.width {
|
|
contents.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
|
}
|
|
contents.push('\n');
|
|
}
|
|
assert!(!contents.contains("README.md (+2 -0)"));
|
|
assert!(!contents.contains("+line one"));
|
|
assert!(!contents.contains("+line two"));
|
|
|
|
Ok(())
|
|
}
|