diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 2621a0bd1..58aa1c444 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3007,7 +3007,7 @@ "type": "object" }, "ThreadForkParams": { - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -3413,7 +3413,7 @@ ] }, "ThreadResumeParams": { - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index e219f4255..df4514e7c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15370,7 +15370,7 @@ }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -16868,7 +16868,7 @@ }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index fb1a8d250..1741b612f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -13194,7 +13194,7 @@ }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -14692,7 +14692,7 @@ }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 102cfa029..9d2f834dd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -77,7 +77,7 @@ "type": "string" } }, - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 253ebf457..027989479 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -963,7 +963,7 @@ "type": "string" } }, - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index 6076a4bb1..c5109b2c7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -12,7 +12,8 @@ import type { ThreadSource } from "./ThreadSource"; * 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. * 2. By path: load the thread from disk by path and fork it into a new thread. * - * If using path, the thread_id param will be ignored. + * If using a non-empty path, the thread_id param will be ignored. + * Empty string path values are treated as absent. * * Prefer using thread_id whenever possible. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 6d1dbdca4..0ec895343 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -13,8 +13,13 @@ import type { SandboxMode } from "./SandboxMode"; * 2. By history: instantiate the thread from memory and resume it. * 3. By path: load the thread from disk by path and resume it. * - * The precedence is: history > path > thread_id. - * If using history or path, the thread_id param will be ignored. + * For non-running threads, the precedence is: history > non-empty path > thread_id. + * If using history or a non-empty path for a non-running thread, the thread_id + * param will be ignored. + * + * If thread_id identifies a running thread, app-server rejoins that thread and + * treats a non-empty path as a consistency check against the active rollout path. + * Empty string path values are treated as absent. * * Prefer using thread_id whenever possible. */ diff --git a/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs index 0e35ebdba..6d6747a5c 100644 --- a/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs +++ b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs @@ -1,8 +1,18 @@ +use std::path::PathBuf; + use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::Serializer; +pub fn deserialize_empty_path_as_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let path = Option::::deserialize(deserializer)?; + Ok(path.filter(|path| !path.as_os_str().is_empty())) +} + pub fn deserialize_double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> where T: Deserialize<'de>, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 0e7161d5e..8c3ca5e6c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -661,6 +661,33 @@ fn permission_profile_selection_uses_id_string() { ); } +#[test] +fn thread_path_params_deserialize_empty_path_as_none() { + let resume: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "path": "", + })) + .expect("thread/resume params deserialize"); + assert_eq!(resume.path, None); + + let fork: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "path": "", + })) + .expect("thread/fork params deserialize"); + assert_eq!(fork.path, None); + + let resume_with_path: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "path": "/tmp/resume-thread.jsonl", + })) + .expect("thread/resume params deserialize"); + assert_eq!( + resume_with_path.path, + Some(PathBuf::from("/tmp/resume-thread.jsonl")) + ); +} + #[test] fn fs_get_metadata_response_round_trips_minimal_fields() { let response = FsGetMetadataResponse { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 41994e59b..059bef05f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -229,8 +229,13 @@ pub struct ThreadStartResponse { /// 2. By history: instantiate the thread from memory and resume it. /// 3. By path: load the thread from disk by path and resume it. /// -/// The precedence is: history > path > thread_id. -/// If using history or path, the thread_id param will be ignored. +/// For non-running threads, the precedence is: history > non-empty path > thread_id. +/// If using history or a non-empty path for a non-running thread, the thread_id +/// param will be ignored. +/// +/// If thread_id identifies a running thread, app-server rejoins that thread and +/// treats a non-empty path as a consistency check against the active rollout path. +/// Empty string path values are treated as absent. /// /// Prefer using thread_id whenever possible. pub struct ThreadResumeParams { @@ -244,8 +249,14 @@ pub struct ThreadResumeParams { pub history: Option>, /// [UNSTABLE] Specify the rollout path to resume from. - /// If specified, the thread_id param will be ignored. + /// If specified for a non-running thread, the thread_id param will be ignored. + /// If thread_id identifies a running thread, the path must match the active + /// rollout path. #[experimental("thread/resume.path")] + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_empty_path_as_none" + )] #[ts(optional = nullable)] pub path: Option, @@ -346,7 +357,8 @@ pub struct ThreadResumeResponse { /// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. /// 2. By path: load the thread from disk by path and fork it into a new thread. /// -/// If using path, the thread_id param will be ignored. +/// If using a non-empty path, the thread_id param will be ignored. +/// Empty string path values are treated as absent. /// /// Prefer using thread_id whenever possible. pub struct ThreadForkParams { @@ -355,6 +367,10 @@ pub struct ThreadForkParams { /// [UNSTABLE] Specify the rollout path to fork from. /// If specified, the thread_id param will be ignored. #[experimental("thread/fork.path")] + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_empty_path_as_none" + )] #[ts(optional = nullable)] pub path: Option, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index f89f4a650..6b6e1729a 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2639,31 +2639,6 @@ impl ThreadRequestProcessor { ))); } None - } else if params.path.is_some() { - let source_thread = self - .read_stored_thread_for_resume( - ¶ms.thread_id, - params.path.as_ref(), - /*include_history*/ true, - ) - .await?; - let existing_thread_id = source_thread.thread_id; - if let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { - if let (Some(requested_path), Some(active_path)) = ( - params.path.as_ref(), - existing_thread.rollout_path().as_ref(), - ) && requested_path != active_path - { - return Err(invalid_request(format!( - "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", - requested_path.display(), - active_path.display() - ))); - } - Some((existing_thread_id, existing_thread, source_thread)) - } else { - None - } } else if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) && let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { @@ -2686,6 +2661,19 @@ impl ThreadRequestProcessor { }; if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread { + let existing_thread_rollout_path = existing_thread.rollout_path(); + let active_path = existing_thread_rollout_path + .as_ref() + .or(source_thread.rollout_path.as_ref()); + if let (Some(requested_path), Some(active_path)) = (params.path.as_ref(), active_path) + && requested_path != active_path + { + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", + requested_path.display(), + active_path.display() + ))); + } let redact_resume_payloads = should_redact_thread_resume_payloads(app_server_client_name.as_deref()); let history_items = source_thread 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 3eb262bd2..cc89dd71c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -454,6 +454,46 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_with_empty_path_uses_thread_id() -> 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 conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + path: Some(std::path::PathBuf::new()), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + assert_eq!( + thread.forked_from_id.as_deref(), + Some(conversation_id.as_str()) + ); + Ok(()) +} + #[tokio::test] async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { let server = MockServer::start().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 8bcad54ef..036f2368a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -184,6 +184,70 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_with_empty_path_uses_running_thread_id() -> 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 mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..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: "materialize rollout".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 resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + path: Some(PathBuf::new()), + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + + assert_eq!(resumed.id, thread.id); + Ok(()) +} + #[tokio::test] async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -2143,7 +2207,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { } #[tokio::test] -async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Result<()> { +async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result<()> { let server = responses::start_mock_server().await; let first_body = responses::sse(vec![ responses::ev_response_created("resp-1"), @@ -2223,7 +2287,6 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu ) .await??; - let other_thread_id = ThreadId::new().to_string(); let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id); std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?; let thread_uuid = Uuid::parse_str(&thread_id)?; @@ -2255,7 +2318,7 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu let stale_resume_id = primary .send_thread_resume_request(ThreadResumeParams { - thread_id: other_thread_id.clone(), + thread_id: thread_id.clone(), path: Some(stale_path), ..Default::default() }) @@ -2271,23 +2334,6 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu stale_resume_err.error.message ); - let resume_by_path_id = primary - .send_thread_resume_request(ThreadResumeParams { - thread_id: other_thread_id.clone(), - path: thread.path, - ..Default::default() - }) - .await?; - let resume_by_path_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_response_message(RequestId::Integer(resume_by_path_id)), - ) - .await??; - let ThreadResumeResponse { - thread: resumed, .. - } = to_response::(resume_by_path_resp)?; - assert_eq!(resumed.id, thread_id); - primary .interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT) .await?; @@ -2984,53 +3030,22 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { } #[tokio::test] -async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> { +async fn thread_resume_uses_path_over_non_running_thread_id() -> 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 mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let RestartedThreadFixture { + mut mcp, + thread_id, + rollout_file_path, + .. + } = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?; - let start_id = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("gpt-5.4".to_string()), - ..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: "materialize".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 thread_path = thread.path.clone().expect("thread path"); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { - thread_id: "not-a-valid-thread-id".to_string(), - path: Some(thread_path.to_path_buf()), + thread_id: ThreadId::new().to_string(), + path: Some(rollout_file_path), ..Default::default() }) .await?; @@ -3043,7 +3058,7 @@ async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> { let ThreadResumeResponse { thread: resumed, .. } = to_response::(resume_resp)?; - assert_eq!(resumed.id, thread.id); + assert_eq!(resumed.id, thread_id); Ok(()) }