Files
codex/codex-rs/tui/tests/suite/resize_reflow.rs
T
andrewgu-oai 49e95cc73f Add GPT-6 Sol and Luna to the model catalog (#47332)
## What changed

- Add `gpt-6-sol` and `gpt-6-luna` catalog entries and refresh model descriptions.
- Offer migrations from `gpt-5.5`, `gpt-5.6-sol`, and `gpt-5.6-terra` to `gpt-6-sol`, and from `gpt-5.6-luna` to `gpt-6-luna`.
- Retarget retired `gpt-5.4` and `gpt-5.4-mini` selections to GPT-6 Sol and Luna, respectively.
- Recommend `gpt-6-luna` in the rate-limit switch prompt.

## Testing

Extend migration tests to cover the new targets, their default reasoning effort, and prompting again after a previously acknowledged migration to an older target. Update model-picker and rate-limit prompt snapshots.

GitOrigin-RevId: d3a093d18ce44a903383504ab8f5cbbc911d602d
2026-09-22 18:17:39 +00:00

727 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;
use std::thread::sleep;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use tempfile::tempdir;
use wiremock::MockServer;
use wiremock::matchers::body_string_contains;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]
async fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Result<()> {
if cfg!(windows) {
return Ok(());
}
skip_if_no_network!(Ok(()));
if Command::new("tmux").arg("-V").output().is_err() {
eprintln!("skipping resize smoke because tmux is unavailable");
return Ok(());
}
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex = codex_binary(&repo_root)?;
let codex_home = tempdir()?;
let server = MockServer::start().await;
let _response_mock = mount_resize_response(&server).await;
write_config(codex_home.path(), &repo_root, &server.uri())?;
write_auth(codex_home.path())?;
let session_name = format!("codex-resize-reflow-smoke-{}", std::process::id());
let _session = TmuxSession {
name: session_name.clone(),
};
let prompt = "Say hi.";
let start_output = checked_output(
Command::new("tmux")
.arg("new-session")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-x")
.arg("120")
.arg("-y")
.arg("40")
.arg("-s")
.arg(&session_name)
.arg("--")
.arg("env")
.arg(format!("CODEX_HOME={}", codex_home.path().display()))
.arg("OPENAI_API_KEY=dummy")
.arg(codex)
.arg("-c")
.arg("analytics.enabled=false")
.arg("--no-alt-screen")
.arg("-C")
.arg(&repo_root)
.arg(prompt),
)?;
let codex_pane = stdout_text(&start_output).trim().to_string();
anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id");
wait_for_capture_contains(
&codex_pane,
"resize reflow sentinel",
Duration::from_secs(/*secs*/ 15),
)?;
wait_for_capture_contains(
&codex_pane,
"gpt-5.4 default",
Duration::from_secs(/*secs*/ 15),
)?;
let draft = "Notice where we are here in terms of y location.";
check(
Command::new("tmux")
.arg("send-keys")
.arg("-t")
.arg(&codex_pane)
.arg("-l")
.arg(draft),
)?;
let baseline_capture =
wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?;
let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?;
let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel")
.context("history row before split")?;
let split_output = checked_output(
Command::new("tmux")
.arg("split-window")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-v")
.arg("-l")
.arg("12")
.arg("-t")
.arg(&codex_pane)
.arg("sleep")
.arg("30"),
)?;
let split_pane = stdout_text(&split_output).trim().to_string();
sleep(Duration::from_millis(/*millis*/ 250));
let first_capture = capture_pane(&codex_pane)?;
let first_row = last_composer_row(&first_capture).context("composer row after split")?;
sleep(Duration::from_millis(/*millis*/ 1_000));
let second_capture = capture_pane(&codex_pane)?;
let second_row =
last_composer_row(&second_capture).context("composer row after reflow wait")?;
anyhow::ensure!(
first_row == second_row,
"composer row drifted after split: before={first_row}, after={second_row}\n\
before:\n{first_capture}\n\
after:\n{second_capture}"
);
anyhow::ensure!(
second_row <= baseline_row + 1,
"composer row snapped downward after split: baseline={baseline_row}, after={second_row}\n\
baseline:\n{baseline_capture}\n\
after:\n{second_capture}"
);
check(
Command::new("tmux")
.arg("kill-pane")
.arg("-t")
.arg(&split_pane),
)?;
sleep(Duration::from_millis(/*millis*/ 500));
let final_capture = capture_pane(&codex_pane)?;
let final_row =
last_composer_row(&final_capture).context("composer row after closing split")?;
anyhow::ensure!(
final_row == baseline_row,
"composer row drifted after closing split: baseline={baseline_row}, after={final_row}\n\
capture:\n{final_capture}"
);
let final_history_row = first_row_containing(&final_capture, "resize reflow sentinel")
.context("history row after closing split")?;
anyhow::ensure!(
final_history_row == baseline_history_row,
"history row drifted after closing split: baseline={baseline_history_row}, \
after={final_history_row}\n\
baseline:\n{baseline_capture}\n\
after:\n{final_capture}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]
async fn tmux_repeated_resizes_do_not_push_composer_down() -> Result<()> {
if cfg!(windows) {
return Ok(());
}
skip_if_no_network!(Ok(()));
if Command::new("tmux").arg("-V").output().is_err() {
eprintln!("skipping resize smoke because tmux is unavailable");
return Ok(());
}
run_repeated_resize_smoke().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]
async fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> {
if cfg!(windows) {
return Ok(());
}
skip_if_no_network!(Ok(()));
if Command::new("tmux").arg("-V").output().is_err() {
eprintln!("skipping resize smoke because tmux is unavailable");
return Ok(());
}
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex = codex_binary(&repo_root)?;
let codex_home = tempdir()?;
let server = MockServer::start().await;
let _response_mock = mount_resize_response(&server).await;
write_config(codex_home.path(), &repo_root, &server.uri())?;
write_auth(codex_home.path())?;
let session_name = format!("codex-resize-width-{}", std::process::id());
let _session = TmuxSession {
name: session_name.clone(),
};
let prompt = "Send me a large paragraph of text for testing.";
let start_output = checked_output(
Command::new("tmux")
.arg("new-session")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-x")
.arg("120")
.arg("-y")
.arg("40")
.arg("-s")
.arg(&session_name)
.arg("--")
.arg("env")
.arg(format!("CODEX_HOME={}", codex_home.path().display()))
.arg("OPENAI_API_KEY=dummy")
.arg(codex)
.arg("-c")
.arg("analytics.enabled=false")
.arg("--no-alt-screen")
.arg("-C")
.arg(&repo_root)
.arg(prompt),
)?;
let codex_pane = stdout_text(&start_output).trim().to_string();
anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id");
wait_for_capture_contains(
&codex_pane,
"resize reflow sentinel",
Duration::from_secs(/*secs*/ 15),
)?;
wait_for_capture_contains(
&codex_pane,
"gpt-5.4 default",
Duration::from_secs(/*secs*/ 15),
)?;
let draft = "Notice where we are here in terms of y location.";
check(
Command::new("tmux")
.arg("send-keys")
.arg("-t")
.arg(&codex_pane)
.arg("-l")
.arg(draft),
)?;
let baseline_capture =
wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?;
let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?;
let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel")
.context("history row before split")?;
let split_output = checked_output(
Command::new("tmux")
.arg("split-window")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-h")
.arg("-l")
.arg("40")
.arg("-t")
.arg(&codex_pane)
.arg("sleep")
.arg("30"),
)?;
let split_pane = stdout_text(&split_output).trim().to_string();
sleep(Duration::from_millis(/*millis*/ 750));
check(
Command::new("tmux")
.arg("kill-pane")
.arg("-t")
.arg(&split_pane),
)?;
sleep(Duration::from_millis(/*millis*/ 1_000));
let restored_capture = capture_pane(&codex_pane)?;
let restored_row =
last_composer_row(&restored_capture).context("composer row after width restore")?;
let restored_history_row = first_row_containing(&restored_capture, "resize reflow sentinel")
.context("history row after width restore")?;
anyhow::ensure!(
restored_row == baseline_row,
"composer row drifted after width restore: baseline={baseline_row}, \
restored={restored_row}\n\
baseline:\n{baseline_capture}\n\
restored:\n{restored_capture}"
);
anyhow::ensure!(
restored_history_row == baseline_history_row,
"history row drifted after width restore: baseline={baseline_history_row}, \
restored={restored_history_row}\n\
baseline:\n{baseline_capture}\n\
restored:\n{restored_capture}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"]
async fn tmux_scrolled_composer_resize_preserves_visible_draft_text() -> Result<()> {
if cfg!(windows) {
return Ok(());
}
skip_if_no_network!(Ok(()));
if Command::new("tmux").arg("-V").output().is_err() {
eprintln!("skipping resize smoke because tmux is unavailable");
return Ok(());
}
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex = codex_binary(&repo_root)?;
let codex_home = tempdir()?;
let server = MockServer::start().await;
write_config(codex_home.path(), &repo_root, &server.uri())?;
write_auth(codex_home.path())?;
let session_name = format!("codex-resize-scrolled-composer-{}", std::process::id());
let _session = TmuxSession {
name: session_name.clone(),
};
let start_output = checked_output(
Command::new("tmux")
.arg("new-session")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-x")
.arg("44")
.arg("-y")
.arg("14")
.arg("-s")
.arg(&session_name)
.arg("--")
.arg("env")
.arg(format!("CODEX_HOME={}", codex_home.path().display()))
.arg("OPENAI_API_KEY=dummy")
.arg(codex)
.arg("--model")
.arg("gpt-5.6-terra")
.arg("-c")
.arg("analytics.enabled=false")
.arg("--no-alt-screen")
.arg("-C")
.arg(&repo_root),
)?;
let codex_pane = stdout_text(&start_output).trim().to_string();
anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id");
wait_for_capture_contains(
&codex_pane,
"gpt-5.6-terra",
Duration::from_secs(/*secs*/ 15),
)?;
for index in 1..=9 {
let line = format!("probe-{index:02} clean words");
check(
Command::new("tmux")
.arg("send-keys")
.arg("-t")
.arg(&codex_pane)
.arg("-l")
.arg(line),
)?;
check(
Command::new("tmux")
.arg("send-keys")
.arg("-t")
.arg(&codex_pane)
.arg("C-j"),
)?;
}
let final_line = "q".repeat(/*n*/ 41);
check(
Command::new("tmux")
.arg("send-keys")
.arg("-t")
.arg(&codex_pane)
.arg("-l")
.arg(&final_line),
)?;
let baseline =
wait_for_capture_contains(&codex_pane, &final_line, Duration::from_secs(/*secs*/ 15))?;
for (phase, width, height, minimum_visible_rows) in
[("narrowed", "28", "9", 2), ("restored", "44", "14", 6)]
{
check(
Command::new("tmux")
.arg("resize-window")
.arg("-t")
.arg(&session_name)
.arg("-x")
.arg(width)
.arg("-y")
.arg(height),
)?;
sleep(Duration::from_millis(/*millis*/ 350));
let capture = capture_pane(&codex_pane)?;
let visible_rows = capture
.lines()
.filter_map(|line| line.find("probe-").map(|start| (line, start)))
.collect::<Vec<_>>();
anyhow::ensure!(
visible_rows.len() >= minimum_visible_rows,
"{phase} composer exposes too few draft rows: expected at least \
{minimum_visible_rows}, found {}\nbaseline:\n{baseline}\ncapture:\n{capture}",
visible_rows.len()
);
for (line, start) in visible_rows {
let draft = &line[start..];
let row_number = draft
.get(6..8)
.with_context(|| format!("malformed draft row after {phase}: {draft}"))?;
let expected = format!("probe-{row_number} clean words");
let prefix = line[..start].trim();
anyhow::ensure!(
draft.starts_with(&expected) && (prefix.is_empty() || prefix == "›"),
"{phase} resize left stale terminal content in the composer: \
expected {expected:?}, found {line:?}\nbaseline:\n{baseline}\ncapture:\n{capture}"
);
}
}
Ok(())
}
async fn run_repeated_resize_smoke() -> Result<()> {
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex = codex_binary(&repo_root)?;
let codex_home = tempdir()?;
let server = MockServer::start().await;
let _response_mock = mount_resize_response(&server).await;
write_config(codex_home.path(), &repo_root, &server.uri())?;
write_auth(codex_home.path())?;
let session_name = format!("codex-resize-repeat-{}", std::process::id());
let _session = TmuxSession {
name: session_name.clone(),
};
let prompt = "Send me a large paragraph of text for testing.";
let start_output = checked_output(
Command::new("tmux")
.arg("new-session")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-x")
.arg("120")
.arg("-y")
.arg("40")
.arg("-s")
.arg(&session_name)
.arg("--")
.arg("env")
.arg(format!("CODEX_HOME={}", codex_home.path().display()))
.arg("OPENAI_API_KEY=dummy")
.arg(codex)
.arg("-c")
.arg("analytics.enabled=false")
.arg("--no-alt-screen")
.arg("-C")
.arg(&repo_root)
.arg(prompt),
)?;
let codex_pane = stdout_text(&start_output).trim().to_string();
anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id");
wait_for_capture_contains(
&codex_pane,
"resize reflow sentinel",
Duration::from_secs(/*secs*/ 15),
)?;
wait_for_capture_contains(
&codex_pane,
"gpt-5.4 default",
Duration::from_secs(/*secs*/ 15),
)?;
let draft = "Notice where we are here in terms of y location.";
check(
Command::new("tmux")
.arg("send-keys")
.arg("-t")
.arg(&codex_pane)
.arg("-l")
.arg(draft),
)?;
let baseline_capture =
wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?;
let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?;
let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel")
.context("history row before split")?;
for cycle in 1..=3 {
let split_output = checked_output(
Command::new("tmux")
.arg("split-window")
.arg("-d")
.arg("-P")
.arg("-F")
.arg("#{pane_id}")
.arg("-v")
.arg("-l")
.arg("12")
.arg("-t")
.arg(&codex_pane)
.arg("sleep")
.arg("30"),
)?;
let split_pane = stdout_text(&split_output).trim().to_string();
sleep(Duration::from_millis(/*millis*/ 250));
check(
Command::new("tmux")
.arg("kill-pane")
.arg("-t")
.arg(&split_pane),
)?;
sleep(Duration::from_millis(/*millis*/ 500));
let restored_capture = capture_pane(&codex_pane)?;
let restored_row = last_composer_row(&restored_capture)
.with_context(|| format!("composer row after resize cycle {cycle}"))?;
let restored_history_row =
first_row_containing(&restored_capture, "resize reflow sentinel")
.with_context(|| format!("history row after resize cycle {cycle}"))?;
anyhow::ensure!(
restored_row == baseline_row,
"composer row drifted after resize cycle {cycle}: baseline={baseline_row}, \
restored={restored_row}\n\
baseline:\n{baseline_capture}\n\
restored:\n{restored_capture}"
);
anyhow::ensure!(
restored_history_row == baseline_history_row,
"history row drifted after resize cycle {cycle}: baseline={baseline_history_row}, \
restored={restored_history_row}\n\
baseline:\n{baseline_capture}\n\
restored:\n{restored_capture}"
);
}
Ok(())
}
struct TmuxSession {
name: String,
}
impl Drop for TmuxSession {
fn drop(&mut self) {
let _ = Command::new("tmux")
.arg("kill-session")
.arg("-t")
.arg(&self.name)
.output();
}
}
fn codex_binary(repo_root: &Path) -> Result<PathBuf> {
if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") {
return Ok(path);
}
let fallback = repo_root.join("codex-rs/target/debug/codex");
anyhow::ensure!(
fallback.is_file(),
"codex binary is unavailable; run `cargo build -p codex-cli` first"
);
Ok(fallback)
}
fn write_config(codex_home: &Path, repo_root: &Path, server_url: &str) -> Result<()> {
let repo_root_display = repo_root.display();
let config = format!(
r#"model = "gpt-5.4"
model_provider = "resize_mock"
suppress_unstable_features_warning = true
# The resize fixture serves HTTP SSE only, not WebSocket upgrades.
[model_providers.resize_mock]
name = "Resize mock"
base_url = "{server_url}/v1"
wire_api = "responses"
requires_openai_auth = false
supports_websockets = false
[notice.model_migrations]
"gpt-5.4" = "gpt-6-sol"
[projects."{repo_root_display}"]
trust_level = "trusted"
"#
);
std::fs::write(codex_home.join("config.toml"), config)?;
Ok(())
}
fn write_auth(codex_home: &Path) -> Result<()> {
std::fs::write(
codex_home.join("auth.json"),
r#"{"OPENAI_API_KEY":"dummy","tokens":null,"last_refresh":null}"#,
)?;
Ok(())
}
// Title generation and the visible turn race; neither may consume the other's response.
async fn mount_resize_response(server: &MockServer) -> responses::ResponseMock {
let _title = responses::mount_sse_once_match(
server,
body_string_contains(r#"\"thread_source\":\"system\""#),
responses::sse(vec![
responses::ev_assistant_message(
"resize-title",
r#"{"title":"Test terminal resizing"}"#,
),
responses::ev_completed("resize-title"),
]),
)
.await;
responses::mount_sse_once_match(
server,
body_string_contains(r#"\"thread_source\":\"user\""#),
resize_reflow_sse(),
)
.await
}
fn resize_reflow_sse() -> String {
let text = "resize reflow sentinel says hi. This paragraph is intentionally long enough to exercise terminal wrapping, scrollback redraw, and pane resize behavior without requiring a live model response. It includes enough ordinary prose to wrap across several rows in a narrow tmux pane, then keep going so repeated split and restore cycles have visible history above the composer. If a resize path accidentally inserts blank rows or anchors the viewport lower on each pass, the composer row will drift after the pane returns to its original height.";
responses::sse(vec![
responses::ev_response_created("resp-resize-smoke"),
responses::ev_assistant_message("msg-resize-smoke", text),
responses::ev_completed("resp-resize-smoke"),
])
}
fn wait_for_capture_contains(pane: &str, needle: &str, timeout: Duration) -> Result<String> {
let deadline = Instant::now() + timeout;
let mut last_capture = String::new();
while Instant::now() < deadline {
last_capture = capture_pane(pane)?;
if last_capture.contains(needle) {
return Ok(last_capture);
}
sleep(Duration::from_millis(/*millis*/ 100));
}
anyhow::bail!("timed out waiting for {needle:?}; last capture:\n{last_capture}");
}
fn capture_pane(pane: &str) -> Result<String> {
let output = output(
Command::new("tmux")
.arg("capture-pane")
.arg("-p")
.arg("-t")
.arg(pane),
)?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn last_composer_row(capture: &str) -> Option<usize> {
capture
.lines()
.enumerate()
.filter_map(|(index, line)| {
if line.trim_start().starts_with('\u{203a}') {
Some(index)
} else {
None
}
})
.last()
}
fn first_row_containing(capture: &str, needle: &str) -> Option<usize> {
capture
.lines()
.enumerate()
.find_map(|(index, line)| line.contains(needle).then_some(index))
}
fn check(command: &mut Command) -> Result<()> {
checked_output(command)?;
Ok(())
}
fn checked_output(command: &mut Command) -> Result<Output> {
let output = output(command)?;
anyhow::ensure!(
output.status.success(),
"command failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Ok(output)
}
fn output(command: &mut Command) -> Result<Output> {
command
.output()
.with_context(|| format!("failed to run {command:?}"))
}
fn stdout_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}