diff --git a/codex-rs/core/tests/suite/parallel_tool_calls.rs b/codex-rs/core/tests/suite/parallel_tool_calls.rs new file mode 100644 index 0000000000..e678375dfd --- /dev/null +++ b/codex-rs/core/tests/suite/parallel_tool_calls.rs @@ -0,0 +1,115 @@ +#![cfg(unix)] + +use std::time::Instant; + +use codex_core::built_in_model_providers; +use codex_core::protocol::EventMsg; +use codex_core::ConversationManager; +use codex_login::CodexAuth; +use core_test_support::load_sse_fixture_with_id_from_str; +use tempfile::TempDir; + +fn build_parallel_exec_sse() -> String { + let item1 = serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "local_shell_call", + "call_id": "c1", + "status": "in_progress", + "action": { + "type": "exec", + "command": ["/bin/sh", "-c", "echo A"], + "timeout_ms": 3000, + "working_directory": null, + "env": null, + "user": null + } + } + }); + let item2 = serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "local_shell_call", + "call_id": "c2", + "status": "in_progress", + "action": { + "type": "exec", + "command": ["/bin/sh", "-c", "echo B"], + "timeout_ms": 3000, + "working_directory": null, + "env": null, + "user": null + } + } + }); + let completed = serde_json::json!({ + "type": "response.completed", + "response": { "id": "__ID__" } + }); + + let raw = serde_json::json!([ + item1, item2, completed + ]) + .to_string(); + load_sse_fixture_with_id_from_str(&raw, "resp_parallel") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_shell_calls_begin_before_any_end_and_run_concurrently() { + let tmp_sse = tempfile::NamedTempFile::new().expect("tmp sse"); + std::fs::write(tmp_sse.path(), build_parallel_exec_sse()).expect("write sse"); + std::env::set_var("CODEX_RS_SSE_FIXTURE", tmp_sse.path()); + + let home = TempDir::new().unwrap(); + let mut config = core_test_support::load_default_config_for_test(&home); + let providers = built_in_model_providers(); + config.model_provider = providers + .get("openai") + .expect("builtin provider") + .clone(); + + let cm = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let conv = cm + .new_conversation(config) + .await + .expect("spawn conversation") + .conversation; + + conv + .submit(codex_core::protocol::Op::UserInput { + items: vec![codex_core::protocol::InputItem::Text { + text: "go".into(), + }], + }) + .await + .expect("submit"); + + let mut begins = 0usize; + let mut ends = 0usize; + let mut seen_first_end = false; + + use tokio::time::{sleep, Duration as TokioDuration}; + let deadline = Instant::now() + TokioDuration::from_secs(5); + + while Instant::now() < deadline { + let ev = conv.next_event().await.expect("event"); + match ev.msg { + EventMsg::ExecCommandBegin(_) => { + begins += 1; + } + EventMsg::ExecCommandEnd(_) => { + if !seen_first_end { + assert_eq!(begins, 2, "expected both begins before first end"); + seen_first_end = true; + } + ends += 1; + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + sleep(TokioDuration::from_millis(5)).await; + } + + assert_eq!(begins, 2, "expected two parallel exec begins"); + assert_eq!(ends, 2, "expected two exec completions"); +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__active_exec_segments_mark_completion_in_place_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__active_exec_segments_mark_completion_in_place_snapshot.snap new file mode 100644 index 0000000000..f0089116d9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__active_exec_segments_mark_completion_in_place_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: s +--- +>_ + ✓ ⌨️ echo A + ⠋ ⌨️ echo B diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__active_exec_segments_render_for_parallel_begins_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__active_exec_segments_render_for_parallel_begins_snapshot.snap new file mode 100644 index 0000000000..1c5870ed15 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__active_exec_segments_render_for_parallel_begins_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: s +--- +>_ + ⠋ ⌨️ echo one + ⠋ ⌨️ echo two diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c91826541e..7e4d1c7dc5 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -214,6 +214,106 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { s } +#[test] +fn active_exec_segments_render_for_parallel_begins_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + // Begin first command + chat.handle_codex_event(Event { + id: "call-1".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-1".into(), + command: vec!["bash".into(), "-lc".into(), "echo one".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd: vec![ + codex_core::parse_command::ParsedCommand::Unknown { + cmd: "echo one".into(), + } + .into(), + ], + }), + }); + + // Begin a second command (parallel segment) + chat.handle_codex_event(Event { + id: "call-2".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-2".into(), + command: vec!["bash".into(), "-lc".into(), "echo two".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd: vec![ + codex_core::parse_command::ParsedCommand::Unknown { + cmd: "echo two".into(), + } + .into(), + ], + }), + }); + + // Snapshot the active exec cell (shows spinners for both segments) + let cell = chat.active_exec_cell.as_ref().expect("active exec cell"); + let s = lines_to_single_string(&cell.display_lines()); + assert_snapshot!(s); +} + +#[test] +fn active_exec_segments_mark_completion_in_place_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + // Begin two commands + chat.handle_codex_event(Event { + id: "call-a".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-a".into(), + command: vec!["bash".into(), "-lc".into(), "echo A".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd: vec![ + codex_core::parse_command::ParsedCommand::Unknown { + cmd: "echo A".into(), + } + .into(), + ], + }), + }); + chat.handle_codex_event(Event { + id: "call-b".into(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "call-b".into(), + command: vec!["bash".into(), "-lc".into(), "echo B".into()], + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + parsed_cmd: vec![ + codex_core::parse_command::ParsedCommand::Unknown { + cmd: "echo B".into(), + } + .into(), + ], + }), + }); + + // Complete first command successfully; second still running + chat.handle_codex_event(Event { + id: "call-a".into(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-a".into(), + stdout: "A".into(), + stderr: String::new(), + aggregated_output: "A".into(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + formatted_output: "A".into(), + }), + }); + + // Drain any history insertions (none expected yet) and snapshot active cell + let _ = drain_insert_history(&mut rx); + let cell = chat + .active_exec_cell + .as_ref() + .expect("active exec cell after first end"); + let s = lines_to_single_string(&cell.display_lines()); + assert_snapshot!(s); +} + fn open_fixture(name: &str) -> std::fs::File { // 1) Prefer fixtures within this crate {