mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## Why Session transitions need stack headroom for embedded-server initialization and configuration loading. Running them inside event dispatch risks exhausting the TUI thread's stack. ## What changed - Queue `/resume` and valid `/cd` requests for processing after event dispatch returns. - Reject additional `/cd` requests while one is pending, and recheck the source session, working directory, idle state, and remote-environment restrictions before applying it. ## Testing Add a Unix PTY regression test that exercises `/resume`, worktree conversation forks, `/cd`, and new worktree conversations through the real TUI event loop. Update the directory-change test to verify that dispatch queues the request without immediately changing configuration. GitOrigin-RevId: 4950921931dfaba9e55e6bb04f98f7ba64b72122
176 lines
5.9 KiB
Rust
176 lines
5.9 KiB
Rust
//! Exercises stack-sensitive session transitions through the real TUI event loop.
|
|
|
|
use super::focus_palette::PtyCodex;
|
|
use super::focus_palette::write_test_config;
|
|
use anyhow::Result;
|
|
use anyhow::ensure;
|
|
use codex_worktree::ManagedWorktree;
|
|
use codex_worktree::WorktreeManager;
|
|
use codex_worktree::WorktreeSettings;
|
|
use core_test_support::responses;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
use wiremock::matchers::body_string_contains;
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn picker_worktree_fork_and_cd_run_on_the_production_stack() -> Result<()> {
|
|
let repository = tempfile::tempdir_in("/tmp")?;
|
|
let root = repository.path().canonicalize()?;
|
|
let status = Command::new("git")
|
|
.args(["init", "-q", "-b", "main"])
|
|
.arg(&root)
|
|
.status()?;
|
|
ensure!(status.success(), "initialize test repository");
|
|
fs::write(root.join("README.md"), "stack transition test\n")?;
|
|
ensure!(
|
|
Command::new("git")
|
|
.args(["-C"])
|
|
.arg(&root)
|
|
.args(["add", "README.md"])
|
|
.status()?
|
|
.success(),
|
|
"stage test repository"
|
|
);
|
|
ensure!(
|
|
Command::new("git")
|
|
.args(["-C"])
|
|
.arg(&root)
|
|
.args([
|
|
"-c",
|
|
"user.name=Codex Test",
|
|
"-c",
|
|
"user.email=test@example.invalid"
|
|
])
|
|
.args(["commit", "--no-gpg-sign", "-qm", "initial"])
|
|
.status()?
|
|
.success(),
|
|
"commit test repository"
|
|
);
|
|
|
|
let codex_home = tempfile::tempdir_in("/tmp")?;
|
|
write_test_config(codex_home.path(), &root)?;
|
|
let server = responses::start_mock_server().await;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = fs::read_to_string(&config_path)?
|
|
.replace("model_provider = \"openai\"", "model_provider = \"test\"");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
"{config}\n[model_providers.test]\nname = \"Mock\"\nbase_url = \"{}/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nsupports_websockets = false\n",
|
|
server.uri()
|
|
),
|
|
)?;
|
|
let _reply = responses::mount_sse_once_match(
|
|
&server,
|
|
body_string_contains("marker requested by this test"),
|
|
responses::sse(vec![
|
|
responses::ev_assistant_message("saved", "STACK_SAVED_HISTORY"),
|
|
responses::ev_completed("saved-response"),
|
|
]),
|
|
)
|
|
.await;
|
|
|
|
let worktrees = WorktreeManager::new(WorktreeSettings::for_cli(
|
|
codex_home.path(),
|
|
/*desktop*/ None,
|
|
)?);
|
|
// The launcher gives codex-main and Tokio workers explicit 16 MiB stacks.
|
|
let mut terminal = PtyCodex::start(
|
|
&root,
|
|
codex_home,
|
|
&[
|
|
"-c",
|
|
"features.worktrees=true",
|
|
"-c",
|
|
"features.shell_snapshot=false",
|
|
"Reply with the marker requested by this test.",
|
|
],
|
|
)?;
|
|
terminal.wait_for_startup()?;
|
|
terminal.wait_for_screen("STACK_SAVED_HISTORY")?;
|
|
terminal.wait_for_screen("Ask Codex to do anything")?;
|
|
|
|
submit(&mut terminal, "/resume")?;
|
|
terminal.wait_for_screen("Resume a previous session")?;
|
|
terminal.ensure_running()?;
|
|
terminal.write_input(b"\x1b")?;
|
|
terminal.wait_for_screen("Ask Codex to do anything")?;
|
|
|
|
submit(&mut terminal, "/worktree")?;
|
|
terminal.wait_for_screen("Continue current conversation")?;
|
|
terminal.write_input(b"1")?;
|
|
let forked = wait_for_owned_worktrees(&mut terminal, &worktrees, &root, /*count*/ 1)?;
|
|
let fork_bucket = forked[0].root.parent().expect("bucket");
|
|
terminal.wait_for_screen(&format!(
|
|
"/{}/",
|
|
fork_bucket.file_name().unwrap().to_string_lossy()
|
|
))?;
|
|
terminal.ensure_running()?;
|
|
|
|
submit(&mut terminal, &format!("/cd {}", root.display()))?;
|
|
terminal.wait_for_screen(&format!("Working directory changed to: {}", root.display()))?;
|
|
terminal.ensure_running()?;
|
|
|
|
submit(&mut terminal, "/worktree")?;
|
|
terminal.wait_for_screen("Start new conversation")?;
|
|
terminal.write_input(b"2")?;
|
|
let created = wait_for_owned_worktrees(&mut terminal, &worktrees, &root, /*count*/ 2)?;
|
|
ensure!(
|
|
created
|
|
.iter()
|
|
.any(|checkout| checkout.root == forked[0].root)
|
|
);
|
|
let second = created
|
|
.iter()
|
|
.find(|checkout| checkout.root != forked[0].root)
|
|
.expect("second checkout");
|
|
ensure!(
|
|
worktrees.owner(&forked[0].root)? != worktrees.owner(&second.root)?,
|
|
"new worktree reused the forked thread owner"
|
|
);
|
|
let second_bucket = second.root.parent().expect("bucket");
|
|
terminal.wait_for_screen(&format!(
|
|
"/{}/",
|
|
second_bucket.file_name().unwrap().to_string_lossy()
|
|
))?;
|
|
terminal.ensure_running()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn submit(terminal: &mut PtyCodex, input: &str) -> Result<()> {
|
|
terminal.write_input(input.as_bytes())?;
|
|
terminal.wait_for_screen(input)?;
|
|
// Bulk PTY writes resemble a paste; wait out the Enter suppression window.
|
|
std::thread::sleep(Duration::from_millis(/*millis*/ 250));
|
|
terminal.write_input(b"\r")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn wait_for_owned_worktrees(
|
|
terminal: &mut PtyCodex,
|
|
manager: &WorktreeManager,
|
|
source: &Path,
|
|
count: usize,
|
|
) -> Result<Vec<ManagedWorktree>> {
|
|
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30);
|
|
while Instant::now() < deadline {
|
|
terminal.read_output(Duration::from_millis(/*millis*/ 50))?;
|
|
let checkouts = manager.list(source).unwrap_or_default();
|
|
if checkouts.len() == count
|
|
&& checkouts
|
|
.iter()
|
|
.all(|checkout| manager.owner(&checkout.root).ok().flatten().is_some())
|
|
{
|
|
return Ok(checkouts);
|
|
}
|
|
terminal.ensure_running()?;
|
|
}
|
|
anyhow::bail!(
|
|
"did not bind {count} managed checkouts; screen:\n{}",
|
|
terminal.screen_contents()
|
|
)
|
|
}
|