diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index bd50d74bb6..7d4b1b736a 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -102,7 +102,8 @@ pub(super) async fn spawn_review_thread( let per_turn_config = Arc::new(per_turn_config); let review_turn_id = sub_id.to_string(); let turn_metadata_state = Arc::new(TurnMetadataState::new( - sess.conversation_id.to_string(), + sess.session_id().to_string(), + sess.thread_id().to_string(), parent_turn_context.thread_source, review_turn_id.clone(), parent_turn_context.cwd.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 3485713bc7..46f25cd1f0 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -325,6 +325,16 @@ pub(crate) struct AppServerClientMetadata { } impl Session { + /// Returns the concrete identity for this thread. + pub(crate) fn thread_id(&self) -> ThreadId { + self.conversation_id + } + + /// Returns the identity shared by the root thread and all descendant threads. + pub(crate) fn session_id(&self) -> SessionId { + self.services.agent_control.session_id() + } + #[instrument(name = "session_init", level = "info", skip_all)] #[allow(clippy::too_many_arguments)] #[expect( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3384a09598..26dca0c25f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3777,6 +3777,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Session::make_turn_context( thread_id, + SessionId::from(thread_id), Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), @@ -4057,10 +4058,8 @@ async fn resumed_root_session_uses_thread_id_as_session_id() { .await .expect("resume should succeed"); - assert_eq!( - session.services.agent_control.session_id(), - SessionId::from(thread_id) - ); + assert_eq!(session.thread_id(), thread_id); + assert_eq!(session.session_id(), SessionId::from(thread_id)); let event = rx_event.recv().await.expect("session configured event"); let EventMsg::SessionConfigured(event) = event.msg else { @@ -4094,10 +4093,8 @@ async fn resumed_subagent_session_keeps_inherited_session_id() { .await .expect("resume should succeed"); - assert_eq!( - session.services.agent_control.session_id(), - parent_session_id - ); + assert_eq!(session.thread_id(), thread_id); + assert_eq!(session.session_id(), parent_session_id); let event = rx_event.recv().await.expect("session configured event"); let EventMsg::SessionConfigured(event) = event.msg else { @@ -5463,6 +5460,7 @@ where let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Arc::new(Session::make_turn_context( thread_id, + SessionId::from(thread_id), Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e42fc2b102..8e78a0c9cd 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -4,6 +4,7 @@ use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; +use codex_protocol::SessionId; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; @@ -440,7 +441,8 @@ impl Session { #[allow(clippy::too_many_arguments)] pub(crate) fn make_turn_context( - conversation_id: ThreadId, + thread_id: ThreadId, + session_id: SessionId, auth_manager: Option>, session_telemetry: &SessionTelemetry, provider: ModelProviderInfo, @@ -522,7 +524,8 @@ impl Session { let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( - conversation_id.to_string(), + session_id.to_string(), + thread_id.to_string(), session_configuration.thread_source, sub_id.clone(), cwd.clone(), @@ -718,7 +721,8 @@ impl Session { ); let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some(); let mut turn_context: TurnContext = Self::make_turn_context( - self.conversation_id, + self.thread_id(), + self.session_id(), Some(Arc::clone(&self.services.auth_manager)), &self.services.session_telemetry, session_configuration.provider.clone(), diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index d4a46944eb..02760582f2 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -69,6 +69,8 @@ pub(crate) struct TurnMetadataBag { #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] thread_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, @@ -115,6 +117,7 @@ fn merge_turn_metadata( fn build_turn_metadata_bag( session_id: Option, + thread_id: Option, thread_source: Option, turn_id: Option, sandbox: Option, @@ -130,6 +133,7 @@ fn build_turn_metadata_bag( TurnMetadataBag { session_id, + thread_id, thread_source, turn_id, workspaces, @@ -159,6 +163,7 @@ pub async fn build_turn_metadata_header( build_turn_metadata_bag( /*session_id*/ None, + /*thread_id*/ None, /*thread_source*/ None, /*turn_id*/ None, sandbox.map(ToString::to_string), @@ -185,8 +190,10 @@ pub(crate) struct TurnMetadataState { } impl TurnMetadataState { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( session_id: String, + thread_id: String, thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, @@ -205,6 +212,7 @@ impl TurnMetadataState { ); let base_metadata = build_turn_metadata_bag( Some(session_id), + Some(thread_id), thread_source, Some(turn_id), sandbox, @@ -320,6 +328,7 @@ impl TurnMetadataState { let enriched_metadata = build_turn_metadata_bag( state.base_metadata.session_id.clone(), + state.base_metadata.thread_id.clone(), state.base_metadata.thread_source, state.base_metadata.turn_id.clone(), state.base_metadata.sandbox.clone(), diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index d840ade91b..2a38447f86 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -94,6 +94,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -106,11 +107,13 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let json: Value = serde_json::from_str(&header).expect("json"); let sandbox_name = json.get("sandbox").and_then(Value::as_str); let session_id = json.get("session_id").and_then(Value::as_str); + let thread_id = json.get("thread_id").and_then(Value::as_str); let thread_source = json.get("thread_source").and_then(Value::as_str); let expected_sandbox = sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled); assert_eq!(sandbox_name, Some(expected_sandbox)); assert_eq!(session_id, Some("session-a")); + assert_eq!(thread_id, Some("thread-a")); assert_eq!(thread_source, Some("user")); assert!(json.get("session_source").is_none()); } @@ -122,6 +125,7 @@ fn turn_metadata_state_uses_explicit_subagent_thread_source() { let permission_profile = PermissionProfile::read_only(); let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::Subagent), "turn-a".to_string(), cwd, @@ -145,6 +149,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -171,6 +176,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), /*thread_source*/ None, "turn-a".to_string(), cwd, @@ -215,6 +221,7 @@ fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -241,6 +248,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -257,6 +265,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( "client-supplied".to_string(), ), ("session_id".to_string(), "client-supplied".to_string()), + ("thread_id".to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), ( "turn_started_at_unix_ms".to_string(), @@ -275,6 +284,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["model"].as_str(), Some("client-supplied")); assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); + assert_eq!(json["thread_id"].as_str(), Some("thread-a")); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert_eq!( diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 307d066928..5e2c7db346 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -596,6 +596,10 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - ); assert_eq!( apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/session_id"), + Some(&json!(test.session_configured.session_id.to_string())) + ); + assert_eq!( + apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/thread_id"), Some(&json!(test.session_configured.thread_id.to_string())) ); assert!(