diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index b8626485e5..8171aa5d60 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -1,11 +1,14 @@ use codex_core::ConversationManager; use codex_core::NewConversation; use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecOutputStream; use codex_core::protocol::Op; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::UserCommandEndEvent; use core_test_support::load_default_config_for_test; +use core_test_support::responses; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use std::path::PathBuf; use std::process::Command; use std::process::Stdio; @@ -138,3 +141,115 @@ async fn user_shell_cmd_can_be_interrupted() { }; assert_eq!(ev.reason, TurnAbortReason::Interrupted); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyhow::Result<()> { + let command = if cfg!(target_os = "windows") { + "$val = $env:CODEX_SANDBOX; if ($null -eq $val -or $val -eq '') { Write-Output 'not-set' } else { Write-Output $val }".to_string() + } else { + "if [ -n \"$CODEX_SANDBOX\" ]; then printf %s \"$CODEX_SANDBOX\"; else printf %s not-set; fi" + .to_string() + }; + + let server = responses::start_mock_server().await; + let mut builder = core_test_support::test_codex::test_codex(); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::RunUserShellCommand { + command: command.clone(), + }) + .await?; + + let begin_event = wait_for_event_match(&test.codex, |ev| match ev { + EventMsg::UserCommandBegin(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!(begin_event.command.last(), Some(&command)); + + let delta_event = wait_for_event_match(&test.codex, |ev| match ev { + EventMsg::UserCommandOutputDelta(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!(delta_event.stream, ExecOutputStream::Stdout); + let chunk_text = + String::from_utf8(delta_event.chunk.clone()).expect("user command chunk is valid utf-8"); + assert_eq!(chunk_text.trim(), "not-set"); + + let end_event = wait_for_event_match(&test.codex, |ev| match ev { + EventMsg::UserCommandEnd(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!(end_event.exit_code, 0); + assert_eq!(end_event.stdout.trim(), "not-set"); + + let responses = vec![responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ])]; + let mock = responses::mount_sse_sequence(&server, responses).await; + + test.submit_turn("follow-up after shell command").await?; + + let request = mock.single_request(); + let items = request.input(); + + fn find_user_text(items: &[serde_json::Value], marker: &str) -> Option { + items.iter().find_map(|item| { + if item.get("type").and_then(serde_json::Value::as_str) != Some("message") { + return None; + } + if item.get("role").and_then(serde_json::Value::as_str) != Some("user") { + return None; + } + let content = item.get("content")?.as_array()?; + content.iter().find_map(|span| { + if span.get("type").and_then(serde_json::Value::as_str) == Some("input_text") { + let text = span.get("text").and_then(serde_json::Value::as_str)?; + if text.contains(marker) { + return Some(text.to_string()); + } + } + None + }) + }) + } + + let command_message = find_user_text(&items, "") + .expect("command message recorded in request"); + assert!( + command_message.contains(&command), + "command message should include shell invocation: {command_message}" + ); + + let output_message = find_user_text(&items, "") + .expect("output message recorded in request"); + let payload = output_message + .strip_prefix("\n") + .and_then(|text| text.strip_suffix("\n")) + .expect("shell command output payload present"); + let parsed: serde_json::Value = + serde_json::from_str(payload).expect("parse shell command output payload"); + assert_eq!( + parsed + .get("metadata") + .and_then(|meta| meta.get("exit_code")) + .and_then(serde_json::Value::as_i64), + Some(0), + "expected exit_code metadata to be present and zero", + ); + let output_text = parsed + .get("output") + .and_then(serde_json::Value::as_str) + .expect("model-facing output string present"); + assert!( + output_text.contains("not-set"), + "model-facing output should include stdout content: {output_text:?}" + ); + + Ok(()) +}