From 050882307559bb22aeec8dfd2561245c08b82313 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 31 Oct 2025 14:46:24 +0000 Subject: [PATCH 01/17] test: undo (#6034) --- codex-rs/core/tests/common/responses.rs | 1 + codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/undo.rs | 491 ++++++++++++++++++++++++ 3 files changed, 493 insertions(+) create mode 100644 codex-rs/core/tests/suite/undo.rs diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index be8acc5fa0..4fd6dcf781 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -479,6 +479,7 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) -> Res let (mock, response_mock) = base_mock(); mock.respond_with(responder) + .up_to_n_times(num_calls as u64) .expect(num_calls as u64) .mount(server) .await; diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 915e8a6548..e597851183 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -38,6 +38,7 @@ mod tool_harness; mod tool_parallelism; mod tools; mod truncation; +mod undo; mod unified_exec; mod user_notification; mod user_shell_cmd; diff --git a/codex-rs/core/tests/suite/undo.rs b/codex-rs/core/tests/suite/undo.rs new file mode 100644 index 0000000000..7883d347f9 --- /dev/null +++ b/codex-rs/core/tests/suite/undo.rs @@ -0,0 +1,491 @@ +#![cfg(not(target_os = "windows"))] + +use std::fs; +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use codex_core::CodexConversation; +use codex_core::config::Config; +use codex_core::features::Feature; +use codex_core::model_family::find_family_for_model; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::UndoCompletedEvent; +use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; + +#[allow(clippy::expect_used)] +async fn undo_harness() -> Result { + TestCodexHarness::with_config(|config: &mut Config| { + config.include_apply_patch_tool = true; + config.model = "gpt-5".to_string(); + config.model_family = find_family_for_model("gpt-5").expect("gpt-5 is valid"); + config.features.enable(Feature::GhostCommit); + }) + .await +} + +fn git(path: &Path, args: &[&str]) -> Result<()> { + let status = Command::new("git") + .args(args) + .current_dir(path) + .status() + .with_context(|| format!("failed to run git {args:?}"))?; + if status.success() { + return Ok(()); + } + let exit_status = status; + bail!("git {args:?} exited with {exit_status}"); +} + +fn git_output(path: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(path) + .output() + .with_context(|| format!("failed to run git {args:?}"))?; + if !output.status.success() { + let exit_status = output.status; + bail!("git {args:?} exited with {exit_status}"); + } + String::from_utf8(output.stdout).context("stdout was not valid utf8") +} + +fn init_git_repo(path: &Path) -> Result<()> { + // Use a consistent initial branch and config across environments to avoid + // CI variance (default-branch hints, line ending differences, etc.). + git(path, &["init", "--initial-branch=main"])?; + git(path, &["config", "core.autocrlf", "false"])?; + git(path, &["config", "user.name", "Codex Tests"])?; + git(path, &["config", "user.email", "codex-tests@example.com"])?; + + // Create README.txt + let readme_path = path.join("README.txt"); + fs::write(&readme_path, "Test repository initialized by Codex.\n")?; + + // Stage and commit + git(path, &["add", "README.txt"])?; + git(path, &["commit", "-m", "Add README.txt"])?; + + Ok(()) +} + +fn apply_patch_responses(call_id: &str, patch: &str, assistant_msg: &str) -> Vec { + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_apply_patch_function_call(call_id, patch), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", assistant_msg), + ev_completed("resp-2"), + ]), + ] +} + +async fn run_apply_patch_turn( + harness: &TestCodexHarness, + prompt: &str, + call_id: &str, + patch: &str, + assistant_msg: &str, +) -> Result<()> { + mount_sse_sequence( + harness.server(), + apply_patch_responses(call_id, patch, assistant_msg), + ) + .await; + harness.submit(prompt).await +} + +async fn invoke_undo(codex: &Arc) -> Result { + codex.submit(Op::Undo).await?; + let event = wait_for_event_match(codex, |msg| match msg { + EventMsg::UndoCompleted(done) => Some(done.clone()), + _ => None, + }) + .await; + Ok(event) +} + +async fn expect_successful_undo(codex: &Arc) -> Result { + let event = invoke_undo(codex).await?; + assert!( + event.success, + "expected undo to succeed but failed with message {:?}", + event.message + ); + Ok(event) +} + +async fn expect_failed_undo(codex: &Arc) -> Result { + let event = invoke_undo(codex).await?; + assert!( + !event.success, + "expected undo to fail but succeeded with message {:?}", + event.message + ); + assert_eq!( + event.message.as_deref(), + Some("No ghost snapshot available to undo.") + ); + Ok(event) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_removes_new_file_created_during_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + init_git_repo(harness.cwd())?; + + let call_id = "undo-create-file"; + let patch = "*** Begin Patch\n*** Add File: new_file.txt\n+from turn\n*** End Patch"; + run_apply_patch_turn(&harness, "create file", call_id, patch, "ok").await?; + + let new_path = harness.path("new_file.txt"); + assert_eq!(fs::read_to_string(&new_path)?, "from turn\n"); + + let codex = Arc::clone(&harness.test().codex); + let completed = expect_successful_undo(&codex).await?; + assert!(completed.success, "undo failed: {:?}", completed.message); + + assert!(!new_path.exists()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_restores_tracked_file_edit() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + init_git_repo(harness.cwd())?; + + let tracked = harness.path("tracked.txt"); + fs::write(&tracked, "before\n")?; + git(harness.cwd(), &["add", "tracked.txt"])?; + git(harness.cwd(), &["commit", "-m", "track file"])?; + + let patch = "*** Begin Patch\n*** Update File: tracked.txt\n@@\n-before\n+after\n*** End Patch"; + run_apply_patch_turn( + &harness, + "update tracked file", + "undo-tracked-edit", + patch, + "done", + ) + .await?; + println!( + "apply_patch output: {}", + harness.function_call_stdout("undo-tracked-edit").await + ); + + assert_eq!(fs::read_to_string(&tracked)?, "after\n"); + + let codex = Arc::clone(&harness.test().codex); + let completed = expect_successful_undo(&codex).await?; + assert!(completed.success, "undo failed: {:?}", completed.message); + + assert_eq!(fs::read_to_string(&tracked)?, "before\n"); + let status = git_output(harness.cwd(), &["status", "--short"])?; + assert_eq!(status, ""); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_restores_untracked_file_edit() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + init_git_repo(harness.cwd())?; + git(harness.cwd(), &["commit", "--allow-empty", "-m", "init"])?; + + let notes = harness.path("notes.txt"); + fs::write(¬es, "original\n")?; + let status_before = git_output(harness.cwd(), &["status", "--short", "--ignored"])?; + assert!(status_before.contains("?? notes.txt")); + + let patch = + "*** Begin Patch\n*** Update File: notes.txt\n@@\n-original\n+modified\n*** End Patch"; + run_apply_patch_turn( + &harness, + "edit untracked", + "undo-untracked-edit", + patch, + "done", + ) + .await?; + + assert_eq!(fs::read_to_string(¬es)?, "modified\n"); + + let codex = Arc::clone(&harness.test().codex); + let completed = expect_successful_undo(&codex).await?; + assert!(completed.success, "undo failed: {:?}", completed.message); + + assert_eq!(fs::read_to_string(¬es)?, "original\n"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_reverts_only_latest_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + init_git_repo(harness.cwd())?; + + let call_id_one = "undo-turn-one"; + let add_patch = "*** Begin Patch\n*** Add File: story.txt\n+first version\n*** End Patch"; + run_apply_patch_turn(&harness, "create story", call_id_one, add_patch, "done").await?; + let story = harness.path("story.txt"); + assert_eq!(fs::read_to_string(&story)?, "first version\n"); + + let call_id_two = "undo-turn-two"; + let update_patch = "*** Begin Patch\n*** Update File: story.txt\n@@\n-first version\n+second version\n*** End Patch"; + run_apply_patch_turn(&harness, "revise story", call_id_two, update_patch, "done").await?; + assert_eq!(fs::read_to_string(&story)?, "second version\n"); + + let codex = Arc::clone(&harness.test().codex); + let completed = expect_successful_undo(&codex).await?; + assert!(completed.success, "undo failed: {:?}", completed.message); + + assert_eq!(fs::read_to_string(&story)?, "first version\n"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_does_not_touch_unrelated_files() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + init_git_repo(harness.cwd())?; + + let tracked_constant = harness.path("stable.txt"); + fs::write(&tracked_constant, "stable\n")?; + let target = harness.path("target.txt"); + fs::write(&target, "start\n")?; + let gitignore = harness.path(".gitignore"); + fs::write(&gitignore, "ignored-stable.log\n")?; + git( + harness.cwd(), + &["add", "stable.txt", "target.txt", ".gitignore"], + )?; + git(harness.cwd(), &["commit", "-m", "seed tracked"])?; + + let preexisting_untracked = harness.path("scratch.txt"); + fs::write(&preexisting_untracked, "scratch before\n")?; + let ignored = harness.path("ignored-stable.log"); + fs::write(&ignored, "ignored before\n")?; + + let full_patch = "*** Begin Patch\n*** Update File: target.txt\n@@\n-start\n+edited\n*** Add File: temp.txt\n+ephemeral\n*** End Patch"; + run_apply_patch_turn( + &harness, + "modify target", + "undo-unrelated", + full_patch, + "done", + ) + .await?; + let temp = harness.path("temp.txt"); + assert_eq!(fs::read_to_string(&target)?, "edited\n"); + assert_eq!(fs::read_to_string(&temp)?, "ephemeral\n"); + + let codex = Arc::clone(&harness.test().codex); + let completed = expect_successful_undo(&codex).await?; + assert!(completed.success, "undo failed: {:?}", completed.message); + + assert_eq!(fs::read_to_string(&tracked_constant)?, "stable\n"); + assert_eq!(fs::read_to_string(&target)?, "start\n"); + assert_eq!( + fs::read_to_string(&preexisting_untracked)?, + "scratch before\n" + ); + assert_eq!(fs::read_to_string(&ignored)?, "ignored before\n"); + assert!(!temp.exists()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_sequential_turns_consumes_snapshots() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + init_git_repo(harness.cwd())?; + + let story = harness.path("story.txt"); + fs::write(&story, "initial\n")?; + git(harness.cwd(), &["add", "story.txt"])?; + git(harness.cwd(), &["commit", "-m", "seed story"])?; + + run_apply_patch_turn( + &harness, + "first change", + "seq-turn-1", + "*** Begin Patch\n*** Update File: story.txt\n@@\n-initial\n+turn one\n*** End Patch", + "ok", + ) + .await?; + assert_eq!(fs::read_to_string(&story)?, "turn one\n"); + + run_apply_patch_turn( + &harness, + "second change", + "seq-turn-2", + "*** Begin Patch\n*** Update File: story.txt\n@@\n-turn one\n+turn two\n*** End Patch", + "ok", + ) + .await?; + assert_eq!(fs::read_to_string(&story)?, "turn two\n"); + + run_apply_patch_turn( + &harness, + "third change", + "seq-turn-3", + "*** Begin Patch\n*** Update File: story.txt\n@@\n-turn two\n+turn three\n*** End Patch", + "ok", + ) + .await?; + assert_eq!(fs::read_to_string(&story)?, "turn three\n"); + + let codex = Arc::clone(&harness.test().codex); + expect_successful_undo(&codex).await?; + assert_eq!(fs::read_to_string(&story)?, "turn two\n"); + + expect_successful_undo(&codex).await?; + assert_eq!(fs::read_to_string(&story)?, "turn one\n"); + + expect_successful_undo(&codex).await?; + assert_eq!(fs::read_to_string(&story)?, "initial\n"); + + expect_failed_undo(&codex).await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_without_snapshot_reports_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + let codex = Arc::clone(&harness.test().codex); + + expect_failed_undo(&codex).await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_restores_moves_and_renames() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + init_git_repo(harness.cwd())?; + + let source = harness.path("rename_me.txt"); + fs::write(&source, "original\n")?; + git(harness.cwd(), &["add", "rename_me.txt"])?; + git(harness.cwd(), &["commit", "-m", "add rename target"])?; + + let patch = "*** Begin Patch\n*** Update File: rename_me.txt\n*** Move to: relocated/renamed.txt\n@@\n-original\n+renamed content\n*** End Patch"; + run_apply_patch_turn(&harness, "rename file", "undo-rename", patch, "done").await?; + + let destination = harness.path("relocated/renamed.txt"); + assert!(!source.exists()); + assert_eq!(fs::read_to_string(&destination)?, "renamed content\n"); + + let codex = Arc::clone(&harness.test().codex); + expect_successful_undo(&codex).await?; + + assert_eq!(fs::read_to_string(&source)?, "original\n"); + assert!(!destination.exists()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_does_not_touch_ignored_directory_contents() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + init_git_repo(harness.cwd())?; + + let gitignore = harness.path(".gitignore"); + fs::write(&gitignore, "logs/\n")?; + git(harness.cwd(), &["add", ".gitignore"])?; + git(harness.cwd(), &["commit", "-m", "ignore logs directory"])?; + + let logs_dir = harness.path("logs"); + fs::create_dir_all(&logs_dir)?; + let preserved = logs_dir.join("persistent.log"); + fs::write(&preserved, "keep me\n")?; + + run_apply_patch_turn( + &harness, + "write log", + "undo-log", + "*** Begin Patch\n*** Add File: logs/session.log\n+ephemeral log\n*** End Patch", + "ok", + ) + .await?; + + let new_log = logs_dir.join("session.log"); + assert_eq!(fs::read_to_string(&new_log)?, "ephemeral log\n"); + + let codex = Arc::clone(&harness.test().codex); + expect_successful_undo(&codex).await?; + + assert!(new_log.exists()); + assert_eq!(fs::read_to_string(&preserved)?, "keep me\n"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undo_overwrites_manual_edits_after_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = undo_harness().await?; + init_git_repo(harness.cwd())?; + + let tracked = harness.path("tracked.txt"); + fs::write(&tracked, "baseline\n")?; + git(harness.cwd(), &["add", "tracked.txt"])?; + git(harness.cwd(), &["commit", "-m", "baseline tracked"])?; + + run_apply_patch_turn( + &harness, + "modify tracked", + "undo-manual-overwrite", + "*** Begin Patch\n*** Update File: tracked.txt\n@@\n-baseline\n+turn change\n*** End Patch", + "ok", + ) + .await?; + assert_eq!(fs::read_to_string(&tracked)?, "turn change\n"); + + fs::write(&tracked, "manual edit\n")?; + assert_eq!(fs::read_to_string(&tracked)?, "manual edit\n"); + + let codex = Arc::clone(&harness.test().codex); + expect_successful_undo(&codex).await?; + + assert_eq!(fs::read_to_string(&tracked)?, "baseline\n"); + + Ok(()) +} From 68731ac74d78df5efe6498e9de582d6047e6ee51 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Fri, 31 Oct 2025 09:51:33 -0700 Subject: [PATCH 02/17] fix: brew upgrade link (#6045) ### Summary Fix brew upgrade FAQ link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4678abd800..faf05f2ce5 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Then simply run `codex` to get started: codex ``` -If you're running into upgrade issues with Homebrew, see the [FAQ entry on brew upgrade codex](./docs/faq.md#brew-update-codex-isnt-upgrading-me). +If you're running into upgrade issues with Homebrew, see the [FAQ entry on brew upgrade codex](./docs/faq.md#brew-upgrade-codex-isnt-upgrading-me).
You can also go to the latest GitHub Release and download the appropriate binary for your platform. From 368f7adfc6efb116879e3b0ae2d80d12eef9275d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 09:56:33 -0700 Subject: [PATCH 03/17] chore(deps): bump actions/github-script from 7 to 8 (#4801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
Release notes

Sourced from actions/github-script's releases.

v8.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

New Contributors

Full Changelog: https://github.com/actions/github-script/compare/v7.1.0...v8.0.0

v7.1.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/github-script/compare/v7...v7.1.0

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/github-script&package-manager=github_actions&previous-version=7&new-version=8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/issue-deduplicator.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 2bac731e61..4c4f18dbeb 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -87,7 +87,7 @@ jobs: issues: write steps: - name: Comment on issue - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: CODEX_OUTPUT: ${{ needs.gather-duplicates.outputs.codex_output }} with: From 01ca2b5df666ca2594ce26b2867cc1a4337cc21e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 09:57:07 -0700 Subject: [PATCH 04/17] chore(deps): bump actions/checkout from 4 to 5 (#4800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
Release notes

Sourced from actions/checkout's releases.

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

Full Changelog: https://github.com/actions/checkout/compare/v4...v5.0.0

v4.3.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4...v4.3.0

v4.2.2

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v4.2.1...v4.2.2

v4.2.1

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4.2.0...v4.2.1

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

V5.0.0

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

v4.1.4

v4.1.3

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/issue-deduplicator.yml | 2 +- .github/workflows/issue-labeler.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 4c4f18dbeb..c36857cab0 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -16,7 +16,7 @@ jobs: outputs: codex_output: ${{ steps.codex.outputs.final-message }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Prepare Codex inputs env: diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 7560967ea4..82ffb83915 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -16,7 +16,7 @@ jobs: outputs: codex_output: ${{ steps.codex.outputs.final-message }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - id: codex uses: openai/codex-action@main From a2fe2f9fb1028f4da82bc52b3bc76b83f5ecab3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 10:15:33 -0700 Subject: [PATCH 05/17] chore(deps): bump anyhow from 1.0.99 to 1.0.100 in /codex-rs (#4802) Bumps [anyhow](https://github.com/dtolnay/anyhow) from 1.0.99 to 1.0.100.
Release notes

Sourced from anyhow's releases.

1.0.100

  • Teach clippy to lint formatting arguments in bail!, ensure!, anyhow! (#426)
Commits
  • 18c2598 Release 1.0.100
  • f271988 Merge pull request #426 from dtolnay/clippyfmt
  • 52f2115 Mark macros with clippy::format_args
  • da5fd9d Raise minimum tested compiler to rust 1.76
  • 211e409 Opt in to generate-macro-expansion when building on docs.rs
  • b48fc02 Enforce trybuild >= 1.0.108
  • d5f59fb Update ui test suite to nightly-2025-09-07
  • 238415d Update ui test suite to nightly-2025-08-24
  • 3bab070 Update actions/checkout@v4 -> v5
  • 4249254 Order cap-lints flag in the same order as thiserror build script
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=anyhow&package-manager=cargo&previous-version=1.0.99&new-version=1.0.100)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eric Traut --- codex-rs/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 61a1b1ce72..aecb054757 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -172,9 +172,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "app_test_support" From ff48ae192bc567da9c9d7c7f9678f42da7d715d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 10:15:52 -0700 Subject: [PATCH 06/17] chore(deps): bump indexmap from 2.10.0 to 2.11.4 in /codex-rs (#4804) Bumps [indexmap](https://github.com/indexmap-rs/indexmap) from 2.10.0 to 2.11.4.
Changelog

Sourced from indexmap's changelog.

2.11.4 (2025-09-18)

  • Updated the hashbrown dependency to a range allowing 0.15 or 0.16.

2.11.3 (2025-09-15)

  • Make the minimum serde version only apply when "serde" is enabled.

2.11.2 (2025-09-15)

  • Switched the "serde" feature to depend on serde_core, improving build parallelism in cases where other dependents have enabled "serde/derive".

2.11.1 (2025-09-08)

  • Added a get_key_value_mut method to IndexMap.
  • Removed the unnecessary Ord bound on insert_sorted_by methods.

2.11.0 (2025-08-22)

  • Added insert_sorted_by and insert_sorted_by_key methods to IndexMap, IndexSet, and VacantEntry, like customizable versions of insert_sorted.
  • Added is_sorted, is_sorted_by, and is_sorted_by_key methods to IndexMap and IndexSet, as well as their Slice counterparts.
  • Added sort_by_key and sort_unstable_by_key methods to IndexMap and IndexSet, as well as parallel counterparts.
  • Added replace_index methods to IndexMap, IndexSet, and VacantEntry to replace the key (or set value) at a given index.
  • Added optional sval serialization support.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=indexmap&package-manager=cargo&previous-version=2.10.0&new-version=2.11.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Eric Traut --- codex-rs/Cargo.lock | 33 ++++++++++++++++++++------------- codex-rs/Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index aecb054757..4b50b444ee 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1084,7 +1084,7 @@ dependencies = [ "futures", "http", "image", - "indexmap 2.10.0", + "indexmap 2.12.0", "keyring", "landlock", "libc", @@ -2722,7 +2722,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.10.0", + "indexmap 2.12.0", "slab", "tokio", "tokio-util", @@ -2766,6 +2766,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "heck" version = "0.5.0" @@ -3201,13 +3207,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -4369,7 +4376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.10.0", + "indexmap 2.12.0", ] [[package]] @@ -4437,7 +4444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ "base64", - "indexmap 2.10.0", + "indexmap 2.12.0", "quick-xml", "serde", "time", @@ -4603,7 +4610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" dependencies = [ "futures", - "indexmap 2.10.0", + "indexmap 2.12.0", "nix 0.30.1", "tokio", "tracing", @@ -5548,7 +5555,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.12.0", "itoa", "memchr", "ryu", @@ -5609,7 +5616,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.10.0", + "indexmap 2.12.0", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -6427,7 +6434,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.12.0", "serde", "serde_spanned", "toml_datetime", @@ -6451,7 +6458,7 @@ version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.12.0", "toml_datetime", "toml_parser", "toml_writer", @@ -6510,7 +6517,7 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", - "indexmap 2.10.0", + "indexmap 2.12.0", "pin-project-lite", "slab", "sync_wrapper", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 82f0ed8dbb..f631c05504 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -128,7 +128,7 @@ icu_provider = { version = "2.1", features = ["sync"] } icu_locale_core = "2.1" ignore = "0.4.23" image = { version = "^0.25.8", default-features = false } -indexmap = "2.6.0" +indexmap = "2.12.0" insta = "1.43.2" itertools = "0.14.0" keyring = "3.6" From 23f31c6bffc2ee7babcf02c9f530e1d873effe36 Mon Sep 17 00:00:00 2001 From: uonr Date: Sat, 1 Nov 2025 02:17:42 +0900 Subject: [PATCH 07/17] docs: "Configuration" is not belongs "Getting started" (#4797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I finished reading “Getting Started,” but couldn’t find the “Configuration” section in the README. After following the link, I realized “Configuration” is in a separate file, so I updated the README accordingly. # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Co-authored-by: Eric Traut --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index faf05f2ce5..abd611e7bb 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [Example prompts](./docs/getting-started.md#example-prompts) - [Custom prompts](./docs/prompts.md) - [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd) - - [Configuration](./docs/config.md) +- [**Configuration**](./docs/config.md) - [**Sandbox & approvals**](./docs/sandbox.md) - [**Authentication**](./docs/authentication.md) - [Auth methods](./docs/authentication.md#forcing-a-specific-auth-method-advanced) From 1c8507b32a1b880283d89546db6b510a03a0d935 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 31 Oct 2025 10:30:36 -0700 Subject: [PATCH 08/17] Truncate total tool calls text (#5979) Put a cap on the aggregate output of text content on tool calls. --------- Co-authored-by: Gabriel Peal --- codex-rs/core/src/conversation_history.rs | 143 +++++++++++++++++++--- codex-rs/core/tests/suite/truncation.rs | 112 +++++++++++++++++ 2 files changed, 238 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index 5c8ea30e50..9a5570a249 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -366,23 +366,10 @@ impl ConversationHistory { match item { ResponseItem::FunctionCallOutput { call_id, output } => { let truncated = format_output_for_model_body(output.content.as_str()); - let truncated_items = output.content_items.as_ref().map(|items| { - items - .iter() - .map(|it| match it { - FunctionCallOutputContentItem::InputText { text } => { - FunctionCallOutputContentItem::InputText { - text: format_output_for_model_body(text), - } - } - FunctionCallOutputContentItem::InputImage { image_url } => { - FunctionCallOutputContentItem::InputImage { - image_url: image_url.clone(), - } - } - }) - .collect() - }); + let truncated_items = output + .content_items + .as_ref() + .map(|items| globally_truncate_function_output_items(items)); ResponseItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload { @@ -411,6 +398,53 @@ impl ConversationHistory { } } +fn globally_truncate_function_output_items( + items: &[FunctionCallOutputContentItem], +) -> Vec { + let mut out: Vec = Vec::with_capacity(items.len()); + let mut remaining = MODEL_FORMAT_MAX_BYTES; + let mut omitted_text_items = 0usize; + + for it in items { + match it { + FunctionCallOutputContentItem::InputText { text } => { + if remaining == 0 { + omitted_text_items += 1; + continue; + } + + let len = text.len(); + if len <= remaining { + out.push(FunctionCallOutputContentItem::InputText { text: text.clone() }); + remaining -= len; + } else { + let slice = take_bytes_at_char_boundary(text, remaining); + if !slice.is_empty() { + out.push(FunctionCallOutputContentItem::InputText { + text: slice.to_string(), + }); + } + remaining = 0; + } + } + // todo(aibrahim): handle input images; resize + FunctionCallOutputContentItem::InputImage { image_url } => { + out.push(FunctionCallOutputContentItem::InputImage { + image_url: image_url.clone(), + }); + } + } + } + + if omitted_text_items > 0 { + out.push(FunctionCallOutputContentItem::InputText { + text: format!("[omitted {omitted_text_items} text items ...]"), + }); + } + + out +} + pub(crate) fn format_output_for_model_body(content: &str) -> String { // Head+tail truncation for the model: show the beginning and end with an elision. // Clients still receive full streams; only this formatted summary is capped. @@ -856,6 +890,81 @@ mod tests { ); } + #[test] + fn truncates_across_multiple_under_limit_texts_and_reports_omitted() { + // Arrange: several text items, none exceeding per-item limit, but total exceeds budget. + let budget = MODEL_FORMAT_MAX_BYTES; + let t1_len = (budget / 2).saturating_sub(10); + let t2_len = (budget / 2).saturating_sub(10); + let remaining_after_t1_t2 = budget.saturating_sub(t1_len + t2_len); + let t3_len = 50; // gets truncated to remaining_after_t1_t2 + let t4_len = 5; // omitted + let t5_len = 7; // omitted + + let t1 = "a".repeat(t1_len); + let t2 = "b".repeat(t2_len); + let t3 = "c".repeat(t3_len); + let t4 = "d".repeat(t4_len); + let t5 = "e".repeat(t5_len); + + let item = ResponseItem::FunctionCallOutput { + call_id: "call-omit".to_string(), + output: FunctionCallOutputPayload { + content: "irrelevant".to_string(), + content_items: Some(vec![ + FunctionCallOutputContentItem::InputText { text: t1 }, + FunctionCallOutputContentItem::InputText { text: t2 }, + FunctionCallOutputContentItem::InputImage { + image_url: "img:mid".to_string(), + }, + FunctionCallOutputContentItem::InputText { text: t3 }, + FunctionCallOutputContentItem::InputText { text: t4 }, + FunctionCallOutputContentItem::InputText { text: t5 }, + ]), + success: Some(true), + }, + }; + + let mut history = ConversationHistory::new(); + history.record_items([&item]); + assert_eq!(history.items.len(), 1); + let json = serde_json::to_value(&history.items[0]).expect("serialize to json"); + + let output = json + .get("output") + .expect("output field") + .as_array() + .expect("array output"); + + // Expect: t1 (full), t2 (full), image, t3 (truncated), summary mentioning 2 omitted. + assert_eq!(output.len(), 5); + + let first = output[0].as_object().expect("first obj"); + assert_eq!(first.get("type").unwrap(), "input_text"); + let first_text = first.get("text").unwrap().as_str().unwrap(); + assert_eq!(first_text.len(), t1_len); + + let second = output[1].as_object().expect("second obj"); + assert_eq!(second.get("type").unwrap(), "input_text"); + let second_text = second.get("text").unwrap().as_str().unwrap(); + assert_eq!(second_text.len(), t2_len); + + assert_eq!( + output[2], + serde_json::json!({"type": "input_image", "image_url": "img:mid"}) + ); + + let fourth = output[3].as_object().expect("fourth obj"); + assert_eq!(fourth.get("type").unwrap(), "input_text"); + let fourth_text = fourth.get("text").unwrap().as_str().unwrap(); + assert_eq!(fourth_text.len(), remaining_after_t1_t2); + + let summary = output[4].as_object().expect("summary obj"); + assert_eq!(summary.get("type").unwrap(), "input_text"); + let summary_text = summary.get("text").unwrap().as_str().unwrap(); + assert!(summary_text.contains("omitted 2 text items")); + } + //TODO(aibrahim): run CI in release mode. #[cfg(not(debug_assertions))] #[test] diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 2f23c541de..aab95fa028 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -3,9 +3,16 @@ use anyhow::Context; use anyhow::Result; +use codex_core::config::types::McpServerConfig; +use codex_core::config::types::McpServerTransportConfig; use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -18,10 +25,13 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; use escargot::CargoBuild; use regex_lite::Regex; use serde_json::Value; use serde_json::json; +use std::collections::HashMap; +use std::time::Duration; use wiremock::matchers::any; // Verifies byte-truncation formatting for function error output (RespondToModel errors) @@ -268,3 +278,105 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> Ok(()) } + +// Verifies that an MCP image tool output is serialized as content_items array with +// the image preserved and no truncation summary appended (since there are no text items). +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let call_id = "rmcp-image-no-trunc"; + let server_name = "rmcp"; + let tool_name = format!("mcp__{server_name}__image"); + + mount_sse_once_match( + &server, + any(), + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, &tool_name, "{}"), + ev_completed("resp-1"), + ]), + ) + .await; + let final_mock = mount_sse_once_match( + &server, + any(), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + // Build the stdio rmcp server and pass a tiny PNG via data URL so it can construct ImageContent. + let rmcp_test_server_bin = CargoBuild::new() + .package("codex-rmcp-client") + .bin("test_stdio_server") + .run()? + .path() + .to_string_lossy() + .into_owned(); + + // 1x1 PNG data URL + let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ee9bQAAAABJRU5ErkJggg=="; + + let mut builder = test_codex().with_config(move |config| { + config.features.enable(Feature::RmcpClient); + config.mcp_servers.insert( + server_name.to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: rmcp_test_server_bin, + args: Vec::new(), + env: Some(HashMap::from([( + "MCP_TEST_IMAGE_DATA_URL".to_string(), + openai_png.to_string(), + )])), + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + }, + ); + }); + let fixture = builder.build(&server).await?; + let session_model = fixture.session_configured.model.clone(); + + fixture + .codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "call the rmcp image tool".into(), + }], + final_output_json_schema: None, + cwd: fixture.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + // Wait for completion to ensure the outbound request is captured. + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + let output_item = final_mock.single_request().function_call_output(call_id); + // Expect exactly one array element: the image item; and no trailing summary text. + let output = output_item.get("output").expect("output"); + assert!(output.is_array(), "expected array output"); + let arr = output.as_array().unwrap(); + assert_eq!(arr.len(), 1, "no truncation summary should be appended"); + assert_eq!( + arr[0], + json!({"type": "input_image", "image_url": openai_png}) + ); + + Ok(()) +} From 88e083a9d0473cfa5f03318433fbb52f88691be5 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 31 Oct 2025 11:01:58 -0700 Subject: [PATCH 09/17] chore: Add shell serialization tests for json (#6043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Can never have enough tests on this code path - checking that json inside a shell call is deserialized correctly. ## Tests - [x] These are tests 😎 --- .../core/tests/suite/shell_serialization.rs | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 50d0c99980..d748fe6b4c 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -30,6 +30,18 @@ use serde_json::Value; use serde_json::json; use std::fs; +const FIXTURE_JSON: &str = r#"{ + "description": "This is an example JSON file.", + "foo": "bar", + "isTest": true, + "testNumber": 123, + "testArray": [1, 2, 3], + "testObject": { + "foo": "bar" + } +} +"#; + async fn submit_turn(test: &TestCodex, prompt: &str, sandbox_policy: SandboxPolicy) -> Result<()> { let session_model = test.session_configured.model.clone(); @@ -225,6 +237,154 @@ freeform shell Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_output_preserves_fixture_json_without_serialization() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.features.disable(Feature::ApplyPatchFreeform); + config.model = "gpt-5".to_string(); + config.model_family = find_family_for_model("gpt-5").expect("gpt-5 is a model family"); + }); + let test = builder.build(&server).await?; + + let fixture_path = test.cwd.path().join("fixture.json"); + fs::write(&fixture_path, FIXTURE_JSON)?; + let fixture_path_str = fixture_path.to_string_lossy().to_string(); + + let call_id = "shell-json-fixture"; + let args = json!({ + "command": ["/usr/bin/sed", "-n", "p", fixture_path_str], + "timeout_ms": 1_000, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "read the fixture JSON with sed", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = find_function_call_output(&bodies, call_id).expect("shell output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("shell output string"); + + let mut parsed: Value = serde_json::from_str(output)?; + if let Some(metadata) = parsed.get_mut("metadata").and_then(Value::as_object_mut) { + let _ = metadata.remove("duration_seconds"); + } + + assert_eq!( + parsed + .get("metadata") + .and_then(|metadata| metadata.get("exit_code")) + .and_then(Value::as_i64), + Some(0), + "expected zero exit code when serialization is disabled", + ); + let stdout = parsed + .get("output") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + assert_eq!( + stdout, FIXTURE_JSON, + "expected shell output to match the fixture contents" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_output_structures_fixture_with_serialization() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::ApplyPatchFreeform); + }); + let test = builder.build(&server).await?; + + let fixture_path = test.cwd.path().join("fixture.json"); + fs::write(&fixture_path, FIXTURE_JSON)?; + let fixture_path_str = fixture_path.to_string_lossy().to_string(); + + let call_id = "shell-structured-fixture"; + let args = json!({ + "command": ["/usr/bin/sed", "-n", "p", fixture_path_str], + "timeout_ms": 1_000, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + submit_turn( + &test, + "read the fixture JSON with structured output", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = server + .received_requests() + .await + .expect("recorded requests present"); + let bodies = request_bodies(&requests)?; + let output_item = + find_function_call_output(&bodies, call_id).expect("structured output present"); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("structured output string"); + + assert!( + serde_json::from_str::(output).is_err(), + "expected structured output to be plain text" + ); + let (header, body) = output + .split_once("Output:\n") + .expect("structured output contains an Output section"); + assert_regex_match( + r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds$", + header.trim_end(), + ); + assert_eq!( + body, FIXTURE_JSON, + "expected Output section to include the fixture contents" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_output_for_freeform_tool_records_duration() -> Result<()> { skip_if_no_network!(Ok(())); From c8ebb2a0dcf9ffe1b740aeaf46e9073b37babfc7 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 31 Oct 2025 13:27:33 -0700 Subject: [PATCH 10/17] Add warning on compact (#6052) This PR introduces the ability for `core` to send `warnings` as it can send `errors. It also sends a warning on compaction. image --- codex-rs/core/src/codex/compact.rs | 6 +++++ codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/tests/suite/compact.rs | 18 +++++++++++++- .../core/tests/suite/compact_resume_fork.rs | 7 ++++++ codex-rs/docs/protocol_v1.md | 1 + .../src/event_processor_with_human_output.rs | 11 +++++++++ .../src/event_processor_with_jsonl_output.rs | 10 ++++++++ .../tests/event_processor_with_json_output.rs | 24 +++++++++++++++++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 3 +++ codex-rs/protocol/src/protocol.rs | 9 +++++++ codex-rs/tui/src/chatwidget.rs | 7 ++++++ codex-rs/tui/src/chatwidget/tests.rs | 22 +++++++++++++++++ 12 files changed, 118 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index a2fade9633..85f7bd887f 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -13,6 +13,7 @@ use crate::protocol::ErrorEvent; use crate::protocol::EventMsg; use crate::protocol::TaskStartedEvent; use crate::protocol::TurnContextItem; +use crate::protocol::WarningEvent; use crate::truncate::truncate_middle; use crate::util::backoff; use askama::Template; @@ -168,6 +169,11 @@ async fn run_compact_task_inner( message: "Compact task completed".to_string(), }); sess.send_event(&turn_context, event).await; + + let warning = EventMsg::Warning(WarningEvent { + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(), + }); + sess.send_event(&turn_context, warning).await; } pub fn content_items_to_text(content: &[ContentItem]) -> Option { diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 77c1620634..e008832641 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -46,6 +46,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::UndoCompleted(_) | EventMsg::TurnAborted(_) => true, EventMsg::Error(_) + | EventMsg::Warning(_) | EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_) | EventMsg::AgentMessageDelta(_) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 0dea8a02cb..f6006ecb28 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -8,6 +8,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; +use codex_core::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::skip_if_no_network; @@ -45,6 +46,7 @@ const CONTEXT_LIMIT_MESSAGE: &str = const DUMMY_FUNCTION_NAME: &str = "unsupported_tool"; const DUMMY_CALL_ID: &str = "call-multi-auto"; const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push"; +pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted."; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn summarize_context_three_requests_and_instructions() { @@ -118,6 +120,11 @@ async fn summarize_context_three_requests_and_instructions() { // 2) Summarize – second hit should include the summarization prompt. codex.submit(Op::Compact).await.unwrap(); + let warning_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await; + let EventMsg::Warning(WarningEvent { message }) = warning_event else { + panic!("expected warning event after compact"); + }; + assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // 3) Next user input – third hit; history should include only the summary. @@ -288,6 +295,11 @@ async fn manual_compact_uses_custom_prompt() { .conversation; codex.submit(Op::Compact).await.expect("trigger compact"); + let warning_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await; + let EventMsg::Warning(WarningEvent { message }) = warning_event else { + panic!("expected warning event after compact"); + }; + assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; let requests = server.received_requests().await.expect("collect requests"); @@ -742,7 +754,6 @@ async fn manual_compact_retries_after_context_window_error() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; codex.submit(Op::Compact).await.unwrap(); - let EventMsg::BackgroundEvent(event) = wait_for_event(&codex, |ev| matches!(ev, EventMsg::BackgroundEvent(_))).await else { @@ -753,6 +764,11 @@ async fn manual_compact_retries_after_context_window_error() { "background event should mention trimmed item count: {}", event.message ); + let warning_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await; + let EventMsg::Warning(WarningEvent { message }) = warning_event else { + panic!("expected warning event after compact retry"); + }; + assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; let requests = request_log.requests(); diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index b13c6e14fd..449091db82 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -7,6 +7,7 @@ //! request payload that Codex would send to the model and assert that the //! model-visible history matches the expected sequence of messages. +use super::compact::COMPACT_WARNING_MESSAGE; use super::compact::FIRST_REPLY; use super::compact::SUMMARY_TEXT; use codex_core::CodexAuth; @@ -20,6 +21,7 @@ use codex_core::config::Config; use codex_core::config::OPENAI_DEFAULT_MODEL; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; +use codex_core::protocol::WarningEvent; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; @@ -813,6 +815,11 @@ async fn compact_conversation(conversation: &Arc) { .submit(Op::Compact) .await .expect("compact conversation"); + let warning_event = wait_for_event(conversation, |ev| matches!(ev, EventMsg::Warning(_))).await; + let EventMsg::Warning(WarningEvent { message }) = warning_event else { + panic!("expected warning event after compact"); + }; + assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(conversation, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; } diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 4d0a6e2533..f6405c9bc3 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -73,6 +73,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command - `EventMsg::TaskComplete` – A task completed successfully - `EventMsg::Error` – A task stopped with an error + - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user - `EventMsg::TurnComplete` – Contains a `response_id` bookmark for last `response_id` executed by the task. This can be used to continue the task at a later point in time, perhaps with additional user input. The `response_id` returned from each task matches the OpenAI `response_id` stored in the API's `/responses` endpoint. It can be stored and used in future `Sessions` to resume threads of work. diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 491c2f5eab..93e0e493bb 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -21,6 +21,7 @@ use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; +use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::num_format::format_with_separators; use owo_colors::OwoColorize; @@ -54,6 +55,7 @@ pub(crate) struct EventProcessorWithHumanOutput { red: Style, green: Style, cyan: Style, + yellow: Style, /// Whether to include `AgentReasoning` events in the output. show_agent_reasoning: bool, @@ -81,6 +83,7 @@ impl EventProcessorWithHumanOutput { red: Style::new().red(), green: Style::new().green(), cyan: Style::new().cyan(), + yellow: Style::new().yellow(), show_agent_reasoning: !config.hide_agent_reasoning, show_raw_agent_reasoning: config.show_raw_agent_reasoning, last_message_path, @@ -97,6 +100,7 @@ impl EventProcessorWithHumanOutput { red: Style::new(), green: Style::new(), cyan: Style::new(), + yellow: Style::new(), show_agent_reasoning: !config.hide_agent_reasoning, show_raw_agent_reasoning: config.show_raw_agent_reasoning, last_message_path, @@ -161,6 +165,13 @@ impl EventProcessor for EventProcessorWithHumanOutput { let prefix = "ERROR:".style(self.red); ts_msg!(self, "{prefix} {message}"); } + EventMsg::Warning(WarningEvent { message }) => { + ts_msg!( + self, + "{} {message}", + "warning:".style(self.yellow).style(self.bold) + ); + } EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }) => { ts_msg!( self, diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 207edb4ec6..23dff015eb 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -8,6 +8,7 @@ use crate::event_processor::handle_last_message; use crate::exec_events::AgentMessageItem; use crate::exec_events::CommandExecutionItem; use crate::exec_events::CommandExecutionStatus; +use crate::exec_events::ErrorItem; use crate::exec_events::FileChangeItem; use crate::exec_events::FileUpdateChange; use crate::exec_events::ItemCompletedEvent; @@ -129,6 +130,15 @@ impl EventProcessorWithJsonOutput { self.last_critical_error = Some(error.clone()); vec![ThreadEvent::Error(error)] } + EventMsg::Warning(ev) => { + let item = ThreadItem { + id: self.get_next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { + message: ev.message.clone(), + }), + }; + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] + } EventMsg::StreamError(ev) => vec![ThreadEvent::Error(ThreadErrorEvent { message: ev.message.clone(), })], diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index aff479eff2..4c1e0c77b5 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -12,11 +12,13 @@ use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; +use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchEndEvent; use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use codex_exec::exec_events::AgentMessageItem; use codex_exec::exec_events::CommandExecutionItem; use codex_exec::exec_events::CommandExecutionStatus; +use codex_exec::exec_events::ErrorItem; use codex_exec::exec_events::ItemCompletedEvent; use codex_exec::exec_events::ItemStartedEvent; use codex_exec::exec_events::ItemUpdatedEvent; @@ -540,6 +542,28 @@ fn error_event_produces_error() { ); } +#[test] +fn warning_event_produces_error_item() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let out = ep.collect_thread_events(&event( + "e1", + EventMsg::Warning(WarningEvent { + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(), + }), + )); + assert_eq!( + out, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Error(ErrorItem { + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(), + }), + }, + })] + ); +} + #[test] fn stream_error_event_produces_error() { let mut ep = EventProcessorWithJsonOutput::new(None); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 365f7e1753..96e875153d 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -204,6 +204,9 @@ async fn run_codex_tool_session_inner( outgoing.send_response(request_id.clone(), result).await; break; } + EventMsg::Warning(_) => { + continue; + } EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id, reason, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index dcb0299e49..413a3aeaa2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -438,6 +438,10 @@ pub enum EventMsg { /// Error while executing a submission Error(ErrorEvent), + /// Warning issued while processing a submission. Unlike `Error`, this + /// indicates the task continued but the user should still be notified. + Warning(WarningEvent), + /// Agent has started a task TaskStarted(TaskStartedEvent), @@ -672,6 +676,11 @@ pub struct ErrorEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct WarningEvent { + pub message: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TaskCompleteEvent { pub last_agent_message: Option, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3dfa1b367f..5239c66e80 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -42,6 +42,7 @@ use codex_core::protocol::UndoCompletedEvent; use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::UserMessageEvent; use codex_core::protocol::ViewImageToolCallEvent; +use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::ConversationId; @@ -519,6 +520,11 @@ impl ChatWidget { self.maybe_send_next_queued_input(); } + fn on_warning(&mut self, message: String) { + self.add_to_history(history_cell::new_warning_event(message)); + self.request_redraw(); + } + /// Handle a turn aborted due to user interrupt (Esc). /// When there are queued user messages, restore them into the composer /// separated by newlines rather than auto‑submitting the next one. @@ -1477,6 +1483,7 @@ impl ChatWidget { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); } + EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), EventMsg::Error(ErrorEvent { message }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a6a991bb38..135dd2491f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -37,6 +37,7 @@ use codex_core::protocol::TaskStartedEvent; use codex_core::protocol::UndoCompletedEvent; use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; +use codex_core::protocol::WarningEvent; use codex_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg; @@ -56,6 +57,8 @@ use tempfile::tempdir; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; +const TEST_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted."; + fn test_config() -> Config { // Use base defaults to avoid depending on host state. Config::load_from_base_config_with_overrides( @@ -2445,6 +2448,25 @@ fn stream_error_updates_status_indicator() { assert_eq!(status.header(), msg); } +#[test] +fn warning_event_adds_warning_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::Warning(WarningEvent { + message: TEST_WARNING_MESSAGE.to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains(TEST_WARNING_MESSAGE), + "warning cell missing content: {rendered}" + ); +} + #[test] fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); From 611e00c862a9cb6e2b2a80485d085bd4107e6f32 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 31 Oct 2025 22:27:08 +0100 Subject: [PATCH 11/17] feat: compactor 2 (#6027) Co-authored-by: pakrym-oai --- codex-rs/Cargo.lock | 7 + codex-rs/core/Cargo.toml | 2 +- codex-rs/core/src/codex/compact.rs | 104 ++-- .../core/templates/compact/history_bridge.md | 7 - codex-rs/core/templates/compact/prompt.md | 14 +- codex-rs/core/tests/suite/compact.rs | 501 +++++++++++++++--- .../core/tests/suite/compact_resume_fork.rs | 73 ++- 7 files changed, 571 insertions(+), 137 deletions(-) delete mode 100644 codex-rs/core/templates/compact/history_bridge.md diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4b50b444ee..64d8790099 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -5685,6 +5685,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -6872,6 +6878,7 @@ dependencies = [ "getrandom 0.3.3", "js-sys", "serde", + "sha1_smol", "wasm-bindgen", ] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2b48c088fd..17a7a16609 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -80,7 +80,7 @@ toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } -uuid = { workspace = true, features = ["serde", "v4"] } +uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } wildmatch = { workspace = true } codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 85f7bd887f..c07503663d 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -16,7 +16,6 @@ use crate::protocol::TurnContextItem; use crate::protocol::WarningEvent; use crate::truncate::truncate_middle; use crate::util::backoff; -use askama::Template; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -29,13 +28,6 @@ use tracing::error; pub const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; -#[derive(Template)] -#[template(path = "compact/history_bridge.md", escape = "none")] -struct HistoryBridgeTemplate<'a> { - user_messages_text: &'a str, - summary_text: &'a str, -} - pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, @@ -150,6 +142,7 @@ async fn run_compact_task_inner( let history_snapshot = sess.clone_history().await.get_history(); let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default(); let user_messages = collect_user_messages(&history_snapshot); + let initial_context = sess.build_initial_context(turn_context.as_ref()); let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); let ghost_snapshots: Vec = history_snapshot @@ -224,33 +217,47 @@ fn build_compacted_history_with_limit( summary_text: &str, max_bytes: usize, ) -> Vec { - let mut user_messages_text = if user_messages.is_empty() { - "(none)".to_string() - } else { - user_messages.join("\n\n") - }; - // Truncate the concatenated prior user messages so the bridge message - // stays well under the context window (approx. 4 bytes/token). - if user_messages_text.len() > max_bytes { - user_messages_text = truncate_middle(&user_messages_text, max_bytes).0; + let mut selected_messages: Vec = Vec::new(); + if max_bytes > 0 { + let mut remaining = max_bytes; + for message in user_messages.iter().rev() { + if remaining == 0 { + break; + } + if message.len() <= remaining { + selected_messages.push(message.clone()); + remaining = remaining.saturating_sub(message.len()); + } else { + let (truncated, _) = truncate_middle(message, remaining); + selected_messages.push(truncated); + break; + } + } + selected_messages.reverse(); } + + for message in &selected_messages { + history.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: message.clone(), + }], + }); + } + let summary_text = if summary_text.is_empty() { "(no summary available)".to_string() } else { summary_text.to_string() }; - let Ok(bridge) = HistoryBridgeTemplate { - user_messages_text: &user_messages_text, - summary_text: &summary_text, - } - .render() else { - return vec![]; - }; + history.push(ResponseItem::Message { id: None, role: "user".to_string(), - content: vec![ContentItem::InputText { text: bridge }], + content: vec![ContentItem::InputText { text: summary_text }], }); + history } @@ -390,30 +397,55 @@ mod tests { "SUMMARY", max_bytes, ); + assert_eq!(history.len(), 2); - // Expect exactly one bridge message added to history (plus any initial context we provided, which is none). - assert_eq!(history.len(), 1); + let truncated_message = &history[0]; + let summary_message = &history[1]; - // Extract the text content of the bridge message. - let bridge_text = match &history[0] { + let truncated_text = match truncated_message { ResponseItem::Message { role, content, .. } if role == "user" => { content_items_to_text(content).unwrap_or_default() } other => panic!("unexpected item in history: {other:?}"), }; - // The bridge should contain the truncation marker and not the full original payload. assert!( - bridge_text.contains("tokens truncated"), - "expected truncation marker in bridge message" + truncated_text.contains("tokens truncated"), + "expected truncation marker in truncated user message" ); assert!( - !bridge_text.contains(&big), - "bridge should not include the full oversized user text" + !truncated_text.contains(&big), + "truncated user message should not include the full oversized user text" ); + + let summary_text = match summary_message { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).unwrap_or_default() + } + other => panic!("unexpected item in history: {other:?}"), + }; + assert_eq!(summary_text, "SUMMARY"); + } + + #[test] + fn build_compacted_history_appends_summary_message() { + let initial_context: Vec = Vec::new(); + let user_messages = vec!["first user message".to_string()]; + let summary_text = "summary text"; + + let history = build_compacted_history(initial_context, &user_messages, summary_text); assert!( - bridge_text.contains("SUMMARY"), - "bridge should include the provided summary text" + !history.is_empty(), + "expected compacted history to include summary" ); + + let last = history.last().expect("history should have a summary entry"); + let summary = match last { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).unwrap_or_default() + } + other => panic!("expected summary message, found {other:?}"), + }; + assert_eq!(summary, summary_text); } } diff --git a/codex-rs/core/templates/compact/history_bridge.md b/codex-rs/core/templates/compact/history_bridge.md deleted file mode 100644 index ef003440af..0000000000 --- a/codex-rs/core/templates/compact/history_bridge.md +++ /dev/null @@ -1,7 +0,0 @@ -You were originally given instructions from a user over one or more turns. Here were the user messages: - -{{ user_messages_text }} - -Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis: - -{{ summary_text }} \ No newline at end of file diff --git a/codex-rs/core/templates/compact/prompt.md b/codex-rs/core/templates/compact/prompt.md index 47dfeaba5e..42fae605db 100644 --- a/codex-rs/core/templates/compact/prompt.md +++ b/codex-rs/core/templates/compact/prompt.md @@ -1,5 +1,9 @@ -You have exceeded the maximum number of tokens, please stop coding and instead write a short memento message for the next agent. Your note should: -- Summarize what you finished and what still needs work. If there was a recent update_plan call, repeat its steps verbatim. -- List outstanding TODOs with file paths / line numbers so they're easy to find. -- Flag code that needs more tests (edge cases, performance, integration, etc.). -- Record any open bugs, quirks, or setup steps that will make it easier for the next agent to pick up where you left off. \ No newline at end of file +You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. + +Include: +- Current progress and key decisions made +- Important context, constraints, or user preferences +- What remains to be done (clear next steps) +- Any critical data, examples, or references needed to continue + +Be concise, structured, and focused on helping the next LLM seamlessly continue the work. diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index f6006ecb28..b634cf0e2f 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3,6 +3,7 @@ use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::NewConversation; use codex_core::built_in_model_providers; +use codex_core::config::Config; use codex_core::protocol::ErrorEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -13,9 +14,9 @@ use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; +use std::collections::VecDeque; use tempfile::TempDir; -use codex_core::codex::compact::SUMMARIZATION_PROMPT; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; @@ -27,6 +28,7 @@ use core_test_support::responses::sse; use core_test_support::responses::sse_failed; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; +use serde_json::json; // --- Test helpers ----------------------------------------------------------- pub(super) const FIRST_REPLY: &str = "FIRST_REPLY"; @@ -46,8 +48,39 @@ const CONTEXT_LIMIT_MESSAGE: &str = const DUMMY_FUNCTION_NAME: &str = "unsupported_tool"; const DUMMY_CALL_ID: &str = "call-multi-auto"; const FUNCTION_CALL_LIMIT_MSG: &str = "function call limit push"; +const POST_AUTO_USER_MSG: &str = "post auto follow-up"; +const COMPACT_PROMPT_MARKER: &str = + "You are performing a CONTEXT CHECKPOINT COMPACTION for a tool."; +pub(super) const TEST_COMPACT_PROMPT: &str = + "You are performing a CONTEXT CHECKPOINT COMPACTION for a tool.\nTest-only compact prompt."; + pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted."; +fn auto_summary(summary: &str) -> String { + summary.to_string() +} + +fn drop_call_id(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(obj) => { + obj.retain(|k, _| k != "call_id"); + for v in obj.values_mut() { + drop_call_id(v); + } + } + serde_json::Value::Array(arr) => { + for v in arr { + drop_call_id(v); + } + } + _ => {} + } +} + +fn set_test_compact_prompt(config: &mut Config) { + config.compact_prompt = Some(TEST_COMPACT_PROMPT.to_string()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn summarize_context_three_requests_and_instructions() { skip_if_no_network!(); @@ -73,14 +106,13 @@ async fn summarize_context_three_requests_and_instructions() { // Mount three expectations, one per request, matched by body content. let first_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains("\"text\":\"hello world\"") - && !body.contains("You have exceeded the maximum number of tokens") + body.contains("\"text\":\"hello world\"") && !body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, first_matcher, sse1).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains("You have exceeded the maximum number of tokens") + body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, second_matcher, sse2).await; @@ -98,6 +130,7 @@ async fn summarize_context_three_requests_and_instructions() { let home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; + set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); let NewConversation { @@ -166,11 +199,11 @@ async fn summarize_context_three_requests_and_instructions() { assert_eq!(last2.get("role").unwrap().as_str().unwrap(), "user"); let text2 = last2["content"][0]["text"].as_str().unwrap(); assert_eq!( - text2, SUMMARIZATION_PROMPT, + text2, TEST_COMPACT_PROMPT, "expected summarize trigger, got `{text2}`" ); - // Third request must contain the refreshed instructions, bridge summary message and new user msg. + // Third request must contain the refreshed instructions, compacted user history, and new user message. let input3 = body3.get("input").and_then(|v| v.as_array()).unwrap(); assert!( @@ -178,13 +211,21 @@ async fn summarize_context_three_requests_and_instructions() { "expected refreshed context and new user message in third request" ); - // Collect all (role, text) message tuples. let mut messages: Vec<(String, String)> = Vec::new(); + for item in input3 { - if item["type"].as_str() == Some("message") { - let role = item["role"].as_str().unwrap_or_default().to_string(); - let text = item["content"][0]["text"] - .as_str() + if let Some("message") = item.get("type").and_then(|v| v.as_str()) { + let role = item + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let text = item + .get("content") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|entry| entry.get("text")) + .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); messages.push((role, text)); @@ -200,26 +241,22 @@ async fn summarize_context_three_requests_and_instructions() { .any(|(r, t)| r == "user" && t == THIRD_USER_MSG), "third request should include the new user message" ); - let Some((_, bridge_text)) = messages.iter().find(|(role, text)| { - role == "user" - && (text.contains("Here were the user messages") - || text.contains("Here are all the user messages")) - && text.contains(SUMMARY_TEXT) - }) else { - panic!("expected a bridge message containing the summary"); - }; assert!( - bridge_text.contains("hello world"), - "bridge should capture earlier user messages" + messages + .iter() + .any(|(r, t)| r == "user" && t == "hello world"), + "third request should include the original user message" ); assert!( - !bridge_text.contains(SUMMARIZATION_PROMPT), - "bridge text should not echo the summarize trigger" + messages + .iter() + .any(|(r, t)| r == "user" && t == SUMMARY_TEXT), + "third request should include the summary message" ); assert!( !messages .iter() - .any(|(_, text)| text.contains(SUMMARIZATION_PROMPT)), + .any(|(_, text)| text.contains(TEST_COMPACT_PROMPT)), "third request should not include the summarize trigger" ); @@ -323,7 +360,7 @@ async fn manual_compact_uses_custom_prompt() { if text == custom_prompt { found_custom_prompt = true; } - if text == SUMMARIZATION_PROMPT { + if text == TEST_COMPACT_PROMPT { found_default_prompt = true; } } @@ -354,12 +391,17 @@ async fn auto_compact_runs_after_token_limit_hit() { ev_assistant_message("m3", AUTO_SUMMARY_TEXT), ev_completed_with_tokens("r3", 200), ]); + let sse_resume = sse(vec![ev_completed("r3-resume")]); + let sse4 = sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 120), + ]); let first_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(FIRST_AUTO_MSG) && !body.contains(SECOND_AUTO_MSG) - && !body.contains("You have exceeded the maximum number of tokens") + && !body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, first_matcher, sse1).await; @@ -367,16 +409,30 @@ async fn auto_compact_runs_after_token_limit_hit() { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(SECOND_AUTO_MSG) && body.contains(FIRST_AUTO_MSG) - && !body.contains("You have exceeded the maximum number of tokens") + && !body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains("You have exceeded the maximum number of tokens") + body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, third_matcher, sse3).await; + let resume_matcher = |req: &wiremock::Request| { + let body = std::str::from_utf8(&req.body).unwrap_or(""); + body.contains(AUTO_SUMMARY_TEXT) + && !body.contains(COMPACT_PROMPT_MARKER) + && !body.contains(POST_AUTO_USER_MSG) + }; + mount_sse_once_match(&server, resume_matcher, sse_resume).await; + + let fourth_matcher = |req: &wiremock::Request| { + let body = std::str::from_utf8(&req.body).unwrap_or(""); + body.contains(POST_AUTO_USER_MSG) && !body.contains(COMPACT_PROMPT_MARKER) + }; + mount_sse_once_match(&server, fourth_matcher, sse4).await; + let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), ..built_in_model_providers()["openai"].clone() @@ -385,6 +441,7 @@ async fn auto_compact_runs_after_token_limit_hit() { let home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; + set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); let codex = conversation_manager @@ -414,18 +471,29 @@ async fn auto_compact_runs_after_token_limit_hit() { .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - // wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: POST_AUTO_USER_MSG.into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; let requests = server.received_requests().await.unwrap(); - assert!( - requests.len() >= 3, - "auto compact should add at least a third request, got {}", + assert_eq!( + requests.len(), + 5, + "expected user turns, a compaction request, a resumed turn, and the follow-up turn; got {}", requests.len() ); let is_auto_compact = |req: &wiremock::Request| { std::str::from_utf8(&req.body) .unwrap_or("") - .contains("You have exceeded the maximum number of tokens") + .contains(COMPACT_PROMPT_MARKER) }; let auto_compact_count = requests.iter().filter(|req| is_auto_compact(req)).count(); assert_eq!( @@ -442,11 +510,41 @@ async fn auto_compact_runs_after_token_limit_hit() { "auto compact should add a third request" ); + let resume_index = requests + .iter() + .enumerate() + .find_map(|(idx, req)| { + let body = std::str::from_utf8(&req.body).unwrap_or(""); + (body.contains(AUTO_SUMMARY_TEXT) + && !body.contains(COMPACT_PROMPT_MARKER) + && !body.contains(POST_AUTO_USER_MSG)) + .then_some(idx) + }) + .expect("resume request missing after compaction"); + + let follow_up_index = requests + .iter() + .enumerate() + .rev() + .find_map(|(idx, req)| { + let body = std::str::from_utf8(&req.body).unwrap_or(""); + (body.contains(POST_AUTO_USER_MSG) && !body.contains(COMPACT_PROMPT_MARKER)) + .then_some(idx) + }) + .expect("follow-up request missing"); + assert_eq!(follow_up_index, 4, "follow-up request should be last"); + let body_first = requests[0].body_json::().unwrap(); - let body3 = requests[auto_compact_index] + let body_auto = requests[auto_compact_index] .body_json::() .unwrap(); - let instructions = body3 + let body_resume = requests[resume_index] + .body_json::() + .unwrap(); + let body_follow_up = requests[follow_up_index] + .body_json::() + .unwrap(); + let instructions = body_auto .get("instructions") .and_then(|v| v.as_str()) .unwrap_or_default(); @@ -460,13 +558,16 @@ async fn auto_compact_runs_after_token_limit_hit() { "auto compact should keep the standard developer instructions", ); - let input3 = body3.get("input").and_then(|v| v.as_array()).unwrap(); - let last3 = input3 + let input_auto = body_auto.get("input").and_then(|v| v.as_array()).unwrap(); + let last_auto = input_auto .last() .expect("auto compact request should append a user message"); - assert_eq!(last3.get("type").and_then(|v| v.as_str()), Some("message")); - assert_eq!(last3.get("role").and_then(|v| v.as_str()), Some("user")); - let last_text = last3 + assert_eq!( + last_auto.get("type").and_then(|v| v.as_str()), + Some("message") + ); + assert_eq!(last_auto.get("role").and_then(|v| v.as_str()), Some("user")); + let last_text = last_auto .get("content") .and_then(|v| v.as_array()) .and_then(|items| items.first()) @@ -474,9 +575,59 @@ async fn auto_compact_runs_after_token_limit_hit() { .and_then(|text| text.as_str()) .unwrap_or_default(); assert_eq!( - last_text, SUMMARIZATION_PROMPT, + last_text, TEST_COMPACT_PROMPT, "auto compact should send the summarization prompt as a user message", ); + + let input_resume = body_resume.get("input").and_then(|v| v.as_array()).unwrap(); + assert!( + input_resume.iter().any(|item| { + item.get("type").and_then(|v| v.as_str()) == Some("message") + && item.get("role").and_then(|v| v.as_str()) == Some("user") + && item + .get("content") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|entry| entry.get("text")) + .and_then(|v| v.as_str()) + == Some(AUTO_SUMMARY_TEXT) + }), + "resume request should include compacted history" + ); + + let input_follow_up = body_follow_up + .get("input") + .and_then(|v| v.as_array()) + .unwrap(); + let user_texts: Vec = input_follow_up + .iter() + .filter(|item| item.get("type").and_then(|v| v.as_str()) == Some("message")) + .filter(|item| item.get("role").and_then(|v| v.as_str()) == Some("user")) + .filter_map(|item| { + item.get("content") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|entry| entry.get("text")) + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) + }) + .collect(); + assert!( + user_texts.iter().any(|text| text == FIRST_AUTO_MSG), + "auto compact follow-up request should include the first user message" + ); + assert!( + user_texts.iter().any(|text| text == SECOND_AUTO_MSG), + "auto compact follow-up request should include the second user message" + ); + assert!( + user_texts.iter().any(|text| text == POST_AUTO_USER_MSG), + "auto compact follow-up request should include the new user message" + ); + assert!( + user_texts.iter().any(|text| text == AUTO_SUMMARY_TEXT), + "auto compact follow-up request should include the summary message" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -495,8 +646,9 @@ async fn auto_compact_persists_rollout_entries() { ev_completed_with_tokens("r2", 330_000), ]); + let auto_summary_payload = auto_summary(AUTO_SUMMARY_TEXT); let sse3 = sse(vec![ - ev_assistant_message("m3", AUTO_SUMMARY_TEXT), + ev_assistant_message("m3", &auto_summary_payload), ev_completed_with_tokens("r3", 200), ]); @@ -504,7 +656,7 @@ async fn auto_compact_persists_rollout_entries() { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(FIRST_AUTO_MSG) && !body.contains(SECOND_AUTO_MSG) - && !body.contains("You have exceeded the maximum number of tokens") + && !body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, first_matcher, sse1).await; @@ -512,13 +664,13 @@ async fn auto_compact_persists_rollout_entries() { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(SECOND_AUTO_MSG) && body.contains(FIRST_AUTO_MSG) - && !body.contains("You have exceeded the maximum number of tokens") + && !body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains("You have exceeded the maximum number of tokens") + body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, third_matcher, sse3).await; @@ -530,6 +682,7 @@ async fn auto_compact_persists_rollout_entries() { let home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; + set_test_compact_prompt(&mut config); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); let NewConversation { conversation: codex, @@ -603,8 +756,9 @@ async fn auto_compact_stops_after_failed_attempt() { ev_completed_with_tokens("r1", 500), ]); + let summary_payload = auto_summary(SUMMARY_TEXT); let sse2 = sse(vec![ - ev_assistant_message("m2", SUMMARY_TEXT), + ev_assistant_message("m2", &summary_payload), ev_completed_with_tokens("r2", 50), ]); @@ -615,21 +769,19 @@ async fn auto_compact_stops_after_failed_attempt() { let first_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains(FIRST_AUTO_MSG) - && !body.contains("You have exceeded the maximum number of tokens") + body.contains(FIRST_AUTO_MSG) && !body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, first_matcher, sse1.clone()).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains("You have exceeded the maximum number of tokens") + body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(&server, second_matcher, sse2.clone()).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); - !body.contains("You have exceeded the maximum number of tokens") - && body.contains(SUMMARY_TEXT) + !body.contains(COMPACT_PROMPT_MARKER) && body.contains(SUMMARY_TEXT) }; mount_sse_once_match(&server, third_matcher, sse3.clone()).await; @@ -641,6 +793,7 @@ async fn auto_compact_stops_after_failed_attempt() { let home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; + set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); let codex = conversation_manager @@ -689,7 +842,7 @@ async fn auto_compact_stops_after_failed_attempt() { .and_then(|items| items.first()) .and_then(|entry| entry.get("text")) .and_then(|text| text.as_str()) - .map(|text| text == SUMMARIZATION_PROMPT) + .map(|text| text == TEST_COMPACT_PROMPT) .unwrap_or(false) }); assert!( @@ -736,6 +889,7 @@ async fn manual_compact_retries_after_context_window_error() { let home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; + set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) .new_conversation(config) @@ -795,7 +949,7 @@ async fn manual_compact_retries_after_context_window_error() { .and_then(|items| items.first()) .and_then(|entry| entry.get("text")) .and_then(|text| text.as_str()), - Some(SUMMARIZATION_PROMPT), + Some(TEST_COMPACT_PROMPT), "compact attempt should include summarization prompt" ); assert_eq!( @@ -806,7 +960,7 @@ async fn manual_compact_retries_after_context_window_error() { .and_then(|items| items.first()) .and_then(|entry| entry.get("text")) .and_then(|text| text.as_str()), - Some(SUMMARIZATION_PROMPT), + Some(TEST_COMPACT_PROMPT), "retry attempt should include summarization prompt" ); assert_eq!( @@ -826,6 +980,228 @@ async fn manual_compact_retries_after_context_window_error() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn manual_compact_twice_preserves_latest_user_messages() { + skip_if_no_network!(); + + let first_user_message = "first manual turn"; + let second_user_message = "second manual turn"; + let final_user_message = "post compact follow-up"; + let first_summary = "FIRST_MANUAL_SUMMARY"; + let second_summary = "SECOND_MANUAL_SUMMARY"; + + let server = start_mock_server().await; + + let first_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed("r1"), + ]); + let first_compact_summary = auto_summary(first_summary); + let first_compact = sse(vec![ + ev_assistant_message("m2", &first_compact_summary), + ev_completed("r2"), + ]); + let second_turn = sse(vec![ + ev_assistant_message("m3", SECOND_LARGE_REPLY), + ev_completed("r3"), + ]); + let second_compact_summary = auto_summary(second_summary); + let second_compact = sse(vec![ + ev_assistant_message("m4", &second_compact_summary), + ev_completed("r4"), + ]); + let final_turn = sse(vec![ + ev_assistant_message("m5", FINAL_REPLY), + ev_completed("r5"), + ]); + + let responses_mock = mount_sse_sequence( + &server, + vec![ + first_turn, + first_compact, + second_turn, + second_compact, + final_turn, + ], + ) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = model_provider; + set_test_compact_prompt(&mut config); + let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) + .new_conversation(config) + .await + .unwrap() + .conversation; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: first_user_message.into(), + }], + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + codex.submit(Op::Compact).await.unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: second_user_message.into(), + }], + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + codex.submit(Op::Compact).await.unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: final_user_message.into(), + }], + }) + .await + .unwrap(); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let requests = responses_mock.requests(); + assert_eq!( + requests.len(), + 5, + "expected exactly 5 requests (user turn, compact, user turn, compact, final turn)" + ); + let contains_user_text = |input: &[serde_json::Value], expected: &str| -> bool { + input.iter().any(|item| { + item.get("type").and_then(|v| v.as_str()) == Some("message") + && item.get("role").and_then(|v| v.as_str()) == Some("user") + && item + .get("content") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().any(|entry| { + entry.get("text").and_then(|v| v.as_str()) == Some(expected) + }) + }) + .unwrap_or(false) + }) + }; + + let first_turn_input = requests[0].input(); + assert!( + contains_user_text(&first_turn_input, first_user_message), + "first turn request missing first user message" + ); + assert!( + !contains_user_text(&first_turn_input, TEST_COMPACT_PROMPT), + "first turn request should not include summarization prompt" + ); + + let first_compact_input = requests[1].input(); + assert!( + contains_user_text(&first_compact_input, TEST_COMPACT_PROMPT), + "first compact request should include summarization prompt" + ); + assert!( + contains_user_text(&first_compact_input, first_user_message), + "first compact request should include history before compaction" + ); + + let second_turn_input = requests[2].input(); + assert!( + contains_user_text(&second_turn_input, second_user_message), + "second turn request missing second user message" + ); + assert!( + contains_user_text(&second_turn_input, first_user_message), + "second turn request should include the compacted user history" + ); + + let second_compact_input = requests[3].input(); + assert!( + contains_user_text(&second_compact_input, TEST_COMPACT_PROMPT), + "second compact request should include summarization prompt" + ); + assert!( + contains_user_text(&second_compact_input, second_user_message), + "second compact request should include latest history" + ); + + let mut final_output = requests + .last() + .unwrap_or_else(|| panic!("final turn request missing for {final_user_message}")) + .input() + .into_iter() + .collect::>(); + + // System prompt + final_output.pop_front(); + // Developer instructions + final_output.pop_front(); + + let _ = final_output + .iter_mut() + .map(drop_call_id) + .collect::>(); + + let expected = vec![ + json!({ + "content": vec![json!({ + "text": first_user_message, + "type": "input_text", + })], + "role": "user", + "type": "message", + }), + json!({ + "content": vec![json!({ + "text": first_summary, + "type": "input_text", + })], + "role": "user", + "type": "message", + }), + json!({ + "content": vec![json!({ + "text": second_user_message, + "type": "input_text", + })], + "role": "user", + "type": "message", + }), + json!({ + "content": vec![json!({ + "text": second_summary, + "type": "input_text", + })], + "role": "user", + "type": "message", + }), + json!({ + "content": vec![json!({ + "text": final_user_message, + "type": "input_text", + })], + "role": "user", + "type": "message", + }), + ]; + assert_eq!(final_output, expected); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_events() { skip_if_no_network!(); @@ -836,8 +1212,9 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ ev_assistant_message("m1", FIRST_REPLY), ev_completed_with_tokens("r1", 500), ]); + let first_summary_payload = auto_summary(FIRST_AUTO_SUMMARY); let sse2 = sse(vec![ - ev_assistant_message("m2", FIRST_AUTO_SUMMARY), + ev_assistant_message("m2", &first_summary_payload), ev_completed_with_tokens("r2", 50), ]); let sse3 = sse(vec![ @@ -848,8 +1225,9 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ ev_assistant_message("m4", SECOND_LARGE_REPLY), ev_completed_with_tokens("r4", 450), ]); + let second_summary_payload = auto_summary(SECOND_AUTO_SUMMARY); let sse5 = sse(vec![ - ev_assistant_message("m5", SECOND_AUTO_SUMMARY), + ev_assistant_message("m5", &second_summary_payload), ev_completed_with_tokens("r5", 60), ]); let sse6 = sse(vec![ @@ -867,6 +1245,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ let home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; + set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); let codex = conversation_manager @@ -925,7 +1304,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "first request should contain the user input" ); assert!( - request_bodies[1].contains("You have exceeded the maximum number of tokens"), + request_bodies[1].contains(COMPACT_PROMPT_MARKER), "first auto compact request should include the summarization prompt" ); assert!( @@ -933,7 +1312,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "function call output should be sent before the second auto compact" ); assert!( - request_bodies[4].contains("You have exceeded the maximum number of tokens"), + request_bodies[4].contains(COMPACT_PROMPT_MARKER), "second auto compact request should include the summarization prompt" ); } @@ -956,8 +1335,9 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { ev_assistant_message("m2", FINAL_REPLY), ev_completed_with_tokens("r2", over_limit_tokens), ]); + let auto_summary_payload = auto_summary(AUTO_SUMMARY_TEXT); let auto_compact_turn = sse(vec![ - ev_assistant_message("m3", AUTO_SUMMARY_TEXT), + ev_assistant_message("m3", &auto_summary_payload), ev_completed_with_tokens("r3", 10), ]); let post_auto_compact_turn = sse(vec![ev_completed_with_tokens("r4", 10)]); @@ -977,6 +1357,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { let home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; + set_test_compact_prompt(&mut config); config.model_context_window = Some(context_window); config.model_auto_compact_token_limit = Some(limit); @@ -1027,7 +1408,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { let auto_compact_body = auto_compact_mock.single_request().body_json().to_string(); assert!( - auto_compact_body.contains("You have exceeded the maximum number of tokens"), + auto_compact_body.contains(COMPACT_PROMPT_MARKER), "auto compact request should include the summarization prompt after exceeding 95% (limit {limit})" ); } diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 449091db82..c3c1354bbf 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -10,13 +10,13 @@ use super::compact::COMPACT_WARNING_MESSAGE; use super::compact::FIRST_REPLY; use super::compact::SUMMARY_TEXT; +use super::compact::TEST_COMPACT_PROMPT; use codex_core::CodexAuth; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; use codex_core::NewConversation; use codex_core::built_in_model_providers; -use codex_core::codex::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; use codex_core::config::OPENAI_DEFAULT_MODEL; use codex_core::protocol::EventMsg; @@ -38,6 +38,8 @@ use tempfile::TempDir; use wiremock::MockServer; const AFTER_SECOND_RESUME: &str = "AFTER_SECOND_RESUME"; +const COMPACT_PROMPT_MARKER: &str = + "You are performing a CONTEXT CHECKPOINT COMPACTION for a tool."; fn network_disabled() -> bool { std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() @@ -66,6 +68,27 @@ fn is_ghost_snapshot_message(item: &Value) -> bool { .is_some_and(|text| text.trim_start().starts_with("")) } +fn extract_summary_message(request: &Value, summary_text: &str) -> Value { + request + .get("input") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user") + && item + .get("content") + .and_then(Value::as_array) + .and_then(|arr| arr.first()) + .and_then(|entry| entry.get("text")) + .and_then(Value::as_str) + == Some(summary_text) + }) + }) + .cloned() + .unwrap_or_else(|| panic!("expected summary message {summary_text}")) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] /// Scenario: compact an initial conversation, resume it, fork one turn back, and /// ensure the model-visible history matches expectations at each request. @@ -157,6 +180,9 @@ async fn compact_resume_and_fork_preserve_model_history_view() { .unwrap_or_default() .to_string(); let expected_model = OPENAI_DEFAULT_MODEL; + let summary_after_compact = extract_summary_message(&requests[2], SUMMARY_TEXT); + let summary_after_resume = extract_summary_message(&requests[3], SUMMARY_TEXT); + let summary_after_fork = extract_summary_message(&requests[4], SUMMARY_TEXT); let user_turn_1 = json!( { "model": expected_model, @@ -257,7 +283,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "content": [ { "type": "input_text", - "text": SUMMARIZATION_PROMPT + "text": TEST_COMPACT_PROMPT } ] } @@ -306,16 +332,11 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "content": [ { "type": "input_text", - "text": "You were originally given instructions from a user over one or more turns. Here were the user messages: - -hello world - -Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis: - -SUMMARY_ONLY_CONTEXT" + "text": "hello world" } ] }, + summary_after_compact, { "type": "message", "role": "user", @@ -371,16 +392,11 @@ SUMMARY_ONLY_CONTEXT" "content": [ { "type": "input_text", - "text": "You were originally given instructions from a user over one or more turns. Here were the user messages: - -hello world - -Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis: - -SUMMARY_ONLY_CONTEXT" + "text": "hello world" } ] }, + summary_after_resume, { "type": "message", "role": "user", @@ -456,16 +472,11 @@ SUMMARY_ONLY_CONTEXT" "content": [ { "type": "input_text", - "text": "You were originally given instructions from a user over one or more turns. Here were the user messages: - -hello world - -Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis: - -SUMMARY_ONLY_CONTEXT" + "text": "hello world" } ] }, + summary_after_fork, { "type": "message", "role": "user", @@ -605,6 +616,11 @@ async fn compact_resume_after_second_compaction_preserves_history() { .unwrap_or_default() .to_string(); + // Build expected final request input: initial context + forked user message + + // compacted summary + post-compact user message + resumed user message. + let summary_after_second_compact = + extract_summary_message(&requests[requests.len() - 3], SUMMARY_TEXT); + let mut expected = json!([ { "instructions": prompt, @@ -635,10 +651,11 @@ async fn compact_resume_after_second_compaction_preserves_history() { "content": [ { "type": "input_text", - "text": "You were originally given instructions from a user over one or more turns. Here were the user messages:\n\nAFTER_FORK\n\nAnother language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:\n\nSUMMARY_ONLY_CONTEXT" + "text": "AFTER_FORK" } ] }, + summary_after_second_compact, { "type": "message", "role": "user", @@ -724,7 +741,7 @@ async fn mount_initial_flow(server: &MockServer) { let match_first = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains("\"text\":\"hello world\"") - && !body.contains("You have exceeded the maximum number of tokens") + && !body.contains(COMPACT_PROMPT_MARKER) && !body.contains(&format!("\"text\":\"{SUMMARY_TEXT}\"")) && !body.contains("\"text\":\"AFTER_COMPACT\"") && !body.contains("\"text\":\"AFTER_RESUME\"") @@ -734,7 +751,7 @@ async fn mount_initial_flow(server: &MockServer) { let match_compact = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains("You have exceeded the maximum number of tokens") + body.contains(COMPACT_PROMPT_MARKER) }; mount_sse_once_match(server, match_compact, sse2).await; @@ -768,8 +785,7 @@ async fn mount_second_compact_flow(server: &MockServer) { let match_second_compact = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains("You have exceeded the maximum number of tokens") - && body.contains("AFTER_FORK") + body.contains(COMPACT_PROMPT_MARKER) && body.contains("AFTER_FORK") }; mount_sse_once_match(server, match_second_compact, sse6).await; @@ -790,6 +806,7 @@ async fn start_test_conversation( let home = TempDir::new().expect("create temp dir"); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; + config.compact_prompt = Some(TEST_COMPACT_PROMPT.to_string()); let manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); let NewConversation { conversation, .. } = manager From d7f8b9754121bb3371b60d487f46a71fdf855e57 Mon Sep 17 00:00:00 2001 From: Ritesh Chauhan <155845254+ritesh-chauhan0x1@users.noreply.github.com> Date: Sat, 1 Nov 2025 03:39:35 +0530 Subject: [PATCH 12/17] docs: fix broken link in contributing guide (#4973) ## Summary This PR fixes a broken self-referencing link in the contributing documentation. ## Changes - Removed the phrase 'Following the [development setup](#development-workflow) instructions above' from the Development workflow section - The link referenced a non-existent section and the phrase didn't make logical sense in context ## Before The text referenced 'development setup instructions above' but: 1. No section called 'development setup' exists 2. There were no instructions 'above' that point 3. The link pointed to the same section it was in ## After Simplified to: 'Ensure your change is free of lint warnings and test failures.' ## Type Documentation fix I have read the CLA Document and I hereby sign the CLA Co-authored-by: Ritesh Chauhan --- docs/contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing.md b/docs/contributing.md index 155f9f1ebe..596afe3b2e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -12,7 +12,7 @@ If you want to add a new feature or change the behavior of an existing one, plea - Create a _topic branch_ from `main` - e.g. `feat/interactive-prompt`. - Keep your changes focused. Multiple unrelated fixes should be opened as separate PRs. -- Following the [development setup](#development-workflow) instructions above, ensure your change is free of lint warnings and test failures. +- Ensure your change is free of lint warnings and test failures. ### Writing high-impact code changes From 0f220672420dc97f06ba1eb90bb6609246bfefa4 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Fri, 31 Oct 2025 15:28:04 -0700 Subject: [PATCH 13/17] [codex][app-server] improve error response for client requests (#6050) --- codex-rs/app-server/src/message_processor.rs | 119 +++++++++++-------- 1 file changed, 67 insertions(+), 52 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 7693cc2ff9..a97b037be0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -64,64 +64,79 @@ impl MessageProcessor { pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) { let request_id = request.id.clone(); - if let Ok(request_json) = serde_json::to_value(request) - && let Ok(codex_request) = serde_json::from_value::(request_json) - { - match codex_request { - // Handle Initialize internally so CodexMessageProcessor does not have to concern - // itself with the `initialized` bool. - ClientRequest::Initialize { request_id, params } => { - if self.initialized { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Already initialized".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } else { - let ClientInfo { - name, - title: _title, - version, - } = params.client_info; - let user_agent_suffix = format!("{name}; {version}"); - if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { - *suffix = Some(user_agent_suffix); - } + let request_json = match serde_json::to_value(&request) { + Ok(request_json) => request_json, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("Invalid request: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; - let user_agent = get_codex_user_agent(); - let response = InitializeResponse { user_agent }; - self.outgoing.send_response(request_id, response).await; + let codex_request = match serde_json::from_value::(request_json) { + Ok(codex_request) => codex_request, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("Invalid request: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; - self.initialized = true; - return; - } - } - _ => { - if !self.initialized { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Not initialized".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + match codex_request { + // Handle Initialize internally so CodexMessageProcessor does not have to concern + // itself with the `initialized` bool. + ClientRequest::Initialize { request_id, params } => { + if self.initialized { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "Already initialized".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } else { + let ClientInfo { + name, + title: _title, + version, + } = params.client_info; + let user_agent_suffix = format!("{name}; {version}"); + if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { + *suffix = Some(user_agent_suffix); } + + let user_agent = get_codex_user_agent(); + let response = InitializeResponse { user_agent }; + self.outgoing.send_response(request_id, response).await; + + self.initialized = true; + return; + } + } + _ => { + if !self.initialized { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "Not initialized".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; } } - - self.codex_message_processor - .process_request(codex_request) - .await; - } else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Invalid request".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; } + + self.codex_message_processor + .process_request(codex_request) + .await; } pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) { From 07b8bdfbf1497cf7c478872bd082a13c5bd82c63 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:36:41 -0700 Subject: [PATCH 14/17] tui: patch crossterm for better color queries (#5935) See https://github.com/crossterm-rs/crossterm/compare/master...nornagon:crossterm:nornagon/color-query This patches crossterm to add support for querying fg/bg color as part of the crossterm event loop, which fixes some issues where this query would fight with other input. - dragging screenshots into the cli would sometimes paste half of the pathname instead of being recognized as an image (https://github.com/openai/codex/issues/5603) - Fixes https://github.com/openai/codex/issues/4945 --- codex-rs/Cargo.lock | 3 +- codex-rs/Cargo.toml | 1 + codex-rs/tui/Cargo.toml | 5 +- codex-rs/tui/src/terminal_palette.rs | 127 ++------------------------- 4 files changed, 15 insertions(+), 121 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 64d8790099..a82cfe0e15 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1750,8 +1750,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +source = "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995" dependencies = [ "bitflags 2.10.0", "crossterm_winapi", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f631c05504..73c401b034 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -276,6 +276,7 @@ opt-level = 0 # Uncomment to debug local changes. # ratatui = { path = "../../ratatui" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } +crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index f087202c22..85a3cc5a2a 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -41,7 +41,10 @@ codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } codex-feedback = { workspace = true } color-eyre = { workspace = true } -crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } +crossterm = { workspace = true, features = [ + "bracketed-paste", + "event-stream", +] } diffy = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index f223f3a9c1..5c6f32cd9e 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -50,6 +50,9 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { #[cfg(all(unix, not(test)))] mod imp { use super::DefaultColors; + use crossterm::style::Color as CrosstermColor; + use crossterm::style::query_background_color; + use crossterm::style::query_foreground_color; use std::sync::Mutex; use std::sync::OnceLock; @@ -105,128 +108,16 @@ mod imp { } fn query_default_colors() -> std::io::Result> { - use std::fs::OpenOptions; - use std::io::ErrorKind; - use std::io::IsTerminal; - use std::io::Read; - use std::io::Write; - use std::os::fd::AsRawFd; - use std::time::Duration; - use std::time::Instant; - - let mut stdout_handle = std::io::stdout(); - if !stdout_handle.is_terminal() { - return Ok(None); - } - - let mut tty = match OpenOptions::new().read(true).open("/dev/tty") { - Ok(file) => file, - Err(_) => return Ok(None), - }; - - let fd = tty.as_raw_fd(); - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFL); - if flags >= 0 { - libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); - } - } - - stdout_handle.write_all(b"\x1b]10;?\x07\x1b]11;?\x07")?; - stdout_handle.flush()?; - - let mut deadline = Instant::now() + Duration::from_millis(200); - let mut buffer = Vec::new(); - let mut fg = None; - let mut bg = None; - - while Instant::now() < deadline { - let mut chunk = [0u8; 128]; - match tty.read(&mut chunk) { - Ok(0) => break, - Ok(n) => { - deadline = Instant::now() + Duration::from_millis(200); - buffer.extend_from_slice(&chunk[..n]); - if fg.is_none() { - fg = parse_osc_color(&buffer, 10); - } - if bg.is_none() { - bg = parse_osc_color(&buffer, 11); - } - if let (Some(fg), Some(bg)) = (fg, bg) { - return Ok(Some(DefaultColors { fg, bg })); - } - } - Err(err) if err.kind() == ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(5)); - } - Err(err) if err.kind() == ErrorKind::Interrupted => continue, - Err(_) => break, - } - } - - if fg.is_none() { - fg = parse_osc_color(&buffer, 10); - } - if bg.is_none() { - bg = parse_osc_color(&buffer, 11); - } - + let fg = query_foreground_color()?.and_then(color_to_tuple); + let bg = query_background_color()?.and_then(color_to_tuple); Ok(fg.zip(bg).map(|(fg, bg)| DefaultColors { fg, bg })) } - fn parse_component(component: &str) -> Option { - let trimmed = component.trim(); - if trimmed.is_empty() { - return None; + fn color_to_tuple(color: CrosstermColor) -> Option<(u8, u8, u8)> { + match color { + CrosstermColor::Rgb { r, g, b } => Some((r, g, b)), + _ => None, } - let bits = trimmed.len().checked_mul(4)?; - if bits == 0 || bits > 64 { - return None; - } - let max = if bits == 64 { - u64::MAX - } else { - (1u64 << bits) - 1 - }; - let value = u64::from_str_radix(trimmed, 16).ok()?; - Some(((value * 255 + max / 2) / max) as u8) - } - - fn parse_osc_color(buffer: &[u8], code: u8) -> Option<(u8, u8, u8)> { - let text = std::str::from_utf8(buffer).ok()?; - let prefix = match code { - 10 => "\u{1b}]10;", - 11 => "\u{1b}]11;", - _ => return None, - }; - let start = text.rfind(prefix)?; - let after_prefix = &text[start + prefix.len()..]; - let end_bel = after_prefix.find('\u{7}'); - let end_st = after_prefix.find("\u{1b}\\"); - let end_idx = match (end_bel, end_st) { - (Some(bel), Some(st)) => bel.min(st), - (Some(bel), None) => bel, - (None, Some(st)) => st, - (None, None) => return None, - }; - let payload = after_prefix[..end_idx].trim(); - parse_color_payload(payload) - } - - fn parse_color_payload(payload: &str) -> Option<(u8, u8, u8)> { - if payload.is_empty() || payload == "?" { - return None; - } - let (model, values) = payload.split_once(':')?; - if model != "rgb" && model != "rgba" { - return None; - } - let mut parts = values.split('/'); - let r = parse_component(parts.next()?)?; - let g = parse_component(parts.next()?)?; - let b = parse_component(parts.next()?)?; - Some((r, g, b)) } } From 1ac4fb45d2fb179e66a97089677586cc74d1f6dd Mon Sep 17 00:00:00 2001 From: Tony Dong Date: Fri, 31 Oct 2025 16:41:05 -0700 Subject: [PATCH 15/17] Fixing small typo in docs (#5659) Fixing a typo in the docs Co-authored-by: Eric Traut --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 7f4c41beb3..e3505e3d49 100644 --- a/docs/config.md +++ b/docs/config.md @@ -415,7 +415,7 @@ cwd = "/Users//code/my-server" ```toml [mcp_servers.figma] -url = "https://mcp.linear.app/mcp" +url = "https://mcp.figma.com/mcp" # Optional environment variable containing a bearer token to use for auth bearer_token_env_var = "ENV_VAR" # Optional map of headers with hard-coded values. From 91e65ac0ce62a12f9064dc0adab7322d9f16dfee Mon Sep 17 00:00:00 2001 From: Vinh Nguyen <1097578+vinhnx@users.noreply.github.com> Date: Sat, 1 Nov 2025 06:53:09 +0700 Subject: [PATCH 16/17] docs: Fix link anchor and markdown format in advanced guide (#5649) Hi OpenAI Codex team, this PR fix an rendering issue in the Markdown table and anchor link in the [docs/advanced.md](https://github.com/openai/codex/blob/main/docs/advanced.md). Thank you! Co-authored-by: Eric Traut --- docs/advanced.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced.md b/docs/advanced.md index 1079de0314..8fa24955c5 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -1,6 +1,6 @@ ## Advanced -If you already lean on Codex every day and just need a little more control, this page collects the knobs you are most likely to reach for: tweak defaults in [Config](./config.md), add extra tools through [Model Context Protocol support](./advanced.md#model-context-protocol), and script full runs with [`codex exec`](./exec.md). Jump to the section you need and keep building. +If you already lean on Codex every day and just need a little more control, this page collects the knobs you are most likely to reach for: tweak defaults in [Config](./config.md), add extra tools through [Model Context Protocol support](#model-context-protocol), and script full runs with [`codex exec`](./exec.md). Jump to the section you need and keep building. ## Config quickstart {#config-quickstart} From d9118c04bf32209d09466ec4f241d243ba6b3195 Mon Sep 17 00:00:00 2001 From: Thomas Stokes Date: Sun, 2 Nov 2025 00:33:13 +0800 Subject: [PATCH 17/17] Parse the Azure OpenAI rate limit message (#5956) Fixes #4161 Currently Codex uses a regex to parse the "Please try again in 1.898s" OpenAI-style rate limit message, so that it can wait the correct duration before retrying. Azure OpenAI returns a different error that looks like "Rate limit exceeded. Try again in 35 seconds." This PR extends the regex and parsing code to match in a more fuzzy manner, handling anything matching the pattern "try again in \\". --- codex-rs/core/src/client.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 2085c39f02..9dfa3a1316 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -931,8 +931,10 @@ async fn stream_from_fixture( fn rate_limit_regex() -> &'static Regex { static RE: OnceLock = OnceLock::new(); + // Match both OpenAI-style messages like "Please try again in 1.898s" + // and Azure OpenAI-style messages like "Try again in 35 seconds". #[expect(clippy::unwrap_used)] - RE.get_or_init(|| Regex::new(r"Please try again in (\d+(?:\.\d+)?)(s|ms)").unwrap()) + RE.get_or_init(|| Regex::new(r"(?i)try again in\s*(\d+(?:\.\d+)?)\s*(s|ms|seconds?)").unwrap()) } fn try_parse_retry_after(err: &Error) -> Option { @@ -940,7 +942,8 @@ fn try_parse_retry_after(err: &Error) -> Option { return None; } - // parse the Please try again in 1.898s format using regex + // parse retry hints like "try again in 1.898s" or + // "Try again in 35 seconds" using regex let re = rate_limit_regex(); if let Some(message) = &err.message && let Some(captures) = re.captures(message) @@ -950,9 +953,9 @@ fn try_parse_retry_after(err: &Error) -> Option { if let (Some(value), Some(unit)) = (seconds, unit) { let value = value.as_str().parse::().ok()?; - let unit = unit.as_str(); + let unit = unit.as_str().to_ascii_lowercase(); - if unit == "s" { + if unit == "s" || unit.starts_with("second") { return Some(Duration::from_secs_f64(value)); } else if unit == "ms" { return Some(Duration::from_millis(value as u64)); @@ -1427,6 +1430,19 @@ mod tests { assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); } + #[test] + fn test_try_parse_retry_after_azure() { + let err = Error { + r#type: None, + message: Some("Rate limit exceeded. Try again in 35 seconds.".to_string()), + code: Some("rate_limit_exceeded".to_string()), + plan_type: None, + resets_at: None, + }; + let delay = try_parse_retry_after(&err); + assert_eq!(delay, Some(Duration::from_secs(35))); + } + #[test] fn error_response_deserializes_schema_known_plan_type_and_serializes_back() { use crate::token_data::KnownPlan;