Files
codex/codex-rs/tui/tests/suite/worktree_stack.rs
Benjamin Carlsson 5ecb3afd1b Defer resume picker and directory changes to a fresh TUI stack (#43376)
## 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
2026-09-07 06:43:23 +00:00

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()
)
}