diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index cb52620317..de11913d7c 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1,10 +1,13 @@ use super::*; use crate::codex::make_session_and_context; +use crate::config::test_config; use assert_matches::assert_matches; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; +use std::time::Duration; +use tempfile::tempdir; fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { @@ -113,3 +116,39 @@ async fn ignores_session_prefix_messages_when_truncating() { serde_json::to_value(&expected).unwrap() ); } + +#[tokio::test] +async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.clone(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let thread_1 = manager + .start_thread(config.clone()) + .await + .expect("start first thread") + .thread_id; + let thread_2 = manager + .start_thread(config) + .await + .expect("start second thread") + .thread_id; + + let report = manager + .shutdown_all_threads_bounded(Duration::from_secs(10)) + .await; + + let mut expected_completed = vec![thread_1, thread_2]; + expected_completed.sort_by_key(std::string::ToString::to_string); + assert_eq!(report.completed, expected_completed); + assert!(report.submit_failed.is_empty()); + assert!(report.timed_out.is_empty()); + assert!(manager.list_thread_ids().await.is_empty()); +} diff --git a/codex-rs/core/src/tools/js_repl/mod_tests.rs b/codex-rs/core/src/tools/js_repl/mod_tests.rs index cebe4f473a..3d9e7930f8 100644 --- a/codex-rs/core/src/tools/js_repl/mod_tests.rs +++ b/codex-rs/core/src/tools/js_repl/mod_tests.rs @@ -287,12 +287,15 @@ async fn emitted_image_content_item_drops_unsupported_explicit_detail() { } #[tokio::test] -async fn emitted_image_content_item_does_not_add_original_detail_when_not_requested() { +async fn emitted_image_content_item_does_not_force_original_when_enabled() { let (_session, mut turn) = make_session_and_context().await; Arc::make_mut(&mut turn.config) .features .enable(Feature::ImageDetailOriginal) .expect("test config should allow feature update"); + turn.features + .enable(Feature::ImageDetailOriginal) + .expect("test turn features should allow feature update"); turn.model_info.supports_image_detail_original = true; let content_item = @@ -307,6 +310,52 @@ async fn emitted_image_content_item_does_not_add_original_detail_when_not_reques ); } +#[tokio::test] +async fn emitted_image_content_item_allows_explicit_original_detail_when_enabled() { + let (_session, mut turn) = make_session_and_context().await; + Arc::make_mut(&mut turn.config) + .features + .enable(Feature::ImageDetailOriginal) + .expect("test config should allow feature update"); + turn.features + .enable(Feature::ImageDetailOriginal) + .expect("test turn features should allow feature update"); + turn.model_info.supports_image_detail_original = true; + + let content_item = emitted_image_content_item( + &turn, + "data:image/png;base64,AAA".to_string(), + Some(ImageDetail::Original), + ); + + assert_eq!( + content_item, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: Some(ImageDetail::Original), + } + ); +} + +#[tokio::test] +async fn emitted_image_content_item_drops_explicit_original_detail_when_disabled() { + let (_session, turn) = make_session_and_context().await; + + let content_item = emitted_image_content_item( + &turn, + "data:image/png;base64,AAA".to_string(), + Some(ImageDetail::Original), + ); + + assert_eq!( + content_item, + FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + detail: None, + } + ); +} + #[test] fn validate_emitted_image_url_accepts_case_insensitive_data_scheme() { assert_eq!( @@ -1347,6 +1396,59 @@ await codex.emitImage({ bytes: png, mimeType: "image/png", detail: "ultra" }); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn js_repl_emit_image_treats_null_detail_as_omitted() -> anyhow::Result<()> { + if !can_run_js_repl_runtime_tests().await { + return Ok(()); + } + + let (session, turn) = make_session_and_context().await; + if !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Ok(()); + } + + let session = Arc::new(session); + let turn = Arc::new(turn); + *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); + let manager = turn.js_repl.manager().await?; + let code = r#" +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", + "base64" +); +await codex.emitImage({ bytes: png, mimeType: "image/png", detail: null }); +"#; + + let result = manager + .execute( + Arc::clone(&session), + turn, + tracker, + JsReplArgs { + code: code.to_string(), + timeout_ms: Some(15_000), + }, + ) + .await?; + assert_eq!( + result.content_items.as_slice(), + [FunctionCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), + detail: None, + }] + .as_slice() + ); + assert!(session.get_pending_input_with_metadata().await.is_empty()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn js_repl_emit_image_rejects_mixed_content() -> anyhow::Result<()> { if !can_run_js_repl_runtime_tests().await { @@ -1712,13 +1814,22 @@ async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Resul } let cwd_dir = tempdir()?; + let expected_home_dir = serde_json::to_string("/tmp/codex-home")?; write_js_repl_test_module( cwd_dir.path(), "globals.js", - "console.log(codex.tmpDir === tmpDir);\nconsole.log(typeof codex.tool);\nconsole.log(\"local-file-console-ok\");\n", + &format!( + "const expectedHomeDir = {expected_home_dir};\nconsole.log(`tmp:${{codex.tmpDir === tmpDir}}`);\nconsole.log(`cwd:${{typeof codex.cwd}}:${{codex.cwd.length > 0}}`);\nconsole.log(`home:${{codex.homeDir === expectedHomeDir}}`);\nconsole.log(`tool:${{typeof codex.tool}}`);\nconsole.log(\"local-file-console-ok\");\n" + ), )?; let (session, mut turn) = make_session_and_context().await; + session + .set_dependency_env(HashMap::from([( + "HOME".to_string(), + "/tmp/codex-home".to_string(), + )])) + .await; turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); @@ -1744,8 +1855,10 @@ async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Resul }, ) .await?; - assert!(result.output.contains("true")); - assert!(result.output.contains("function")); + assert!(result.output.contains("tmp:true")); + assert!(result.output.contains("cwd:string:true")); + assert!(result.output.contains("home:true")); + assert!(result.output.contains("tool:function")); assert!(result.output.contains("local-file-console-ok")); Ok(()) }