From 9e41f8ddbe087ae6b9bdfb8d5821ab2f09f8c5fe Mon Sep 17 00:00:00 2001 From: kbazzi Date: Thu, 4 Jun 2026 11:24:12 -0700 Subject: [PATCH] [codex-analytics] emit forked thread id on initialization (#26248) ## Why - Thread initialization analytics do not identify the source thread for forked threads. - The session viewer needs this lineage to construct thread trees. - Depends on openai/openai#987854. Do not release this change before that backend schema change is deployed. ## What Changed - Adds optional `forked_from_thread_id` to `codex_thread_initialized`. - Populates it from the existing thread fork lineage for app-server and in-process subagent initialization paths. - Keeps it null for non-forked threads. ## Verification - `just fmt` - `just test -p codex-analytics` - `just test -p codex-app-server thread_fork_tracks_thread_initialized_analytics` --- .../analytics/src/analytics_client_tests.rs | 23 +++++- codex-rs/analytics/src/events.rs | 2 + codex-rs/analytics/src/facts.rs | 1 + codex-rs/analytics/src/reducer.rs | 2 + .../thread_processor_tests.rs | 1 + .../app-server/tests/suite/v2/thread_fork.rs | 7 ++ codex-rs/core/src/codex_thread.rs | 1 + codex-rs/core/src/session/mod.rs | 3 + codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 79 +++++++++++++++++++ 10 files changed, 119 insertions(+), 1 deletion(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 59e3f5046..7177952c3 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1347,6 +1347,7 @@ fn thread_initialized_event_serializes_expected_shape() { initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, + forked_from_thread_id: None, created_at: 1, }, }); @@ -1379,6 +1380,7 @@ fn thread_initialized_event_serializes_expected_shape() { "initialization_mode": "new", "subagent_source": null, "parent_thread_id": null, + "forked_from_thread_id": null, "created_at": 1 } }) @@ -2429,6 +2431,7 @@ fn subagent_thread_started_review_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2461,18 +2464,26 @@ fn subagent_thread_started_review_serializes_expected_shape() { assert_eq!(payload["event_params"]["initialization_mode"], "new"); assert_eq!(payload["event_params"]["subagent_source"], "review"); assert_eq!(payload["event_params"]["parent_thread_id"], json!(null)); + assert_eq!( + payload["event_params"]["forked_from_thread_id"], + json!(null) + ); } #[test] -fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { +fn subagent_thread_started_thread_spawn_serializes_thread_lineage() { let parent_thread_id = codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111") .expect("valid thread id"); + let forked_from_thread_id = + codex_protocol::ThreadId::from_string("22222222-2222-4222-8222-222222222222") + .expect("valid thread id"); let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { session_id: "session-root".to_string(), thread_id: "thread-spawn".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: Some(forked_from_thread_id.to_string()), product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2497,6 +2508,10 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { payload["event_params"]["parent_thread_id"], "11111111-1111-1111-1111-111111111111" ); + assert_eq!( + payload["event_params"]["forked_from_thread_id"], + "22222222-2222-4222-8222-222222222222" + ); assert_eq!(payload["event_params"]["session_id"], "session-root"); } @@ -2507,6 +2522,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-memory".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2533,6 +2549,7 @@ fn subagent_thread_started_other_serializes_expected_shape() { session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2558,6 +2575,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2588,6 +2606,7 @@ async fn subagent_thread_started_publishes_without_initialize() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2662,6 +2681,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: Some(parent_thread_id.to_string()), + forked_from_thread_id: None, product_client_id: "parent-client".to_string(), client_name: "parent-client".to_string(), client_version: "1.0.0".to_string(), @@ -2732,6 +2752,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { session_id: "session-root".to_string(), thread_id: "thread-subagent".to_string(), parent_thread_id: Some("thread-1".to_string()), + forked_from_thread_id: None, product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 5367e651e..bdc2c996d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -157,6 +157,7 @@ pub(crate) struct ThreadInitializedEventParams { pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, + pub(crate) forked_from_thread_id: Option, pub(crate) created_at: u64, } @@ -1060,6 +1061,7 @@ pub(crate) fn subagent_thread_started_event_request( initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id: input.parent_thread_id, + forked_from_thread_id: input.forked_from_thread_id, created_at: input.created_at, }; ThreadInitializedEvent { diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index b05610850..1dff5de89 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -348,6 +348,7 @@ pub struct SubAgentThreadStartedInput { pub session_id: String, pub thread_id: String, pub parent_thread_id: Option, + pub forked_from_thread_id: Option, pub product_client_id: String, pub client_name: String, pub client_version: String, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 5b4117853..877000928 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -1268,6 +1268,7 @@ impl AnalyticsReducer { let session_id = thread.session_id; let thread_id = thread.id; let parent_thread_id = thread.parent_thread_id; + let forked_from_thread_id = thread.forked_from_id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; @@ -1299,6 +1300,7 @@ impl AnalyticsReducer { initialization_mode, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id, + forked_from_thread_id, created_at: u64::try_from(thread.created_at).unwrap_or_default(), }, }, 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 6be022aa6..c0a909e57 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 @@ -701,6 +701,7 @@ mod thread_processor_behavior_tests { }, }, session_source: SessionSource::Cli, + forked_from_thread_id: None, parent_thread_id: None, thread_source: None, }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index ff9f65563..a335b441f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -490,6 +490,13 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { "forked", "user", ); + assert_eq!( + event["event_params"]["forked_from_thread_id"], + thread + .forked_from_id + .as_deref() + .expect("forked thread has a source thread") + ); Ok(()) } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ac28a58ec..2ed8a9ae6 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -70,6 +70,7 @@ pub struct ThreadConfigSnapshot { pub personality: Option, pub collaboration_mode: CollaborationMode, pub session_source: SessionSource, + pub forked_from_thread_id: Option, pub parent_thread_id: Option, pub thread_source: Option, } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3ea33a6bd..294ab2f21 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3364,6 +3364,9 @@ pub(crate) fn emit_subagent_session_started( session_id: session_id.to_string(), thread_id: thread_id.to_string(), parent_thread_id: parent_thread_id.map(|thread_id| thread_id.to_string()), + forked_from_thread_id: thread_config + .forked_from_thread_id + .map(|thread_id| thread_id.to_string()), product_client_id: client_name.clone(), client_name, client_version, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 07d249613..756f87831 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -185,6 +185,7 @@ impl SessionConfiguration { personality: self.personality, collaboration_mode: self.collaboration_mode.clone(), session_source: self.session_source.clone(), + forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, thread_source: self.thread_source, } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 164357420..93dc1e109 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3831,6 +3831,85 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati } } +#[tokio::test] +async fn emit_subagent_session_started_includes_fork_lineage_from_session_configuration() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/analytics-events/events")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let analytics_events_client = AnalyticsEventsClient::new( + auth_manager, + server.uri(), + /*analytics_enabled*/ Some(true), + ); + + let parent_thread_id = ThreadId::new(); + let forked_from_thread_id = ThreadId::new(); + let child_thread_id = ThreadId::new(); + let mut session_configuration = make_session_configuration_for_tests().await; + session_configuration.forked_from_thread_id = Some(forked_from_thread_id); + + emit_subagent_session_started( + &analytics_events_client, + AppServerClientMetadata { + client_name: Some("codex-tui".to_string()), + client_version: Some("1.0.0".to_string()), + }, + SessionId::from(child_thread_id), + child_thread_id, + Some(parent_thread_id), + session_configuration.thread_config_snapshot(), + SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }, + ); + + let event = timeout(Duration::from_secs(1), async { + 'wait_for_event: loop { + if let Some(requests) = server.received_requests().await { + for request in requests { + let payload: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid analytics payload"); + if let Some(event) = payload["events"].as_array().and_then(|events| { + events + .iter() + .find(|event| event["event_type"] == "codex_thread_initialized") + }) { + break 'wait_for_event event.clone(); + } + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("subagent initialization analytics should be emitted"); + + assert_eq!( + event["event_params"]["parent_thread_id"], + parent_thread_id.to_string() + ); + assert_eq!( + event["event_params"]["forked_from_thread_id"], + forked_from_thread_id.to_string() + ); +} + fn turn_environments_for_tests( environment: &Arc, cwd: &codex_utils_absolute_path::AbsolutePathBuf,