From cf0911076f234e0219bd8d61dd3bc2f80a2df287 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Sun, 31 May 2026 21:33:20 -0700 Subject: [PATCH] store and expose parent_thread_id on Threads (#25113) ## Why This PR https://github.com/openai/codex/pull/24161#discussion_r3325692763 revealed a subagent data modeling issue, where we overloaded `forked_from_id` to also mean `parent_thread_id`. That's incorrect since guardian and review subagents can be a subagent and NOT fork the main thread's history. The solution here is to explicitly store a new `parent_thread_id` on `SessionMeta`, alongside `forked_from_id` which already exists. While we're at it, also expose it in the app-server protocol on the `Thread` object. A thread->subagent relationship and a fork of thread history are orthogonal concepts. ## What Changed - Added top-level `parent_thread_id` persistence on `SessionMeta` and runtime/session plumbing through `SessionConfiguredEvent`, `CodexSpawnArgs`, `SessionConfiguration`, `ThreadConfigSnapshot`, `TurnContext`, and `ModelClient`. - Made turn metadata, request headers, analytics, and subagent-start events read the separate runtime/top-level parent field instead of deriving general parent lineage from `SessionSource` or `forked_from_thread_id`. - Passed parent lineage separately at delegated subagent, review, guardian, agent-job, and multi-agent spawn construction sites; copied-history fork lineage remains derived only from `InitialHistory`. - Persisted and exposed parent lineage through rollout/thread-store projections and app-server v2 `Thread.parentThreadId`. - Updated app-server README text and regenerated app-server schema fixtures for the additive `parentThreadId` response field. --- .../analytics/src/analytics_client_tests.rs | 25 +++++-- codex-rs/analytics/src/client_tests.rs | 1 + codex-rs/analytics/src/events.rs | 18 +---- codex-rs/analytics/src/reducer.rs | 18 ++--- .../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/v2/Thread.ts | 4 ++ .../src/protocol/common.rs | 2 + .../src/protocol/v2/tests.rs | 2 + .../src/protocol/v2/thread_data.rs | 2 + codex-rs/app-server/README.md | 4 +- .../app-server/src/bespoke_event_handling.rs | 1 + .../request_processors/thread_processor.rs | 2 + .../thread_processor_tests.rs | 2 + .../thread_resume_redaction.rs | 1 + .../src/request_processors/thread_summary.rs | 1 + codex-rs/app-server/src/thread_status.rs | 1 + codex-rs/app-server/tests/common/lib.rs | 1 + codex-rs/app-server/tests/common/rollout.rs | 49 +++++++++++++ .../tests/suite/conversation_summary.rs | 1 + .../tests/suite/v2/client_metadata.rs | 28 ++++---- .../app-server/tests/suite/v2/thread_list.rs | 8 ++- .../app-server/tests/suite/v2/thread_read.rs | 1 + .../tests/suite/v2/thread_resume.rs | 1 + .../tests/suite/v2/thread_unarchive.rs | 1 + codex-rs/core/src/agent/agent_resolver.rs | 2 +- codex-rs/core/src/agent/control.rs | 22 +++--- codex-rs/core/src/agent/control_tests.rs | 9 +++ codex-rs/core/src/client.rs | 13 ++-- codex-rs/core/src/client_tests.rs | 26 +++++-- codex-rs/core/src/codex_delegate.rs | 7 +- codex-rs/core/src/codex_thread.rs | 2 + codex-rs/core/src/guardian/review.rs | 4 +- .../core/src/personality_migration_tests.rs | 1 + codex-rs/core/src/realtime_context_tests.rs | 1 + codex-rs/core/src/session/mod.rs | 3 + codex-rs/core/src/session/review.rs | 2 + codex-rs/core/src/session/session.rs | 12 +++- codex-rs/core/src/session/tests.rs | 13 ++++ .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/session/turn_context.rs | 5 ++ codex-rs/core/src/thread_manager.rs | 17 +++++ .../core/src/tools/handlers/agent_jobs.rs | 1 + .../src/tools/handlers/multi_agents/spawn.rs | 1 + .../handlers/multi_agents_v2/list_agents.rs | 2 +- .../tools/handlers/multi_agents_v2/spawn.rs | 1 + codex-rs/core/src/turn_metadata.rs | 10 ++- codex-rs/core/src/turn_metadata_tests.rs | 68 ++++++++++++------- codex-rs/core/tests/responses_headers.rs | 3 + codex-rs/core/tests/suite/client.rs | 4 ++ .../core/tests/suite/client_websockets.rs | 1 + .../core/tests/suite/personality_migration.rs | 2 + .../suite/responses_api_proxy_headers.rs | 3 +- codex-rs/core/tests/suite/review.rs | 3 +- .../core/tests/suite/rollout_list_find.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + ...event_processor_with_human_output_tests.rs | 1 + codex-rs/exec/src/lib.rs | 8 +++ codex-rs/exec/src/lib_tests.rs | 22 ++++++ .../tests/event_processor_with_json_output.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 3 + codex-rs/memories/write/src/runtime.rs | 4 +- codex-rs/protocol/src/protocol.rs | 13 ++++ codex-rs/rollout/src/list.rs | 6 ++ codex-rs/rollout/src/metadata_tests.rs | 3 + codex-rs/rollout/src/recorder.rs | 10 +++ codex-rs/rollout/src/recorder_tests.rs | 5 ++ codex-rs/rollout/src/session_index_tests.rs | 1 + codex-rs/rollout/src/tests.rs | 12 ++++ codex-rs/state/src/extract.rs | 2 + codex-rs/state/src/runtime/threads.rs | 2 + codex-rs/thread-store/src/in_memory.rs | 1 + .../thread-store/src/local/create_thread.rs | 1 + codex-rs/thread-store/src/local/helpers.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 1 + .../thread-store/src/local/read_thread.rs | 4 ++ codex-rs/thread-store/src/types.rs | 4 ++ codex-rs/tui/src/app/loaded_threads.rs | 1 + codex-rs/tui/src/app/tests.rs | 4 ++ codex-rs/tui/src/app/thread_session_state.rs | 1 + codex-rs/tui/src/app_server_session.rs | 1 + codex-rs/tui/src/resume_picker.rs | 4 ++ 92 files changed, 504 insertions(+), 111 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 6a47ec78c..b3f6ba22c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -160,11 +160,13 @@ fn sample_thread_with_metadata( ephemeral: bool, source: AppServerSessionSource, thread_source: Option, + parent_thread_id: Option, ) -> Thread { Thread { id: thread_id.to_string(), session_id: format!("session-{thread_id}"), forked_from_id: None, + parent_thread_id, preview: "first prompt".to_string(), ephemeral, model_provider: "openai".to_string(), @@ -195,6 +197,7 @@ fn sample_thread_start_response( ephemeral, AppServerSessionSource::Exec, Some(AppServerThreadSource::User), + /*parent_thread_id*/ None, ), model: model.to_string(), model_provider: "openai".to_string(), @@ -240,6 +243,7 @@ fn sample_thread_resume_response( model, AppServerSessionSource::Exec, Some(AppServerThreadSource::User), + /*parent_thread_id*/ None, ) } @@ -249,9 +253,16 @@ fn sample_thread_resume_response_with_source( model: &str, source: AppServerSessionSource, thread_source: Option, + parent_thread_id: Option, ) -> ClientResponsePayload { ClientResponsePayload::ThreadResume(ThreadResumeResponse { - thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source), + thread: sample_thread_with_metadata( + thread_id, + ephemeral, + source, + thread_source, + parent_thread_id, + ), model: model.to_string(), model_provider: "openai".to_string(), service_tier: None, @@ -1755,6 +1766,7 @@ async fn compaction_event_ingests_custom_fact() { agent_role: None, }), Some(AppServerThreadSource::Subagent), + Some(parent_thread_id.to_string()), )), }, &mut events, @@ -2456,7 +2468,7 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { SubAgentThreadStartedInput { session_id: "session-root".to_string(), thread_id: "thread-spawn".to_string(), - parent_thread_id: None, + parent_thread_id: Some(parent_thread_id.to_string()), product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2534,11 +2546,14 @@ fn subagent_thread_started_other_serializes_expected_shape() { #[test] fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { + let parent_thread_id = + codex_protocol::ThreadId::from_string("33333333-3333-4333-8333-333333333333") + .expect("valid thread id"); let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), - parent_thread_id: Some("parent-thread-guardian".to_string()), + parent_thread_id: Some(parent_thread_id.to_string()), product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2553,7 +2568,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { assert_eq!(payload["event_params"]["subagent_source"], "guardian"); assert_eq!( payload["event_params"]["parent_thread_id"], - "parent-thread-guardian" + "33333333-3333-4333-8333-333333333333" ); } @@ -2642,7 +2657,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { SubAgentThreadStartedInput { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), - parent_thread_id: None, + parent_thread_id: Some(parent_thread_id.to_string()), product_client_id: "parent-client".to_string(), client_name: "parent-client".to_string(), client_version: "1.0.0".to_string(), diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 4a375b063..b7aa9f1c9 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -124,6 +124,7 @@ fn sample_thread(thread_id: &str) -> Thread { id: thread_id.to_string(), session_id: format!("session-{thread_id}"), forked_from_id: None, + parent_thread_id: None, preview: "first prompt".to_string(), ephemeral: false, model_provider: "openai".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index c88e0d93e..fd52fefc1 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1048,9 +1048,7 @@ pub(crate) fn subagent_thread_started_event_request( thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), - parent_thread_id: input - .parent_thread_id - .or_else(|| subagent_parent_thread_id(&input.subagent_source)), + parent_thread_id: input.parent_thread_id, created_at: input.created_at, }; ThreadInitializedEvent { @@ -1060,19 +1058,7 @@ pub(crate) fn subagent_thread_started_event_request( } pub(crate) fn subagent_source_name(subagent_source: &SubAgentSource) -> String { - match subagent_source { - SubAgentSource::Review => "review".to_string(), - SubAgentSource::Compact => "compact".to_string(), - SubAgentSource::ThreadSpawn { .. } => "thread_spawn".to_string(), - SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(), - SubAgentSource::Other(other) => other.clone(), - } -} - -pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option { - subagent_source - .parent_thread_id() - .map(|parent_thread_id| parent_thread_id.to_string()) + subagent_source.kind().to_string() } fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus { diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index f2f036b8f..f20639347 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -55,7 +55,6 @@ use crate::events::codex_hook_run_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::plugin_state_event_type; -use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; @@ -267,20 +266,18 @@ impl ThreadMetadataState { session_id: String, session_source: &SessionSource, thread_source: Option, + parent_thread_id: Option, initialization_mode: ThreadInitializationMode, ) -> Self { - let (subagent_source, parent_thread_id) = match session_source { - SessionSource::SubAgent(subagent_source) => ( - Some(subagent_source_name(subagent_source)), - subagent_parent_thread_id(subagent_source), - ), + let subagent_source = match session_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec | SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Internal(_) - | SessionSource::Unknown => (None, None), + | SessionSource::Unknown => None, }; Self { session_id, @@ -516,10 +513,7 @@ impl AnalyticsReducer { input: SubAgentThreadStartedInput, out: &mut Vec, ) { - let parent_thread_id = input - .parent_thread_id - .clone() - .or_else(|| subagent_parent_thread_id(&input.subagent_source)); + let parent_thread_id = input.parent_thread_id.clone(); let parent_connection_id = parent_thread_id .as_ref() .and_then(|parent_thread_id| self.threads.get(parent_thread_id)) @@ -1238,6 +1232,7 @@ impl AnalyticsReducer { let session_source: SessionSource = thread.source.into(); let session_id = thread.session_id; let thread_id = thread.id; + let parent_thread_id = thread.parent_thread_id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -1245,6 +1240,7 @@ impl AnalyticsReducer { session_id.clone(), &session_source, thread.thread_source.map(Into::into), + parent_thread_id, initialization_mode, ); self.threads.insert( diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 61768f79a..4eb5cae38 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3366,6 +3366,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 01d05e687..b97c1f8fb 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 @@ -15513,6 +15513,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 0d10bdb80..f8261c86d 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 @@ -13337,6 +13337,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 ec4613035..0d3a3fe8d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1036,6 +1036,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 5ace3f7af..a500c2158 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -851,6 +851,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 a4cc7d91f..6e08edb96 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -851,6 +851,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 234e0867f..eed031d1e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -851,6 +851,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 59ed236c4..3a9e51f2a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1036,6 +1036,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 7815f261c..9bc687fbf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -851,6 +851,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 ca52f10ca..8640c4e9e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1036,6 +1036,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 f6cb72b6b..f7830e221 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -851,6 +851,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 e4e317e60..9a634c111 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -851,6 +851,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ 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 d917094e3..5fa30b64e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -17,6 +17,10 @@ sessionId: string, * Source thread id when this thread was created by forking another thread. */ forkedFromId: string | null, +/** + * The ID of the parent thread. This will only be set if this thread is a subagent. + */ +parentThreadId: 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 71c89a185..d532952f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2328,6 +2328,7 @@ mod tests { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, + parent_thread_id: None, preview: "first prompt".to_string(), ephemeral: true, model_provider: "openai".to_string(), @@ -2370,6 +2371,7 @@ mod tests { "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", "forkedFromId": null, + "parentThreadId": null, "preview": "first prompt", "ephemeral": true, "modelProvider": "openai", 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 bb6003c7a..1e7558363 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -142,6 +142,7 @@ fn thread_resume_response_round_trips_initial_turns_page() { id: "thr_123".to_string(), session_id: "thr_123".to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), @@ -3581,6 +3582,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response"); assert_eq!(start.instruction_sources, Vec::::new()); + assert_eq!(start.thread.parent_thread_id, None); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); assert_eq!(start.active_permission_profile, None); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index f0c518adf..35b618387 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -108,6 +108,8 @@ pub struct Thread { pub session_id: String, /// Source thread id when this thread was created by forking another thread. pub forked_from_id: Option, + /// The ID of the parent thread. This will only be set if this thread is a subagent. + pub parent_thread_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 2bf6035b2..29acc5198 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/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `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. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. -- `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/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. Subagent threads also include `parentThreadId` when the immediate control/spawn parent is known. - `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. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. @@ -424,7 +424,7 @@ Later, after the idle unload timeout: ### Example: Read a thread -Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. +Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `parentThreadId`, `agentNickname`, and `agentRole` for subagent threads when available. ```json { "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b870b5b17..56754868f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2170,6 +2170,7 @@ mod tests { thread_id, rollout_path: None, forked_from_id: None, + parent_thread_id: None, preview: "fallback preview".to_string(), name: Some("Rollback thread".to_string()), model_provider: "openai".to_string(), 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 6440f0786..1e8444d1b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -4012,6 +4012,7 @@ pub(crate) fn thread_from_stored_thread( id: thread_id.clone(), session_id: thread_id, forked_from_id: thread.forked_from_id.map(|id| id.to_string()), + parent_thread_id: thread.parent_thread_id.map(|id| id.to_string()), preview: thread.preview, ephemeral: false, model_provider: if thread.model_provider.is_empty() { @@ -4220,6 +4221,7 @@ fn build_thread_from_snapshot( id: thread_id.to_string(), session_id, forked_from_id: None, + parent_thread_id: config_snapshot.parent_thread_id.map(|id| id.to_string()), preview: String::new(), ephemeral: config_snapshot.ephemeral, model_provider: config_snapshot.model_provider_id.clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 2a4b2722e..00fb3fff4 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -395,6 +395,7 @@ mod thread_processor_behavior_tests { thread_id, rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), forked_from_id: None, + parent_thread_id: None, preview: "preview".to_string(), name: None, model_provider: "openai".to_string(), @@ -681,6 +682,7 @@ mod thread_processor_behavior_tests { }, }, session_source: SessionSource::Cli, + parent_thread_id: None, thread_source: None, }; diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index b265e3a63..e970a88a9 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -172,6 +172,7 @@ mod tests { id: "thread-1".to_string(), session_id: "session-1".to_string(), forked_from_id: None, + parent_thread_id: None, preview: "preview".to_string(), ephemeral: false, model_provider: "mock_provider".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 0a6c1bfb0..da7ae5480 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -305,6 +305,7 @@ pub(crate) fn summary_to_thread( id: thread_id.clone(), session_id: thread_id, forked_from_id: None, + parent_thread_id: None, preview, ephemeral: false, model_provider, diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 7315a13c0..6d66a32d6 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -891,6 +891,7 @@ mod tests { id: thread_id.to_string(), session_id: thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::new(), ephemeral: false, model_provider: "mock-provider".to_string(), diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 6bb600bd8..46eeae7ec 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -38,6 +38,7 @@ pub use responses::create_final_assistant_message_sse_response; pub use responses::create_request_permissions_sse_response; pub use responses::create_request_user_input_sse_response; pub use responses::create_shell_command_sse_response; +pub use rollout::create_fake_parented_rollout_with_source; pub use rollout::create_fake_rollout; pub use rollout::create_fake_rollout_with_source; pub use rollout::create_fake_rollout_with_text_elements; diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 6b2a9a0ab..63681382e 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -118,6 +118,53 @@ pub fn create_fake_rollout_with_source( model_provider: Option<&str>, git_info: Option, source: SessionSource, +) -> Result { + create_fake_rollout_with_source_and_parent_thread_id( + codex_home, + filename_ts, + meta_rfc3339, + preview, + model_provider, + git_info, + source, + /*parent_thread_id*/ None, + ) +} + +/// Create a minimal rollout file with an explicit session source and control parent. +#[allow(clippy::too_many_arguments)] +pub fn create_fake_parented_rollout_with_source( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, + git_info: Option, + source: SessionSource, + parent_thread_id: ThreadId, +) -> Result { + create_fake_rollout_with_source_and_parent_thread_id( + codex_home, + filename_ts, + meta_rfc3339, + preview, + model_provider, + git_info, + source, + Some(parent_thread_id), + ) +} + +#[allow(clippy::too_many_arguments)] +fn create_fake_rollout_with_source_and_parent_thread_id( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, + git_info: Option, + source: SessionSource, + parent_thread_id: Option, ) -> Result { let uuid = Uuid::new_v4(); let uuid_str = uuid.to_string(); @@ -133,6 +180,7 @@ pub fn create_fake_rollout_with_source( let meta = SessionMeta { id: conversation_id, forked_from_id: None, + parent_thread_id, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".to_string(), @@ -217,6 +265,7 @@ pub fn create_fake_rollout_with_text_elements( let meta = SessionMeta { id: conversation_id, forked_from_id: None, + parent_thread_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".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 525385010..eb534e517 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -124,6 +124,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> .create_thread(CreateThreadParams { thread_id, forked_from_id: None, + parent_thread_id: None, source: SessionSource::Cli, thread_source: None, base_instructions: BaseInstructions::default(), diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index ab94fe618..2e6ede73d 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -1,7 +1,7 @@ use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_fake_parented_rollout_with_source; use app_test_support::create_fake_rollout; -use app_test_support::create_fake_rollout_with_source; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -9,6 +9,7 @@ use codex_app_server_protocol::ReviewDelivery; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget; +use codex_app_server_protocol::SessionSource as ApiSessionSource; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadResumeParams; @@ -198,7 +199,7 @@ async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> } #[tokio::test] -async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> { +async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> { skip_if_no_network!(Ok(())); let review_payload = serde_json::json!({ @@ -276,8 +277,9 @@ async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() - request.header("x-openai-subagent").as_deref(), Some("review") ); + assert!(metadata.get("forked_from_thread_id").is_none()); assert_eq!( - metadata["forked_from_thread_id"].as_str(), + metadata["parent_thread_id"].as_str(), Some(review_thread_id.as_str()) ); let review_request_thread_id = metadata["thread_id"] @@ -297,7 +299,7 @@ async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() - } #[tokio::test] -async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> { +async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -320,20 +322,15 @@ async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> Resu let parent_thread_id = CoreThreadId::new(); let parent_thread_id_str = parent_thread_id.to_string(); - let subagent_thread_id = create_fake_rollout_with_source( + let subagent_thread_id = create_fake_parented_rollout_with_source( codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", "Saved subagent message", Some("mock_provider"), /*git_info*/ None, - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_path: None, - agent_nickname: None, - agent_role: None, - }), + SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())), + parent_thread_id, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -352,6 +349,11 @@ async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> Resu .await??; let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; assert_eq!(thread.id, subagent_thread_id); + assert_eq!(thread.parent_thread_id, Some(parent_thread_id_str.clone())); + assert_eq!( + thread.source, + ApiSessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())) + ); let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -386,7 +388,7 @@ async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> Resu metadata["parent_thread_id"].as_str(), Some(parent_thread_id_str.as_str()) ); - assert_eq!(metadata["subagent_kind"].as_str(), Some("thread_spawn")); + assert_eq!(metadata["subagent_kind"].as_str(), Some("guardian")); assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str())); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("forked_from_thread_id").is_none()); diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 880809236..77e2e281f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1,5 +1,6 @@ use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_fake_parented_rollout_with_source; use app_test_support::create_fake_rollout; use app_test_support::create_fake_rollout_with_source; use app_test_support::create_final_assistant_message_sse_response; @@ -1049,7 +1050,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; - let review_id = create_fake_rollout_with_source( + let review_id = create_fake_parented_rollout_with_source( codex_home.path(), "2025-02-02T09-00-00", "2025-02-02T09:00:00Z", @@ -1057,6 +1058,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { Some("mock_provider"), /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::Review), + parent_thread_id, )?; let compact_id = create_fake_rollout_with_source( codex_home.path(), @@ -1109,6 +1111,10 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { .map(|thread| thread.id.as_str()) .collect(); assert_eq!(review_ids, vec![review_id.as_str()]); + assert_eq!( + review.data[0].parent_thread_id, + Some(parent_thread_id.to_string()) + ); let compact = list_threads( &mut mcp, 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 ee430f159..df79aea9b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1358,6 +1358,7 @@ async fn seed_pathless_store_thread( .create_thread(CreateThreadParams { thread_id, forked_from_id: None, + parent_thread_id: None, source: ProtocolSessionSource::Cli, thread_source: None, base_instructions: BaseInstructions::default(), 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 8b302954e..e0263d77f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1791,6 +1791,7 @@ stream_max_retries = 0 let session_meta = SessionMeta { id: conversation_id, forked_from_id: None, + parent_thread_id: None, timestamp: "2025-01-05T12:00:00Z".to_string(), cwd: repo_path.clone(), originator: "codex".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index dea8433d4..46ce38f48 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -211,6 +211,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { .create_thread(CreateThreadParams { thread_id, forked_from_id: Some(parent_thread_id), + parent_thread_id: None, source: SessionSource::Cli, thread_source: None, base_instructions: BaseInstructions::default(), diff --git a/codex-rs/core/src/agent/agent_resolver.rs b/codex-rs/core/src/agent/agent_resolver.rs index eb806da3a..115739c2f 100644 --- a/codex-rs/core/src/agent/agent_resolver.rs +++ b/codex-rs/core/src/agent/agent_resolver.rs @@ -32,5 +32,5 @@ fn register_session_root(session: &Arc, turn: &Arc) { session .services .agent_control - .register_session_root(session.conversation_id, &turn.session_source); + .register_session_root(session.conversation_id, turn.parent_thread_id); } diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 12fe4f6be..67b5668b1 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -54,6 +54,7 @@ pub(crate) enum SpawnAgentForkMode { pub(crate) struct SpawnAgentOptions { pub(crate) fork_parent_spawn_call_id: Option, pub(crate) fork_mode: Option, + pub(crate) parent_thread_id: Option, pub(crate) environments: Option>, } @@ -262,12 +263,12 @@ impl AgentControl { .await? } (Some(session_source), None) => { - let forked_from_thread_id = thread_spawn_parent_thread_id(&session_source); Box::pin(state.spawn_new_thread_with_source( config.clone(), self.clone(), session_source, - forked_from_thread_id, + options.parent_thread_id, + /*forked_from_thread_id*/ None, /*thread_source*/ Some(ThreadSource::Subagent), /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -309,6 +310,7 @@ impl AgentControl { } }; let thread_config = new_thread.thread.codex.thread_config_snapshot().await; + let parent_thread_id = thread_config.parent_thread_id; emit_subagent_session_started( &new_thread .thread @@ -319,7 +321,7 @@ impl AgentControl { client_metadata, new_thread.thread.codex.session.session_id(), new_thread.thread_id, - /*parent_thread_id*/ None, + parent_thread_id, thread_config, subagent_source.clone(), ); @@ -490,6 +492,7 @@ impl AgentControl { self.clone(), session_source, /*thread_source*/ Some(ThreadSource::Subagent), + /*parent_thread_id*/ Some(parent_thread_id), /*forked_from_thread_id*/ Some(parent_thread_id), /*persist_extended_history*/ false, inherited_shell_snapshot, @@ -638,6 +641,7 @@ impl AgentControl { .history .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? .items; + let parent_thread_id = stored_thread.parent_thread_id; let resumed_thread = state .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { @@ -649,6 +653,7 @@ impl AgentControl { }), agent_control: self.clone(), session_source, + parent_thread_id, inherited_shell_snapshot, inherited_exec_policy, }) @@ -841,9 +846,9 @@ impl AgentControl { pub(crate) fn register_session_root( &self, current_thread_id: ThreadId, - current_session_source: &SessionSource, + current_parent_thread_id: Option, ) { - if thread_spawn_parent_thread_id(current_session_source).is_none() { + if current_parent_thread_id.is_none() { self.state.register_root_thread(current_thread_id); } } @@ -1218,7 +1223,8 @@ impl AgentControl { child_thread_id: ThreadId, session_source: Option<&SessionSource>, ) { - let Some(parent_thread_id) = session_source.and_then(thread_spawn_parent_thread_id) else { + let Some(parent_thread_id) = session_source.and_then(SessionSource::parent_thread_id) + else { return; }; let Some(state_db_ctx) = thread.state_db() else { @@ -1263,10 +1269,6 @@ impl AgentControl { } } -fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option { - session_source.parent_thread_id() -} - fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool { if prefix.is_root() { return true; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 43a6e47bf..c73c80eb1 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -74,6 +74,15 @@ fn assistant_message(text: &str, phase: Option) -> ResponseItem { } } +#[test] +fn register_session_root_skips_threads_with_explicit_parent() { + let control = AgentControl::default(); + + control.register_session_root(ThreadId::new(), Some(ThreadId::new())); + + assert_eq!(control.state.agent_id_for_path(&AgentPath::root()), None); +} + fn spawn_agent_call(call_id: &str) -> ResponseItem { ResponseItem::FunctionCall { id: None, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 620645d8f..124dfb18c 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -173,6 +173,7 @@ struct ModelClientState { provider: SharedModelProvider, auth_env_telemetry: AuthEnvTelemetry, session_source: SessionSource, + parent_thread_id: Option, model_verbosity: Option, enable_request_compression: bool, include_timing_metrics: bool, @@ -321,6 +322,7 @@ impl ModelClient { installation_id: String, provider_info: ModelProviderInfo, session_source: SessionSource, + parent_thread_id: Option, model_verbosity: Option, enable_request_compression: bool, include_timing_metrics: bool, @@ -344,6 +346,7 @@ impl ModelClient { provider: model_provider, auth_env_telemetry, session_source, + parent_thread_id, model_verbosity, enable_request_compression, include_timing_metrics, @@ -637,7 +640,7 @@ impl ModelClient { fn build_responses_identity_headers(&self) -> ApiHeaderMap { let mut extra_headers = self.build_subagent_headers(); - if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) + if let Some(parent_thread_id) = parent_thread_id_header_value(self.state.parent_thread_id) && let Ok(val) = HeaderValue::from_str(&parent_thread_id) { extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val); @@ -664,7 +667,7 @@ impl ModelClient { if let Some(subagent) = subagent_header_value(&self.state.session_source) { client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent); } - if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) { + if let Some(parent_thread_id) = parent_thread_id_header_value(self.state.parent_thread_id) { client_metadata.insert( X_CODEX_PARENT_THREAD_ID_HEADER.to_string(), parent_thread_id, @@ -1733,10 +1736,8 @@ fn subagent_header_value(session_source: &SessionSource) -> Option { } } -fn parent_thread_id_header_value(session_source: &SessionSource) -> Option { - session_source - .parent_thread_id() - .map(|parent_thread_id| parent_thread_id.to_string()) +fn parent_thread_id_header_value(parent_thread_id: Option) -> Option { + parent_thread_id.map(|parent_thread_id| parent_thread_id.to_string()) } const RESPONSE_STREAM_CHANNEL_CAPACITY: usize = 1600; diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index b9d9172c8..f8d904036 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -61,6 +61,13 @@ use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::util::SubscriberInitExt; fn test_model_client(session_source: SessionSource) -> ModelClient { + test_model_client_with_parent(session_source, /*parent_thread_id*/ None) +} + +fn test_model_client_with_parent( + session_source: SessionSource, + parent_thread_id: Option, +) -> ModelClient { let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); let thread_id = ThreadId::new(); ModelClient::new( @@ -70,6 +77,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, session_source, + parent_thread_id, /*model_verbosity*/ None, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, @@ -272,13 +280,16 @@ fn build_subagent_headers_sets_internal_memory_consolidation_label() { #[test] fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { let parent_thread_id = ThreadId::new(); - let client = test_model_client(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 2, - agent_path: None, - agent_nickname: None, - agent_role: None, - })); + let client = test_model_client_with_parent( + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + Some(parent_thread_id), + ); client.advance_window_generation(); @@ -520,6 +531,7 @@ fn model_client_with_counting_attestation( /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, SessionSource::Exec, + /*parent_thread_id*/ None, /*model_verbosity*/ None, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 3c55f6376..44a8c0ac6 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -74,6 +74,8 @@ pub(crate) async fn run_codex_thread_interactive( ) -> Result { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + let conversation_history = initial_history.unwrap_or(InitialHistory::New); + let forked_from_thread_id = conversation_history.forked_from_id(); let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, installation_id: parent_session.installation_id.clone(), @@ -84,9 +86,10 @@ pub(crate) async fn run_codex_thread_interactive( plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), extensions: Arc::clone(&parent_session.services.extensions), - conversation_history: initial_history.unwrap_or(InitialHistory::New), + conversation_history, session_source: SessionSource::SubAgent(subagent_source.clone()), - forked_from_thread_id: Some(parent_session.conversation_id), + forked_from_thread_id, + parent_thread_id: Some(parent_session.conversation_id), thread_source: Some(ThreadSource::Subagent), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index dec1ce689..729c75ebc 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -7,6 +7,7 @@ use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; use codex_features::Feature; use codex_otel::SessionTelemetry; +use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; @@ -68,6 +69,7 @@ pub struct ThreadConfigSnapshot { pub personality: Option, pub collaboration_mode: CollaborationMode, pub session_source: SessionSource, + pub parent_thread_id: Option, pub thread_source: Option, } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index db4343448..8f53e8f8f 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -156,8 +156,8 @@ pub(crate) fn is_guardian_reviewer_source( ) -> bool { matches!( session_source, - codex_protocol::protocol::SessionSource::SubAgent(SubAgentSource::Other(name)) - if name == GUARDIAN_REVIEWER_NAME + codex_protocol::protocol::SessionSource::SubAgent(SubAgentSource::Other(label)) + if label == GUARDIAN_REVIEWER_NAME ) } diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index ba9a037d7..ad42a41ba 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -45,6 +45,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R meta: SessionMeta { id: thread_id, forked_from_id: None, + parent_thread_id: None, timestamp: TEST_TIMESTAMP.to_string(), cwd: std::path::PathBuf::from("."), originator: "test_originator".to_string(), diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index d60484d2e..8e7459989 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -32,6 +32,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre thread_id: ThreadId::new(), rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")), forked_from_id: None, + parent_thread_id: None, preview: first_user_message.to_string(), name: (!title.is_empty()).then(|| title.to_string()), model_provider: "test-provider".to_string(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8c535cd72..1908c46bf 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -402,6 +402,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) conversation_history: InitialHistory, pub(crate) session_source: SessionSource, pub(crate) forked_from_thread_id: Option, + pub(crate) parent_thread_id: Option, pub(crate) thread_source: Option, pub(crate) agent_control: AgentControl, pub(crate) dynamic_tools: Vec, @@ -467,6 +468,7 @@ impl Codex { conversation_history, session_source, forked_from_thread_id, + parent_thread_id, thread_source, agent_control, dynamic_tools, @@ -595,6 +597,7 @@ impl Codex { app_server_client_version: None, session_source, forked_from_thread_id, + parent_thread_id, thread_source, dynamic_tools, persist_extended_history, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 22f00d743..18cacb4d0 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -90,6 +90,7 @@ pub(super) async fn spawn_review_thread( sess.session_id().to_string(), sess.thread_id().to_string(), forked_from_thread_id, + parent_turn_context.parent_thread_id, &session_source, parent_turn_context.thread_source, review_turn_id.clone(), @@ -113,6 +114,7 @@ pub(super) async fn spawn_review_thread( reasoning_effort, reasoning_summary, session_source, + parent_thread_id: parent_turn_context.parent_thread_id, thread_source: parent_turn_context.thread_source, environments: parent_turn_context.environments.clone(), available_models, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c9e1807fe..995f07d70 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -97,6 +97,8 @@ pub(crate) struct SessionConfiguration { pub(super) session_source: SessionSource, /// Immediate history source copied into this thread, when this thread was forked. pub(super) forked_from_thread_id: Option, + /// Immediate control/spawn parent for this thread, when it has one. + pub(super) parent_thread_id: Option, /// Optional analytics source classification for this thread. pub(super) thread_source: Option, pub(super) dynamic_tools: Vec, @@ -187,6 +189,7 @@ impl SessionConfiguration { personality: self.personality, collaboration_mode: self.collaboration_mode.clone(), session_source: self.session_source.clone(), + parent_thread_id: self.parent_thread_id, thread_source: self.thread_source, } } @@ -511,6 +514,10 @@ impl Session { .forked_from_thread_id .or_else(|| initial_history.forked_from_id()); session_configuration.forked_from_thread_id = forked_from_id; + let parent_thread_id = session_configuration + .parent_thread_id + .or_else(|| initial_history.get_resumed_parent_thread_id()); + session_configuration.parent_thread_id = parent_thread_id; let event_persistence_mode = if session_configuration.persist_extended_history { ThreadEventPersistenceMode::Extended @@ -550,6 +557,7 @@ impl Session { CreateThreadParams { thread_id, forked_from_id, + parent_thread_id, source: session_source, thread_source: session_configuration.thread_source, base_instructions: BaseInstructions { @@ -1031,6 +1039,7 @@ impl Session { installation_id.clone(), session_configuration.provider.clone(), session_configuration.session_source.clone(), + session_configuration.parent_thread_id, config.model_verbosity, config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), @@ -1040,7 +1049,7 @@ impl Session { .with_prompt_cache_key_override( crate::guardian::prompt_cache_key_override_for_review_session( &session_configuration.session_source, - session_configuration.forked_from_thread_id, + session_configuration.parent_thread_id, ), ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), @@ -1083,6 +1092,7 @@ impl Session { session_id, thread_id, forked_from_id, + parent_thread_id, thread_source: session_configuration.thread_source, thread_name: session_configuration.thread_name.clone(), model: session_configuration.collaboration_mode.model().to_string(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index aa2586cae..d03177fd6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -433,6 +433,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None), codex_protocol::protocol::SessionSource::Exec, + /*parent_thread_id*/ None, /*model_verbosity*/ None, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, @@ -3096,6 +3097,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_version: None, session_source: SessionSource::Exec, forked_from_thread_id: None, + parent_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -3201,6 +3203,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_version: None, session_source: SessionSource::Exec, forked_from_thread_id: None, + parent_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -3449,6 +3452,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { CreateThreadParams { thread_id: session.conversation_id, forked_from_id: None, + parent_thread_id: None, source: SessionSource::Exec, thread_source: None, base_instructions: BaseInstructions::default(), @@ -3729,6 +3733,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_version: None, session_source: SessionSource::Exec, forked_from_thread_id: None, + parent_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -4473,6 +4478,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { app_server_client_version: None, session_source: SessionSource::Exec, forked_from_thread_id: None, + parent_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -4583,6 +4589,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_version: None, session_source: SessionSource::Exec, forked_from_thread_id: None, + parent_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -4677,6 +4684,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), + session_configuration.parent_thread_id, config.model_verbosity, config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), @@ -4818,6 +4826,7 @@ async fn make_session_with_config_and_rx( app_server_client_version: None, session_source: SessionSource::Exec, forked_from_thread_id: None, + parent_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -4922,6 +4931,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( app_server_client_version: None, session_source: session_source.clone(), forked_from_thread_id: None, + parent_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -5944,6 +5954,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { CreateThreadParams { thread_id: session.conversation_id, forked_from_id: None, + parent_thread_id: None, source: SessionSource::Exec, thread_source: None, base_instructions: BaseInstructions::default(), @@ -6426,6 +6437,7 @@ where app_server_client_version: None, session_source: SessionSource::Exec, forked_from_thread_id: None, + parent_thread_id: None, thread_source: None, dynamic_tools, persist_extended_history: false, @@ -6520,6 +6532,7 @@ where /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), + session_configuration.parent_thread_id, config.model_verbosity, config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index dabf9c360..a0625d65e 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -693,6 +693,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { GUARDIAN_REVIEWER_NAME.to_string(), )), forked_from_thread_id: None, + parent_thread_id: None, thread_source: None, agent_control: AgentControl::default(), dynamic_tools: Vec::new(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 4d1c27e49..3bdd2b768 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -5,6 +5,7 @@ use crate::environment_selection::ResolvedTurnEnvironments; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; use codex_protocol::SessionId; +use codex_protocol::ThreadId; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::ThreadSource; @@ -62,6 +63,7 @@ pub struct TurnContext { pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, + pub(crate) parent_thread_id: Option, pub(crate) thread_source: Option, pub(crate) environments: ResolvedTurnEnvironments, /// The session's absolute working directory. All relative paths provided @@ -232,6 +234,7 @@ impl TurnContext { reasoning_effort, reasoning_summary: self.reasoning_summary, session_source: self.session_source.clone(), + parent_thread_id: self.parent_thread_id, thread_source: self.thread_source, environments: self.environments.clone(), #[allow(deprecated)] @@ -506,6 +509,7 @@ impl Session { session_id.to_string(), thread_id.to_string(), session_configuration.forked_from_thread_id, + session_configuration.parent_thread_id, &session_configuration.session_source, session_configuration.thread_source, sub_id.clone(), @@ -529,6 +533,7 @@ impl Session { reasoning_effort, reasoning_summary, session_source, + parent_thread_id: session_configuration.parent_thread_id, thread_source: session_configuration.thread_source, environments, #[allow(deprecated)] diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 0a3a08941..98a3d01a7 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -187,6 +187,7 @@ pub(crate) struct ResumeThreadWithHistoryOptions { pub(crate) initial_history: InitialHistory, pub(crate) agent_control: AgentControl, pub(crate) session_source: SessionSource, + pub(crate) parent_thread_id: Option, pub(crate) inherited_shell_snapshot: Option>, pub(crate) inherited_exec_policy: Option>, } @@ -601,6 +602,7 @@ impl ThreadManager { Arc::clone(&self.state.auth_manager), self.agent_control(), session_source, + /*parent_thread_id*/ None, forked_from_thread_id, thread_source, options.dynamic_tools, @@ -685,6 +687,7 @@ impl ThreadManager { auth_manager, self.agent_control(), session_source, + /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, thread_source, Vec::new(), @@ -713,6 +716,7 @@ impl ThreadManager { InitialHistory::New, Arc::clone(&self.state.auth_manager), self.agent_control(), + /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, /*thread_source*/ None, Vec::new(), @@ -746,6 +750,7 @@ impl ThreadManager { auth_manager, self.agent_control(), session_source, + /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, thread_source, Vec::new(), @@ -916,6 +921,7 @@ impl ThreadManager { history, Arc::clone(&self.state.auth_manager), self.agent_control(), + /*parent_thread_id*/ None, forked_from_thread_id, thread_source, Vec::new(), @@ -1039,6 +1045,7 @@ impl ThreadManagerState { config, agent_control, self.session_source.clone(), + /*parent_thread_id*/ None, /*forked_from_thread_id*/ None, /*thread_source*/ None, /*persist_extended_history*/ false, @@ -1056,6 +1063,7 @@ impl ThreadManagerState { config: Config, agent_control: AgentControl, session_source: SessionSource, + parent_thread_id: Option, forked_from_thread_id: Option, thread_source: Option, persist_extended_history: bool, @@ -1073,6 +1081,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + parent_thread_id, forked_from_thread_id, thread_source, Vec::new(), @@ -1096,6 +1105,7 @@ impl ThreadManagerState { initial_history, agent_control, session_source, + parent_thread_id, inherited_shell_snapshot, inherited_exec_policy, } = options; @@ -1108,6 +1118,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + parent_thread_id, /*forked_from_thread_id*/ None, thread_source, Vec::new(), @@ -1130,6 +1141,7 @@ impl ThreadManagerState { agent_control: AgentControl, session_source: SessionSource, thread_source: Option, + parent_thread_id: Option, forked_from_thread_id: Option, persist_extended_history: bool, inherited_shell_snapshot: Option>, @@ -1145,6 +1157,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + parent_thread_id, forked_from_thread_id, thread_source, Vec::new(), @@ -1167,6 +1180,7 @@ impl ThreadManagerState { initial_history: InitialHistory, auth_manager: Arc, agent_control: AgentControl, + parent_thread_id: Option, forked_from_thread_id: Option, thread_source: Option, dynamic_tools: Vec, @@ -1182,6 +1196,7 @@ impl ThreadManagerState { auth_manager, agent_control, self.session_source.clone(), + parent_thread_id, forked_from_thread_id, thread_source, dynamic_tools, @@ -1204,6 +1219,7 @@ impl ThreadManagerState { auth_manager: Arc, agent_control: AgentControl, session_source: SessionSource, + parent_thread_id: Option, forked_from_thread_id: Option, thread_source: Option, dynamic_tools: Vec, @@ -1258,6 +1274,7 @@ impl ThreadManagerState { conversation_history: initial_history, session_source, forked_from_thread_id, + parent_thread_id, thread_source, agent_control, dynamic_tools, diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index 4c3cd34c2..821b3619c 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -211,6 +211,7 @@ async fn run_agent_job_loop( "agent_job:{job_id}" )))), SpawnAgentOptions { + parent_thread_id: Some(session.conversation_id), environments: Some(turn.environments.to_selections()), ..Default::default() }, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index a6461b358..c4120dd5a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -124,6 +124,7 @@ async fn handle_spawn_agent( SpawnAgentOptions { fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), + parent_thread_id: Some(session.conversation_id), environments: Some(turn.environments.to_selections()), }, )) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index dfd5483a9..6abf9a30c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -30,7 +30,7 @@ impl ToolExecutor for Handler { session .services .agent_control - .register_session_root(session.conversation_id, &turn.session_source); + .register_session_root(session.conversation_id, turn.parent_thread_id); let agents = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 9a4e39195..f767f88d7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -144,6 +144,7 @@ async fn handle_spawn_agent( SpawnAgentOptions { fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), fork_mode, + parent_thread_id: Some(session.conversation_id), environments: Some(turn.environments.to_selections()), }, ), diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index af4d23a7c..7296d6d3f 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -261,6 +261,7 @@ impl TurnMetadataState { session_id: String, thread_id: String, forked_from_thread_id: Option, + parent_thread_id: Option, session_source: &SessionSource, thread_source: Option, turn_id: String, @@ -278,18 +279,15 @@ impl TurnMetadataState { ) .to_string(), ); - let (parent_thread_id, subagent_kind) = match session_source { - SessionSource::SubAgent(subagent_source) => ( - subagent_source.parent_thread_id().or(forked_from_thread_id), - Some(subagent_source.kind().to_string()), - ), + let subagent_kind = match session_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source.kind().to_string()), SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec | SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Internal(_) - | SessionSource::Unknown => (None, None), + | SessionSource::Unknown => None, }; let base_metadata = TurnMetadataBag { request_kind: None, diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index f6338f1c6..ffbeb83d2 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -122,6 +122,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + /*parent_thread_id*/ None, &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), @@ -163,6 +164,7 @@ fn turn_metadata_state_uses_explicit_subagent_thread_source() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + /*parent_thread_id*/ None, &SessionSource::Exec, Some(ThreadSource::Subagent), "turn-a".to_string(), @@ -191,6 +193,7 @@ fn turn_metadata_state_includes_root_fork_lineage() { "session-a".to_string(), "thread-a".to_string(), Some(source_thread_id), + /*parent_thread_id*/ None, &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), @@ -223,6 +226,7 @@ fn turn_metadata_state_includes_thread_spawn_subagent_parent_without_fork() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + Some(parent_thread_id), &SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, @@ -261,6 +265,7 @@ fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { "session-a".to_string(), "thread-a".to_string(), Some(parent_thread_id), + Some(parent_thread_id), &SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, @@ -291,38 +296,46 @@ fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { } #[test] -fn turn_metadata_state_includes_known_parent_for_other_subagent() { +fn turn_metadata_state_includes_known_parent_for_non_thread_spawn_subagents_without_fork() { let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); let permission_profile = PermissionProfile::read_only(); let parent_thread_id = ThreadId::from_string("44444444-4444-4444-8444-444444444444").expect("thread id"); + let sources = [ + (SubAgentSource::Review, "review"), + (SubAgentSource::Other("guardian".to_string()), "guardian"), + ( + SubAgentSource::Other("agent_job:job-1".to_string()), + "agent_job:job-1", + ), + ]; - let state = TurnMetadataState::new( - "session-a".to_string(), - "thread-a".to_string(), - Some(parent_thread_id), - &SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())), - Some(ThreadSource::Subagent), - "turn-a".to_string(), - cwd, - &permission_profile, - WindowsSandboxLevel::Disabled, - /*enforce_managed_network*/ false, - ); + for (subagent_source, subagent_kind) in sources { + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + /*forked_from_thread_id*/ None, + Some(parent_thread_id), + &SessionSource::SubAgent(subagent_source), + Some(ThreadSource::Subagent), + "turn-a".to_string(), + cwd.clone(), + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); - let header = state.current_header_value().expect("header"); - let json: Value = serde_json::from_str(&header).expect("json"); + let header = state.current_header_value().expect("header"); + let json: Value = serde_json::from_str(&header).expect("json"); - assert_eq!( - json["forked_from_thread_id"].as_str(), - Some("44444444-4444-4444-8444-444444444444") - ); - assert_eq!( - json["parent_thread_id"].as_str(), - Some("44444444-4444-4444-8444-444444444444") - ); - assert_eq!(json["subagent_kind"].as_str(), Some("guardian")); + assert!(json.get("forked_from_thread_id").is_none()); + assert_eq!( + json["parent_thread_id"].as_str(), + Some("44444444-4444-4444-8444-444444444444") + ); + assert_eq!(json["subagent_kind"].as_str(), Some(subagent_kind)); + } } #[test] @@ -335,6 +348,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + /*parent_thread_id*/ None, &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), @@ -364,6 +378,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + /*parent_thread_id*/ None, &SessionSource::Exec, /*thread_source*/ None, "turn-a".to_string(), @@ -412,6 +427,7 @@ fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_reque "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + /*parent_thread_id*/ None, &SessionSource::Exec, /*thread_source*/ None, "turn-a".to_string(), @@ -464,6 +480,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + /*parent_thread_id*/ None, &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), @@ -511,6 +528,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( "session-a".to_string(), "thread-a".to_string(), Some(source_thread_id), + Some(parent_thread_id), &SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, @@ -612,6 +630,7 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + /*parent_thread_id*/ None, &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), @@ -671,6 +690,7 @@ async fn turn_metadata_state_preserves_lineage_after_git_enrichment() { "session-a".to_string(), "thread-a".to_string(), Some(parent_thread_id), + Some(parent_thread_id), &SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, depth: 1, diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 6f0429e64..eda48a2c0 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -105,6 +105,7 @@ async fn responses_stream_includes_subagent_header_on_review() { /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, + /*parent_thread_id*/ None, config.model_verbosity, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, @@ -233,6 +234,7 @@ async fn responses_stream_includes_subagent_header_on_other() { /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, + /*parent_thread_id*/ None, config.model_verbosity, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, @@ -350,6 +352,7 @@ async fn responses_respects_model_info_overrides_from_config() { /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, + /*parent_thread_id*/ None, config.model_verbosity, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 7234c59c0..48ba697aa 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -488,6 +488,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { id: ThreadId::default(), + parent_thread_id: None, timestamp: "2024-01-01T00:00:00Z".to_string(), cwd: ".".into(), originator: "test_originator".to_string(), @@ -618,6 +619,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { id: ThreadId::default(), + parent_thread_id: None, timestamp: "2024-01-01T00:00:00Z".to_string(), cwd: ".".into(), originator: "test_originator".to_string(), @@ -902,6 +904,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, SessionSource::Exec, + /*parent_thread_id*/ None, config.model_verbosity, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, @@ -2358,6 +2361,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider.clone(), SessionSource::Exec, + /*parent_thread_id*/ None, config.model_verbosity, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 09d0093f9..d9e94204f 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -2155,6 +2155,7 @@ async fn websocket_harness_with_provider_options( /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), SessionSource::Exec, + /*parent_thread_id*/ None, config.model_verbosity, /*enable_request_compression*/ false, runtime_metrics_enabled, diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 9cee89d76..84a2e1882 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -61,6 +61,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R meta: SessionMeta { id: thread_id, forked_from_id: None, + parent_thread_id: None, timestamp: TEST_TIMESTAMP.to_string(), cwd: std::path::PathBuf::from("."), originator: "test_originator".to_string(), @@ -109,6 +110,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re meta: SessionMeta { id: thread_id, forked_from_id: None, + parent_thread_id: None, timestamp: TEST_TIMESTAMP.to_string(), cwd: std::path::PathBuf::from("."), originator: "test_originator".to_string(), diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 0c401d05f..144feb016 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -130,8 +130,9 @@ async fn responses_api_parent_and_subagent_requests_include_identity_headers() - .header("x-codex-turn-metadata") .ok_or_else(|| anyhow!("child request missing x-codex-turn-metadata"))?, )?; + assert!(child_turn_metadata.get("forked_from_thread_id").is_none()); assert_eq!( - child_turn_metadata["forked_from_thread_id"].as_str(), + child_turn_metadata["parent_thread_id"].as_str(), Some(parent_thread_id) ); diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 9efaf6c2d..0d3f56489 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -136,8 +136,9 @@ async fn review_op_emits_lifecycle_and_review_output() { .expect("review request turn metadata"), ) .expect("review request turn metadata json"); + assert!(turn_metadata.get("forked_from_thread_id").is_none()); assert_eq!( - turn_metadata["forked_from_thread_id"].as_str(), + turn_metadata["parent_thread_id"].as_str(), Some(parent_thread_id.as_str()) ); diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 48a648d1b..4ff35449c 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -184,6 +184,7 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()> RolloutRecorderParams::new( thread_id, /*forked_from_id*/ None, + /*parent_thread_id*/ None, SessionSource::Exec, /*thread_source*/ None, BaseInstructions::default(), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 4d9c89638..aecb9a392 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -212,6 +212,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { meta: SessionMeta { id: thread_id, forked_from_id: None, + parent_thread_id: None, timestamp: "2026-01-27T12:00:00Z".to_string(), cwd: codex_home.to_path_buf(), originator: "test".to_string(), diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 1e900f2b6..5e89d48c7 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -211,6 +211,7 @@ async fn config_summary_entries_include_runtime_workspace_roots() { session_id: SessionId::new(), thread_id: ThreadId::new(), forked_from_id: None, + parent_thread_id: None, thread_source: None, thread_name: None, model: "gpt-5.4".to_string(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8d632396b..69ad57466 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1070,6 +1070,7 @@ fn session_configured_from_thread_start_response( session_configured_from_thread_response( &response.thread.session_id, &response.thread.id, + response.thread.parent_thread_id.as_deref(), response.thread.thread_source.map(Into::into), response.thread.name.clone(), response.thread.path.clone(), @@ -1092,6 +1093,7 @@ fn session_configured_from_thread_resume_response( session_configured_from_thread_response( &response.thread.session_id, &response.thread.id, + response.thread.parent_thread_id.as_deref(), response.thread.thread_source.map(Into::into), response.thread.name.clone(), response.thread.path.clone(), @@ -1123,6 +1125,7 @@ fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { fn session_configured_from_thread_response( session_id: &str, thread_id: &str, + parent_thread_id: Option<&str>, thread_source: Option, thread_name: Option, rollout_path: Option, @@ -1140,11 +1143,16 @@ fn session_configured_from_thread_response( .map_err(|err| format!("session id `{session_id}` is invalid: {err}"))?; let thread_id = ThreadId::from_string(thread_id) .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; + let parent_thread_id = parent_thread_id + .map(ThreadId::from_string) + .transpose() + .map_err(|err| format!("parent thread id is invalid: {err}"))?; Ok(SessionConfiguredEvent { session_id, thread_id, forked_from_id: None, + parent_thread_id, thread_source, thread_name, model, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 79d07a0b7..8662c2cfe 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -307,6 +307,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { id: "thread-1".to_string(), session_id: "thread-1".to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), @@ -585,12 +586,33 @@ async fn session_configured_from_thread_response_preserves_thread_source() { ); } +#[tokio::test] +async fn session_configured_from_thread_response_preserves_parent_thread_id() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build config"); + let parent_thread_id = ThreadId::new(); + let mut response = sample_thread_start_response(); + response.thread.parent_thread_id = Some(parent_thread_id.to_string()); + + let event = session_configured_from_thread_start_response(&response, &config) + .expect("build bootstrap session configured event"); + + assert_eq!(event.parent_thread_id, Some(parent_thread_id)); +} + fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index ff97b6832..2cee9b0e8 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -111,6 +111,7 @@ fn session_configured_produces_thread_started_event() { session_id: SessionId::from(thread_id), thread_id, forked_from_id: None, + parent_thread_id: None, thread_source: None, thread_name: None, model: "codex-mini-latest".to_string(), diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index e6ecdd441..3790fed60 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -299,6 +299,7 @@ mod tests { session_id: codex_protocol::SessionId::new(), thread_id, forked_from_id: None, + parent_thread_id: None, thread_source: None, thread_name: None, model: "gpt-4o".to_string(), @@ -344,6 +345,7 @@ mod tests { session_id: codex_protocol::SessionId::new(), thread_id, forked_from_id: None, + parent_thread_id: None, thread_source: None, thread_name: None, model: "gpt-4o".to_string(), @@ -411,6 +413,7 @@ mod tests { session_id: codex_protocol::SessionId::new(), thread_id, forked_from_id: None, + parent_thread_id: None, thread_source: None, thread_name: None, model: "gpt-4o".to_string(), diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 2aeda32a5..c568dd5a2 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -171,7 +171,8 @@ impl MemoryStartupContext { context: &StageOneRequestContext, ) -> anyhow::Result<(String, Option)> { let installation_id = resolve_installation_id(&config.codex_home).await?; - let session_source = self.thread.config_snapshot().await.session_source; + let config_snapshot = self.thread.config_snapshot().await; + let session_source = config_snapshot.session_source; let model_client = ModelClient::new( Some(Arc::clone(&self.auth_manager)), SessionId::from(self.thread_id), // We use thread_id to detach this query from the foreground user session. @@ -179,6 +180,7 @@ impl MemoryStartupContext { installation_id, config.model_provider.clone(), session_source, + config_snapshot.parent_thread_id, config.model_verbosity, config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 9cbd37706..7c91223f7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2451,6 +2451,11 @@ impl InitialHistory { .and_then(|meta| meta.thread_source) } + pub fn get_resumed_parent_thread_id(&self) -> Option { + self.get_resumed_session_meta() + .and_then(|meta| meta.parent_thread_id) + } + fn get_resumed_session_meta(&self) -> Option<&SessionMeta> { match self { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, @@ -2716,6 +2721,8 @@ pub struct SessionMeta { pub id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_thread_id: Option, pub timestamp: String, pub cwd: PathBuf, pub originator: String, @@ -2750,6 +2757,7 @@ impl Default for SessionMeta { SessionMeta { id: ThreadId::default(), forked_from_id: None, + parent_thread_id: None, timestamp: String::new(), cwd: PathBuf::new(), originator: String::new(), @@ -3421,6 +3429,8 @@ pub struct SessionConfiguredEvent { pub thread_id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_thread_id: Option, /// Optional analytics source classification for this thread. #[serde(default, skip_serializing_if = "Option::is_none")] pub thread_source: Option, @@ -3490,6 +3500,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { #[serde(default)] thread_id: Option, forked_from_id: Option, + parent_thread_id: Option, #[serde(default)] thread_source: Option, #[serde(default)] @@ -3530,6 +3541,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { session_id: wire.session_id, thread_id: wire.thread_id.unwrap_or_else(|| wire.session_id.into()), forked_from_id: wire.forked_from_id, + parent_thread_id: wire.parent_thread_id, thread_source: wire.thread_source, thread_name: wire.thread_name, model: wire.model, @@ -5329,6 +5341,7 @@ mod tests { session_id, thread_id, forked_from_id: None, + parent_thread_id: None, thread_source: None, thread_name: None, model: "codex-mini-latest".to_string(), diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs index d4f067789..8c44c6703 100644 --- a/codex-rs/rollout/src/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -62,6 +62,8 @@ pub struct ThreadItem { pub git_origin_url: Option, /// Session source from session metadata. pub source: Option, + /// Immediate control/spawn parent thread id from session metadata. + pub parent_thread_id: Option, /// Random unique nickname from session metadata for AgentControl-spawned sub-agents. pub agent_nickname: Option, /// Role (agent_role) from session metadata for AgentControl-spawned sub-agents. @@ -95,6 +97,7 @@ struct HeadTailSummary { git_sha: Option, git_origin_url: Option, source: Option, + parent_thread_id: Option, agent_nickname: Option, agent_role: Option, model_provider: Option, @@ -778,6 +781,7 @@ async fn build_thread_item( git_sha, git_origin_url, source, + parent_thread_id, agent_nickname, agent_role, model_provider, @@ -799,6 +803,7 @@ async fn build_thread_item( git_sha, git_origin_url, source, + parent_thread_id, agent_nickname, agent_role, model_provider, @@ -1101,6 +1106,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { if !summary.saw_session_meta { summary.source = Some(session_meta_line.meta.source.clone()); + summary.parent_thread_id = session_meta_line.meta.parent_thread_id; summary.agent_nickname = session_meta_line.meta.agent_nickname.clone(); summary.agent_role = session_meta_line.meta.agent_role.clone(); summary.model_provider = session_meta_line.meta.model_provider.clone(); diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 45db758c6..7ae384308 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -35,6 +35,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { let session_meta = SessionMeta { id, forked_from_id: None, + parent_thread_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: dir.path().to_path_buf(), originator: "cli".to_string(), @@ -87,6 +88,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { let session_meta = SessionMeta { id, forked_from_id: None, + parent_thread_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: dir.path().to_path_buf(), originator: "cli".to_string(), @@ -347,6 +349,7 @@ fn write_rollout_in_sessions_with_cwd( let session_meta = SessionMeta { id, forked_from_id: None, + parent_thread_id: None, timestamp: event_ts.to_string(), cwd, originator: "cli".to_string(), diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 5fbc2823a..d908ac270 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -81,6 +81,7 @@ pub enum RolloutRecorderParams { Create { conversation_id: ThreadId, forked_from_id: Option, + parent_thread_id: Option, source: SessionSource, thread_source: Option, base_instructions: BaseInstructions, @@ -156,6 +157,7 @@ impl RolloutRecorderParams { pub fn new( conversation_id: ThreadId, forked_from_id: Option, + parent_thread_id: Option, source: SessionSource, thread_source: Option, base_instructions: BaseInstructions, @@ -164,6 +166,7 @@ impl RolloutRecorderParams { Self::Create { conversation_id, forked_from_id, + parent_thread_id, source, thread_source, base_instructions, @@ -652,6 +655,7 @@ impl RolloutRecorder { RolloutRecorderParams::Create { conversation_id, forked_from_id, + parent_thread_id, source, thread_source, base_instructions, @@ -673,6 +677,7 @@ impl RolloutRecorder { let session_meta = SessionMeta { id: session_id, forked_from_id, + parent_thread_id, timestamp, cwd: config.cwd().to_path_buf(), originator: originator().value, @@ -1020,6 +1025,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt git_sha, git_origin_url, source, + parent_thread_id, agent_nickname, agent_role, model_provider, @@ -1049,6 +1055,9 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt if item.source.is_none() { item.source = source; } + if item.parent_thread_id.is_none() { + item.parent_thread_id = parent_thread_id; + } if item.agent_nickname.is_none() { item.agent_nickname = agent_nickname; } @@ -1690,6 +1699,7 @@ fn thread_item_from_state_metadata(item: codex_state::ThreadMetadata) -> ThreadI .or_else(|_| serde_json::from_value(Value::String(item.source))) .unwrap_or(SessionSource::Unknown), ), + parent_thread_id: None, agent_nickname: item.agent_nickname, agent_role: item.agent_role, model_provider: Some(item.model_provider), diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 371155e04..d396b27ee 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -85,6 +85,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { meta: SessionMeta { id: thread_id, forked_from_id: None, + parent_thread_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: home.path().to_path_buf(), originator: "test".to_string(), @@ -369,6 +370,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< RolloutRecorderParams::new( thread_id, /*forked_from_id*/ None, + /*parent_thread_id*/ None, SessionSource::Exec, /*thread_source*/ None, BaseInstructions::default(), @@ -449,6 +451,7 @@ async fn persist_reports_filesystem_error_and_retries_buffered_items() -> std::i RolloutRecorderParams::new( thread_id, /*forked_from_id*/ None, + /*parent_thread_id*/ None, SessionSource::Exec, /*thread_source*/ None, BaseInstructions::default(), @@ -974,6 +977,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi git_sha: Some("filesystem-sha".to_string()), git_origin_url: Some("https://example.com/filesystem.git".to_string()), source: None, + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: None, @@ -991,6 +995,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi git_sha: Some("state-sha".to_string()), git_origin_url: Some("https://example.com/state.git".to_string()), source: Some(SessionSource::Exec), + parent_thread_id: None, agent_nickname: Some("state-agent".to_string()), agent_role: Some("state-role".to_string()), model_provider: Some("state-provider".to_string()), diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 757b08b4d..91b475caf 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -27,6 +27,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res meta: SessionMeta { id: thread_id, forked_from_id: None, + parent_thread_id: None, timestamp, cwd: ".".into(), originator: "test_originator".into(), diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index a8bfc0cd8..73431b041 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -606,6 +606,7 @@ async fn test_list_conversations_latest_first() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -623,6 +624,7 @@ async fn test_list_conversations_latest_first() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -640,6 +642,7 @@ async fn test_list_conversations_latest_first() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -750,6 +753,7 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -767,6 +771,7 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -820,6 +825,7 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -837,6 +843,7 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -882,6 +889,7 @@ async fn test_pagination_cursor() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -1052,6 +1060,7 @@ async fn test_get_thread_contents() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -1251,6 +1260,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { meta: SessionMeta { id: conversation_id, forked_from_id: None, + parent_thread_id: None, timestamp: ts.to_string(), cwd: ".".into(), originator: "test_originator".into(), @@ -1403,6 +1413,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), @@ -1420,6 +1431,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { git_sha: None, git_origin_url: None, source: Some(SessionSource::VSCode), + parent_thread_id: None, agent_nickname: None, agent_role: None, model_provider: Some(TEST_PROVIDER.to_string()), diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 0094e362e..f64e6f7fe 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -322,6 +322,7 @@ mod tests { forked_from_id: Some( ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id"), ), + parent_thread_id: None, timestamp: "2026-02-26T00:00:00.000Z".to_string(), cwd: PathBuf::from("/child/worktree"), originator: "codex_cli_rs".to_string(), @@ -479,6 +480,7 @@ mod tests { meta: SessionMeta { id: thread_id, forked_from_id: None, + parent_thread_id: None, timestamp: "2026-02-26T00:00:00.000Z".to_string(), cwd: PathBuf::from("/workspace"), originator: "codex_cli_rs".to_string(), diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index e005a0e94..9522b6176 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1318,6 +1318,7 @@ mod tests { meta: SessionMeta { id: thread_id, forked_from_id: None, + parent_thread_id: None, timestamp: metadata.created_at.to_rfc3339(), cwd: PathBuf::new(), originator: String::new(), @@ -1377,6 +1378,7 @@ mod tests { meta: SessionMeta { id: thread_id, forked_from_id: None, + parent_thread_id: None, timestamp: created_at, cwd: PathBuf::new(), originator: String::new(), diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 5dade3a26..02e64a1ff 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -328,6 +328,7 @@ fn stored_thread_from_state( .and_then(|metadata| metadata.rollout_path.clone()) .or(rollout_path), forked_from_id: created.forked_from_id, + parent_thread_id: created.parent_thread_id, preview: metadata .and_then(|metadata| metadata.preview.clone()) .unwrap_or_default(), diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 56882e4cb..a71bb48b0 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -30,6 +30,7 @@ pub(super) async fn create_thread( RolloutRecorderParams::new( params.thread_id, params.forked_from_id, + params.parent_thread_id, params.source, params.thread_source, params.base_instructions, diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index 75120eb56..8981187fe 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -122,6 +122,7 @@ pub(super) fn stored_thread_from_rollout_item( thread_id, rollout_path: Some(item.path), forked_from_id: None, + parent_thread_id: item.parent_thread_id, preview, name: None, model_provider: item diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 7cb2ff827..e26c314e7 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1023,6 +1023,7 @@ mod tests { CreateThreadParams { thread_id, forked_from_id: None, + parent_thread_id: None, source: SessionSource::Exec, thread_source: None, base_instructions: BaseInstructions::default(), diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 767dbda6c..b2a15688c 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -235,6 +235,7 @@ async fn read_thread_from_rollout_path( })?; if let Ok(meta_line) = read_session_meta_line(path.as_path()).await { thread.forked_from_id = meta_line.meta.forked_from_id; + thread.parent_thread_id = meta_line.meta.parent_thread_id; if let Some(model_provider) = meta_line .meta .model_provider @@ -287,6 +288,7 @@ async fn stored_thread_from_sqlite_metadata( .ok() .map(|meta_line| meta_line.meta); let forked_from_id = session_meta.as_ref().and_then(|meta| meta.forked_from_id); + let parent_thread_id = session_meta.as_ref().and_then(|meta| meta.parent_thread_id); let preview = metadata .preview .clone() @@ -298,6 +300,7 @@ async fn stored_thread_from_sqlite_metadata( thread_id: metadata.id, rollout_path: Some(metadata.rollout_path), forked_from_id, + parent_thread_id, preview, name, model_provider: if metadata.model_provider.is_empty() { @@ -361,6 +364,7 @@ fn stored_thread_from_meta_line( thread_id: meta_line.meta.id, rollout_path: Some(path), forked_from_id: meta_line.meta.forked_from_id, + parent_thread_id: meta_line.meta.parent_thread_id, preview: String::new(), name: None, model_provider: meta_line diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 61763f10f..723f08c11 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -72,6 +72,8 @@ pub struct CreateThreadParams { pub thread_id: ThreadId, /// Source thread id when this thread is created as a fork. pub forked_from_id: Option, + /// The ID of the parent thread. This will only be set if this thread is a subagent. + pub parent_thread_id: Option, /// Runtime source for the thread. pub source: SessionSource, /// Optional analytics source classification for this thread. @@ -362,6 +364,8 @@ pub struct StoredThread { pub rollout_path: Option, /// Source thread id when this thread was forked from another thread. pub forked_from_id: Option, + /// The ID of the parent thread. This will only be set if this thread is a subagent. + pub parent_thread_id: Option, /// Best available user-facing preview, usually the first user message. pub preview: String, /// Optional user-facing thread name/title. diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index 0ab8e14ee..d988c8051 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -120,6 +120,7 @@ mod tests { id: thread_id.to_string(), session_id: thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 19da8ea77..1f16ea56a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2767,6 +2767,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re id: agent_thread_id.to_string(), session_id: agent_thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: "agent thread".to_string(), ephemeral: false, model_provider: "agent-provider".to_string(), @@ -2856,6 +2857,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ id: agent_thread_id.to_string(), session_id: agent_thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: "agent thread".to_string(), ephemeral: false, model_provider: "agent-provider".to_string(), @@ -2914,6 +2916,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { id: read_thread_id.to_string(), session_id: read_thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: "read thread".to_string(), ephemeral: false, model_provider: "read-provider".to_string(), @@ -5011,6 +5014,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { id: thread_id.to_string(), session_id: thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::new(), ephemeral: false, model_provider: "openai".to_string(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 80aa54efb..bcc172ef9 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -411,6 +411,7 @@ mod tests { id: read_thread_id.to_string(), session_id: read_thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: "read thread".to_string(), ephemeral: false, model_provider: "read-provider".to_string(), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index ac6ef45d0..79dfc587b 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2279,6 +2279,7 @@ mod tests { id: thread_id.to_string(), session_id: ThreadId::new().to_string(), forked_from_id: Some(forked_from_id.to_string()), + parent_thread_id: None, preview: "hello".to_string(), 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 2d8619d53..6ae9f37a2 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5726,6 +5726,7 @@ session_picker_view = "dense" id: thread_id.to_string(), session_id: thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::from("remote thread"), ephemeral: false, model_provider: String::from("openai"), @@ -5760,6 +5761,7 @@ session_picker_view = "dense" id: thread_id.to_string(), session_id: thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::from("preview"), ephemeral: false, model_provider: String::from("openai"), @@ -5828,6 +5830,7 @@ session_picker_view = "dense" id: thread_id.to_string(), session_id: thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::from("preview"), ephemeral: false, model_provider: String::from("openai"), @@ -5885,6 +5888,7 @@ session_picker_view = "dense" id: thread_id.to_string(), session_id: thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::from("preview"), ephemeral: false, model_provider: String::from("openai"),