From 2a0401da8f12685de3e41a3be2cb1ce88486327a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 2 Apr 2026 10:49:30 -0700 Subject: [PATCH 1/4] Expose fork source thread ids in app-server v2 --- .../schema/json/ServerNotification.json | 7 +++ .../codex_app_server_protocol.schemas.json | 7 +++ .../codex_app_server_protocol.v2.schemas.json | 7 +++ .../schema/json/v2/ThreadForkResponse.json | 7 +++ .../schema/json/v2/ThreadListResponse.json | 7 +++ .../json/v2/ThreadMetadataUpdateResponse.json | 7 +++ .../schema/json/v2/ThreadReadResponse.json | 7 +++ .../schema/json/v2/ThreadResumeResponse.json | 7 +++ .../json/v2/ThreadRollbackResponse.json | 7 +++ .../schema/json/v2/ThreadStartResponse.json | 7 +++ .../json/v2/ThreadStartedNotification.json | 7 +++ .../json/v2/ThreadUnarchiveResponse.json | 7 +++ .../schema/typescript/ConversationSummary.ts | 2 +- .../schema/typescript/v2/Thread.ts | 4 ++ .../src/protocol/common.rs | 2 + .../app-server-protocol/src/protocol/v1.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 2 + codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 55 +++++++++++++++++++ codex-rs/app-server/src/thread_status.rs | 1 + .../tests/suite/conversation_summary.rs | 1 + .../app-server/tests/suite/v2/thread_fork.rs | 1 + codex-rs/tui/src/app_server_session.rs | 43 ++++++++++++++- 23 files changed, 195 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index fc36adaa550..b8b539a03b5 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 444c8af7c25..a5899030323 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 e604157ad6e..f041f8aae83 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 5ae0c5a122b..88448a1658d 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 126d78603a1..f26bd03a34b 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 dfdab228df3..88c8e688df0 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 8f48dee4b73..8453207380f 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 edccc337da2..e21f253b728 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 cc41aac27f5..d719ba7d8fe 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 c3b50fee3b0..27a8cdd6bfc 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 2240150394d..c202363e3b3 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 41b0d2d409c..542aea17654 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/ConversationSummary.ts b/codex-rs/app-server-protocol/schema/typescript/ConversationSummary.ts index 2cc2a05706b..bb86d2c79b4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ConversationSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ConversationSummary.ts @@ -5,4 +5,4 @@ import type { ConversationGitInfo } from "./ConversationGitInfo"; import type { SessionSource } from "./SessionSource"; import type { ThreadId } from "./ThreadId"; -export type ConversationSummary = { conversationId: ThreadId, path: string, preview: string, timestamp: string | null, updatedAt: string | null, modelProvider: string, cwd: string, cliVersion: string, source: SessionSource, gitInfo: ConversationGitInfo | null, }; +export type ConversationSummary = { conversationId: ThreadId, forkedFromId: ThreadId | null, path: string, preview: string, timestamp: string | null, updatedAt: string | null, modelProvider: string, cwd: string, cliVersion: string, source: SessionSource, gitInfo: ConversationGitInfo | null, }; 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 56c56884953..7a8bf4aca47 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 30061c716e9..ebd1ba11c50 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/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 6aa2e9fa30c..fb8324b6fc8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -89,6 +89,7 @@ pub struct GetConversationSummaryResponse { #[serde(rename_all = "camelCase")] pub struct ConversationSummary { pub conversation_id: ThreadId, + pub forked_from_id: Option, pub path: PathBuf, pub preview: String, pub timestamp: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a6a4aa5403e..82b568515c2 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 2f244909f19..43cdbf08e43 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 2b9aa12ab42..1b712721d7a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4386,6 +4386,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), @@ -8299,6 +8307,7 @@ async fn summary_from_thread_list_item( ); return Some(ConversationSummary { conversation_id: thread_id, + forked_from_id: None, path: it.path, preview: it.first_user_message.unwrap_or_default(), timestamp, @@ -8373,6 +8382,7 @@ fn summary_from_state_db_metadata( }; ConversationSummary { conversation_id, + forked_from_id: None, path, preview, timestamp: Some(timestamp), @@ -8470,6 +8480,7 @@ pub(crate) async fn read_summary_from_rollout( Ok(ConversationSummary { conversation_id: session_meta.id, + forked_from_id: session_meta.forked_from_id, timestamp, updated_at, path: path.to_path_buf(), @@ -8530,6 +8541,7 @@ fn extract_conversation_summary( Some(ConversationSummary { conversation_id, + forked_from_id: session_meta.forked_from_id, timestamp, updated_at, path, @@ -8657,6 +8669,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(), @@ -8678,6 +8691,7 @@ fn build_thread_from_snapshot( pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { let ConversationSummary { conversation_id, + forked_from_id, path, preview, timestamp, @@ -8699,6 +8713,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { Thread { id: conversation_id.to_string(), + forked_from_id: forked_from_id.map(|id| id.to_string()), preview, ephemeral: false, model_provider, @@ -9077,6 +9092,7 @@ mod tests { let expected = ConversationSummary { conversation_id, + forked_from_id: None, timestamp: timestamp.clone(), updated_at: timestamp, path, @@ -9133,6 +9149,7 @@ mod tests { let expected = ConversationSummary { conversation_id, + forked_from_id: None, timestamp: Some(timestamp.clone()), updated_at: Some("2025-09-05T16:53:11Z".to_string()), path: path.clone(), @@ -9195,6 +9212,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)?))?; + + let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; + let thread = summary_to_thread(summary); + + assert_eq!(thread.forked_from_id, 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 0f0b3eea753..802f7e197c8 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/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 9e292d602fd..159c2edbe96 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -24,6 +24,7 @@ const MODEL_PROVIDER: &str = "openai"; fn expected_summary(conversation_id: ThreadId, path: PathBuf) -> ConversationSummary { ConversationSummary { conversation_id, + forked_from_id: None, path, preview: PREVIEW.to_string(), timestamp: Some(META_RFC3339.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 0a62ad7ff20..fd65a681a62 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/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 68424de82ee..c0be4a94f64 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( From 06eb9106368524892765e0cf00a7dd2ec00b893a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 2 Apr 2026 10:58:19 -0700 Subject: [PATCH 2/4] Move fork source lookup out of v1 ConversationSummary --- .../schema/typescript/ConversationSummary.ts | 2 +- .../app-server-protocol/src/protocol/v1.rs | 1 - .../app-server/src/codex_message_processor.rs | 33 +++++++++++-------- .../tests/suite/conversation_summary.rs | 1 - 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/typescript/ConversationSummary.ts b/codex-rs/app-server-protocol/schema/typescript/ConversationSummary.ts index bb86d2c79b4..2cc2a05706b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ConversationSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ConversationSummary.ts @@ -5,4 +5,4 @@ import type { ConversationGitInfo } from "./ConversationGitInfo"; import type { SessionSource } from "./SessionSource"; import type { ThreadId } from "./ThreadId"; -export type ConversationSummary = { conversationId: ThreadId, forkedFromId: ThreadId | null, path: string, preview: string, timestamp: string | null, updatedAt: string | null, modelProvider: string, cwd: string, cliVersion: string, source: SessionSource, gitInfo: ConversationGitInfo | null, }; +export type ConversationSummary = { conversationId: ThreadId, path: string, preview: string, timestamp: string | null, updatedAt: string | null, modelProvider: string, cwd: string, cliVersion: string, source: SessionSource, gitInfo: ConversationGitInfo | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index fb8324b6fc8..6aa2e9fa30c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -89,7 +89,6 @@ pub struct GetConversationSummaryResponse { #[serde(rename_all = "camelCase")] pub struct ConversationSummary { pub conversation_id: ThreadId, - pub forked_from_id: Option, pub path: PathBuf, pub preview: String, pub timestamp: Option, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1b712721d7a..1fabb2315a7 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4352,7 +4352,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, @@ -8307,7 +8312,6 @@ async fn summary_from_thread_list_item( ); return Some(ConversationSummary { conversation_id: thread_id, - forked_from_id: None, path: it.path, preview: it.first_user_message.unwrap_or_default(), timestamp, @@ -8382,7 +8386,6 @@ fn summary_from_state_db_metadata( }; ConversationSummary { conversation_id, - forked_from_id: None, path, preview, timestamp: Some(timestamp), @@ -8480,7 +8483,6 @@ pub(crate) async fn read_summary_from_rollout( Ok(ConversationSummary { conversation_id: session_meta.id, - forked_from_id: session_meta.forked_from_id, timestamp, updated_at, path: path.to_path_buf(), @@ -8541,7 +8543,6 @@ fn extract_conversation_summary( Some(ConversationSummary { conversation_id, - forked_from_id: session_meta.forked_from_id, timestamp, updated_at, path, @@ -8578,6 +8579,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, @@ -8589,6 +8591,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; } @@ -8691,7 +8701,6 @@ fn build_thread_from_snapshot( pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { let ConversationSummary { conversation_id, - forked_from_id, path, preview, timestamp, @@ -8713,7 +8722,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { Thread { id: conversation_id.to_string(), - forked_from_id: forked_from_id.map(|id| id.to_string()), + forked_from_id: None, preview, ephemeral: false, model_provider, @@ -9092,7 +9101,6 @@ mod tests { let expected = ConversationSummary { conversation_id, - forked_from_id: None, timestamp: timestamp.clone(), updated_at: timestamp, path, @@ -9149,7 +9157,6 @@ mod tests { let expected = ConversationSummary { conversation_id, - forked_from_id: None, timestamp: Some(timestamp.clone()), updated_at: Some("2025-09-05T16:53:11Z".to_string()), path: path.clone(), @@ -9243,10 +9250,10 @@ mod tests { }; fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; - let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; - let thread = summary_to_thread(summary); - - assert_eq!(thread.forked_from_id, Some(forked_from_id.to_string())); + assert_eq!( + forked_from_id_from_rollout(path.as_path()).await, + Some(forked_from_id.to_string()) + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 159c2edbe96..9e292d602fd 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -24,7 +24,6 @@ const MODEL_PROVIDER: &str = "openai"; fn expected_summary(conversation_id: ThreadId, path: PathBuf) -> ConversationSummary { ConversationSummary { conversation_id, - forked_from_id: None, path, preview: PREVIEW.to_string(), timestamp: Some(META_RFC3339.to_string()), From 9d0ffad78070b4b3bb93d7e4d5882514c042e432 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 2 Apr 2026 11:17:19 -0700 Subject: [PATCH 3/4] codex: fix CI failure on PR #16596 --- codex-rs/tui/src/app.rs | 3 +++ codex-rs/tui/src/app/app_server_adapter.rs | 2 ++ codex-rs/tui/src/app/loaded_threads.rs | 1 + codex-rs/tui/src/resume_picker.rs | 1 + 4 files changed, 7 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6e2e89e195c..7e8a15679de 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -8575,6 +8575,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(), @@ -8655,6 +8656,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(), @@ -10593,6 +10595,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 12b1c669b66..ef5a061e327 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 c6ac09890df..6351e3e3200 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/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index d5e7fbd1ef8..d71d56eb0c5 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -2589,6 +2589,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"), From 65795f5e53c7a6decf6bad97b15202cf007bb381 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 2 Apr 2026 11:34:03 -0700 Subject: [PATCH 4/4] codex: address PR review feedback (#16596) --- .../analytics/src/analytics_client_tests.rs | 1 + .../app-server/src/codex_message_processor.rs | 5 ++ .../app-server/tests/suite/v2/thread_read.rs | 52 +++++++++++++++++++ codex-rs/exec/src/lib_tests.rs | 2 + 4 files changed, 60 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 3a98fbb8376..d51112a8bd0 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/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1fabb2315a7..e5d51dd4742 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() { 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 e565cf1336e..c5fd699855c 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 b223ba13ccf..09b43471dfa 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(),