diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 088a9fb7d..24f5155c1 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -8,6 +8,10 @@ license.workspace = true name = "codex-app-server" path = "src/main.rs" +[[bin]] +name = "codex-app-server-test-notify-capture" +path = "src/bin/notify_capture.rs" + [lib] name = "codex_app_server" path = "src/lib.rs" diff --git a/codex-rs/app-server/src/bin/notify_capture.rs b/codex-rs/app-server/src/bin/notify_capture.rs new file mode 100644 index 000000000..7217e2631 --- /dev/null +++ b/codex-rs/app-server/src/bin/notify_capture.rs @@ -0,0 +1,44 @@ +use std::env; +use std::fs; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; + +fn main() -> Result<()> { + let mut args = env::args_os(); + let _program = args.next(); + let output_path = PathBuf::from( + args.next() + .ok_or_else(|| anyhow!("expected output path as first argument"))?, + ); + let payload = args + .next() + .ok_or_else(|| anyhow!("expected payload as final argument"))?; + + if args.next().is_some() { + bail!("expected payload as final argument"); + } + + let payload = payload.to_string_lossy(); + let temp_path = PathBuf::from(format!("{}.tmp", output_path.display())); + let mut file = File::create(&temp_path) + .with_context(|| format!("failed to create {}", temp_path.display()))?; + file.write_all(payload.as_bytes()) + .with_context(|| format!("failed to write {}", temp_path.display()))?; + file.sync_all() + .with_context(|| format!("failed to sync {}", temp_path.display()))?; + fs::rename(&temp_path, &output_path).with_context(|| { + format!( + "failed to move {} into {}", + temp_path.display(), + output_path.display() + ) + })?; + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 6647ad70d..8887e87f5 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -193,13 +193,13 @@ async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<( let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; let notify_file = codex_home.path().join("notify.json"); - let notify_capture = cargo_bin("test_notify_capture")?; + let notify_capture = cargo_bin("codex-app-server-test-notify-capture")?; let notify_capture = notify_capture .to_str() .expect("notify capture path should be valid UTF-8"); - let notify_file = notify_file + let notify_file_str = notify_file .to_str() - .expect("notify output path should be valid UTF-8"); + .expect("notify file path should be valid UTF-8"); create_config_toml_with_extra( codex_home.path(), &server.uri(), @@ -207,7 +207,7 @@ async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<( &format!( "notify = [{}, {}]", toml_basic_string(notify_capture), - toml_basic_string(notify_file) + toml_basic_string(notify_file_str) ), )?; @@ -255,9 +255,8 @@ async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<( ) .await??; - let notify_file = Path::new(notify_file); - fs_wait::wait_for_path_exists(notify_file, Duration::from_secs(5)).await?; - let payload_raw = tokio::fs::read_to_string(notify_file).await?; + fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?; + let payload_raw = tokio::fs::read_to_string(¬ify_file).await?; let payload: Value = serde_json::from_str(&payload_raw)?; assert_eq!(payload["client"], "xcode"); @@ -292,6 +291,9 @@ model_provider = "mock_provider" {extra} +[features] +shell_snapshot = false + [model_providers.mock_provider] name = "Mock provider for test" base_url = "{server_uri}/v1" diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index 423388a4a..6427ae7ce 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -35,6 +35,51 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +async fn wait_for_responses_request_count_to_stabilize( + server: &wiremock::MockServer, + expected_count: usize, + settle_duration: std::time::Duration, +) -> Result<()> { + timeout(DEFAULT_READ_TIMEOUT, async { + let mut stable_since: Option = None; + loop { + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let responses_request_count = requests + .iter() + .filter(|request| { + request.method == "POST" && request.url.path().ends_with("/responses") + }) + .count(); + + if responses_request_count > expected_count { + anyhow::bail!( + "expected exactly {expected_count} /responses requests, got {responses_request_count}" + ); + } + + if responses_request_count == expected_count { + match stable_since { + Some(stable_since) if stable_since.elapsed() >= settle_duration => { + return Ok::<(), anyhow::Error>(()); + } + None => stable_since = Some(tokio::time::Instant::now()), + Some(_) => {} + } + } else { + stable_since = None; + } + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await??; + + Ok(()) +} + #[tokio::test] async fn thread_unsubscribe_unloads_thread_and_emits_thread_closed_notification() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -172,6 +217,13 @@ async fn thread_unsubscribe_during_turn_interrupts_turn_and_emits_thread_closed( }; assert_eq!(payload.thread_id, thread_id); + wait_for_responses_request_count_to_stabilize( + &server, + 1, + std::time::Duration::from_millis(200), + ) + .await?; + Ok(()) }