diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 3a98fbb83..d51112a8b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -58,6 +58,7 @@ use tokio::sync::mpsc; fn sample_thread(thread_id: &str, ephemeral: bool) -> Thread { Thread { id: thread_id.to_string(), + forked_from_id: None, preview: "first prompt".to_string(), ephemeral, model_provider: "openai".to_string(), diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index fc36adaa5..b8b539a03 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2425,6 +2425,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "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 444c8af7c..a58990303 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 @@ -12186,6 +12186,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "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 e604157ad..f041f8aae 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 @@ -10041,6 +10041,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 5ae0c5a12..88448a165 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1042,6 +1042,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 126d78603..f26bd03a3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -800,6 +800,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index dfdab228d..88c8e688d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -800,6 +800,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 8f48dee4b..845320738 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -800,6 +800,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index edccc337d..e21f253b7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1042,6 +1042,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index cc41aac27..d719ba7d8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -800,6 +800,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index c3b50fee3..27a8cdd6b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1042,6 +1042,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 224015039..c202363e3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -800,6 +800,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 41b0d2d40..542aea176 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -800,6 +800,13 @@ "description": "Whether the thread is ephemeral and should not be materialized on disk.", "type": "boolean" }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": [ + "string", + "null" + ] + }, "gitInfo": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 56c568849..7a8bf4aca 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -7,6 +7,10 @@ import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; export type Thread = { id: string, +/** + * Source thread id when this thread was created by forking another thread. + */ +forkedFromId: string | null, /** * Usually the first user message in the thread, if available. */ diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 30061c716..ebd1ba11c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1307,6 +1307,7 @@ mod tests { response: v2::ThreadStartResponse { thread: v2::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + forked_from_id: None, preview: "first prompt".to_string(), ephemeral: true, model_provider: "openai".to_string(), @@ -1343,6 +1344,7 @@ mod tests { "response": { "thread": { "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "forkedFromId": null, "preview": "first prompt", "ephemeral": true, "modelProvider": "openai", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a6a4aa540..82b568515 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3574,6 +3574,8 @@ impl From for SkillErrorInfo { #[ts(export_to = "v2/")] pub struct Thread { pub id: String, + /// Source thread id when this thread was created by forking another thread. + pub forked_from_id: Option, /// Usually the first user message in the thread, if available. pub preview: String, /// Whether the thread is ephemeral and should not be materialized on disk. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2f244909f..43cdbf08e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -134,7 +134,7 @@ Example with notification opt-out: - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2b9aa12ab..e5d51dd47 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3523,6 +3523,11 @@ impl CodexMessageProcessor { } build_thread_from_snapshot(thread_uuid, &config_snapshot, loaded_rollout_path) }; + if thread.forked_from_id.is_none() + && let Some(rollout_path) = rollout_path.as_ref() + { + thread.forked_from_id = forked_from_id_from_rollout(rollout_path).await; + } self.attach_thread_name(thread_uuid, &mut thread).await; if include_turns && let Some(rollout_path) = rollout_path.as_ref() { @@ -4352,7 +4357,12 @@ impl CodexMessageProcessor { ) .await { - Ok(summary) => summary_to_thread(summary), + Ok(summary) => { + let mut thread = summary_to_thread(summary); + thread.forked_from_id = + forked_from_id_from_rollout(fork_rollout_path.as_path()).await; + thread + } Err(err) => { self.send_internal_error( request_id, @@ -4386,6 +4396,14 @@ impl CodexMessageProcessor { } }; thread.preview = preview_from_rollout_items(&history_items); + thread.forked_from_id = source_thread_id + .or_else(|| { + history_items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.id), + _ => None, + }) + }) + .map(|id| id.to_string()); if let Err(message) = populate_thread_turns( &mut thread, ThreadTurnSource::HistoryItems(&history_items), @@ -8566,6 +8584,7 @@ async fn load_thread_summary_for_rollout( rollout_path.display() ) })?; + thread.forked_from_id = forked_from_id_from_rollout(rollout_path).await; if let Some(persisted_metadata) = persisted_metadata { merge_mutable_thread_metadata( &mut thread, @@ -8577,6 +8596,14 @@ async fn load_thread_summary_for_rollout( Ok(thread) } +async fn forked_from_id_from_rollout(path: &Path) -> Option { + read_session_meta_line(path) + .await + .ok() + .and_then(|meta_line| meta_line.meta.forked_from_id) + .map(|thread_id| thread_id.to_string()) +} + fn merge_mutable_thread_metadata(thread: &mut Thread, persisted_thread: Thread) { thread.git_info = persisted_thread.git_info; } @@ -8657,6 +8684,7 @@ fn build_thread_from_snapshot( let now = time::OffsetDateTime::now_utc().unix_timestamp(); Thread { id: thread_id.to_string(), + forked_from_id: None, preview: String::new(), ephemeral: config_snapshot.ephemeral, model_provider: config_snapshot.model_provider_id.clone(), @@ -8699,6 +8727,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { Thread { id: conversation_id.to_string(), + forked_from_id: None, preview, ephemeral: false, model_provider, @@ -9195,6 +9224,44 @@ mod tests { Ok(()) } + #[tokio::test] + async fn read_summary_from_rollout_preserves_forked_from_id() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let forked_from_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + forked_from_id: Some(forked_from_id), + timestamp: timestamp.clone(), + model_provider: Some("test-provider".to_string()), + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp, + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + assert_eq!( + forked_from_id_from_rollout(path.as_path()).await, + Some(forked_from_id.to_string()) + ); + Ok(()) + } + #[tokio::test] async fn aborting_pending_request_clears_pending_state() -> Result<()> { let thread_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 0f0b3eea7..802f7e197 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -792,6 +792,7 @@ mod tests { fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread { Thread { id: thread_id.to_string(), + forked_from_id: None, preview: String::new(), ephemeral: false, model_provider: "mock-provider".to_string(), 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 0a62ad7ff..fd65a681a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -112,6 +112,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ); assert_ne!(thread.id, conversation_id); + assert_eq!(thread.forked_from_id, Some(conversation_id.clone())); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); assert_eq!(thread.status, ThreadStatus::Idle); diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index e565cf133..c5fd69985 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -7,6 +7,8 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; @@ -152,6 +154,56 @@ async fn thread_read_can_include_turns() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_read_returns_forked_from_id_for_forked_threads() -> 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_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + vec![], + 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(), + ..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: forked, .. } = to_response::(fork_resp)?; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: forked.id, + include_turns: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread } = to_response::(read_resp)?; + + assert_eq!(thread.forked_from_id, Some(conversation_id)); + + Ok(()) +} + #[tokio::test] async fn thread_read_loaded_thread_returns_precomputed_path_before_materialization() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index b223ba13c..09b43471d 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -242,6 +242,7 @@ async fn resume_lookup_model_providers_filters_only_last_lookup() { fn turn_items_for_thread_returns_matching_turn_items() { let thread = AppServerThread { id: "thread-1".to_string(), + forked_from_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), @@ -360,6 +361,7 @@ fn session_configured_from_thread_response_uses_review_policy_from_response() { let response = ThreadStartResponse { thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + forked_from_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6b8749b3c..e262b55a3 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -8639,6 +8639,7 @@ guardian_approval = true ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + forked_from_id: None, preview: "agent thread".to_string(), ephemeral: false, model_provider: "agent-provider".to_string(), @@ -8719,6 +8720,7 @@ guardian_approval = true ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + forked_from_id: None, preview: "agent thread".to_string(), ephemeral: false, model_provider: "agent-provider".to_string(), @@ -10680,6 +10682,7 @@ guardian_approval = true &ThreadRollbackResponse { thread: Thread { id: thread_id.to_string(), + forked_from_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 12b1c669b..ef5a061e3 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -1251,6 +1251,7 @@ mod tests { fn replays_command_execution_items_from_thread_snapshots() { let thread = Thread { id: "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(), + forked_from_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), @@ -1417,6 +1418,7 @@ mod tests { let events = thread_snapshot_events( &Thread { id: thread_id.to_string(), + forked_from_id: None, preview: "hello".to_string(), ephemeral: false, model_provider: "openai".to_string(), diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index c6ac09890..6351e3e32 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -111,6 +111,7 @@ mod tests { fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread { Thread { id: thread_id.to_string(), + forked_from_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 68424de82..c0be4a94f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -945,6 +945,7 @@ async fn thread_session_state_from_thread_start_response( ) -> Result { thread_session_state_from_thread_response( &response.thread.id, + response.thread.forked_from_id.clone(), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), @@ -966,6 +967,7 @@ async fn thread_session_state_from_thread_resume_response( ) -> Result { thread_session_state_from_thread_response( &response.thread.id, + response.thread.forked_from_id.clone(), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), @@ -987,6 +989,7 @@ async fn thread_session_state_from_thread_fork_response( ) -> Result { thread_session_state_from_thread_response( &response.thread.id, + response.thread.forked_from_id.clone(), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), @@ -1027,6 +1030,7 @@ fn review_target_to_app_server( )] async fn thread_session_state_from_thread_response( thread_id: &str, + forked_from_id: Option, thread_name: Option, rollout_path: Option, model: String, @@ -1041,12 +1045,17 @@ async fn thread_session_state_from_thread_response( ) -> Result { let thread_id = ThreadId::from_string(thread_id) .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; + let forked_from_id = forked_from_id + .as_deref() + .map(ThreadId::from_string) + .transpose() + .map_err(|err| format!("forked_from_id is invalid: {err}"))?; let (history_log_id, history_entry_count) = message_history::history_metadata(config).await; let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX); Ok(ThreadSessionState { thread_id, - forked_from_id: None, + forked_from_id, thread_name, model, model_provider_id, @@ -1164,9 +1173,11 @@ mod tests { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); + let forked_from_id = ThreadId::new(); let response = ThreadResumeResponse { thread: codex_app_server_protocol::Thread { id: thread_id.to_string(), + forked_from_id: Some(forked_from_id.to_string()), preview: "hello".to_string(), ephemeral: false, model_provider: "openai".to_string(), @@ -1215,6 +1226,7 @@ mod tests { let started = started_thread_from_resume_response(response.clone(), &config) .await .expect("resume response should map"); + assert_eq!(started.session.forked_from_id, Some(forked_from_id)); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); } @@ -1234,6 +1246,7 @@ mod tests { let session = thread_session_state_from_thread_response( &thread_id.to_string(), + /*forked_from_id*/ None, Some("restore".to_string()), /*rollout_path*/ None, "gpt-5.4".to_string(), @@ -1253,6 +1266,34 @@ mod tests { assert_eq!(session.history_entry_count, 2); } + #[tokio::test] + async fn session_configured_preserves_fork_source_thread_id() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let thread_id = ThreadId::new(); + let forked_from_id = ThreadId::new(); + + let session = thread_session_state_from_thread_response( + &thread_id.to_string(), + Some(forked_from_id.to_string()), + Some("restore".to_string()), + /*rollout_path*/ None, + "gpt-5.4".to_string(), + "openai".to_string(), + /*service_tier*/ None, + AskForApproval::Never, + codex_protocol::config_types::ApprovalsReviewer::User, + SandboxPolicy::new_read_only_policy(), + PathBuf::from("/tmp/project"), + /*reasoning_effort*/ None, + &config, + ) + .await + .expect("session should map"); + + assert_eq!(session.forked_from_id, Some(forked_from_id)); + } + #[test] fn status_account_display_from_auth_mode_uses_remapped_plan_labels() { let business = status_account_display_from_auth_mode( diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 0b7c50467..ba399521d 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -2639,6 +2639,7 @@ mod tests { let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + forked_from_id: None, preview: String::from("remote thread"), ephemeral: false, model_provider: String::from("openai"),