From 30e2561fbb96ba0795260038d51a078f2f36efc6 Mon Sep 17 00:00:00 2001 From: won Date: Mon, 9 Mar 2026 11:47:55 -0700 Subject: [PATCH 1/8] pass on save info to model + ui tweaks --- codex-rs/protocol/src/prompts/base_instructions/default.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codex-rs/protocol/src/prompts/base_instructions/default.md b/codex-rs/protocol/src/prompts/base_instructions/default.md index 4886c7ef445..bc75070ce91 100644 --- a/codex-rs/protocol/src/prompts/base_instructions/default.md +++ b/codex-rs/protocol/src/prompts/base_instructions/default.md @@ -273,3 +273,9 @@ To create a new plan, call `update_plan` with a short list of 1‑sentence steps When steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call. If all steps are complete, ensure you call `update_plan` to mark all steps as `completed`. + +## `image_generation` + +A tool named `image_generation` is available to you. you can use it to generate images to satisfy user request. + +You should only return one image per call. If the user asks for N images, emit exactly N image-generation calls total. Do not try to satisfy "5 images" with one call that asks for a multi-image set. Keep an internal count of image-generation calls already emitted for the current user request. \ No newline at end of file From 32f1a813020e3e386c17c37ed4b7bd39d1ef5ebe Mon Sep 17 00:00:00 2001 From: won Date: Mon, 9 Mar 2026 12:20:22 -0700 Subject: [PATCH 2/8] revert prompt to original --- codex-rs/protocol/src/prompts/base_instructions/default.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/codex-rs/protocol/src/prompts/base_instructions/default.md b/codex-rs/protocol/src/prompts/base_instructions/default.md index bc75070ce91..4886c7ef445 100644 --- a/codex-rs/protocol/src/prompts/base_instructions/default.md +++ b/codex-rs/protocol/src/prompts/base_instructions/default.md @@ -273,9 +273,3 @@ To create a new plan, call `update_plan` with a short list of 1‑sentence steps When steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call. If all steps are complete, ensure you call `update_plan` to mark all steps as `completed`. - -## `image_generation` - -A tool named `image_generation` is available to you. you can use it to generate images to satisfy user request. - -You should only return one image per call. If the user asks for N images, emit exactly N image-generation calls total. Do not try to satisfy "5 images" with one call that asks for a multi-image set. Keep an internal count of image-generation calls already emitted for the current user request. \ No newline at end of file From 01ee5cdde708237fa31904d90f8d5727fcc874fe Mon Sep 17 00:00:00 2001 From: won Date: Mon, 9 Mar 2026 18:41:34 -0700 Subject: [PATCH 3/8] simplified logic, unified all saves to /tmp --- codex-rs/core/src/codex.rs | 11 +-- codex-rs/core/src/codex_tests.rs | 14 +++ .../core/src/context_manager/history_tests.rs | 6 -- .../core/src/context_manager/normalize.rs | 3 - codex-rs/core/src/stream_events_utils.rs | 94 +++++++------------ codex-rs/core/tests/suite/items.rs | 21 +++-- ...mage_generation_call_history_snapshot.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 2 +- 8 files changed, 69 insertions(+), 84 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b30b93cc18b..061198cd8c7 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3327,6 +3327,9 @@ impl Session { ) .into_text(), ); + developer_sections.push( + "Generated images are saved to /tmp as /tmp/.png by default.".to_string(), + ); if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { developer_sections.push(developer_instructions.to_string()); } @@ -6636,9 +6639,7 @@ async fn handle_assistant_item_done_in_plan_mode( { maybe_complete_plan_item_from_message(sess, turn_context, state, item).await; - if let Some(turn_item) = - handle_non_tool_response_item(item, true, Some(&turn_context.cwd)).await - { + if let Some(turn_item) = handle_non_tool_response_item(item, true).await { emit_turn_item_in_plan_mode( sess, turn_context, @@ -6818,9 +6819,7 @@ async fn try_run_sampling_request( needs_follow_up |= output_result.needs_follow_up; } ResponseEvent::OutputItemAdded(item) => { - if let Some(turn_item) = - handle_non_tool_response_item(&item, plan_mode, Some(&turn_context.cwd)).await - { + if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await { let mut turn_item = turn_item; let mut seeded_parsed: Option = None; let mut seeded_item_id: Option = None; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index e3410ffb930..59b6a5e3d10 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -3116,6 +3116,20 @@ async fn build_initial_context_uses_previous_realtime_state() { ); } +#[tokio::test] +async fn build_initial_context_describes_default_image_save_location() { + let (session, turn_context) = make_session_and_context().await; + + let initial_context = session.build_initial_context(&turn_context).await; + let developer_texts = developer_input_texts(&initial_context); + assert!( + developer_texts.iter().any(|text| { + text.contains("Generated images are saved to /tmp as /tmp/.png by default.") + }), + "expected initial context to describe the default image save location, got {developer_texts:?}" + ); +} + #[tokio::test] async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 7ef6a341083..104fedab066 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -434,9 +434,6 @@ fn for_prompt_rewrites_image_generation_calls_when_images_are_supported() { ContentItem::InputImage { image_url: "data:image/png;base64,Zm9v".to_string(), }, - ContentItem::InputText { - text: "Saved to: CWD".to_string(), - }, ], end_turn: None, phase: None, @@ -503,9 +500,6 @@ fn for_prompt_rewrites_image_generation_calls_when_images_are_unsupported() { text: "image content omitted because you do not support image input" .to_string(), }, - ContentItem::InputText { - text: "Saved to: CWD".to_string(), - }, ], end_turn: None, phase: None, diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 95d36f2f584..a0009f18ab7 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -242,9 +242,6 @@ pub(crate) fn rewrite_image_generation_calls_for_stateless_input(items: &mut Vec text: format!("Prompt: {revised_prompt}"), }, ContentItem::InputImage { image_url }, - ContentItem::InputText { - text: "Saved to: CWD".to_string(), - }, ], end_turn: None, phase: None, diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 8f77a80e370..bda26c125cb 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -54,11 +54,7 @@ pub(crate) fn raw_assistant_output_text_from_item(item: &ResponseItem) -> Option None } -async fn save_image_generation_result_to_cwd( - cwd: &Path, - call_id: &str, - result: &str, -) -> Result { +async fn save_image_generation_result(call_id: &str, result: &str) -> Result { let bytes = BASE64_STANDARD .decode(result.trim().as_bytes()) .map_err(|err| { @@ -77,7 +73,7 @@ async fn save_image_generation_result_to_cwd( if file_stem.is_empty() { file_stem = "generated_image".to_string(); } - let path = cwd.join(format!("{file_stem}.png")); + let path = Path::new("/tmp").join(format!("{file_stem}.png")); tokio::fs::write(&path, bytes).await?; Ok(path) } @@ -189,9 +185,7 @@ pub(crate) async fn handle_output_item_done( } // No tool call: convert messages/reasoning into turn items and mark them as complete. Ok(None) => { - if let Some(turn_item) = - handle_non_tool_response_item(&item, plan_mode, Some(&ctx.turn_context.cwd)).await - { + if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await { if previously_active_item.is_none() { let mut started_item = turn_item.clone(); if let TurnItem::ImageGeneration(item) = &mut started_item { @@ -278,7 +272,6 @@ pub(crate) async fn handle_output_item_done( pub(crate) async fn handle_non_tool_response_item( item: &ResponseItem, plan_mode: bool, - image_output_cwd: Option<&Path>, ) -> Option { debug!(?item, "Output item"); @@ -300,19 +293,15 @@ pub(crate) async fn handle_non_tool_response_item( agent_message.content = vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }]; } - if let TurnItem::ImageGeneration(image_item) = &mut turn_item - && let Some(cwd) = image_output_cwd - { - match save_image_generation_result_to_cwd(cwd, &image_item.id, &image_item.result) - .await - { + if let TurnItem::ImageGeneration(image_item) = &mut turn_item { + match save_image_generation_result(&image_item.id, &image_item.result).await { Ok(path) => { image_item.saved_path = Some(path.to_string_lossy().into_owned()); } Err(err) => { tracing::warn!( call_id = %image_item.id, - cwd = %cwd.display(), + cwd = "/tmp", "failed to save generated image: {err}" ); } @@ -380,13 +369,13 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti mod tests { use super::handle_non_tool_response_item; use super::last_assistant_message_from_item; - use super::save_image_generation_result_to_cwd; + use super::save_image_generation_result; use crate::error::CodexErr; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; - use tempfile::tempdir; + use std::path::Path; fn assistant_output_text(text: &str) -> ResponseItem { ResponseItem::Message { @@ -404,10 +393,9 @@ mod tests { async fn handle_non_tool_response_item_strips_citations_from_assistant_message() { let item = assistant_output_text("hellodoc1 world"); - let turn_item = - handle_non_tool_response_item(&item, false, Some(std::path::Path::new("."))) - .await - .expect("assistant message should parse"); + let turn_item = handle_non_tool_response_item(&item, false) + .await + .expect("assistant message should parse"); let TurnItem::AgentMessage(agent_message) = turn_item else { panic!("expected agent message"); @@ -449,26 +437,24 @@ mod tests { } #[tokio::test] - async fn save_image_generation_result_saves_base64_to_png_in_cwd() { - let dir = tempdir().expect("tempdir"); + async fn save_image_generation_result_saves_base64_to_png_in_tmp() { + let expected_path = Path::new("/tmp").join("ig_save_base64.png"); + let _ = std::fs::remove_file(&expected_path); - let saved_path = save_image_generation_result_to_cwd(dir.path(), "ig_123", "Zm9v") + let saved_path = save_image_generation_result("ig_save_base64", "Zm9v") .await .expect("image should be saved"); - assert_eq!( - saved_path.file_name().and_then(|v| v.to_str()), - Some("ig_123.png") - ); - assert_eq!(std::fs::read(saved_path).expect("saved file"), b"foo"); + assert_eq!(saved_path, expected_path); + assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo"); + let _ = std::fs::remove_file(&saved_path); } #[tokio::test] async fn save_image_generation_result_rejects_data_url_payload() { - let dir = tempdir().expect("tempdir"); let result = "data:image/jpeg;base64,Zm9v"; - let err = save_image_generation_result_to_cwd(dir.path(), "ig_456", result) + let err = save_image_generation_result("ig_456", result) .await .expect_err("data url payload should error"); assert!(matches!(err, CodexErr::InvalidRequest(_))); @@ -476,42 +462,35 @@ mod tests { #[tokio::test] async fn save_image_generation_result_overwrites_existing_file() { - let dir = tempdir().expect("tempdir"); - let existing_path = dir.path().join("ig_123.png"); + let existing_path = Path::new("/tmp").join("ig_overwrite.png"); std::fs::write(&existing_path, b"existing").expect("seed existing image"); - let saved_path = save_image_generation_result_to_cwd(dir.path(), "ig_123", "Zm9v") + let saved_path = save_image_generation_result("ig_overwrite", "Zm9v") .await .expect("image should be saved"); - assert_eq!( - saved_path.file_name().and_then(|v| v.to_str()), - Some("ig_123.png") - ); - assert_eq!(std::fs::read(saved_path).expect("saved file"), b"foo"); + assert_eq!(saved_path, existing_path); + assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo"); + let _ = std::fs::remove_file(&saved_path); } #[tokio::test] - async fn save_image_generation_result_sanitizes_call_id_for_output_path() { - let dir = tempdir().expect("tempdir"); + async fn save_image_generation_result_sanitizes_call_id_for_tmp_output_path() { + let expected_path = Path::new("/tmp").join("___ig___.png"); + let _ = std::fs::remove_file(&expected_path); - let saved_path = save_image_generation_result_to_cwd(dir.path(), "../ig/..", "Zm9v") + let saved_path = save_image_generation_result("../ig/..", "Zm9v") .await .expect("image should be saved"); - assert_eq!(saved_path.parent(), Some(dir.path())); - assert_eq!( - saved_path.file_name().and_then(|v| v.to_str()), - Some("___ig___.png") - ); - assert_eq!(std::fs::read(saved_path).expect("saved file"), b"foo"); + assert_eq!(saved_path, expected_path); + assert_eq!(std::fs::read(&saved_path).expect("saved file"), b"foo"); + let _ = std::fs::remove_file(&saved_path); } #[tokio::test] async fn save_image_generation_result_rejects_non_standard_base64() { - let dir = tempdir().expect("tempdir"); - - let err = save_image_generation_result_to_cwd(dir.path(), "ig_urlsafe", "_-8") + let err = save_image_generation_result("ig_urlsafe", "_-8") .await .expect_err("non-standard base64 should error"); assert!(matches!(err, CodexErr::InvalidRequest(_))); @@ -519,12 +498,9 @@ mod tests { #[tokio::test] async fn save_image_generation_result_rejects_non_base64_data_urls() { - let dir = tempdir().expect("tempdir"); - - let err = - save_image_generation_result_to_cwd(dir.path(), "ig_svg", "data:image/svg+xml,") - .await - .expect_err("non-base64 data url should error"); + let err = save_image_generation_result("ig_svg", "data:image/svg+xml,") + .await + .expect_err("non-base64 data url should error"); assert!(matches!(err, CodexErr::InvalidRequest(_))); } } diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 01136a84abf..63ab357f772 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -269,11 +269,14 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { let server = start_mock_server().await; - let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?; + let TestCodex { codex, .. } = test_codex().build(&server).await?; + let call_id = "ig_image_saved_to_tmp_default"; + let expected_saved_path = std::path::Path::new("/tmp").join(format!("{call_id}.png")); + let _ = std::fs::remove_file(&expected_saved_path); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_image_generation_call("ig_123", "completed", "A tiny blue square", "Zm9v"), + ev_image_generation_call(call_id, "completed", "A tiny blue square", "Zm9v"), ev_completed("resp-1"), ]); mount_sse_once(&server, first_response).await; @@ -299,17 +302,17 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { }) .await; - assert_eq!(begin.call_id, "ig_123"); - assert_eq!(end.call_id, "ig_123"); + assert_eq!(begin.call_id, call_id); + assert_eq!(end.call_id, call_id); assert_eq!(end.status, "completed"); assert_eq!(end.revised_prompt, Some("A tiny blue square".to_string())); assert_eq!(end.result, "Zm9v"); - let expected_saved_path = cwd.path().join("ig_123.png"); assert_eq!( end.saved_path, Some(expected_saved_path.to_string_lossy().into_owned()) ); - assert_eq!(std::fs::read(expected_saved_path)?, b"foo"); + assert_eq!(std::fs::read(&expected_saved_path)?, b"foo"); + let _ = std::fs::remove_file(&expected_saved_path); Ok(()) } @@ -320,7 +323,9 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho let server = start_mock_server().await; - let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?; + let TestCodex { codex, .. } = test_codex().build(&server).await?; + let expected_saved_path = std::path::Path::new("/tmp").join("ig_invalid.png"); + let _ = std::fs::remove_file(&expected_saved_path); let first_response = sse(vec![ ev_response_created("resp-1"), @@ -356,7 +361,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho assert_eq!(end.revised_prompt, Some("broken payload".to_string())); assert_eq!(end.result, "_-8"); assert_eq!(end.saved_path, None); - assert!(!cwd.path().join("ig_invalid.png").exists()); + assert!(!expected_saved_path.exists()); Ok(()) } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__image_generation_call_history_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__image_generation_call_history_snapshot.snap index 05f2b371ceb..38fc024ac2f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__image_generation_call_history_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__image_generation_call_history_snapshot.snap @@ -5,4 +5,4 @@ expression: combined --- • Generated Image: └ A tiny blue square - └ Saved to: /tmp/project + └ Saved to: /tmp diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 98e5005c7a2..71cbe98eb26 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6288,7 +6288,7 @@ async fn image_generation_call_adds_history_cell() { status: "completed".into(), revised_prompt: Some("A tiny blue square".into()), result: "Zm9v".into(), - saved_path: Some("/tmp/project/ig-1.png".into()), + saved_path: Some("/tmp/ig-1.png".into()), }), }); From 994f4ab0c755fcf80ed89a35c713290f9aab01e5 Mon Sep 17 00:00:00 2001 From: won Date: Mon, 9 Mar 2026 19:58:30 -0700 Subject: [PATCH 4/8] make it cross-platform on both windows and macos --- codex-rs/core/src/codex.rs | 10 +++++++--- codex-rs/core/src/codex_tests.rs | 12 +++++++++--- codex-rs/core/src/stream_events_utils.rs | 22 +++++++++++++--------- codex-rs/core/tests/suite/items.rs | 6 +++--- 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 061198cd8c7..40645133a48 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -41,6 +41,7 @@ use crate::realtime_conversation::handle_start as handle_realtime_conversation_s use crate::realtime_conversation::handle_text as handle_realtime_conversation_text; use crate::rollout::session_index; use crate::stream_events_utils::HandleOutputCtx; +use crate::stream_events_utils::default_image_generation_output_dir; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; use crate::stream_events_utils::last_assistant_message_from_item; @@ -3327,9 +3328,12 @@ impl Session { ) .into_text(), ); - developer_sections.push( - "Generated images are saved to /tmp as /tmp/.png by default.".to_string(), - ); + let image_output_dir = default_image_generation_output_dir(); + developer_sections.push(format!( + "Generated images are saved to {} as {} by default.", + image_output_dir.display(), + image_output_dir.join(".png").display(), + )); if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { developer_sections.push(developer_instructions.to_string()); } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 59b6a5e3d10..dfbb23adf70 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -3122,10 +3122,16 @@ async fn build_initial_context_describes_default_image_save_location() { let initial_context = session.build_initial_context(&turn_context).await; let developer_texts = developer_input_texts(&initial_context); + let image_output_dir = crate::stream_events_utils::default_image_generation_output_dir(); + let expected_text = format!( + "Generated images are saved to {} as {} by default.", + image_output_dir.display(), + image_output_dir.join(".png").display(), + ); assert!( - developer_texts.iter().any(|text| { - text.contains("Generated images are saved to /tmp as /tmp/.png by default.") - }), + developer_texts + .iter() + .any(|text| text.contains(expected_text.as_str())), "expected initial context to describe the default image save location, got {developer_texts:?}" ); } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index bda26c125cb..23e0667a548 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -1,4 +1,3 @@ -use std::path::Path; use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; @@ -73,11 +72,15 @@ async fn save_image_generation_result(call_id: &str, result: &str) -> Result PathBuf { + std::env::temp_dir() +} + /// Persist a completed model response item and record any cited memory usage. pub(crate) async fn record_completed_response_item( sess: &Session, @@ -299,9 +302,10 @@ pub(crate) async fn handle_non_tool_response_item( image_item.saved_path = Some(path.to_string_lossy().into_owned()); } Err(err) => { + let output_dir = default_image_generation_output_dir(); tracing::warn!( call_id = %image_item.id, - cwd = "/tmp", + output_dir = %output_dir.display(), "failed to save generated image: {err}" ); } @@ -367,6 +371,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti #[cfg(test)] mod tests { + use super::default_image_generation_output_dir; use super::handle_non_tool_response_item; use super::last_assistant_message_from_item; use super::save_image_generation_result; @@ -375,7 +380,6 @@ mod tests { use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; - use std::path::Path; fn assistant_output_text(text: &str) -> ResponseItem { ResponseItem::Message { @@ -437,8 +441,8 @@ mod tests { } #[tokio::test] - async fn save_image_generation_result_saves_base64_to_png_in_tmp() { - let expected_path = Path::new("/tmp").join("ig_save_base64.png"); + async fn save_image_generation_result_saves_base64_to_png_in_temp_dir() { + let expected_path = default_image_generation_output_dir().join("ig_save_base64.png"); let _ = std::fs::remove_file(&expected_path); let saved_path = save_image_generation_result("ig_save_base64", "Zm9v") @@ -462,7 +466,7 @@ mod tests { #[tokio::test] async fn save_image_generation_result_overwrites_existing_file() { - let existing_path = Path::new("/tmp").join("ig_overwrite.png"); + let existing_path = default_image_generation_output_dir().join("ig_overwrite.png"); std::fs::write(&existing_path, b"existing").expect("seed existing image"); let saved_path = save_image_generation_result("ig_overwrite", "Zm9v") @@ -475,8 +479,8 @@ mod tests { } #[tokio::test] - async fn save_image_generation_result_sanitizes_call_id_for_tmp_output_path() { - let expected_path = Path::new("/tmp").join("___ig___.png"); + async fn save_image_generation_result_sanitizes_call_id_for_temp_dir_output_path() { + let expected_path = default_image_generation_output_dir().join("___ig___.png"); let _ = std::fs::remove_file(&expected_path); let saved_path = save_image_generation_result("../ig/..", "Zm9v") diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 63ab357f772..113a946019f 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -270,8 +270,8 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { let server = start_mock_server().await; let TestCodex { codex, .. } = test_codex().build(&server).await?; - let call_id = "ig_image_saved_to_tmp_default"; - let expected_saved_path = std::path::Path::new("/tmp").join(format!("{call_id}.png")); + let call_id = "ig_image_saved_to_temp_dir_default"; + let expected_saved_path = std::env::temp_dir().join(format!("{call_id}.png")); let _ = std::fs::remove_file(&expected_saved_path); let first_response = sse(vec![ @@ -324,7 +324,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho let server = start_mock_server().await; let TestCodex { codex, .. } = test_codex().build(&server).await?; - let expected_saved_path = std::path::Path::new("/tmp").join("ig_invalid.png"); + let expected_saved_path = std::env::temp_dir().join("ig_invalid.png"); let _ = std::fs::remove_file(&expected_saved_path); let first_response = sse(vec![ From fe60764b0410bfc718e55b65735d59f458b44ab2 Mon Sep 17 00:00:00 2001 From: won Date: Mon, 9 Mar 2026 20:31:53 -0700 Subject: [PATCH 5/8] issue on guardian fixed --- codex-rs/core/src/codex.rs | 14 ++++++++------ codex-rs/core/src/codex_tests.rs | 21 ++++++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 40645133a48..de7abc5ae8a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3328,12 +3328,14 @@ impl Session { ) .into_text(), ); - let image_output_dir = default_image_generation_output_dir(); - developer_sections.push(format!( - "Generated images are saved to {} as {} by default.", - image_output_dir.display(), - image_output_dir.join(".png").display(), - )); + if turn_context.features.enabled(Feature::ImageGeneration) { + let image_output_dir = default_image_generation_output_dir(); + developer_sections.push(format!( + "Generated images are saved to {} as {} by default.", + image_output_dir.display(), + image_output_dir.join(".png").display(), + )); + } if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { developer_sections.push(developer_instructions.to_string()); } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index dfbb23adf70..0421f6e0b7c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -3118,7 +3118,11 @@ async fn build_initial_context_uses_previous_realtime_state() { #[tokio::test] async fn build_initial_context_describes_default_image_save_location() { - let (session, turn_context) = make_session_and_context().await; + let (session, mut turn_context) = make_session_and_context().await; + turn_context + .features + .enable(Feature::ImageGeneration) + .expect("enable image generation feature"); let initial_context = session.build_initial_context(&turn_context).await; let developer_texts = developer_input_texts(&initial_context); @@ -3136,6 +3140,21 @@ async fn build_initial_context_describes_default_image_save_location() { ); } +#[tokio::test] +async fn build_initial_context_omits_default_image_save_location_when_disabled() { + let (session, turn_context) = make_session_and_context().await; + + let initial_context = session.build_initial_context(&turn_context).await; + let developer_texts = developer_input_texts(&initial_context); + + assert!( + !developer_texts + .iter() + .any(|text| text.contains("Generated images are saved to")), + "expected initial context to omit image save instructions when image generation is disabled, got {developer_texts:?}" + ); +} + #[tokio::test] async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() { let (session, turn_context) = make_session_and_context().await; From 9fb11a0b8c5dcd36dbbfdee80599830eb4b4fb2d Mon Sep 17 00:00:00 2001 From: won Date: Tue, 10 Mar 2026 10:39:15 -0700 Subject: [PATCH 6/8] image_history_rendering --- codex-rs/core/src/codex.rs | 15 +++++++++++++-- codex-rs/core/src/codex_tests.rs | 21 ++++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index de7abc5ae8a..a05a3736fad 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3301,9 +3301,20 @@ impl Session { let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); let shell = self.user_shell(); - let (reference_context_item, previous_turn_settings, collaboration_mode, base_instructions) = { + let ( + has_image_generation_history, + reference_context_item, + previous_turn_settings, + collaboration_mode, + base_instructions, + ) = { let state = self.state.lock().await; ( + state + .history + .raw_items() + .iter() + .any(|item| matches!(item, ResponseItem::ImageGenerationCall { .. })), state.reference_context_item(), state.previous_turn_settings(), state.session_configuration.collaboration_mode.clone(), @@ -3328,7 +3339,7 @@ impl Session { ) .into_text(), ); - if turn_context.features.enabled(Feature::ImageGeneration) { + if has_image_generation_history { let image_output_dir = default_image_generation_output_dir(); developer_sections.push(format!( "Generated images are saved to {} as {} by default.", diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 0421f6e0b7c..d92e8a2c73f 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -3118,11 +3118,18 @@ async fn build_initial_context_uses_previous_realtime_state() { #[tokio::test] async fn build_initial_context_describes_default_image_save_location() { - let (session, mut turn_context) = make_session_and_context().await; - turn_context - .features - .enable(Feature::ImageGeneration) - .expect("enable image generation feature"); + let (session, turn_context) = make_session_and_context().await; + session + .replace_history( + vec![ResponseItem::ImageGenerationCall { + id: "ig-test".to_string(), + status: "completed".to_string(), + revised_prompt: Some("a tiny blue square".to_string()), + result: "Zm9v".to_string(), + }], + None, + ) + .await; let initial_context = session.build_initial_context(&turn_context).await; let developer_texts = developer_input_texts(&initial_context); @@ -3141,7 +3148,7 @@ async fn build_initial_context_describes_default_image_save_location() { } #[tokio::test] -async fn build_initial_context_omits_default_image_save_location_when_disabled() { +async fn build_initial_context_omits_default_image_save_location_without_image_history() { let (session, turn_context) = make_session_and_context().await; let initial_context = session.build_initial_context(&turn_context).await; @@ -3151,7 +3158,7 @@ async fn build_initial_context_omits_default_image_save_location_when_disabled() !developer_texts .iter() .any(|text| text.contains("Generated images are saved to")), - "expected initial context to omit image save instructions when image generation is disabled, got {developer_texts:?}" + "expected initial context to omit image save instructions without image history, got {developer_texts:?}" ); } From fc3ded2868a0ee46b522c1dbb367ba070c571688 Mon Sep 17 00:00:00 2001 From: won Date: Tue, 10 Mar 2026 12:16:03 -0700 Subject: [PATCH 7/8] saved_path info sending logic --- codex-rs/core/src/codex.rs | 22 +---- codex-rs/core/src/codex_tests.rs | 102 ++++++++++++++++++++--- codex-rs/core/src/stream_events_utils.rs | 15 ++++ 3 files changed, 108 insertions(+), 31 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a05a3736fad..55f15ec9c92 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -41,7 +41,6 @@ use crate::realtime_conversation::handle_start as handle_realtime_conversation_s use crate::realtime_conversation::handle_text as handle_realtime_conversation_text; use crate::rollout::session_index; use crate::stream_events_utils::HandleOutputCtx; -use crate::stream_events_utils::default_image_generation_output_dir; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; use crate::stream_events_utils::last_assistant_message_from_item; @@ -3301,20 +3300,9 @@ impl Session { let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); let shell = self.user_shell(); - let ( - has_image_generation_history, - reference_context_item, - previous_turn_settings, - collaboration_mode, - base_instructions, - ) = { + let (reference_context_item, previous_turn_settings, collaboration_mode, base_instructions) = { let state = self.state.lock().await; ( - state - .history - .raw_items() - .iter() - .any(|item| matches!(item, ResponseItem::ImageGenerationCall { .. })), state.reference_context_item(), state.previous_turn_settings(), state.session_configuration.collaboration_mode.clone(), @@ -3339,14 +3327,6 @@ impl Session { ) .into_text(), ); - if has_image_generation_history { - let image_output_dir = default_image_generation_output_dir(); - developer_sections.push(format!( - "Generated images are saved to {} as {} by default.", - image_output_dir.display(), - image_output_dir.join(".png").display(), - )); - } if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { developer_sections.push(developer_instructions.to_string()); } diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index d92e8a2c73f..acee92c45ab 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -154,6 +154,26 @@ fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> { .collect() } +fn default_image_save_developer_message_text() -> String { + let image_output_dir = crate::stream_events_utils::default_image_generation_output_dir(); + format!( + "Generated images are saved to {} as {} by default.", + image_output_dir.display(), + image_output_dir.join(".png").display(), + ) +} + +fn test_tool_runtime(session: Arc, turn_context: Arc) -> ToolCallRuntime { + let router = Arc::new(ToolRouter::from_config( + &turn_context.tools_config, + None, + None, + turn_context.dynamic_tools.as_slice(), + )); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + ToolCallRuntime::new(router, session, turn_context, tracker) +} + fn make_connector(id: &str, name: &str) -> AppInfo { AppInfo { id: id.to_string(), @@ -3117,7 +3137,7 @@ async fn build_initial_context_uses_previous_realtime_state() { } #[tokio::test] -async fn build_initial_context_describes_default_image_save_location() { +async fn build_initial_context_omits_default_image_save_location_with_image_history() { let (session, turn_context) = make_session_and_context().await; session .replace_history( @@ -3133,17 +3153,11 @@ async fn build_initial_context_describes_default_image_save_location() { let initial_context = session.build_initial_context(&turn_context).await; let developer_texts = developer_input_texts(&initial_context); - let image_output_dir = crate::stream_events_utils::default_image_generation_output_dir(); - let expected_text = format!( - "Generated images are saved to {} as {} by default.", - image_output_dir.display(), - image_output_dir.join(".png").display(), - ); assert!( - developer_texts + !developer_texts .iter() - .any(|text| text.contains(expected_text.as_str())), - "expected initial context to describe the default image save location, got {developer_texts:?}" + .any(|text| text.contains("Generated images are saved to")), + "expected initial context to omit image save instructions even with image history, got {developer_texts:?}" ); } @@ -3162,6 +3176,74 @@ async fn build_initial_context_omits_default_image_save_location_without_image_h ); } +#[tokio::test] +async fn handle_output_item_done_records_image_save_message_after_successful_save() { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let call_id = "ig_history_records_message"; + let expected_saved_path = crate::stream_events_utils::default_image_generation_output_dir() + .join(format!("{call_id}.png")); + let _ = std::fs::remove_file(&expected_saved_path); + let item = ResponseItem::ImageGenerationCall { + id: call_id.to_string(), + status: "completed".to_string(), + revised_prompt: Some("a tiny blue square".to_string()), + result: "Zm9v".to_string(), + }; + + let mut ctx = HandleOutputCtx { + sess: Arc::clone(&session), + turn_context: Arc::clone(&turn_context), + tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), + cancellation_token: CancellationToken::new(), + }; + handle_output_item_done(&mut ctx, item.clone(), None) + .await + .expect("image generation item should succeed"); + + let history = session.clone_history().await; + let expected_message: ResponseItem = + DeveloperInstructions::new(default_image_save_developer_message_text()).into(); + assert_eq!(history.raw_items(), &[item, expected_message]); + assert_eq!( + std::fs::read(&expected_saved_path).expect("saved file"), + b"foo" + ); + let _ = std::fs::remove_file(&expected_saved_path); +} + +#[tokio::test] +async fn handle_output_item_done_skips_image_save_message_when_save_fails() { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let call_id = "ig_history_no_message"; + let expected_saved_path = crate::stream_events_utils::default_image_generation_output_dir() + .join(format!("{call_id}.png")); + let _ = std::fs::remove_file(&expected_saved_path); + let item = ResponseItem::ImageGenerationCall { + id: call_id.to_string(), + status: "completed".to_string(), + revised_prompt: Some("broken payload".to_string()), + result: "_-8".to_string(), + }; + + let mut ctx = HandleOutputCtx { + sess: Arc::clone(&session), + turn_context: Arc::clone(&turn_context), + tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), + cancellation_token: CancellationToken::new(), + }; + handle_output_item_done(&mut ctx, item.clone(), None) + .await + .expect("image generation item should still complete"); + + let history = session.clone_history().await; + assert_eq!(history.raw_items(), &[item]); + assert!(!expected_saved_path.exists()); +} + #[tokio::test] async fn build_initial_context_uses_previous_turn_settings_for_realtime_end() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 23e0667a548..b003eb8595f 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -19,6 +19,7 @@ use crate::parse_turn_item; use crate::state_db; use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolRouter; +use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; @@ -188,7 +189,9 @@ pub(crate) async fn handle_output_item_done( } // No tool call: convert messages/reasoning into turn items and mark them as complete. Ok(None) => { + let mut record_default_image_save_developer_message = false; if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await { + record_default_image_save_developer_message = matches!(&turn_item, TurnItem::ImageGeneration(item) if item.saved_path.is_some()); if previously_active_item.is_none() { let mut started_item = turn_item.clone(); if let TurnItem::ImageGeneration(item) = &mut started_item { @@ -209,6 +212,18 @@ pub(crate) async fn handle_output_item_done( record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item) .await; + if record_default_image_save_developer_message { + let image_output_dir = default_image_generation_output_dir(); + let message: ResponseItem = DeveloperInstructions::new(format!( + "Generated images are saved to {} as {} by default.", + image_output_dir.display(), + image_output_dir.join(".png").display(), + )) + .into(); + ctx.sess + .record_conversation_items(&ctx.turn_context, std::slice::from_ref(&message)) + .await; + } let last_agent_message = last_assistant_message_from_item(&item, plan_mode); output.last_agent_message = last_agent_message; From 9fbaf7be7c0e63468c2c60a87e418f1f1d40b9c7 Mon Sep 17 00:00:00 2001 From: won Date: Tue, 10 Mar 2026 13:44:07 -0700 Subject: [PATCH 8/8] moving logic to one fn --- codex-rs/core/src/codex.rs | 12 +++++-- codex-rs/core/src/codex_tests.rs | 2 +- codex-rs/core/src/stream_events_utils.rs | 41 +++++++++++++++--------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 55f15ec9c92..7ea30f52dc5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6636,7 +6636,8 @@ async fn handle_assistant_item_done_in_plan_mode( { maybe_complete_plan_item_from_message(sess, turn_context, state, item).await; - if let Some(turn_item) = handle_non_tool_response_item(item, true).await { + if let Some(turn_item) = handle_non_tool_response_item(sess, turn_context, item, true).await + { emit_turn_item_in_plan_mode( sess, turn_context, @@ -6816,7 +6817,14 @@ async fn try_run_sampling_request( needs_follow_up |= output_result.needs_follow_up; } ResponseEvent::OutputItemAdded(item) => { - if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await { + if let Some(turn_item) = handle_non_tool_response_item( + sess.as_ref(), + turn_context.as_ref(), + &item, + plan_mode, + ) + .await + { let mut turn_item = turn_item; let mut seeded_parsed: Option = None; let mut seeded_item_id: Option = None; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index acee92c45ab..47dca708d04 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -3205,7 +3205,7 @@ async fn handle_output_item_done_records_image_save_message_after_successful_sav let history = session.clone_history().await; let expected_message: ResponseItem = DeveloperInstructions::new(default_image_save_developer_message_text()).into(); - assert_eq!(history.raw_items(), &[item, expected_message]); + assert_eq!(history.raw_items(), &[expected_message, item]); assert_eq!( std::fs::read(&expected_saved_path).expect("saved file"), b"foo" diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index b003eb8595f..ce6ce99385b 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -189,9 +189,14 @@ pub(crate) async fn handle_output_item_done( } // No tool call: convert messages/reasoning into turn items and mark them as complete. Ok(None) => { - let mut record_default_image_save_developer_message = false; - if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await { - record_default_image_save_developer_message = matches!(&turn_item, TurnItem::ImageGeneration(item) if item.saved_path.is_some()); + if let Some(turn_item) = handle_non_tool_response_item( + ctx.sess.as_ref(), + ctx.turn_context.as_ref(), + &item, + plan_mode, + ) + .await + { if previously_active_item.is_none() { let mut started_item = turn_item.clone(); if let TurnItem::ImageGeneration(item) = &mut started_item { @@ -212,18 +217,6 @@ pub(crate) async fn handle_output_item_done( record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item) .await; - if record_default_image_save_developer_message { - let image_output_dir = default_image_generation_output_dir(); - let message: ResponseItem = DeveloperInstructions::new(format!( - "Generated images are saved to {} as {} by default.", - image_output_dir.display(), - image_output_dir.join(".png").display(), - )) - .into(); - ctx.sess - .record_conversation_items(&ctx.turn_context, std::slice::from_ref(&message)) - .await; - } let last_agent_message = last_assistant_message_from_item(&item, plan_mode); output.last_agent_message = last_agent_message; @@ -288,6 +281,8 @@ pub(crate) async fn handle_output_item_done( } pub(crate) async fn handle_non_tool_response_item( + sess: &Session, + turn_context: &TurnContext, item: &ResponseItem, plan_mode: bool, ) -> Option { @@ -315,6 +310,18 @@ pub(crate) async fn handle_non_tool_response_item( match save_image_generation_result(&image_item.id, &image_item.result).await { Ok(path) => { image_item.saved_path = Some(path.to_string_lossy().into_owned()); + let image_output_dir = default_image_generation_output_dir(); + let message: ResponseItem = DeveloperInstructions::new(format!( + "Generated images are saved to {} as {} by default.", + image_output_dir.display(), + image_output_dir.join(".png").display(), + )) + .into(); + sess.record_conversation_items( + turn_context, + std::slice::from_ref(&message), + ) + .await; } Err(err) => { let output_dir = default_image_generation_output_dir(); @@ -390,6 +397,7 @@ mod tests { use super::handle_non_tool_response_item; use super::last_assistant_message_from_item; use super::save_image_generation_result; + use crate::codex::make_session_and_context; use crate::error::CodexErr; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; @@ -410,9 +418,10 @@ mod tests { #[tokio::test] async fn handle_non_tool_response_item_strips_citations_from_assistant_message() { + let (session, turn_context) = make_session_and_context().await; let item = assistant_output_text("hellodoc1 world"); - let turn_item = handle_non_tool_response_item(&item, false) + let turn_item = handle_non_tool_response_item(&session, &turn_context, &item, false) .await .expect("assistant message should parse");