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(
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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.
*/
@@ -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",
@@ -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::<AbsolutePathBuf>::new());
assert_eq!(start.thread.parent_thread_id, None);
assert_eq!(resume.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(start.active_permission_profile, None);
@@ -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<String>,
/// The ID of the parent thread. This will only be set if this thread is a subagent.
pub parent_thread_id: Option<String>,
/// 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.
+2 -2
View File
@@ -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 threads 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" } }
@@ -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(),
@@ -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(),
@@ -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,
};
@@ -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(),
@@ -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,
+1
View File
@@ -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(),
+1
View File
@@ -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;
@@ -118,6 +118,53 @@ pub fn create_fake_rollout_with_source(
model_provider: Option<&str>,
git_info: Option<GitInfo>,
source: SessionSource,
) -> Result<String> {
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<GitInfo>,
source: SessionSource,
parent_thread_id: ThreadId,
) -> Result<String> {
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<GitInfo>,
source: SessionSource,
parent_thread_id: Option<ThreadId>,
) -> Result<String> {
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(),
@@ -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(),
@@ -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::<ThreadResumeResponse>(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());
@@ -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,
@@ -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(),
@@ -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(),
@@ -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(),
+1 -1
View File
@@ -32,5 +32,5 @@ fn register_session_root(session: &Arc<Session>, turn: &Arc<TurnContext>) {
session
.services
.agent_control
.register_session_root(session.conversation_id, &turn.session_source);
.register_session_root(session.conversation_id, turn.parent_thread_id);
}
+12 -10
View File
@@ -54,6 +54,7 @@ pub(crate) enum SpawnAgentForkMode {
pub(crate) struct SpawnAgentOptions {
pub(crate) fork_parent_spawn_call_id: Option<String>,
pub(crate) fork_mode: Option<SpawnAgentForkMode>,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) environments: Option<Vec<TurnEnvironmentSelection>>,
}
@@ -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<ThreadId>,
) {
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<ThreadId> {
session_source.parent_thread_id()
}
fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool {
if prefix.is_root() {
return true;
+9
View File
@@ -74,6 +74,15 @@ fn assistant_message(text: &str, phase: Option<MessagePhase>) -> 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,
+7 -6
View File
@@ -173,6 +173,7 @@ struct ModelClientState {
provider: SharedModelProvider,
auth_env_telemetry: AuthEnvTelemetry,
session_source: SessionSource,
parent_thread_id: Option<ThreadId>,
model_verbosity: Option<VerbosityConfig>,
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<ThreadId>,
model_verbosity: Option<VerbosityConfig>,
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<String> {
}
}
fn parent_thread_id_header_value(session_source: &SessionSource) -> Option<String> {
session_source
.parent_thread_id()
.map(|parent_thread_id| parent_thread_id.to_string())
fn parent_thread_id_header_value(parent_thread_id: Option<ThreadId>) -> Option<String> {
parent_thread_id.map(|parent_thread_id| parent_thread_id.to_string())
}
const RESPONSE_STREAM_CHANNEL_CAPACITY: usize = 1600;
+19 -7
View File
@@ -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<ThreadId>,
) -> 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,
+5 -2
View File
@@ -74,6 +74,8 @@ pub(crate) async fn run_codex_thread_interactive(
) -> Result<Codex, CodexErr> {
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(),
+2
View File
@@ -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<Personality>,
pub collaboration_mode: CollaborationMode,
pub session_source: SessionSource,
pub parent_thread_id: Option<ThreadId>,
pub thread_source: Option<ThreadSource>,
}
+2 -2
View File
@@ -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
)
}
@@ -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(),
@@ -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(),
+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)]
+17
View File
@@ -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<ThreadId>,
pub(crate) inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
pub(crate) inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
}
@@ -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<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
thread_source: Option<ThreadSource>,
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<ThreadSource>,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
persist_extended_history: bool,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
@@ -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<AuthManager>,
agent_control: AgentControl,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
thread_source: Option<ThreadSource>,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
@@ -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<AuthManager>,
agent_control: AgentControl,
session_source: SessionSource,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
thread_source: Option<ThreadSource>,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
@@ -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,
@@ -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()
},
@@ -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()),
},
))
@@ -30,7 +30,7 @@ impl ToolExecutor<ToolInvocation> 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
@@ -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()),
},
),
+4 -6
View File
@@ -261,6 +261,7 @@ impl TurnMetadataState {
session_id: String,
thread_id: String,
forked_from_thread_id: Option<ThreadId>,
parent_thread_id: Option<ThreadId>,
session_source: &SessionSource,
thread_source: Option<ThreadSource>,
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,
+44 -24
View File
@@ -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,
+3
View File
@@ -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,
+4
View File
@@ -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,
@@ -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,
@@ -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(),
@@ -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)
);
+2 -1
View File
@@ -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())
);
@@ -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(),
@@ -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(),
@@ -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(),
+8
View File
@@ -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<codex_protocol::protocol::ThreadSource>,
thread_name: Option<String>,
rollout_path: Option<PathBuf>,
@@ -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,
+22
View File
@@ -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(),
@@ -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(),
@@ -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(),
+3 -1
View File
@@ -171,7 +171,8 @@ impl MemoryStartupContext {
context: &StageOneRequestContext,
) -> anyhow::Result<(String, Option<TokenUsage>)> {
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),
+13
View File
@@ -2451,6 +2451,11 @@ impl InitialHistory {
.and_then(|meta| meta.thread_source)
}
pub fn get_resumed_parent_thread_id(&self) -> Option<ThreadId> {
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<ThreadId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_thread_id: Option<ThreadId>,
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<ThreadId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_thread_id: Option<ThreadId>,
/// Optional analytics source classification for this thread.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_source: Option<ThreadSource>,
@@ -3490,6 +3500,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
#[serde(default)]
thread_id: Option<ThreadId>,
forked_from_id: Option<ThreadId>,
parent_thread_id: Option<ThreadId>,
#[serde(default)]
thread_source: Option<ThreadSource>,
#[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(),
+6
View File
@@ -62,6 +62,8 @@ pub struct ThreadItem {
pub git_origin_url: Option<String>,
/// Session source from session metadata.
pub source: Option<SessionSource>,
/// Immediate control/spawn parent thread id from session metadata.
pub parent_thread_id: Option<ThreadId>,
/// Random unique nickname from session metadata for AgentControl-spawned sub-agents.
pub agent_nickname: Option<String>,
/// Role (agent_role) from session metadata for AgentControl-spawned sub-agents.
@@ -95,6 +97,7 @@ struct HeadTailSummary {
git_sha: Option<String>,
git_origin_url: Option<String>,
source: Option<SessionSource>,
parent_thread_id: Option<ThreadId>,
agent_nickname: Option<String>,
agent_role: Option<String>,
model_provider: Option<String>,
@@ -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<HeadTai
RolloutItem::SessionMeta(session_meta_line) => {
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();
+3
View File
@@ -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(),
+10
View File
@@ -81,6 +81,7 @@ pub enum RolloutRecorderParams {
Create {
conversation_id: ThreadId,
forked_from_id: Option<ThreadId>,
parent_thread_id: Option<ThreadId>,
source: SessionSource,
thread_source: Option<ThreadSource>,
base_instructions: BaseInstructions,
@@ -156,6 +157,7 @@ impl RolloutRecorderParams {
pub fn new(
conversation_id: ThreadId,
forked_from_id: Option<ThreadId>,
parent_thread_id: Option<ThreadId>,
source: SessionSource,
thread_source: Option<ThreadSource>,
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),
+5
View File
@@ -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()),
@@ -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(),
+12
View File
@@ -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()),
+2
View File
@@ -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(),
+2
View File
@@ -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(),
+1
View File
@@ -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(),
@@ -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,
@@ -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
+1
View File
@@ -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(),
@@ -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
+4
View File
@@ -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<ThreadId>,
/// The ID of the parent thread. This will only be set if this thread is a subagent.
pub parent_thread_id: Option<ThreadId>,
/// 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<PathBuf>,
/// Source thread id when this thread was forked from another thread.
pub forked_from_id: Option<ThreadId>,
/// The ID of the parent thread. This will only be set if this thread is a subagent.
pub parent_thread_id: Option<ThreadId>,
/// Best available user-facing preview, usually the first user message.
pub preview: String,
/// Optional user-facing thread name/title.
+1
View File
@@ -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(),
+4
View File
@@ -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(),
@@ -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(),
+1
View File
@@ -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(),
+4
View File
@@ -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"),