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.
This commit is contained in:
Owen Lin
2026-05-31 21:33:20 -07:00
committed by GitHub
Unverified
parent 3b7334d099
commit cf0911076f
92 changed files with 504 additions and 111 deletions
+3
View File
@@ -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<ThreadId>,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) agent_control: AgentControl,
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
@@ -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,
+2
View File
@@ -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,
+11 -1
View File
@@ -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<ThreadId>,
/// Immediate control/spawn parent for this thread, when it has one.
pub(super) parent_thread_id: Option<ThreadId>,
/// Optional analytics source classification for this thread.
pub(super) thread_source: Option<ThreadSource>,
pub(super) dynamic_tools: Vec<DynamicToolSpec>,
@@ -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(),
+13
View File
@@ -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),
@@ -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(),
@@ -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<ReasoningEffortConfig>,
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) thread_source: Option<ThreadSource>,
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)]