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
@@ -160,11 +160,13 @@ fn sample_thread_with_metadata(
ephemeral: bool,
source: AppServerSessionSource,
thread_source: Option<AppServerThreadSource>,
parent_thread_id: Option<String>,
) -> 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<AppServerThreadSource>,
parent_thread_id: Option<String>,
) -> 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(),
+1
View File
@@ -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(),
+2 -16
View File
@@ -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<String> {
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 {
+7 -11
View File
@@ -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<ThreadSource>,
parent_thread_id: Option<String>,
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<TrackEventRequest>,
) {
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(