diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 3eb262bd2b..bdfa6af39b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -29,6 +29,7 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -216,6 +217,88 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_preserves_persisted_workspace_roots_when_request_omits_them() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let repo = TempDir::new()?; + let shared = TempDir::new()?; + let workspace_roots = vec![ + AbsolutePathBuf::try_from(repo.path())?, + AbsolutePathBuf::try_from(shared.path())?, + ]; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(repo.path().display().to_string()), + permissions: Some(":workspace".to_string()), + workspace_roots: Some(workspace_roots.clone()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: thread.id, + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { + workspace_roots: forked_roots, + permission_profile, + .. + } = to_response::(fork_resp)?; + + assert_eq!(forked_roots, workspace_roots); + let permission_profile: codex_protocol::models::PermissionProfile = permission_profile + .expect("fork response should include exact permissions") + .into(); + assert!( + permission_profile + .file_system_sandbox_policy() + .can_write_path_with_cwd(shared.path(), repo.path()) + ); + + Ok(()) +} + #[tokio::test] async fn thread_fork_can_load_source_by_path() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 2444ce8699..fafc75cc15 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -3090,6 +3090,64 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_preserves_persisted_workspace_roots_when_request_omits_them() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let repo = TempDir::new()?; + let shared = TempDir::new()?; + let workspace_roots = vec![ + AbsolutePathBuf::try_from(repo.path())?, + AbsolutePathBuf::try_from(shared.path())?, + ]; + + let RestartedThreadFixture { + mut mcp, thread_id, .. + } = start_materialized_thread_with_params_and_restart( + codex_home.path(), + "seed history", + ThreadStartParams { + model: Some("gpt-5.4".to_string()), + cwd: Some(repo.path().display().to_string()), + permissions: Some(":workspace".to_string()), + workspace_roots: Some(workspace_roots.clone()), + ..Default::default() + }, + ) + .await?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + workspace_roots: resumed_roots, + permission_profile, + .. + } = to_response::(resume_resp)?; + + assert_eq!(resumed_roots, workspace_roots); + let permission_profile: codex_protocol::models::PermissionProfile = permission_profile + .expect("resume response should include exact permissions") + .into(); + assert!( + permission_profile + .file_system_sandbox_policy() + .can_write_path_with_cwd(shared.path(), repo.path()) + ); + + Ok(()) +} + struct RestartedThreadFixture { mcp: McpProcess, thread_id: String, @@ -3100,16 +3158,27 @@ struct RestartedThreadFixture { async fn start_materialized_thread_and_restart( codex_home: &Path, seed_text: &str, +) -> Result { + start_materialized_thread_with_params_and_restart( + codex_home, + seed_text, + ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }, + ) + .await +} + +async fn start_materialized_thread_with_params_and_restart( + codex_home: &Path, + seed_text: &str, + start_params: ThreadStartParams, ) -> Result { let mut first_mcp = McpProcess::new(codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; - let start_id = first_mcp - .send_thread_start_request(ThreadStartParams { - model: Some("gpt-5.4".to_string()), - ..Default::default() - }) - .await?; + let start_id = first_mcp.send_thread_start_request(start_params).await?; let start_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, first_mcp.read_stream_until_response_message(RequestId::Integer(start_id)),