diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index ef310acf2c..c6a2f195f7 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -862,6 +862,7 @@ fn session_event_to_analytics_notification( thread_id: thread_id.to_string(), turn: Turn { started_at: started.started_at, + root_turn_id: started.root_turn_id.clone(), ..analytics_turn(&started.turn_id, TurnStatus::InProgress) }, }) @@ -920,6 +921,7 @@ fn session_event_to_analytics_notification( fn analytics_turn(turn_id: &str, status: TurnStatus) -> Turn { Turn { id: turn_id.to_string(), + root_turn_id: None, items: Vec::new(), items_view: TurnItemsView::NotLoaded, status, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 79f8a28697..55f53efe24 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -721,6 +721,7 @@ fn sample_turn_start_response() -> ClientResponsePayload { ClientResponsePayload::TurnStart(TurnStartResponse { turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, diff --git a/codex-rs/analytics/src/tests/support.rs b/codex-rs/analytics/src/tests/support.rs index 634f516ea5..e32fe28aca 100644 --- a/codex-rs/analytics/src/tests/support.rs +++ b/codex-rs/analytics/src/tests/support.rs @@ -265,6 +265,7 @@ pub(super) fn sample_turn_start_response(turn_id: &str) -> ClientResponsePayload ClientResponsePayload::TurnStart(codex_app_server_protocol::TurnStartResponse { turn: Turn { id: turn_id.to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![], status: AppServerTurnStatus::InProgress, @@ -284,6 +285,7 @@ pub(super) fn sample_turn_started_notification( thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![], status: AppServerTurnStatus::InProgress, @@ -321,6 +323,7 @@ pub(super) fn sample_turn_completed_notification( thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![], status, diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 079d1bd2ca..df5dc3e99f 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1082,6 +1082,7 @@ mod tests { thread_id: "thread".to_string(), turn: codex_app_server_protocol::Turn { id: "turn".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 8c530ac641..00752e35cd 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5736,6 +5736,13 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "parentTurnId": { + "description": "ID of the turn that caused this new turn to start.\n\nSet this when starting work on behalf of another turn, such as delegated work in a different thread. Leave unset for work started directly by the user. Ignored when this request adds input to an active turn.", + "type": [ + "string", + "null" + ] + }, "personality": { "anyOf": [ { @@ -5747,6 +5754,13 @@ ], "description": "@deprecated `friendly` and `pragmatic` no longer select a style. Changing this does not rewrite the thread's existing instructions." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this new turn.\n\nWhen setting `parentTurnId`, set this to the parent turn's `rootTurnId` when known. This keeps descendant work attributed to the original turn. If omitted, the new turn becomes its own root. Ignored when this request adds input to an active turn.", + "type": [ + "string", + "null" + ] + }, "sandboxPolicy": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b48ed37529..b57a1ca471 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -6403,6 +6403,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0525c96a99..6260a20dfd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -25604,6 +25604,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -25921,6 +25928,13 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "parentTurnId": { + "description": "ID of the turn that caused this new turn to start.\n\nSet this when starting work on behalf of another turn, such as delegated work in a different thread. Leave unset for work started directly by the user. Ignored when this request adds input to an active turn.", + "type": [ + "string", + "null" + ] + }, "personality": { "anyOf": [ { @@ -25932,6 +25946,13 @@ ], "description": "@deprecated `friendly` and `pragmatic` no longer select a style. Changing this does not rewrite the thread's existing instructions." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this new turn.\n\nWhen setting `parentTurnId`, set this to the parent turn's `rootTurnId` when known. This keeps descendant work attributed to the original turn. If omitted, the new turn becomes its own root. Ignored when this request adds input to an active turn.", + "type": [ + "string", + "null" + ] + }, "sandboxPolicy": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6a6e3dad5d..637d715a00 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -23318,6 +23318,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -23635,6 +23642,13 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "parentTurnId": { + "description": "ID of the turn that caused this new turn to start.\n\nSet this when starting work on behalf of another turn, such as delegated work in a different thread. Leave unset for work started directly by the user. Ignored when this request adds input to an active turn.", + "type": [ + "string", + "null" + ] + }, "personality": { "anyOf": [ { @@ -23646,6 +23660,13 @@ ], "description": "@deprecated `friendly` and `pragmatic` no longer select a style. Changing this does not rewrite the thread's existing instructions." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this new turn.\n\nWhen setting `parentTurnId`, set this to the parent turn's `rootTurnId` when known. This keeps descendant work attributed to the original turn. If omitted, the new turn becomes its own root. Ignored when this request adds input to an active turn.", + "type": [ + "string", + "null" + ] + }, "sandboxPolicy": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 928e56e572..3934100532 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1888,6 +1888,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 9e1b212d6a..116c703cc2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -2628,6 +2628,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 70bf0d107f..03f1aa2c97 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -2419,6 +2419,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index d90c52e1e4..44cdf7f557 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -2419,6 +2419,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index d86656672e..e20bfa1c75 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -2419,6 +2419,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 5a8248c1d8..9c03d89695 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -2680,6 +2680,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index 1639c808ee..f51ce46103 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -2419,6 +2419,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 5f8b018216..ed56fcde2a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -2628,6 +2628,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 1195bf7602..58d032d99c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -2419,6 +2419,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index 1b885d1b6d..8e5a334fb1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -1888,6 +1888,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 2fd357b835..de9b859959 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -2419,6 +2419,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 1d72c8cd2a..eb4ac85aa6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1888,6 +1888,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 751bb8bc4e..ff77ccec85 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -819,6 +819,13 @@ "outputSchema": { "description": "Optional JSON Schema used to constrain the final assistant message for this turn." }, + "parentTurnId": { + "description": "ID of the turn that caused this new turn to start.\n\nSet this when starting work on behalf of another turn, such as delegated work in a different thread. Leave unset for work started directly by the user. Ignored when this request adds input to an active turn.", + "type": [ + "string", + "null" + ] + }, "personality": { "anyOf": [ { @@ -830,6 +837,13 @@ ], "description": "@deprecated `friendly` and `pragmatic` no longer select a style. Changing this does not rewrite the thread's existing instructions." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this new turn.\n\nWhen setting `parentTurnId`, set this to the parent turn's `rootTurnId` when known. This keeps descendant work attributed to the original turn. If omitted, the new turn becomes its own root. Ignored when this request adds input to an active turn.", + "type": [ + "string", + "null" + ] + }, "sandboxPolicy": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index e99476202c..0d6620adfb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1888,6 +1888,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 2a4b666554..ab6c2ea72c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1888,6 +1888,13 @@ "default": "full", "description": "Describes how much of `items` has been loaded for this turn." }, + "rootTurnId": { + "description": "ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + "type": [ + "string", + "null" + ] + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 44b7e44a47..f568cd09ab 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 668e51f34b..0c2346600a 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index e804fa974c..ce309330d5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -11,6 +11,12 @@ export type Turn = { * Identifier for this turn. Codex-generated turn IDs are UUIDv7. */ id: string, +/** + * ID of the first turn in the chain of work that led to this turn. + * Pass this as `rootTurnId` when starting work on behalf of this turn. + * May be null in older history or a `review/start` response. + */ +rootTurnId: string | null, /** * Thread items currently included in this turn payload. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index b5f5321916..e83b681899 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -19,7 +19,22 @@ disabledPluginIds?: Array | null, clientUserMessageId?: string | null, i * Optional source classification for the caller that starts this turn. * Ignored when this request steers an already-active turn. */ -turnTrigger?: string | null, toolOutput?: TurnToolOutput | null, /** +turnTrigger?: string | null, /** + * ID of the turn that caused this new turn to start. + * + * Set this when starting work on behalf of another turn, such as delegated + * work in a different thread. Leave unset for work started directly by the + * user. Ignored when this request adds input to an active turn. + */ +parentTurnId?: string | null, /** + * ID of the first turn in the chain of work that led to this new turn. + * + * When setting `parentTurnId`, set this to the parent turn's `rootTurnId` + * when known. This keeps descendant work attributed to the original turn. + * If omitted, the new turn becomes its own root. Ignored when this request + * adds input to an active turn. + */ +rootTurnId?: string | null, toolOutput?: TurnToolOutput | null, /** * Override the working directory for this turn and subsequent turns. */ cwd?: string | null, /** diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 35610e8b93..3e73b44fff 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -225,6 +225,7 @@ macro_rules! client_request_definitions { ) => { /// Request from the client to the server. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] + #[allow(clippy::large_enum_variant)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientRequest { $( diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index c0928c758c..1559004762 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -134,6 +134,7 @@ impl From for Turn { fn from(value: ThreadHistoryTurnMetadata) -> Self { Self { id: value.turn_id, + root_turn_id: value.root_turn_id, items: Vec::new(), items_view: TurnItemsView::NotLoaded, error: value.error, @@ -1729,6 +1730,7 @@ impl PendingTurn { fn snapshot(&self, items_view: TurnItemsView) -> Turn { Turn { id: self.id.clone(), + root_turn_id: self.root_turn_id.clone(), items: items_view.project_items(&self.items), items_view, error: self.error.clone(), @@ -1744,6 +1746,7 @@ impl From for Turn { fn from(value: PendingTurn) -> Self { Self { id: value.id, + root_turn_id: value.root_turn_id, items: value.items, items_view: TurnItemsView::Full, error: value.error, @@ -1988,6 +1991,7 @@ mod tests { review_output: None, }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-1".into(), started_at: None, last_agent_message: None, @@ -2050,6 +2054,7 @@ mod tests { completed_at_ms: 0, }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-1".into(), started_at: None, last_agent_message: None, @@ -2213,6 +2218,7 @@ mod tests { started_at_ms: 0, }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), started_at: None, last_agent_message: None, @@ -2269,6 +2275,7 @@ mod tests { completed_at_ms: 1_000, }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), started_at: None, last_agent_message: None, @@ -2351,6 +2358,7 @@ mod tests { completed_at_ms: 1_000, }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), started_at: None, last_agent_message: None, @@ -2463,6 +2471,7 @@ mod tests { status: CoreExecCommandStatus::Completed, }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), started_at: None, last_agent_message: None, @@ -2567,6 +2576,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), started_at: None, last_agent_message: None, @@ -2670,6 +2680,7 @@ mod tests { saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()), })), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-image".into(), started_at: None, last_agent_message: None, @@ -2686,6 +2697,7 @@ mod tests { turns[0], Turn { id: "turn-image".into(), + root_turn_id: None, status: TurnStatus::Completed, error: None, started_at: None, @@ -2797,6 +2809,7 @@ mod tests { questions: None, }), EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some("turn-1".into()), started_at: None, reason: TurnAbortReason::Replaced, @@ -3060,6 +3073,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -3732,6 +3746,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -3799,6 +3814,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -3844,6 +3860,7 @@ mod tests { status: CoreExecCommandStatus::Completed, }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-b".into(), started_at: None, last_agent_message: None, @@ -3908,6 +3925,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -3953,6 +3971,7 @@ mod tests { status: CoreExecCommandStatus::Completed, }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-b".into(), started_at: None, last_agent_message: None, @@ -4038,6 +4057,7 @@ mod tests { Some(expected.clone()) ); builder.handle_event(&EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-1".into(), started_at: Some(100), last_agent_message: None, @@ -4090,8 +4110,13 @@ mod tests { Some(expected.clone()) ); assert_eq!(builder.active_turn_snapshot().unwrap().items.len(), 1); + assert_eq!( + builder.active_turn_snapshot().unwrap().root_turn_id, + expected.root_turn_id + ); builder.handle_event(&EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-1".into(), started_at: Some(100), last_agent_message: None, @@ -4103,8 +4128,16 @@ mod tests { expected.status = TurnStatus::Completed; expected.completed_at = Some(102); expected.duration_ms = Some(2_000); - assert_eq!(builder.active_turn_metadata_snapshot(), Some(expected)); + assert_eq!( + builder.active_turn_metadata_snapshot(), + Some(expected.clone()) + ); + assert_eq!( + builder.active_turn_snapshot_with_items_view(TurnItemsView::NotLoaded), + Some(Turn::from(expected.clone())) + ); assert_eq!(builder.active_turn_snapshot().unwrap().items.len(), 1); + assert_eq!(builder.finish()[0].root_turn_id, expected.root_turn_id); } #[test] @@ -4270,6 +4303,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -4296,6 +4330,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -4312,6 +4347,7 @@ mod tests { questions: None, }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-b".into(), started_at: None, last_agent_message: None, @@ -4371,6 +4407,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: Some(10), last_agent_message: None, @@ -4395,6 +4432,7 @@ mod tests { vec![ Turn { id: "turn-a".into(), + root_turn_id: None, items_view: TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "item-1".into(), @@ -4420,6 +4458,7 @@ mod tests { }, Turn { id: "turn-b".into(), + root_turn_id: None, items_view: TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "item-2".into(), @@ -4460,6 +4499,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -4486,6 +4526,7 @@ mod tests { ..Default::default() }), EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some("turn-a".into()), started_at: None, reason: TurnAbortReason::Replaced, @@ -4541,6 +4582,7 @@ mod tests { resume_metadata: None, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-compact".into(), started_at: None, last_agent_message: None, @@ -4556,6 +4598,7 @@ mod tests { turns, vec![Turn { id: "turn-compact".into(), + root_turn_id: None, status: TurnStatus::Completed, error: None, started_at: None, @@ -4810,6 +4853,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -4835,6 +4879,7 @@ mod tests { turns[0], Turn { id: "turn-a".into(), + root_turn_id: None, status: TurnStatus::Completed, error: None, started_at: None, @@ -4881,6 +4926,7 @@ mod tests { }), }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -4935,6 +4981,7 @@ mod tests { ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: Some(10), last_agent_message: None, @@ -4958,6 +5005,7 @@ mod tests { build_turns_from_rollout_items(&items), vec![Turn { id: "turn-a".into(), + root_turn_id: None, items_view: TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "item-1".into(), @@ -5008,6 +5056,7 @@ mod tests { })), RolloutItem::ResponseItem(hook_prompt.into()), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -5091,6 +5140,7 @@ mod tests { collaboration_mode_kind: Default::default(), })), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -5152,6 +5202,7 @@ mod tests { .into(), ), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -5331,6 +5382,7 @@ mod tests { ))); let complete_changes = builder.handle_rollout_item_with_changes(&RolloutItem::EventMsg( EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, @@ -5421,6 +5473,7 @@ mod tests { collaboration_mode_kind: Default::default(), })), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-a".into(), started_at: None, last_agent_message: None, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs index b6ad558bb5..756f42e3ab 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection.rs @@ -35,7 +35,7 @@ pub fn project_rollout_line(line: &RolloutLine) -> ThreadHistoryChangeSet { RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => ThreadHistoryChangeSet { changed_turns: vec![ThreadHistoryTurnMetadata { turn_id: event.turn_id.clone(), - root_turn_id: None, + root_turn_id: event.root_turn_id.clone(), status: if event.error.is_some() { TurnStatus::Failed } else { @@ -60,7 +60,7 @@ pub fn project_rollout_line(line: &RolloutLine) -> ThreadHistoryChangeSet { ThreadHistoryChangeSet { changed_turns: vec![ThreadHistoryTurnMetadata { turn_id: turn_id.clone(), - root_turn_id: None, + root_turn_id: event.root_turn_id.clone(), status: TurnStatus::Interrupted, error: event.error.as_ref().map(|error| TurnError { message: error.message.clone(), diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs index bed87ac9ab..6afb4411ee 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history_projection_tests.rs @@ -37,6 +37,7 @@ fn projects_turn_lifecycle_without_prior_builder_state() { ))); let completed = project(RolloutItem::EventMsg(EventMsg::TurnComplete( TurnCompleteEvent { + root_turn_id: Some("root-turn".into()), turn_id: "turn-1".to_string(), last_agent_message: None, error: None, @@ -60,7 +61,7 @@ fn projects_turn_lifecycle_without_prior_builder_state() { ThreadHistoryChangeSet { changed_turns: vec![ThreadHistoryTurnMetadata { turn_id: "turn-1".to_string(), - root_turn_id: None, + root_turn_id: Some("root-turn".into()), status: TurnStatus::Completed, error: None, started_at: Some(10), @@ -82,6 +83,7 @@ fn projects_failed_turn_completion_as_snapshot() { let changes = project(RolloutItem::EventMsg(EventMsg::TurnComplete( TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-1".to_string(), last_agent_message: None, error: Some(error), @@ -199,6 +201,7 @@ fn projects_optional_completed_item_lifecycle_timestamps() { fn ignores_legacy_abort_without_turn_id_and_context_only_records() { let aborted = project(RolloutItem::EventMsg(EventMsg::TurnAborted( TurnAbortedEvent { + root_turn_id: None, turn_id: None, reason: TurnAbortReason::Interrupted, error: None, @@ -237,6 +240,7 @@ fn ignores_legacy_abort_without_turn_id_and_context_only_records() { fn projects_identified_turn_aborts() { let changes = project(RolloutItem::EventMsg(EventMsg::TurnAborted( TurnAbortedEvent { + root_turn_id: Some("root-turn".into()), turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, error: None, @@ -251,7 +255,7 @@ fn projects_identified_turn_aborts() { ThreadHistoryChangeSet { changed_turns: vec![ThreadHistoryTurnMetadata { turn_id: "turn-1".to_string(), - root_turn_id: None, + root_turn_id: Some("root-turn".into()), status: TurnStatus::Interrupted, error: None, started_at: Some(10), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index e299c13095..598c9b71d1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -5060,6 +5060,8 @@ fn turn_start_params_preserve_explicit_null_service_tier() { client_user_message_id: None, input: vec![], turn_trigger: None, + parent_turn_id: None, + root_turn_id: None, tool_output: None, responsesapi_client_metadata: None, additional_context: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index e54fa77765..015d813462 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -386,6 +386,10 @@ impl<'de> Deserialize<'de> for Thread { pub struct Turn { /// Identifier for this turn. Codex-generated turn IDs are UUIDv7. pub id: String, + /// ID of the first turn in the chain of work that led to this turn. + /// Pass this as `rootTurnId` when starting work on behalf of this turn. + /// May be null in older history or a `review/start` response. + pub root_turn_id: Option, /// Thread items currently included in this turn payload. pub items: Vec, /// Describes how much of `items` has been loaded for this turn. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index f7a7ec09ea..6b784ada66 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -177,6 +177,21 @@ pub struct TurnStartParams { /// Ignored when this request steers an already-active turn. #[ts(optional = nullable)] pub turn_trigger: Option, + /// ID of the turn that caused this new turn to start. + /// + /// Set this when starting work on behalf of another turn, such as delegated + /// work in a different thread. Leave unset for work started directly by the + /// user. Ignored when this request adds input to an active turn. + #[ts(optional = nullable)] + pub parent_turn_id: Option, + /// ID of the first turn in the chain of work that led to this new turn. + /// + /// When setting `parentTurnId`, set this to the parent turn's `rootTurnId` + /// when known. This keeps descendant work attributed to the original turn. + /// If omitted, the new turn becomes its own root. Ignored when this request + /// adds input to an active turn. + #[ts(optional = nullable)] + pub root_turn_id: Option, #[ts(optional = nullable)] pub tool_output: Option>, /// Optional metadata to enrich Codex's ResponsesAPI turn metadata. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 14908318e3..0e0c31fbca 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -162,6 +162,7 @@ pub(crate) async fn apply_bespoke_event_handling( let state = thread_state.lock().await; let mut turn = state.active_turn_snapshot().unwrap_or_else(|| Turn { id: payload.turn_id.clone(), + root_turn_id: payload.root_turn_id.clone(), items: Vec::new(), items_view: TurnItemsView::NotLoaded, error: None, @@ -1311,6 +1312,7 @@ async fn handle_turn_plan_update( struct TurnCompletionMetadata { status: TurnStatus, + root_turn_id: Option, error: Option, last_agent_message: Option, started_at: Option, @@ -1332,6 +1334,7 @@ async fn emit_turn_completed_with_status( thread_id: conversation_id.to_string(), turn: Turn { id: event_turn_id, + root_turn_id: turn_completion_metadata.root_turn_id, items, items_view, error: turn_completion_metadata.error, @@ -1519,6 +1522,7 @@ async fn handle_turn_complete( event_turn_id, TurnCompletionMetadata { status, + root_turn_id: turn_complete_event.root_turn_id, error, last_agent_message, started_at: turn_summary.started_at, @@ -1544,6 +1548,7 @@ async fn handle_turn_interrupted( event_turn_id, TurnCompletionMetadata { status: TurnStatus::Interrupted, + root_turn_id: turn_aborted_event.root_turn_id, error: turn_aborted_event.error.map(|error| TurnError { message: error.message, codex_error_info: error.codex_error_info.map(Into::into), @@ -2179,6 +2184,7 @@ mod tests { fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), started_at: None, last_agent_message: None, @@ -2191,6 +2197,7 @@ mod tests { fn turn_aborted_event(turn_id: &str) -> TurnAbortedEvent { TurnAbortedEvent { + root_turn_id: None, turn_id: Some(turn_id.to_string()), started_at: None, reason: codex_protocol::protocol::TurnAbortReason::Interrupted, @@ -3513,7 +3520,10 @@ mod tests { handle_turn_interrupted( conversation_id, event_turn_id.clone(), - turn_aborted_event(&event_turn_id), + TurnAbortedEvent { + root_turn_id: Some("root-a".to_string()), + ..turn_aborted_event(&event_turn_id) + }, &outgoing, &thread_state, ) @@ -3523,6 +3533,7 @@ mod tests { match msg { ServerNotification::TurnCompleted(n) => { assert_eq!(n.turn.id, event_turn_id); + assert_eq!(n.turn.root_turn_id.as_deref(), Some("root-a")); assert_eq!(n.turn.status, TurnStatus::Interrupted); assert_eq!(n.turn.error, None); assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index b467a33ec2..9673442d38 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -1033,6 +1033,7 @@ mod tests { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items: Vec::new(), items_view: TurnItemsView::NotLoaded, status: TurnStatus::Completed, diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 41d843c743..518df3077e 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -684,6 +684,8 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { text_elements: Vec::new(), }], turn_trigger: None, + parent_turn_id: None, + root_turn_id: None, tool_output: None, responsesapi_client_metadata: None, additional_context: None, diff --git a/codex-rs/app-server/src/request_processors/daemon_continuation.rs b/codex-rs/app-server/src/request_processors/daemon_continuation.rs index 5c6ea2bb4d..7eef0e2ffc 100644 --- a/codex-rs/app-server/src/request_processors/daemon_continuation.rs +++ b/codex-rs/app-server/src/request_processors/daemon_continuation.rs @@ -103,6 +103,7 @@ impl ThreadRequestProcessor { if let Err(err) = thread .append_rollout_items(&[RolloutItem::EventMsg(EventMsg::TurnAborted( TurnAbortedEvent { + root_turn_id: None, turn_id: Some(saved.turn_id.clone()), reason: TurnAbortReason::Interrupted, error: None, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 19e5a9c6d0..8bdf6bbed3 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -5901,6 +5901,7 @@ fn stored_turn_to_api_turn( .collect::, _>>()?; Ok(Turn { id: turn.turn_id, + root_turn_id: turn.root_turn_id, items, items_view, status, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index c4cf0cfc2b..f1bd1bfe92 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -291,6 +291,7 @@ mod thread_processor_behavior_tests { ))]; let active_turn = Turn { id: "live-turn".to_string(), + root_turn_id: None, items: vec![ThreadItem::UserMessage { id: "live-user-message".to_string(), client_id: None, diff --git a/codex-rs/app-server/src/request_processors/thread_queue_processor.rs b/codex-rs/app-server/src/request_processors/thread_queue_processor.rs index ea6ec7ddab..a2f5b5ad68 100644 --- a/codex-rs/app-server/src/request_processors/thread_queue_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_queue_processor.rs @@ -205,8 +205,11 @@ impl ThreadQueueRequestProcessor { ) .await .map_err(queue_error)?; - let turn_id = match submission { - StartIfIdleSubmission::Started { turn_id } => turn_id, + let (turn_id, root_turn_id) = match submission { + StartIfIdleSubmission::Started { + turn_id, + root_turn_id, + } => (turn_id, root_turn_id), StartIfIdleSubmission::NotSubmitted { reason: NotSubmittedReason::ServerDraining, } => return Err(crate::error_code::server_draining_error()), @@ -229,6 +232,7 @@ impl ThreadQueueRequestProcessor { Ok(ThreadQueueStartResponse { turn: Turn { id: turn_id, + root_turn_id: Some(root_turn_id), items: vec![], items_view: TurnItemsView::NotLoaded, error: None, diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 62b10cf0b9..f35d0341e0 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -236,6 +236,7 @@ mod tests { name: None, turns: vec![Turn { id: "turn-1".to_string(), + root_turn_id: None, items, items_view: TurnItemsView::Full, status: TurnStatus::Completed, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 605a7b38eb..051408204f 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -654,10 +654,12 @@ impl TurnRequestProcessor { .with_thread_settings(thread_settings) .on_start(TurnStartOptions { turn_trigger: params.turn_trigger, + parent_turn_id: params.parent_turn_id, + initiating_agent_path: None, + root_turn_id: params.root_turn_id, final_output_json_schema: params.output_schema, service_tier: params.service_tier_for_turn, cyber_access_program: params.cyber_access_program.map(Into::into), - ..Default::default() }) .with_additional_context(additional_context) .with_responses_metadata(params.responsesapi_client_metadata) @@ -669,9 +671,15 @@ impl TurnRequestProcessor { self.track_error_response(&request_id, &error, /*error_type*/ None); error })?; - let (turn_id, started) = match submission { - TurnInputSubmission::Started { turn_id } => (turn_id, true), - TurnInputSubmission::Steered { turn_id } => (turn_id, false), + let (turn_id, root_turn_id, started) = match submission { + TurnInputSubmission::Started { + turn_id, + root_turn_id, + } => (turn_id, root_turn_id, true), + TurnInputSubmission::Steered { + turn_id, + root_turn_id, + } => (turn_id, root_turn_id, false), TurnInputSubmission::NotSubmitted { reason } => { let error = if reason == NotSubmittedReason::ServerDraining { crate::error_code::server_draining_error() @@ -703,6 +711,7 @@ impl TurnRequestProcessor { .await; let turn = Turn { id: turn_id, + root_turn_id: Some(root_turn_id), items: vec![], items_view: TurnItemsView::NotLoaded, error: None, @@ -1409,6 +1418,7 @@ impl TurnRequestProcessor { Turn { id: turn_id, + root_turn_id: None, items, items_view: TurnItemsView::NotLoaded, error: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 35bfda57be..9ce85c98a0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -1534,6 +1534,7 @@ async fn thread_fork_creates_reference_backed_paginated_thread() -> Result<()> { collaboration_mode_kind: Default::default(), })), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-1".to_string(), last_agent_message: None, error: None, @@ -1858,7 +1859,7 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( &RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_attribution: None, turn_id: "active-turn".to_string(), - root_turn_id: None, + root_turn_id: Some("causal-root".to_string()), trace_id: None, started_at: Some(10), model_context_window: None, @@ -1967,6 +1968,7 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( append_rollout_item_to_path( source_path.as_path(), &RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "active-turn".to_string(), last_agent_message: None, error: None, @@ -2034,6 +2036,7 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( .await?; assert_eq!(turns.len(), 1); assert_eq!(turns[0].id, "active-turn"); + assert_eq!(turns[0].root_turn_id.as_deref(), Some("causal-root")); assert_eq!(turns[0].status, TurnStatus::Interrupted); assert_eq!(turns[0].items.len(), 1); assert!(matches!( diff --git a/codex-rs/app-server/tests/suite/v2/thread_queue.rs b/codex-rs/app-server/tests/suite/v2/thread_queue.rs index caaf4f77ee..a27c3b5a1f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_queue.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_queue.rs @@ -815,6 +815,10 @@ async fn queue_start_without_id_starts_the_head_when_idle() -> Result<()> { .await?; let started: ThreadQueueStartResponse = timeout(READ_TIMEOUT, app.read_response(start_request_id)).await??; + assert_eq!( + started.turn.root_turn_id.as_deref(), + Some(started.turn.id.as_str()) + ); let started_item = loop { let started_item: ItemStartedNotification = timeout(READ_TIMEOUT, app.read_notification("item/started")).await??; diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 58505d0d61..6b7d924952 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -215,6 +215,7 @@ async fn thread_read_can_include_turns() -> Result<()> { assert_eq!(thread.turns.len(), 1); let turn = &thread.turns[0]; assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.root_turn_id, None); assert_eq!(turn.items_view, TurnItemsView::Full); assert_eq!(turn.items.len(), 1, "expected user message item"); match &turn.items[0] { @@ -238,9 +239,13 @@ async fn thread_read_can_include_turns() -> Result<()> { Ok(()) } -/// Preserves a file-backed image across a completed turn, including a cold history read. +/// Preserves a file-backed image and causal root across completion and a cold history read. +#[test_case::test_case(ThreadHistoryMode::Legacy; "legacy")] +#[test_case::test_case(ThreadHistoryMode::Paginated; "paginated")] #[tokio::test] -async fn thread_read_preserves_file_id_from_completed_turn() -> Result<()> { +async fn thread_read_preserves_file_id_and_root_from_completed_turn( + history_mode: ThreadHistoryMode, +) -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; @@ -252,6 +257,7 @@ async fn thread_read_preserves_file_id_from_completed_turn() -> Result<()> { let start_id = mcp .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), + history_mode: Some(history_mode), ..Default::default() }) .await?; @@ -264,15 +270,18 @@ async fn thread_read_preserves_file_id_from_completed_turn() -> Result<()> { detail: None, }; - timeout( + let completed = timeout( DEFAULT_READ_TIMEOUT, mcp.start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread.id.clone(), input: vec![image.clone()], + root_turn_id: Some("causal-root".to_string()), ..Default::default() }), ) .await??; + let expected_root = "causal-root"; + assert_eq!(completed.turn.root_turn_id.as_deref(), Some(expected_root)); let read_id = mcp .send_thread_read_request(ThreadReadParams { @@ -287,6 +296,10 @@ async fn thread_read_preserves_file_id_from_completed_turn() -> Result<()> { panic!("expected completed turn to start with a user message"); }; assert_eq!(content, &vec![image.clone()]); + assert_eq!( + loaded_thread.turns[0].root_turn_id.as_deref(), + Some(expected_root) + ); timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??; drop(mcp); @@ -297,7 +310,7 @@ async fn thread_read_preserves_file_id_from_completed_turn() -> Result<()> { .await?; let read_id = mcp .send_thread_read_request(ThreadReadParams { - thread_id: thread.id, + thread_id: thread.id.clone(), include_turns: true, }) .await?; @@ -308,6 +321,24 @@ async fn thread_read_preserves_file_id_from_completed_turn() -> Result<()> { panic!("expected persisted turn to start with a user message"); }; assert_eq!(content, &vec![image]); + assert_eq!( + restarted_thread.turns[0].root_turn_id.as_deref(), + Some(expected_root) + ); + let page_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: thread.id, + cursor: None, + limit: Some(1), + sort_direction: Some(SortDirection::Asc), + items_view: None, + }) + .await?; + let page: ThreadTurnsListResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(page_id)).await??; + assert_eq!(page.data.len(), 1); + assert_eq!(page.data[0].id, completed.turn.id); + assert_eq!(page.data[0].root_turn_id.as_deref(), Some(expected_root)); Ok(()) } @@ -1787,6 +1818,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( let expected_turn_1_full = Turn { id: "turn-1".to_string(), + root_turn_id: None, items: vec![ ThreadItem::UserMessage { id: "user-1".to_string(), @@ -1816,6 +1848,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( }; let expected_turn_2_full = Turn { id: "turn-2".to_string(), + root_turn_id: None, items: vec![ThreadItem::UserMessage { id: "user-2".to_string(), client_id: None, @@ -1979,6 +2012,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( first_page.data, vec![Turn { id: "turn-1".to_string(), + root_turn_id: None, items: vec![ ThreadItem::UserMessage { id: "user-1".to_string(), @@ -2016,6 +2050,7 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items( second_page.data, vec![Turn { id: "turn-2".to_string(), + root_turn_id: None, items: Vec::new(), items_view: TurnItemsView::NotLoaded, status: TurnStatus::Interrupted, @@ -2438,6 +2473,7 @@ fn paginated_turn_started(turn_id: &str) -> RolloutItem { fn paginated_turn_completed(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), last_agent_message: None, error: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 6e339c75a5..7fe3bb7a7c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -4317,6 +4317,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re "timestamp": meta_rfc3339, "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some(interrupted_turn_id.to_string()), started_at: None, reason: TurnAbortReason::Interrupted, diff --git a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs index 954f09eaf6..4dd2a6f77c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_timeline.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_timeline.rs @@ -129,6 +129,7 @@ async fn timeline_pages_mix_items_and_resolve_the_opening_realtime_session() -> }, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-1".to_string(), last_agent_message: None, error: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 68d02cf2bd..491207ad77 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -583,11 +583,13 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { }) .await?; assert!(!turn.id.is_empty()); + assert_eq!(turn.root_turn_id.as_deref(), Some(turn.id.as_str())); let started: TurnStartedNotification = timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("turn/started")).await??; assert_eq!(started.thread_id, thread.id); assert_eq!(started.turn.id, turn.id); + assert_eq!(started.turn.root_turn_id.as_deref(), Some(turn.id.as_str())); assert_eq!(started.turn.status, TurnStatus::InProgress); let completed: TurnCompletedNotification = timeout( @@ -597,6 +599,10 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { .await??; assert_eq!(completed.thread_id, thread.id); assert_eq!(completed.turn.id, turn.id); + assert_eq!( + completed.turn.root_turn_id.as_deref(), + Some(turn.id.as_str()) + ); assert_eq!(completed.turn.status, TurnStatus::Completed); assert_eq!(completed.turn.items_view, TurnItemsView::Summary); assert!(matches!( @@ -703,16 +709,17 @@ async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<() text_elements: Vec::new(), }], turn_trigger: Some("goal".to_string()), + parent_turn_id: Some("initiating-turn".to_string()), + root_turn_id: Some("causal-root".to_string()), cyber_access_program: Some(CyberAccessProgram::DaybreakBlue), ..Default::default() }, }) .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/started"), - ) - .await??; + let started: TurnStartedNotification = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("turn/started")).await??; + assert_eq!(active_turn.root_turn_id.as_deref(), Some("causal-root")); + assert_eq!(started.turn.root_turn_id.as_deref(), Some("causal-root")); timeout( DEFAULT_READ_TIMEOUT, server.wait_for_request_count(/*count*/ 1), @@ -746,21 +753,25 @@ async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<() text_elements: Vec::new(), }], turn_trigger: Some("user".to_string()), + parent_turn_id: Some("steering-turn".to_string()), + root_turn_id: Some("steering-root".to_string()), cyber_access_program: Some(CyberAccessProgram::Standard), ..Default::default() }, }) .await?; assert_eq!(steered_turn.id, active_turn.id); + assert_eq!(steered_turn.root_turn_id, active_turn.root_turn_id); release_response .send(()) .expect("active response gate should remain open"); - timeout( + let completed: TurnCompletedNotification = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), + mcp.read_notification("turn/completed"), ) .await??; + assert_eq!(completed.turn.root_turn_id.as_deref(), Some("causal-root")); let requests = server.requests().await; assert_eq!(requests.len(), 2); @@ -773,6 +784,8 @@ async fn turn_start_steers_active_turn_and_returns_active_turn_id() -> Result<() .context("expected x-codex-turn-metadata")?, )?; assert_eq!(turn_metadata["turn_trigger"].as_str(), Some("goal")); + assert_eq!(turn_metadata["parent_turn_id"], "initiating-turn"); + assert_eq!(turn_metadata["root_turn_id"], "causal-root"); } Ok(()) } @@ -3389,6 +3402,8 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns( text_elements: Vec::new(), }], turn_trigger: None, + parent_turn_id: None, + root_turn_id: None, tool_output: None, responsesapi_client_metadata: None, additional_context: None, @@ -3442,6 +3457,8 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns( text_elements: Vec::new(), }], turn_trigger: None, + parent_turn_id: None, + root_turn_id: None, tool_output: None, responsesapi_client_metadata: None, additional_context: None, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 903f178b17..952632070b 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -142,7 +142,7 @@ impl LocalAgentControl { .start_or_steer_turn(TurnInputRequest::user_input(input).on_start(start_options)) .await { - Ok(TurnInputSubmission::Started { turn_id }) => Ok(turn_id), + Ok(TurnInputSubmission::Started { turn_id, .. }) => Ok(turn_id), Ok(TurnInputSubmission::Steered { .. }) => { // MAv1 exposes an opaque `submission_id` to the model. The legacy // `Op::UserInput` path returned a fresh ID for every steer, while the diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs index b23f02ff71..5b524c8278 100644 --- a/codex-rs/core/src/agent/control/residency_tests.rs +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -194,6 +194,7 @@ async fn mark_thread_completed(thread: &CodexThread) { .send_event( turn.as_ref(), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn.sub_id.clone(), started_at: None, last_agent_message: Some("done".to_string()), @@ -214,6 +215,7 @@ async fn mark_thread_interrupted(thread: &CodexThread) { .send_event( turn.as_ref(), EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some(turn.sub_id.clone()), started_at: None, reason: TurnAbortReason::Interrupted, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 17404e0acc..efe629928c 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -590,6 +590,7 @@ async fn on_event_updates_status_from_task_complete() { ), ] { let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-1".to_string(), started_at: None, last_agent_message: Some("done".to_string()), @@ -617,6 +618,7 @@ async fn on_event_updates_status_from_error() { #[tokio::test] async fn on_event_updates_status_from_turn_aborted() { let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some("turn-1".to_string()), started_at: None, reason: TurnAbortReason::Interrupted, @@ -3948,6 +3950,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { .send_event( tester_turn.as_ref(), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: tester_turn.sub_id.clone(), started_at: None, last_agent_message: Some("done".to_string()), @@ -4030,6 +4033,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() { .send_event( tester_turn.as_ref(), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: tester_turn.sub_id.clone(), started_at: None, last_agent_message: Some("done".to_string()), diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 0ca83da485..d4e347c252 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -51,6 +51,7 @@ async fn forward_events_filters_private_events_before_blocked_send_is_cancelled( .send(Event { id: "full".to_string(), msg: EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some("turn-1".to_string()), started_at: None, reason: TurnAbortReason::Interrupted, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 7eb186016d..9628c834d3 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -425,9 +425,13 @@ impl CodexThread { .submit_turn_input_with_mode(request, TurnInputMode::StartIfIdle) .await? { - TurnInputSubmission::Started { turn_id } => { - Ok(StartIfIdleSubmission::Started { turn_id }) - } + TurnInputSubmission::Started { + turn_id, + root_turn_id, + } => Ok(StartIfIdleSubmission::Started { + turn_id, + root_turn_id, + }), TurnInputSubmission::NotSubmitted { reason } => { Ok(StartIfIdleSubmission::NotSubmitted { reason }) } @@ -489,9 +493,13 @@ impl CodexThread { ) .await? { - TurnInputSubmission::Started { turn_id } => { - Ok(StartIfIdleSubmission::Started { turn_id }) - } + TurnInputSubmission::Started { + turn_id, + root_turn_id, + } => Ok(StartIfIdleSubmission::Started { + turn_id, + root_turn_id, + }), TurnInputSubmission::NotSubmitted { reason } => { Ok(StartIfIdleSubmission::NotSubmitted { reason }) } @@ -554,7 +562,9 @@ impl CodexThread { .submit_turn_input_with_mode(request, TurnInputMode::Steer { expected_turn_id }) .await? { - TurnInputSubmission::Steered { turn_id } => Ok(SteerSubmission::Steered { turn_id }), + TurnInputSubmission::Steered { turn_id, .. } => { + Ok(SteerSubmission::Steered { turn_id }) + } TurnInputSubmission::NotSubmitted { reason } => { Ok(SteerSubmission::NotSubmitted { reason }) } diff --git a/codex-rs/core/src/guardian/review_session_tests.rs b/codex-rs/core/src/guardian/review_session_tests.rs index fe6323b7ae..47fdc5f6ce 100644 --- a/codex-rs/core/src/guardian/review_session_tests.rs +++ b/codex-rs/core/src/guardian/review_session_tests.rs @@ -107,6 +107,7 @@ async fn run_review_preserves_evidence_during_parent_compaction() { let text = serde_json::to_string(&content).unwrap(); reply .send(Ok(TurnInputSubmission::Started { + root_turn_id: id.clone(), turn_id: id.clone(), })) .unwrap(); @@ -179,6 +180,7 @@ fn turn_complete_event( Event { id: turn_id.to_string(), msg: EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), started_at: None, last_agent_message: last_agent_message.map(str::to_string), @@ -194,6 +196,7 @@ fn turn_aborted_event(turn_id: &str) -> Event { Event { id: turn_id.to_string(), msg: EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some(turn_id.to_string()), started_at: None, reason: TurnAbortReason::Interrupted, @@ -728,6 +731,7 @@ async fn run_review_on_reused_session_waits_for_submitted_turn() { }; reply .send(Ok(TurnInputSubmission::Started { + root_turn_id: id.clone(), turn_id: id.clone(), })) .expect("reply to guardian submission"); @@ -791,7 +795,10 @@ async fn run_review_removes_trunk_when_event_stream_is_broken() { panic!("expected turn-input submission"); }; reply - .send(Ok(TurnInputSubmission::Started { turn_id: id })) + .send(Ok(TurnInputSubmission::Started { + root_turn_id: id.clone(), + turn_id: id, + })) .expect("reply to guardian submission"); drop(tx_event); diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 70589be7f3..cb9f819740 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -2971,6 +2971,7 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow:: .send_trunk_event_raw_for_test(Event { id: "stale-turn".to_string(), msg: EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "stale-turn".to_string(), started_at: None, last_agent_message: Some( diff --git a/codex-rs/core/src/realtime_conversation/transcript_tail.rs b/codex-rs/core/src/realtime_conversation/transcript_tail.rs index eb778e4a0b..db8e85da72 100644 --- a/codex-rs/core/src/realtime_conversation/transcript_tail.rs +++ b/codex-rs/core/src/realtime_conversation/transcript_tail.rs @@ -73,6 +73,7 @@ pub(super) async fn record(session: &Session, text: String) -> std::io::Result<( if recording_turn { events.push(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn.sub_id.clone(), + root_turn_id: Some(turn.sub_id.clone()), last_agent_message: None, error: None, started_at: Some(now.timestamp()), diff --git a/codex-rs/core/src/realtime_history_tests.rs b/codex-rs/core/src/realtime_history_tests.rs index c6b0b977a6..18aea1636a 100644 --- a/codex-rs/core/src/realtime_history_tests.rs +++ b/codex-rs/core/src/realtime_history_tests.rs @@ -140,6 +140,7 @@ fn interrupted_turn_is_not_associated_with_a_new_voice_session( }, )); let aborted = EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: aborted_turn_id.map(str::to_string), reason: TurnAbortReason::Interrupted, error: None, @@ -175,6 +176,7 @@ fn interrupted_turn_is_not_associated_with_a_new_voice_session( fn interrupted_turn_keeps_its_existing_voice_session_for_late_artifacts() { let mut state = started_state(); state.observe(&EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some("turn-1".to_string()), reason: TurnAbortReason::Interrupted, error: None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 7f0bd90d16..279bacf897 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2266,12 +2266,7 @@ impl Session { let event = TurnStartedEvent { turn_attribution: (task_kind == TaskKind::Regular).then(|| turn_context.attribution()), turn_id: turn_context.sub_id.clone(), - root_turn_id: Some( - turn_context - .turn_metadata_state - .root_turn_id() - .unwrap_or_else(|| turn_context.sub_id.clone()), - ), + root_turn_id: Some(turn_context.root_turn_id()), trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index ddf567e159..4cbf3518a2 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -284,6 +284,7 @@ fn completed_user_turn_rollout( rollout_items.extend(items); rollout_items.push(RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id, last_agent_message: None, error: None, @@ -547,6 +548,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif RolloutItem::TurnContext(previous_context_item), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id, last_agent_message: None, error: None, @@ -638,6 +640,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::ResponseItem(turn_one_assistant.clone().into()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: first_turn_id, started_at: None, last_agent_message: None, @@ -681,6 +684,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com RolloutItem::ResponseItem(turn_two_assistant.into()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: rolled_back_turn_id, started_at: None, last_agent_message: None, @@ -768,6 +772,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc RolloutItem::ResponseItem(turn_one_assistant.clone().into()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: first_turn_id, started_at: None, last_agent_message: None, @@ -873,6 +878,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::ResponseItem(turn_one_assistant.clone().into()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: first_turn_id, started_at: None, last_agent_message: None, @@ -907,6 +913,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad RolloutItem::ResponseItem(turn_two_assistant.into()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: second_turn_id, started_at: None, last_agent_message: None, @@ -935,6 +942,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad }), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: standalone_turn_id, started_at: None, last_agent_message: None, @@ -1017,6 +1025,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::ResponseItem(assistant_message("turn 1 assistant").into()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: first_turn_id, started_at: None, last_agent_message: None, @@ -1042,6 +1051,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { RolloutItem::ResponseItem(assistant_reply.into()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: assistant_turn_id, started_at: None, last_agent_message: None, @@ -1119,6 +1129,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding RolloutItem::ResponseItem(assistant_message("only assistant").into()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: only_turn_id, started_at: None, last_agent_message: None, @@ -1177,6 +1188,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { RolloutItem::TurnContext(previous_context_item), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: user_turn_id, started_at: None, last_agent_message: None, @@ -1200,6 +1212,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { )), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: standalone_turn_id, started_at: None, last_agent_message: None, @@ -1262,6 +1275,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::TurnContext(previous_context_item.clone()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: previous_turn_id, started_at: None, last_agent_message: None, @@ -1640,6 +1654,7 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions( }); wake_items.push(RolloutItem::EventMsg(EventMsg::TurnAborted( codex_protocol::protocol::TurnAbortedEvent { + root_turn_id: None, turn_id: Some(format!("wake-{window_number}")), reason: TurnAbortReason::Interrupted, error: None, @@ -2095,6 +2110,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::TurnContext(current_context_item), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: current_turn_id, started_at: None, last_agent_message: None, @@ -2189,6 +2205,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::TurnContext(previous_context_item), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: previous_turn_id, started_at: None, last_agent_message: None, @@ -2315,6 +2332,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::TurnContext(previous_context_item), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: previous_turn_id, started_at: None, last_agent_message: None, @@ -2347,6 +2365,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu )), RolloutItem::EventMsg(EventMsg::TurnAborted( codex_protocol::protocol::TurnAbortedEvent { + root_turn_id: None, turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, @@ -2457,6 +2476,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::TurnContext(previous_context_item), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: previous_turn_id, started_at: None, last_agent_message: None, @@ -2489,6 +2509,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo )), RolloutItem::EventMsg(EventMsg::TurnAborted( codex_protocol::protocol::TurnAbortedEvent { + root_turn_id: None, turn_id: Some(unmatched_abort_turn_id), started_at: None, reason: TurnAbortReason::Interrupted, @@ -2500,6 +2521,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo RolloutItem::TurnContext(current_context_item.clone()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: current_turn_id, started_at: None, last_agent_message: None, @@ -2600,6 +2622,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::TurnContext(previous_context_item), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: previous_turn_id, started_at: None, last_agent_message: None, @@ -2791,6 +2814,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::TurnContext(previous_context_item), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id: previous_turn_id, started_at: None, last_agent_message: None, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 81e5a2d050..01f1e7d651 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -816,6 +816,7 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted .expect("expected turn aborted event") .expect("channel open"); let EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id, turn_id, reason, error: _, @@ -826,6 +827,7 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted else { panic!("expected turn aborted event"); }; + assert_eq!(root_turn_id, Some(tc.root_turn_id())); assert_eq!(turn_id, Some(tc.sub_id.clone())); assert_eq!(reason, TurnAbortReason::Interrupted); assert!(started_at.is_some()); @@ -4360,6 +4362,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { RolloutItem::TurnContext(previous_context_item.clone()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { + root_turn_id: None, turn_id, last_agent_message: None, error: None, @@ -6282,6 +6285,7 @@ async fn compaction_persists_resume_metadata_and_companion_records() { if let Some(turn_id) = last_started_turn_id { items.push(RolloutItem::EventMsg(EventMsg::TurnComplete( TurnCompleteEvent { + root_turn_id: None, turn_id, last_agent_message: None, error: None, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 0c85aa0d7d..9a57d40291 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -399,6 +399,13 @@ enum TurnContextBuildMode { } impl TurnContext { + /// Returns the resolved causal root for this admitted turn. + pub(crate) fn root_turn_id(&self) -> String { + self.turn_metadata_state + .root_turn_id() + .unwrap_or_else(|| self.sub_id.clone()) + } + /// Builds a review turn with shared session grants and fresh turn-local state. pub(super) fn for_review( &self, diff --git a/codex-rs/core/src/session/turn_input.rs b/codex-rs/core/src/session/turn_input.rs index c1ce885c7c..84b418a94d 100644 --- a/codex-rs/core/src/session/turn_input.rs +++ b/codex-rs/core/src/session/turn_input.rs @@ -268,8 +268,9 @@ pub(super) async fn handle( } }; // Link this request's trace to the accepted turn, which may have an older trace. - if let Ok(TurnInputSubmission::Started { turn_id } | TurnInputSubmission::Steered { turn_id }) = - &result + if let Ok( + TurnInputSubmission::Started { turn_id, .. } | TurnInputSubmission::Steered { turn_id, .. }, + ) = &result { tracing::Span::current().record("turn.id", turn_id); } @@ -306,7 +307,7 @@ pub(super) async fn handle_recovery( /*expected_previous_turn_id*/ None, ) .await; - if let Ok(TurnInputSubmission::Started { turn_id }) = &result { + if let Ok(TurnInputSubmission::Started { turn_id, .. }) = &result { tracing::Span::current().record("turn.id", turn_id); } result @@ -363,9 +364,12 @@ async fn start_or_steer( ) .await { - Ok(turn_id) => { + Ok((turn_id, root_turn_id)) => { settings.apply_steered(session, submission_id).await?; - Ok(TurnInputSubmission::Steered { turn_id }) + Ok(TurnInputSubmission::Steered { + turn_id, + root_turn_id, + }) } Err(NotSubmittedReason::NoActiveTurn) => { // MAv1 sends explicit input to spawned agents as part of an existing @@ -413,10 +417,11 @@ async fn start_or_steer( .push(pending_turn_input(session, input, &turn_context.sub_id, origin).await); } session - .spawn_task(turn_context, task_input, RegularTask::new()) + .spawn_task(Arc::clone(&turn_context), task_input, RegularTask::new()) .await; Ok(TurnInputSubmission::Started { turn_id: submission_id, + root_turn_id: turn_context.root_turn_id(), }) } Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }), @@ -575,10 +580,11 @@ async fn start_if_idle( } } session - .start_task(turn_context, task_input, RegularTask::new()) + .start_task(Arc::clone(&turn_context), task_input, RegularTask::new()) .await; Ok(TurnInputSubmission::Started { turn_id: submission_id, + root_turn_id: turn_context.root_turn_id(), }) } @@ -626,9 +632,12 @@ async fn steer( ) .await { - Ok(turn_id) => { + Ok((turn_id, root_turn_id)) => { settings.apply_steered(session, submission_id).await?; - Ok(TurnInputSubmission::Steered { turn_id }) + Ok(TurnInputSubmission::Steered { + turn_id, + root_turn_id, + }) } Err(reason) => Ok(TurnInputSubmission::NotSubmitted { reason }), } @@ -721,7 +730,7 @@ impl Session { required_final_output_json_schema: Option<&Value>, responsesapi_client_metadata: Option>, origin: UserInputOrigin, - ) -> Result { + ) -> Result<(String, String), NotSubmittedReason> { let mut active = self.active_turn.lock().await; let Some(active_turn) = active.as_mut() else { return Err(NotSubmittedReason::NoActiveTurn); @@ -799,7 +808,10 @@ impl Session { pending_input, ) .await; - Ok(active_turn_id.clone()) + Ok(( + active_turn_id.clone(), + active_task.turn_context.root_turn_id(), + )) } } diff --git a/codex-rs/core/src/session/turn_input_tests.rs b/codex-rs/core/src/session/turn_input_tests.rs index e76b3e2684..bb338c72dd 100644 --- a/codex-rs/core/src/session/turn_input_tests.rs +++ b/codex-rs/core/src/session/turn_input_tests.rs @@ -159,6 +159,7 @@ async fn steering_does_not_wait_for_realtime_history() { assert_eq!( submission, TurnInputSubmission::Steered { + root_turn_id: turn_context.sub_id.clone(), turn_id: turn_context.sub_id.clone() } ); @@ -922,6 +923,7 @@ async fn steer_only_enforces_expected_turn_id() { assert_eq!( submission, TurnInputSubmission::Steered { + root_turn_id: turn_context.sub_id.clone(), turn_id: turn_context.sub_id.clone() } ); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index fc9272e911..44baa9d60d 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -820,6 +820,7 @@ impl Session { self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) .await; EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: Some(turn_context.root_turn_id()), turn_id: Some(turn_context.sub_id.clone()), reason, error: None, @@ -836,6 +837,7 @@ impl Session { self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) .await; EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: Some(turn_context.root_turn_id()), turn_id: turn_context.sub_id.clone(), last_agent_message, error, @@ -1003,6 +1005,7 @@ impl Session { self.emit_turn_abort_lifecycle(reason.clone(), task.turn_context.extension_data.as_ref()) .await; let event = EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: Some(task.turn_context.root_turn_id()), turn_id: Some(task.turn_context.sub_id.clone()), reason, error, diff --git a/codex-rs/core/src/tasks/mod_tests.rs b/codex-rs/core/src/tasks/mod_tests.rs index 94c2373519..86a0dbf561 100644 --- a/codex-rs/core/src/tasks/mod_tests.rs +++ b/codex-rs/core/src/tasks/mod_tests.rs @@ -137,7 +137,7 @@ async fn turn_completion_metrics_follow_model_switch(scenario: UsageScenario) { .with_runtime_reader(), ) .expect("in-memory metrics client"); - let (mut session, mut turn_context, _receiver) = make_session_and_context_with_rx().await; + let (mut session, mut turn_context, receiver) = make_session_and_context_with_rx().await; let session_telemetry = session .services .session_telemetry @@ -243,7 +243,21 @@ async fn turn_completion_metrics_follow_model_switch(scenario: UsageScenario) { } else { Ok(None) }; + let expected_root = turn_context.root_turn_id(); session.on_task_finished(turn_context, task_result).await; + let mut terminal_root = None; + while let Ok(event) = receiver.try_recv() { + match event.msg { + codex_protocol::protocol::EventMsg::TurnComplete(event) => { + terminal_root = event.root_turn_id; + } + codex_protocol::protocol::EventMsg::TurnAborted(event) => { + terminal_root = event.root_turn_id; + } + _ => {} + } + } + assert_eq!(terminal_root, Some(expected_root)); let snapshot = metrics.snapshot().expect("runtime metrics snapshot"); let token_usage_metric = find_metric(&snapshot, TURN_TOKEN_USAGE_METRIC); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 49d42eccfd..4bd5bff358 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -2606,6 +2606,7 @@ struct SnapshotTurnState { ends_mid_turn: bool, active_turn_id: Option, active_turn_started_at: Option, + active_turn_root_id: Option, active_turn_start_index: Option, } @@ -2626,6 +2627,7 @@ fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { ends_mid_turn: false, active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, }; } @@ -2633,7 +2635,10 @@ fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { return SnapshotTurnState { ends_mid_turn: true, active_turn_id, - active_turn_started_at: active_turn_snapshot.and_then(|turn| turn.started_at), + active_turn_started_at: active_turn_snapshot + .as_ref() + .and_then(|turn| turn.started_at), + active_turn_root_id: active_turn_snapshot.and_then(|turn| turn.root_turn_id), active_turn_start_index: builder.active_turn_start_index(), }; } @@ -2646,6 +2651,7 @@ fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { ends_mid_turn: false, active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, }; }; @@ -2662,6 +2668,7 @@ fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { }), active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, } } @@ -2689,6 +2696,7 @@ fn fork_history_from_snapshot( append_interrupted_boundary( history, snapshot_state.active_turn_id, + snapshot_state.active_turn_root_id, snapshot_state.active_turn_started_at, interrupted_marker, ) @@ -2705,10 +2713,12 @@ fn fork_history_from_snapshot( fn append_interrupted_boundary( history: InitialHistory, turn_id: Option, + root_turn_id: Option, started_at: Option, interrupted_marker: InterruptedTurnHistoryMarker, ) -> InitialHistory { let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id, turn_id, reason: TurnAbortReason::Interrupted, error: None, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 13e0b069bc..6d8ae1e327 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -718,6 +718,7 @@ fn truncates_before_requested_user_message() { ends_mid_turn: false, active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, }, ); @@ -744,6 +745,7 @@ fn truncates_before_requested_user_message() { ends_mid_turn: false, active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, }, ); @@ -769,6 +771,7 @@ fn out_of_range_truncation_drops_only_unfinished_suffix_mid_turn() { ends_mid_turn: true, active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, }, ); @@ -818,6 +821,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { ends_mid_turn: true, active_turn_id: Some("turn-2".to_string()), active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: Some(2), }, ); @@ -863,6 +867,7 @@ async fn ignores_session_prefix_messages_when_truncating() { ends_mid_turn: false, active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, }, ); @@ -2772,6 +2777,7 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { append_interrupted_boundary( committed_history, /*turn_id*/ None, + /*root_turn_id*/ None, /*started_at*/ None, InterruptedTurnHistoryMarker::ContextualUser, ) @@ -2782,6 +2788,7 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { RolloutItem::ResponseItem(user_msg("hello").into()), RolloutItem::ResponseItem(contextual_user_interrupted_marker().into()), RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, @@ -2797,6 +2804,7 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { append_interrupted_boundary( InitialHistory::New, /*turn_id*/ None, + /*root_turn_id*/ None, /*started_at*/ None, InterruptedTurnHistoryMarker::ContextualUser, ) @@ -2806,6 +2814,7 @@ fn interrupted_fork_snapshot_appends_interrupt_boundary() { serde_json::to_value(vec![ RolloutItem::ResponseItem(contextual_user_interrupted_marker().into()), RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, @@ -2828,6 +2837,7 @@ fn disabled_interrupted_fork_snapshot_appends_only_interrupt_event() { append_interrupted_boundary( committed_history, /*turn_id*/ None, + /*root_turn_id*/ None, /*started_at*/ None, InterruptedTurnHistoryMarker::Disabled, ) @@ -2837,6 +2847,7 @@ fn disabled_interrupted_fork_snapshot_appends_only_interrupt_event() { serde_json::to_value(vec![ RolloutItem::ResponseItem(user_msg("hello").into()), RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, @@ -2852,6 +2863,7 @@ fn disabled_interrupted_fork_snapshot_appends_only_interrupt_event() { append_interrupted_boundary( InitialHistory::New, /*turn_id*/ None, + /*root_turn_id*/ None, /*started_at*/ None, InterruptedTurnHistoryMarker::Disabled, ) @@ -2860,6 +2872,7 @@ fn disabled_interrupted_fork_snapshot_appends_only_interrupt_event() { .expect("serialize disabled interrupted empty fork history"), serde_json::to_value(vec![RolloutItem::EventMsg(EventMsg::TurnAborted( TurnAbortedEvent { + root_turn_id: None, turn_id: None, started_at: None, reason: TurnAbortReason::Interrupted, @@ -2879,6 +2892,7 @@ fn interrupted_snapshot_is_not_mid_turn() { RolloutItem::ResponseItem(assistant_msg("partial").into()), RolloutItem::ResponseItem(contextual_user_interrupted_marker().into()), RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some("turn-1".to_string()), started_at: None, reason: TurnAbortReason::Interrupted, @@ -2894,6 +2908,7 @@ fn interrupted_snapshot_is_not_mid_turn() { ends_mid_turn: false, active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, }, ); @@ -2951,6 +2966,7 @@ fn completed_legacy_event_history_is_not_mid_turn() { ends_mid_turn: false, active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, }, ); @@ -2976,6 +2992,7 @@ fn mixed_response_and_legacy_user_event_history_is_mid_turn() { ends_mid_turn: true, active_turn_id: None, active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: None, }, ); @@ -3060,6 +3077,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor .expect("serialize interrupted marker"); let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg( EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: expected_turn_id, started_at: None, reason: TurnAbortReason::Interrupted, @@ -3155,6 +3173,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { ends_mid_turn: true, active_turn_id: Some("turn-explicit".to_string()), active_turn_started_at: None, + active_turn_root_id: None, active_turn_start_index: Some(1), }, ); @@ -3184,6 +3203,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { matches!( item, RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some(turn_id), started_at: None, reason: TurnAbortReason::Interrupted, diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index 1495a9dfe5..cf034dff79 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -55,6 +55,7 @@ fn turn_started(turn_id: &str) -> RolloutItem { fn turn_completed(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), started_at: None, last_agent_message: None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index bd75eafc9f..79b0f7f50c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1502,6 +1502,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status() { .send_event( child_turn.as_ref(), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: child_turn.sub_id.clone(), started_at: None, last_agent_message: Some("done".to_string()), @@ -1939,6 +1940,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() .send_event( first_turn.as_ref(), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: first_turn.sub_id.clone(), started_at: None, last_agent_message: Some("first done".to_string()), @@ -1981,6 +1983,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() .send_event( second_turn.as_ref(), EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: second_turn.sub_id.clone(), started_at: None, last_agent_message: Some("second done".to_string()), @@ -2144,6 +2147,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() { .send_event( aborted_turn.as_ref(), EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some(aborted_turn.sub_id.clone()), started_at: None, reason: TurnAbortReason::Interrupted, diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index 797ba476fe..19b39e5c9e 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -111,7 +111,7 @@ async fn root_turn_suspension_preserves_unfinished_turn_history() { }])) .await .expect("start root turn"); - let codex_core::TurnInputSubmission::Started { turn_id } = submitted else { + let codex_core::TurnInputSubmission::Started { turn_id, .. } = submitted else { panic!("expected a started root turn"); }; wait_for_event(&codex, |event| matches!(event, EventMsg::TurnStarted(_))).await; @@ -191,7 +191,8 @@ async fn root_turn_suspension_preserves_unfinished_turn_history() { .await .expect("recover the unfinished turn"), codex_core::StartIfIdleSubmission::Started { - turn_id: turn_id.clone(), + root_turn_id: turn_id.clone(), + turn_id: turn_id.clone() }, ); let completed = wait_for_event(&resumed.codex, |event| { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 8368404004..99eb604e82 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -4622,7 +4622,7 @@ async fn paginated_compaction_cold_resume_from_bounded_suffix() -> Result<()> { }, ) .await?; - let codex_core::TurnInputSubmission::Started { turn_id } = test + let codex_core::TurnInputSubmission::Started { turn_id, .. } = test .codex .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { text: FUNCTION_CALL_LIMIT_MSG.into(), diff --git a/codex-rs/core/tests/suite/cyber_access_program.rs b/codex-rs/core/tests/suite/cyber_access_program.rs index dbb709aa2b..829ec85c50 100644 --- a/codex-rs/core/tests/suite/cyber_access_program.rs +++ b/codex-rs/core/tests/suite/cyber_access_program.rs @@ -39,7 +39,7 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - .await; let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); let initial = builder.build_with_streaming_server(&initial_server).await?; - let TurnInputSubmission::Started { turn_id } = initial + let TurnInputSubmission::Started { turn_id, .. } = initial .codex .start_or_steer_turn( TurnInputRequest::user_input(vec![UserInput::Text { @@ -105,7 +105,8 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - assert_eq!( submission, StartIfIdleSubmission::Started { - turn_id: turn_id.clone(), + root_turn_id: turn_id.clone(), + turn_id: turn_id.clone() } ); wait_for_event(&test.codex, |event| { @@ -133,6 +134,7 @@ async fn recover_turn_restores_cyber_access_program_without_making_it_sticky() - .await; let TurnInputSubmission::Started { turn_id: next_turn_id, + .. } = test .codex .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { diff --git a/codex-rs/core/tests/suite/git_enrichment.rs b/codex-rs/core/tests/suite/git_enrichment.rs index 5286068b10..bb06c8bda5 100644 --- a/codex-rs/core/tests/suite/git_enrichment.rs +++ b/codex-rs/core/tests/suite/git_enrichment.rs @@ -408,7 +408,7 @@ async fn ephemeral_system_thread_prewarm_skips_and_turn_observes_fresh_state( .and_then(|connection| connection.get(2)) .context("system turn follow-up request")? .body_json(); - let codex_core::TurnInputSubmission::Started { turn_id } = submission else { + let codex_core::TurnInputSubmission::Started { turn_id, .. } = submission else { panic!("background input should start a turn"); }; assert_root_turn(&turn, Some(&turn_id))?; diff --git a/codex-rs/core/tests/suite/mcp_auth_elicitation.rs b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs index 80d3c9074c..732162376b 100644 --- a/codex-rs/core/tests/suite/mcp_auth_elicitation.rs +++ b/codex-rs/core/tests/suite/mcp_auth_elicitation.rs @@ -271,7 +271,7 @@ approvals_reviewer = "user" text_elements: Vec::new(), }]); let submitted = test.codex.start_or_steer_turn(request).await?; - let TurnInputSubmission::Started { turn_id } = submitted else { + let TurnInputSubmission::Started { turn_id, .. } = submitted else { anyhow::bail!("expected a new turn, got {submitted:?}"); }; diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 0df8b4a20b..fee9018175 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -97,7 +97,7 @@ async fn idle_response_items_include_pending_mailbox_in_first_request() -> anyho responses::user_message_item("automatic response item"), ))) .await?; - let StartIfIdleSubmission::Started { turn_id } = submission else { + let StartIfIdleSubmission::Started { turn_id, .. } = submission else { panic!("automatic input should start a turn"); }; wait_for_turn_complete(test.codex.as_ref()).await; @@ -152,7 +152,7 @@ async fn standalone_tool_output_starts_instruction_turn() -> anyhow::Result<()> .codex .start_or_steer_turn(TurnInputRequest::new(TurnInput::ResponseItem(output))) .await?; - let TurnInputSubmission::Started { turn_id } = submission else { + let TurnInputSubmission::Started { turn_id, .. } = submission else { panic!("standalone output should start a turn"); }; wait_for_turn_complete(test.codex.as_ref()).await; @@ -1924,7 +1924,7 @@ async fn interrupt_if_no_pending_input_checks_turn_and_queue( .build_with_auto_env(&config_server) .await?; let codex = &test.codex; - let TurnInputSubmission::Started { turn_id } = codex + let TurnInputSubmission::Started { turn_id, .. } = codex .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { text: INITIAL_PROMPT.to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/pending_input_persistence.rs b/codex-rs/core/tests/suite/pending_input_persistence.rs index e84680b572..81a7f71ddc 100644 --- a/codex-rs/core/tests/suite/pending_input_persistence.rs +++ b/codex-rs/core/tests/suite/pending_input_persistence.rs @@ -235,7 +235,7 @@ async fn steered_input_checkpoint_controls_next_request( text_elements: Vec::new(), }])) .await?; - let TurnInputSubmission::Started { turn_id } = first else { + let TurnInputSubmission::Started { turn_id, .. } = first else { panic!("first input should start a turn"); }; wait_for_event(&test.codex, |event| { @@ -259,7 +259,10 @@ async fn steered_input_checkpoint_controls_next_request( }; assert_eq!( test.codex.start_or_steer_turn(input).await?, - TurnInputSubmission::Steered { turn_id } + TurnInputSubmission::Steered { + root_turn_id: turn_id.clone(), + turn_id + } ); // Steering still waits for the existing inference stream to finish. assert!(checkpoint_requests.try_recv().is_err()); diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index cebce3c147..760fb69988 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -5751,7 +5751,7 @@ async fn inbound_handoff_request_updates_realtime_state_during_active_turn( text_elements: Vec::new(), }])) .await?; - let TurnInputSubmission::Started { turn_id } = started else { + let TurnInputSubmission::Started { turn_id, .. } = started else { panic!("expected the text input to start a turn"); }; @@ -5867,7 +5867,13 @@ async fn inbound_handoff_request_updates_realtime_state_during_active_turn( text_elements: Vec::new(), }])) .await?; - assert_eq!(steered, TurnInputSubmission::Steered { turn_id }); + assert_eq!( + steered, + TurnInputSubmission::Steered { + root_turn_id: turn_id.clone(), + turn_id + } + ); let _ = second_completed_tx.send(()); second_completion .await diff --git a/codex-rs/core/tests/suite/reasoning_effort_override.rs b/codex-rs/core/tests/suite/reasoning_effort_override.rs index 14a6b0a920..6cce88e2cf 100644 --- a/codex-rs/core/tests/suite/reasoning_effort_override.rs +++ b/codex-rs/core/tests/suite/reasoning_effort_override.rs @@ -265,7 +265,7 @@ async fn reasoning_effort_override_recovery_reuses_trusted_tail_update() -> anyh text_elements: Vec::new(), }])) .await?; - let TurnInputSubmission::Started { turn_id } = submission else { + let TurnInputSubmission::Started { turn_id, .. } = submission else { panic!("expected a new turn"); }; wait_for_event(&test.codex, |event| { @@ -314,7 +314,10 @@ async fn reasoning_effort_override_recovery_reuses_trusted_tail_update() -> anyh cyber_access_program: None, }) .await?, - StartIfIdleSubmission::Started { turn_id }, + StartIfIdleSubmission::Started { + root_turn_id: turn_id.clone(), + turn_id + }, ); wait_for_event(&resumed.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index aa69d0edd9..5f1fa0682d 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -2555,7 +2555,7 @@ async fn active_environment_update_wakes_the_old_wait_with_the_new_selection() - ], ) .await; - let TurnInputSubmission::Started { turn_id } = thread + let TurnInputSubmission::Started { turn_id, .. } = thread .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { text: "use the selected environment".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/core/tests/suite/remote_env_spawn_tests.rs b/codex-rs/core/tests/suite/remote_env_spawn_tests.rs index fbd1943671..cd07ca7691 100644 --- a/codex-rs/core/tests/suite/remote_env_spawn_tests.rs +++ b/codex-rs/core/tests/suite/remote_env_spawn_tests.rs @@ -157,7 +157,7 @@ pub(crate) async fn pending_subagent_scenario( .await; let mut created = test.thread_manager.subscribe_thread_created(); - let TurnInputSubmission::Started { turn_id } = root + let TurnInputSubmission::Started { turn_id, .. } = root .thread .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { text: "Delegate waiting for the shared workspace, and report what the worker finds." diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 14a443f834..599f307c07 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -80,6 +80,7 @@ fn resume_history( })), RolloutItem::TurnContext(turn_ctx), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id, last_agent_message: None, error: None, diff --git a/codex-rs/core/tests/suite/settings_commits.rs b/codex-rs/core/tests/suite/settings_commits.rs index ea368e2c03..73c9ae83aa 100644 --- a/codex-rs/core/tests/suite/settings_commits.rs +++ b/codex-rs/core/tests/suite/settings_commits.rs @@ -86,7 +86,7 @@ async fn initial_plugin_ids_use_turn_context_without_extra_settings_checkpoints( .codex .start_turn_if_idle(TurnInputRequest::user_input(Vec::new())) .await?; - let StartIfIdleSubmission::Started { turn_id } = submission else { + let StartIfIdleSubmission::Started { turn_id, .. } = submission else { panic!("expected an accepted first turn, got {submission:?}"); }; wait_for_event( @@ -220,7 +220,7 @@ async fn settings_notifications_keep_their_commit_across_postcommit_work( .with_thread_settings(thread_settings), ) .await?; - let TurnInputSubmission::Started { turn_id } = result else { + let TurnInputSubmission::Started { turn_id, .. } = result else { panic!("expected a new turn, got {result:?}"); }; Ok(turn_id) diff --git a/codex-rs/core/tests/suite/settings_constraints.rs b/codex-rs/core/tests/suite/settings_constraints.rs index e85d9a2351..3f4c32bf28 100644 --- a/codex-rs/core/tests/suite/settings_constraints.rs +++ b/codex-rs/core/tests/suite/settings_constraints.rs @@ -86,7 +86,7 @@ impl SettingsOperation { .with_thread_settings(thread_settings), ) .await?; - let TurnInputSubmission::Started { turn_id } = result else { + let TurnInputSubmission::Started { turn_id, .. } = result else { anyhow::bail!("expected a new turn, got {result:?}"); }; turn_id diff --git a/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs b/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs index 034c77c49a..dd071818db 100644 --- a/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs +++ b/codex-rs/core/tests/suite/skills_extension/yielded_skill_tests.rs @@ -90,7 +90,10 @@ async fn yielded_skill_read_keeps_originating_turn_metadata() -> Result<()> { text_elements: Vec::new(), }]); let submitted = test.codex.start_or_steer_turn(request).await?; - let TurnInputSubmission::Started { turn_id: turn_a } = submitted else { + let TurnInputSubmission::Started { + turn_id: turn_a, .. + } = submitted + else { anyhow::bail!("expected turn A to start, got {submitted:?}"); }; wait_for_event(&test.codex, |event| { @@ -135,7 +138,10 @@ async fn yielded_skill_read_keeps_originating_turn_metadata() -> Result<()> { text_elements: Vec::new(), }])) .await?; - let TurnInputSubmission::Started { turn_id: turn_b } = submitted else { + let TurnInputSubmission::Started { + turn_id: turn_b, .. + } = submitted + else { anyhow::bail!("expected turn B to start, got {submitted:?}"); }; wait_for_event(&test.codex, |event| { diff --git a/codex-rs/core/tests/suite/turn_input_submission.rs b/codex-rs/core/tests/suite/turn_input_submission.rs index 4686dba102..fcb4ef0ed5 100644 --- a/codex-rs/core/tests/suite/turn_input_submission.rs +++ b/codex-rs/core/tests/suite/turn_input_submission.rs @@ -87,7 +87,8 @@ async fn turn_extension_data_is_captured_for_automatic_turns() -> anyhow::Result }) .await? .thread; - let TurnInputSubmission::Started { turn_id } = submit_user_message(&thread, "start").await? + let TurnInputSubmission::Started { turn_id, .. } = + submit_user_message(&thread, "start").await? else { anyhow::bail!("first input must start a turn"); }; @@ -106,6 +107,7 @@ async fn turn_extension_data_is_captured_for_automatic_turns() -> anyhow::Result )) .await?, TurnInputSubmission::Steered { + root_turn_id: turn_id.clone(), turn_id: turn_id.clone() } ); @@ -635,7 +637,8 @@ async fn recover_turn_if_idle_preserves_id_and_resumes_plan_mode() { assert_eq!( submission, StartIfIdleSubmission::Started { - turn_id: turn_id.to_string(), + root_turn_id: turn_id.to_string(), + turn_id: turn_id.to_string() } ); assert_eq!( @@ -752,6 +755,7 @@ async fn recovery_preserves_durable_turn_attribution( ); let TurnInputSubmission::Started { turn_id: previous_turn_id, + .. } = submit_user_message(&test.codex, "establish the initial context baseline").await? else { panic!("expected the initial turn"); @@ -801,14 +805,14 @@ async fn recovery_preserves_durable_turn_attribution( ..Default::default() }); let turn_id = if matches!(pause, RecoveryPause::AfterLateCompletion) { - let TurnInputSubmission::Started { turn_id } = + let TurnInputSubmission::Started { turn_id, .. } = test.codex.start_or_steer_turn(request).await? else { panic!("expected a new turn while the previous stop hook is pending"); }; turn_id } else { - let StartIfIdleSubmission::Started { turn_id } = + let StartIfIdleSubmission::Started { turn_id, .. } = test.codex.start_turn_if_idle(request).await? else { panic!("expected a new turn"); @@ -887,6 +891,7 @@ async fn recovery_preserves_durable_turn_attribution( }) .await?, StartIfIdleSubmission::Started { + root_turn_id: root.to_owned(), turn_id: turn_id.clone() }, ); @@ -930,10 +935,15 @@ async fn recovery_preserves_durable_turn_attribution( ); } test.codex.submit(Op::Interrupt).await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnAborted(_)) + let aborted = wait_for_event_match(&test.codex, |event| match event { + EventMsg::TurnAborted(event) => Some(event.clone()), + _ => None, }) .await; + assert_eq!( + (aborted.turn_id.as_deref(), aborted.root_turn_id.as_deref()), + (Some(turn_id.as_str()), Some(root)), + ); if trigger != Some("automation") { return Ok(()); } @@ -943,6 +953,7 @@ async fn recovery_preserves_durable_turn_attribution( let response = responses::mount_sse_once(&next_server, responses::sse_completed("new")).await; let TurnInputSubmission::Started { turn_id: next_turn_id, + .. } = test .codex .start_or_steer_turn( @@ -994,6 +1005,7 @@ async fn continue_turn_if_idle_starts_new_turn_with_internal_input() { responses::mount_sse_once(&server, responses::sse_completed("original")).await; let TurnInputSubmission::Started { turn_id: previous_turn_id, + .. } = test .codex .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { @@ -1030,7 +1042,7 @@ async fn continue_turn_if_idle_starts_new_turn_with_internal_input() { ) .await .unwrap(); - let TurnInputSubmission::Started { turn_id } = submission else { + let TurnInputSubmission::Started { turn_id, .. } = submission else { panic!("continuation did not start") }; wait_for_event(&test.codex, |event| { @@ -1166,12 +1178,20 @@ async fn turn_input_submission_reports_started_and_steered_for_concurrent_submis let (started_turn_id, steered_turn_id, started_message) = match (&first_submission, &second_submission) { ( - TurnInputSubmission::Started { turn_id: started }, - TurnInputSubmission::Steered { turn_id: steered }, + TurnInputSubmission::Started { + turn_id: started, .. + }, + TurnInputSubmission::Steered { + turn_id: steered, .. + }, ) => (started, steered, "first message"), ( - TurnInputSubmission::Steered { turn_id: steered }, - TurnInputSubmission::Started { turn_id: started }, + TurnInputSubmission::Steered { + turn_id: steered, .. + }, + TurnInputSubmission::Started { + turn_id: started, .. + }, ) => (started, steered, "second message"), _ => panic!( "concurrent messages must start exactly one turn and steer the other: \ @@ -1231,7 +1251,7 @@ async fn turn_input_submission_applies_thread_settings_only_after_accepted_input let started = submit_user_message(codex, "start turn") .await .expect("first message should start a turn"); - let TurnInputSubmission::Started { turn_id } = started else { + let TurnInputSubmission::Started { turn_id, .. } = started else { panic!("first message should start a turn"); }; timeout( @@ -1256,7 +1276,13 @@ async fn turn_input_submission_applies_thread_settings_only_after_accepted_input ) .await .expect("persistent settings should not reject a steer"); - assert_eq!(steered, TurnInputSubmission::Steered { turn_id }); + assert_eq!( + steered, + TurnInputSubmission::Steered { + root_turn_id: turn_id.clone(), + turn_id + } + ); assert_eq!( codex.config_snapshot().await.approval_policy, AskForApproval::Never @@ -1353,7 +1379,7 @@ async fn start_or_steer_turn_requires_matching_active_output_schema() { ) .await .expect("first message should start a turn"); - let TurnInputSubmission::Started { turn_id } = started else { + let TurnInputSubmission::Started { turn_id, .. } = started else { panic!("first message should start a turn"); }; timeout( @@ -1397,7 +1423,13 @@ async fn start_or_steer_turn_requires_matching_active_output_schema() { ) .await .expect("matching schema should steer"); - assert_eq!(steered, TurnInputSubmission::Steered { turn_id }); + assert_eq!( + steered, + TurnInputSubmission::Steered { + root_turn_id: turn_id.clone(), + turn_id + } + ); release_response .send(()) @@ -1438,7 +1470,7 @@ async fn sampling_is_ready_for_daemon_recovery( builder = builder.with_exec_server_url(&remote.websocket_url); } let test = builder.build_with_streaming_server(&server).await?; - let StartIfIdleSubmission::Started { turn_id } = test + let StartIfIdleSubmission::Started { turn_id, .. } = test .codex .start_turn_if_idle(TurnInputRequest::user_input(input)) .await? @@ -1510,7 +1542,7 @@ async fn daemon_recovery_includes_local_environment_that_finished_starting() -> ), ) .await?; - let StartIfIdleSubmission::Started { turn_id } = started else { + let StartIfIdleSubmission::Started { turn_id, .. } = started else { anyhow::bail!("turn should start"); }; diff --git a/codex-rs/core/tests/suite/turn_phase_trace.rs b/codex-rs/core/tests/suite/turn_phase_trace.rs index 3614c9f34c..c242df1490 100644 --- a/codex-rs/core/tests/suite/turn_phase_trace.rs +++ b/codex-rs/core/tests/suite/turn_phase_trace.rs @@ -163,6 +163,7 @@ async fn steer_joins_existing_turn_without_extending_sampling( ) .await?, TurnInputSubmission::Steered { + root_turn_id: call.turn_id.clone(), turn_id: call.turn_id.clone() } ); @@ -223,6 +224,7 @@ async fn steer_joins_existing_turn_without_extending_sampling( }) .await?, StartIfIdleSubmission::Started { + root_turn_id: call.turn_id.clone(), turn_id: call.turn_id.clone() } ); diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index eea78a58b3..594cd5b157 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -320,6 +320,7 @@ fn turn_completed_recovers_final_message_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::AgentMessage { id: "msg-1".to_string(), @@ -370,6 +371,7 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::AgentMessage { id: "msg-1".to_string(), @@ -421,6 +423,7 @@ fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, @@ -465,6 +468,7 @@ fn turn_failed_clears_stale_final_message() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Failed, @@ -510,6 +514,7 @@ fn turn_interrupted_clears_stale_final_message() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Interrupted, diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs index 5109848a6e..9082f912ce 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs @@ -35,6 +35,7 @@ fn failed_turn_does_not_overwrite_output_last_message_file() { thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Failed, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 90733404ad..8bf9262480 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1233,6 +1233,8 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { disabled_plugin_ids: None, thread_id: primary_thread_id_for_span.clone(), turn_trigger: Some("exec".to_string()), + parent_turn_id: None, + root_turn_id: None, client_user_message_id: None, input: items.into_iter().map(Into::into).collect(), tool_output: None, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index fb79e7b839..895cd7eef7 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -385,6 +385,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { turns: vec![ codex_app_server_protocol::Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![AppServerThreadItem::AgentMessage { id: "msg-1".to_string(), @@ -402,6 +403,7 @@ fn turn_items_for_thread_returns_matching_turn_items() { }, codex_app_server_protocol::Turn { id: "turn-2".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![AppServerThreadItem::Plan { id: "plan-1".to_string(), @@ -437,6 +439,7 @@ fn should_backfill_turn_completed_items_backfills_persisted_summaries_only() { thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Summary, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 45f849c4f6..c1cc894749 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -145,6 +145,7 @@ fn turn_started_emits_turn_started_event() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::InProgress, @@ -1197,6 +1198,7 @@ fn plan_update_emits_started_then_updated_then_completed() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, @@ -1257,6 +1259,7 @@ fn plan_update_after_completion_starts_new_todo_list_with_new_id() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, @@ -1342,6 +1345,7 @@ fn token_usage_update_is_emitted_on_turn_completion() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, @@ -1378,6 +1382,7 @@ fn turn_completion_recovers_final_message_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::AgentMessage { id: "msg-1".to_string(), @@ -1457,6 +1462,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::CommandExecution { model_context: None, @@ -1531,6 +1537,7 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::AgentMessage { id: "msg-1".to_string(), @@ -1585,6 +1592,7 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, @@ -1636,6 +1644,7 @@ fn failed_turn_clears_stale_final_message() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Failed, @@ -1665,6 +1674,7 @@ fn turn_completion_falls_back_to_final_plan_text() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::Plan { id: "plan-1".to_string(), @@ -1721,6 +1731,7 @@ fn turn_failure_prefers_structured_error_message() { thread_id: "thread-1".to_string(), turn: Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Failed, diff --git a/codex-rs/ext/agent/src/lib.rs b/codex-rs/ext/agent/src/lib.rs index e6b747522e..9bc77c64c9 100644 --- a/codex-rs/ext/agent/src/lib.rs +++ b/codex-rs/ext/agent/src/lib.rs @@ -85,7 +85,7 @@ impl AgentRunner { ) .await? { - StartIfIdleSubmission::Started { turn_id } => turn_id, + StartIfIdleSubmission::Started { turn_id, .. } => turn_id, StartIfIdleSubmission::NotSubmitted { reason } => { return Err(CodexErr::InvalidRequest(format!( "agent prompt was not submitted: {reason:?}" diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 7d826ac238..3e35f98072 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -485,7 +485,7 @@ impl GoalRuntimeHandle { ) .await { - Ok(StartIfIdleSubmission::Started { turn_id }) => { + Ok(StartIfIdleSubmission::Started { turn_id, .. }) => { // Turn-stop evaluation takes the same permit, so even a fast response // cannot finish before this host-admitted continuation is identified. self.inner.accounting_state.mark_goal_continuation(turn_id); diff --git a/codex-rs/ext/guardian-reviewer/src/execution.rs b/codex-rs/ext/guardian-reviewer/src/execution.rs index eacbb7a451..fabc89b77f 100644 --- a/codex-rs/ext/guardian-reviewer/src/execution.rs +++ b/codex-rs/ext/guardian-reviewer/src/execution.rs @@ -43,7 +43,7 @@ pub async fn start_review_turn( match crate::run_before_review_deadline(deadline, external_cancel, runtime.submit_turn(request)) .await? { - Ok(TurnInputSubmission::Started { turn_id }) => Ok(turn_id), + Ok(TurnInputSubmission::Started { turn_id, .. }) => Ok(turn_id), result => Err(GuardianReviewSessionOutcome::SessionFailed { error: match result { Ok(submission) => anyhow!("guardian review input was not started: {submission:?}"), diff --git a/codex-rs/ext/queue/tests/queue_service.rs b/codex-rs/ext/queue/tests/queue_service.rs index 0e8ffa84a8..305b8ed6ad 100644 --- a/codex-rs/ext/queue/tests/queue_service.rs +++ b/codex-rs/ext/queue/tests/queue_service.rs @@ -393,7 +393,7 @@ async fn starting_a_selected_item_preserves_the_remaining_queue() -> anyhow::Res assert!(matches!( submission, - StartIfIdleSubmission::Started { turn_id } if !turn_id.is_empty() + StartIfIdleSubmission::Started { turn_id, .. } if !turn_id.is_empty() )); assert_eq!(vec![first], service.list(thread_id).await?); wait_for_event_match(test.codex.as_ref(), |event| match event { diff --git a/codex-rs/external-agent-migration/src/sessions/export.rs b/codex-rs/external-agent-migration/src/sessions/export.rs index fbe2e37003..f50ee959ba 100644 --- a/codex-rs/external-agent-migration/src/sessions/export.rs +++ b/codex-rs/external-agent-migration/src/sessions/export.rs @@ -207,6 +207,7 @@ fn turn_complete_item( completed_at: Option, ) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id, last_agent_message: None, error: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6e6b64817a..d84f30f60a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2130,6 +2130,10 @@ pub struct ContextCompactedEvent; #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnCompleteEvent { + /// Resolved causal root for this turn; absent on older or synthetic events. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub root_turn_id: Option, pub turn_id: String, pub last_agent_message: Option, /// Terminal error details when the turn completed unsuccessfully. @@ -4225,6 +4229,10 @@ pub struct Chunk { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct TurnAbortedEvent { + /// Resolved causal root for this turn; absent on older or synthetic events. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub root_turn_id: Option, pub turn_id: Option, pub reason: TurnAbortReason, /// Optional error describing why the turn was interrupted. @@ -6103,9 +6111,12 @@ mod tests { match event { EventMsg::TurnAborted(TurnAbortedEvent { - turn_id, reason, .. + turn_id, + root_turn_id, + reason, + .. }) => { - assert_eq!(turn_id, None); + assert_eq!((turn_id, root_turn_id), (None, None)); assert_eq!(reason, TurnAbortReason::Interrupted); } _ => panic!("expected turn_aborted event"), diff --git a/codex-rs/protocol/src/turn_input.rs b/codex-rs/protocol/src/turn_input.rs index c145e0543d..3069a5f6ff 100644 --- a/codex-rs/protocol/src/turn_input.rs +++ b/codex-rs/protocol/src/turn_input.rs @@ -208,16 +208,23 @@ pub struct TurnStartOptions { /// /// Started and Steered only mean Core accepted the input for turn processing. They /// do not wait for user-prompt hooks, updating the in-memory model context, -/// rollout persistence, or sampling. +/// rollout persistence, or sampling. The returned root is the accepted turn's +/// resolved causal root, including when steering preserves an existing turn. #[derive(Clone, Debug, Eq, PartialEq)] pub enum TurnInputSubmission { /// Core started a turn. Persistent thread settings and start options were applied. - Started { turn_id: String }, + Started { + turn_id: String, + root_turn_id: String, + }, /// Core steered an active turn. Persistent thread settings were applied for /// subsequent turns. No new turn was created, so lineage metadata was not /// recorded. If the request included `final_output_json_schema`, the active /// turn already used the same schema. - Steered { turn_id: String }, + Steered { + turn_id: String, + root_turn_id: String, + }, /// Core rejected the input without applying settings or start options. NotSubmitted { reason: NotSubmittedReason }, } @@ -226,7 +233,10 @@ pub enum TurnInputSubmission { #[derive(Clone, Debug, Eq, PartialEq)] pub enum StartIfIdleSubmission { /// Core started a turn. Persistent thread settings and start options were applied. - Started { turn_id: String }, + Started { + turn_id: String, + root_turn_id: String, + }, /// Core rejected the input without applying settings or start options. NotSubmitted { reason: NotSubmittedReason }, } diff --git a/codex-rs/rollout/src/persistence_metrics_tests.rs b/codex-rs/rollout/src/persistence_metrics_tests.rs index 48b32f712c..ceebe75a3c 100644 --- a/codex-rs/rollout/src/persistence_metrics_tests.rs +++ b/codex-rs/rollout/src/persistence_metrics_tests.rs @@ -61,6 +61,7 @@ fn turn_started(turn_id: &str) -> RolloutItem { fn turn_complete(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), started_at: None, last_agent_message: None, @@ -73,6 +74,7 @@ fn turn_complete(turn_id: &str) -> RolloutItem { fn turn_aborted(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + root_turn_id: None, turn_id: Some(turn_id.to_string()), started_at: None, reason: TurnAbortReason::Interrupted, diff --git a/codex-rs/state/src/migrations_tests.rs b/codex-rs/state/src/migrations_tests.rs index 4f07d1c11b..d8e14b752b 100644 --- a/codex-rs/state/src/migrations_tests.rs +++ b/codex-rs/state/src/migrations_tests.rs @@ -614,6 +614,15 @@ async fn realtime_items_preserve_older_thread_history_writers() { .await .expect("existing turn-scoped item should be inserted"); + sqlx::query("INSERT INTO thread_turns (thread_id, turn_id, rollout_ordinal, status) VALUES ('thread-1', 'turn-1', 0, 'completed')") + .execute(&pool) + .await + .expect("insert an existing turn before migration"); + sqlx::query("INSERT INTO thread_history_projection_state (thread_id, next_rollout_byte_offset, next_rollout_ordinal) VALUES ('thread-1', 42, 3)") + .execute(&pool) + .await + .expect("insert an existing projection checkpoint"); + THREAD_HISTORY_MIGRATOR .run(&pool) .await @@ -632,12 +641,13 @@ async fn realtime_items_preserve_older_thread_history_writers() { .execute(&pool) .await .expect("thread-scoped realtime item should be inserted separately"); - sqlx::query( - "INSERT INTO thread_history_projection_state (thread_id, next_rollout_byte_offset, next_rollout_ordinal) VALUES ('thread-1', 0, 0)", + let checkpoint = sqlx::query_as::<_, (i64, i64)>( + "SELECT next_rollout_byte_offset, next_rollout_ordinal FROM thread_history_projection_state WHERE thread_id = 'thread-1'", ) - .execute(&pool) + .fetch_one(&pool) .await - .expect("thread projection checkpoint should be inserted"); + .expect("existing projection checkpoint survives migration"); + assert_eq!(checkpoint, (42, 3)); let older_pool = sqlite .open_thread_history_db(&older_migrator, /*telemetry_override*/ None) @@ -649,6 +659,17 @@ async fn realtime_items_preserve_older_thread_history_writers() { .execute(&older_pool) .await .expect("older binaries should continue writing ordinary turn-scoped items"); + sqlx::query("INSERT INTO thread_turns (thread_id, turn_id, rollout_ordinal, status) VALUES ('thread-1', 'turn-2', 4, 'completed')") + .execute(&older_pool) + .await + .expect("older writers can still insert turns without roots"); + let roots = sqlx::query_scalar::<_, Option>( + "SELECT root_turn_id FROM thread_turns ORDER BY rollout_ordinal", + ) + .fetch_all(&older_pool) + .await + .expect("old turns and older writers leave causal roots unknown"); + assert_eq!(roots, vec![None, None]); let ordinary_items = sqlx::query_as::<_, (String, String)>( "SELECT item_id, turn_id FROM thread_items WHERE thread_id = ? ORDER BY rollout_ordinal", ) diff --git a/codex-rs/state/thread_history_migrations/0008_thread_turn_root.sql b/codex-rs/state/thread_history_migrations/0008_thread_turn_root.sql new file mode 100644 index 0000000000..55304755c4 --- /dev/null +++ b/codex-rs/state/thread_history_migrations/0008_thread_turn_root.sql @@ -0,0 +1 @@ +ALTER TABLE thread_turns ADD COLUMN root_turn_id TEXT; diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index e8dc915728..983a3637b9 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1210,6 +1210,7 @@ mod tests { }, )), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: "turn-1".to_string(), started_at: None, last_agent_message: None, diff --git a/codex-rs/thread-store/src/local/model_context_tests.rs b/codex-rs/thread-store/src/local/model_context_tests.rs index 6b1fad47f8..fe7bdc46c7 100644 --- a/codex-rs/thread-store/src/local/model_context_tests.rs +++ b/codex-rs/thread-store/src/local/model_context_tests.rs @@ -629,6 +629,7 @@ fn turn_started(turn_id: &str) -> RolloutItem { fn turn_complete(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), last_agent_message: None, error: None, diff --git a/codex-rs/thread-store/src/local/revert_thread_tests.rs b/codex-rs/thread-store/src/local/revert_thread_tests.rs index 827bf8c107..974d58c93e 100644 --- a/codex-rs/thread-store/src/local/revert_thread_tests.rs +++ b/codex-rs/thread-store/src/local/revert_thread_tests.rs @@ -333,6 +333,7 @@ fn turn_started(turn_id: &str) -> RolloutItem { fn turn_completed(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), last_agent_message: None, error: None, diff --git a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs index 37c353fa08..6a8ce712ea 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs @@ -375,6 +375,7 @@ impl LegacyRolloutCanonicalizer { writer, timestamp, RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id, last_agent_message: None, error: None, diff --git a/codex-rs/thread-store/src/local/rollout_migration_tests.rs b/codex-rs/thread-store/src/local/rollout_migration_tests.rs index 589acf7508..4ce943a61e 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -256,6 +256,7 @@ fn compacted(replacement_history: Vec) -> RolloutItem { fn completed(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), last_agent_message: None, error: None, diff --git a/codex-rs/thread-store/src/local/thread_history.rs b/codex-rs/thread-store/src/local/thread_history.rs index aca9fdc835..ee59619a13 100644 --- a/codex-rs/thread-store/src/local/thread_history.rs +++ b/codex-rs/thread-store/src/local/thread_history.rs @@ -316,6 +316,7 @@ async fn apply_change_set( INSERT INTO thread_turns ( thread_id, turn_id, + root_turn_id, rollout_ordinal, rollout_byte_offset, rollout_end_ordinal, @@ -325,8 +326,9 @@ INSERT INTO thread_turns ( started_at, completed_at, duration_ms -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(thread_id, turn_id) DO UPDATE SET + root_turn_id = COALESCE(thread_turns.root_turn_id, excluded.root_turn_id), rollout_end_ordinal = excluded.rollout_end_ordinal, rollout_end_byte_offset = excluded.rollout_end_byte_offset, status = excluded.status, @@ -340,6 +342,7 @@ WHERE thread_turns.rollout_end_ordinal IS NULL ) .bind(thread_id) .bind(turn_id.as_str()) + .bind(turn.root_turn_id) .bind(rollout_ordinal) .bind(rollout_byte_offset) .bind(terminal_ordinal) diff --git a/codex-rs/thread-store/src/local/thread_history/read.rs b/codex-rs/thread-store/src/local/thread_history/read.rs index 49c326c1a4..bbfe61fac2 100644 --- a/codex-rs/thread-store/src/local/thread_history/read.rs +++ b/codex-rs/thread-store/src/local/thread_history/read.rs @@ -52,6 +52,7 @@ pub(super) struct RolloutHistoryPosition { pub(super) struct StoredTurnRow { pub position: RolloutHistoryPosition, pub turn_id: String, + pub root_turn_id: Option, pub status: StoredTurnStatus, pub error: Option, pub started_at: Option, @@ -141,6 +142,7 @@ pub(in crate::local) async fn list_turns( }; turns.push(StoredTurn { turn_id: turn.turn_id, + root_turn_id: turn.root_turn_id, items, items_view: params.items_view, status: turn.status, @@ -312,6 +314,7 @@ pub(super) fn stored_turn_row(row: sqlx::sqlite::SqliteRow) -> ThreadStoreResult rollout_ordinal: row.try_get("rollout_ordinal")?, }, turn_id: row.try_get("turn_id")?, + root_turn_id: row.try_get("root_turn_id")?, status, error, started_at: row.try_get("started_at")?, diff --git a/codex-rs/thread-store/src/local/thread_history/read_tests.rs b/codex-rs/thread-store/src/local/thread_history/read_tests.rs index dccd8752e1..fb8ca3ee0c 100644 --- a/codex-rs/thread-store/src/local/thread_history/read_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history/read_tests.rs @@ -68,6 +68,11 @@ async fn list_turns_pages_projected_rows_and_applies_item_views() { ) .await; } + sqlx::query("UPDATE thread_turns SET root_turn_id = 'root-1' WHERE thread_id = ? AND turn_id IN ('turn-1', 'turn-3')") + .bind(thread_id.to_string()) + .execute(db) + .await + .expect("record known causal roots"); for (turn_id, item_id, ordinal) in [ ("turn-1", "user-1", 11), ("turn-1", "middle-1", 12), @@ -87,6 +92,14 @@ async fn list_turns_pages_projected_rows_and_applies_item_views() { .await .expect("first turns page"); assert_eq!(turn_ids(&first_page), vec!["turn-1", "turn-2"]); + assert_eq!( + first_page + .turns + .iter() + .map(|turn| turn.root_turn_id.as_deref()) + .collect::>(), + vec![Some("root-1"), None], + ); assert_eq!( first_page.turns[0].items, vec![ @@ -113,6 +126,7 @@ async fn list_turns_pages_projected_rows_and_applies_item_views() { .await .expect("second turns page"); assert_eq!(turn_ids(&second_page), vec!["turn-3"]); + assert_eq!(second_page.turns[0].root_turn_id.as_deref(), Some("root-1")); assert_eq!(second_page.turns[0].items, Vec::new()); assert_eq!(second_page.turns[0].status, StoredTurnStatus::InProgress); let backwards_page = store @@ -1357,6 +1371,8 @@ async fn lineage_reads_nested_forks() { for (thread_id, turn_id, ordinal, status, first_user_item_id) in [ (root_id, "root", 1, "completed", None), (root_id, "shared", 2, "completed", Some("before-fork")), + (root_id, "child", 4, "completed", None), + (middle_id, "child", 3, "completed", None), (middle_id, "shared", 5, "interrupted", None), (middle_id, "middle", 6, "completed", None), (child_id, "child", 8, "completed", None), @@ -1373,6 +1389,11 @@ async fn lineage_reads_nested_forks() { ) .await; } + sqlx::query("UPDATE thread_turns SET root_turn_id = 'causal-root' WHERE thread_id = ? AND turn_id = 'shared'") + .bind(middle_id.to_string()) + .execute(db) + .await + .expect("record terminal turn root"); insert_item( db, root_id, @@ -1401,6 +1422,7 @@ async fn lineage_reads_nested_forks() { .await .expect("first nested descending page"); assert_eq!(turn_ids(&first_descending_page), vec!["child", "middle"]); + assert_eq!(first_descending_page.turns[0].root_turn_id, None); let second_descending_page = store .list_turns(turn_params( child_id, @@ -1412,6 +1434,10 @@ async fn lineage_reads_nested_forks() { .await .expect("second nested descending page"); assert_eq!(turn_ids(&second_descending_page), vec!["shared", "root"]); + assert_eq!( + second_descending_page.turns[0].root_turn_id.as_deref(), + Some("causal-root") + ); assert_eq!( second_descending_page.turns[0].status, StoredTurnStatus::Interrupted @@ -1467,6 +1493,10 @@ async fn lineage_reads_nested_forks() { .await .expect("navigate to effective occurrence turn"); assert_eq!(turn_ids(&occurrence_turn), vec!["shared"]); + assert_eq!( + occurrence_turn.turns[0].root_turn_id.as_deref(), + Some("causal-root") + ); } async fn store_with_mode(history_mode: ThreadHistoryMode) -> (TempDir, LocalThreadStore, ThreadId) { diff --git a/codex-rs/thread-store/src/local/thread_history/segment_paging.rs b/codex-rs/thread-store/src/local/thread_history/segment_paging.rs index e16a6dc48f..a87542bf89 100644 --- a/codex-rs/thread-store/src/local/thread_history/segment_paging.rs +++ b/codex-rs/thread-store/src/local/thread_history/segment_paging.rs @@ -60,12 +60,14 @@ pub(super) async fn page_turn_rows( if remaining == 0 { break; } - let mut query = - QueryBuilder::::new(if matches!(items_view, StoredTurnItemsView::Summary) { - r#" -WITH page_turns AS ( -SELECT + let mut query = QueryBuilder::::new(""); + if matches!(items_view, StoredTurnItemsView::Summary) { + query.push("WITH page_turns AS ("); + } + query.push( + r#"SELECT turn_id, + root_turn_id, rollout_ordinal, status, error_json, @@ -76,23 +78,8 @@ SELECT final_agent_item_id FROM thread_turns WHERE thread_id = - "# - } else { - r#" -SELECT - turn_id, - rollout_ordinal, - status, - error_json, - started_at, - completed_at, - duration_ms, - first_user_item_id, - final_agent_item_id -FROM thread_turns -WHERE thread_id = - "# - }); + "#, + ); query.push_bind(segment.rollout_id().to_string()); push_segment_range(&mut query, segment)?; for newer_segment in &lineage.segments()[segment_index + 1..] { diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index 8b691b6a2c..c711043c99 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -314,7 +314,7 @@ async fn paginated_live_append_materializes_turn_items_and_state() { .append_items(AppendThreadItemsParams { thread_id, items: vec![ - turn_started("turn-1"), + turn_started_with_root("turn-1", "root-1"), completed_item( thread_id, "turn-1", @@ -370,6 +370,7 @@ async fn paginated_live_append_materializes_turn_items_and_state() { Option, Option, Option, + Option, ), >( r#" @@ -383,7 +384,8 @@ SELECT completed_at, duration_ms, first_user_item_id, - final_agent_item_id + final_agent_item_id, + root_turn_id FROM thread_turns WHERE thread_id = ? AND turn_id = ? "#, @@ -406,6 +408,7 @@ WHERE thread_id = ? AND turn_id = ? Some(10_000), Some("user-1".to_string()), Some("agent-1".to_string()), + Some("root-1".to_string()), ) ); @@ -1691,7 +1694,10 @@ async fn terminal_turn_does_not_change_after_later_records() { store .append_items(AppendThreadItemsParams { thread_id, - items: vec![turn_started("turn-1"), turn_completed("turn-1")], + items: vec![ + turn_started_with_root("turn-1", "root-1"), + turn_completed("turn-1"), + ], }) .await .expect("append terminal turn"); @@ -1699,7 +1705,7 @@ async fn terminal_turn_does_not_change_after_later_records() { .append_items(AppendThreadItemsParams { thread_id, items: vec![ - turn_started("turn-1"), + turn_started_with_root("turn-1", "wrong-root"), completed_item( thread_id, "turn-1", @@ -1736,6 +1742,7 @@ async fn terminal_turn_does_not_change_after_later_records() { Option, String, Option, + Option, ), >( r#" @@ -1745,7 +1752,8 @@ SELECT rollout_end_ordinal, rollout_end_byte_offset, status, - first_user_item_id + first_user_item_id, + root_turn_id FROM thread_turns WHERE thread_id = ? AND turn_id = ? "#, @@ -1764,6 +1772,7 @@ WHERE thread_id = ? AND turn_id = ? Some(turn_end_byte_offset), "completed".to_string(), None, + Some("root-1".to_string()), ) ); } @@ -2842,8 +2851,17 @@ fn turn_started(turn_id: &str) -> RolloutItem { })) } +fn turn_started_with_root(turn_id: &str, root_turn_id: &str) -> RolloutItem { + let mut item = turn_started(turn_id); + if let RolloutItem::EventMsg(EventMsg::TurnStarted(event)) = &mut item { + event.root_turn_id = Some(root_turn_id.to_string()); + } + item +} + fn turn_completed(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), last_agent_message: None, error: None, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 7cc8a70b21..1dd5b4e760 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -452,6 +452,8 @@ pub struct ListTurnsParams { pub struct StoredTurn { /// Turn id. pub turn_id: String, + /// Causal root recorded for this turn. Older projected turns may not have one. + pub root_turn_id: Option, /// Projected app-server item snapshots associated with this turn, according to `items_view`. pub items: Vec, /// Amount of item detail included in `items`. diff --git a/codex-rs/tui/src/app/activity_groups_tests.rs b/codex-rs/tui/src/app/activity_groups_tests.rs index aa6ca11567..4e5ae718ce 100644 --- a/codex-rs/tui/src/app/activity_groups_tests.rs +++ b/codex-rs/tui/src/app/activity_groups_tests.rs @@ -44,6 +44,7 @@ fn reasoning(id: &str) -> ThreadItem { fn turn(items: Vec) -> Turn { Turn { id: "turn".to_owned(), + root_turn_id: None, items, items_view: TurnItemsView::Full, status: TurnStatus::Completed, diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 7ed35541cc..787b4b81eb 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -2357,6 +2357,7 @@ async fn resume_picker_round_trip_preserves_each_threads_input() -> Result<()> { thread_id: target.thread_id.to_string(), turn: codex_app_server_protocol::Turn { id: "turn-with-follow-up".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::InProgress, diff --git a/codex-rs/tui/src/app/composer_hints_tests.rs b/codex-rs/tui/src/app/composer_hints_tests.rs index 75e44b906c..8c5ff521e2 100644 --- a/codex-rs/tui/src/app/composer_hints_tests.rs +++ b/codex-rs/tui/src/app/composer_hints_tests.rs @@ -47,6 +47,7 @@ async fn usage_notice_preserves_composer_geometry_on_recovery() -> Result<()> { thread_id: ThreadId::new().to_string(), turn: Turn { id: "turn".into(), + root_turn_id: None, items_view: TurnItemsView::Full, items: Vec::new(), status: TurnStatus::InProgress, diff --git a/codex-rs/tui/src/app/history_completion_tests.rs b/codex-rs/tui/src/app/history_completion_tests.rs index 273b9fa776..d82b71bb71 100644 --- a/codex-rs/tui/src/app/history_completion_tests.rs +++ b/codex-rs/tui/src/app/history_completion_tests.rs @@ -7,6 +7,7 @@ use pretty_assertions::assert_eq; fn turn(id: &str, status: TurnStatus, item_ids: &[&str]) -> Turn { Turn { id: id.to_string(), + root_turn_id: None, items: item_ids .iter() .map(|id| ThreadItem::UserMessage { diff --git a/codex-rs/tui/src/app/history_pagination_tests.rs b/codex-rs/tui/src/app/history_pagination_tests.rs index c3cbb00ef0..e64fa5bc2a 100644 --- a/codex-rs/tui/src/app/history_pagination_tests.rs +++ b/codex-rs/tui/src/app/history_pagination_tests.rs @@ -11,6 +11,7 @@ use pretty_assertions::assert_eq; fn turn(id: &str, status: TurnStatus, item_ids: &[&str]) -> Turn { Turn { id: id.to_string(), + root_turn_id: None, items: item_ids .iter() .map(|id| ThreadItem::UserMessage { diff --git a/codex-rs/tui/src/app/owned_transcript_follow_tests.rs b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs index 6f05c021e0..552bee01b6 100644 --- a/codex-rs/tui/src/app/owned_transcript_follow_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs @@ -29,6 +29,7 @@ async fn follow_control_click_preserves_draft_caret_and_composer_geometry() -> R thread_id: ThreadId::new().to_string(), turn: codex_app_server_protocol::Turn { id: "turn".into(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::InProgress, diff --git a/codex-rs/tui/src/app/owned_transcript_input_tests.rs b/codex-rs/tui/src/app/owned_transcript_input_tests.rs index ee1dbf71f0..4c4119b344 100644 --- a/codex-rs/tui/src/app/owned_transcript_input_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_input_tests.rs @@ -79,6 +79,7 @@ async fn configured_ctrl_space_submit_wins_over_transcript_selection() -> Result thread_id: thread_id.to_string(), turn: Turn { id: "turn".into(), + root_turn_id: None, items_view: TurnItemsView::Full, items: Vec::new(), status: TurnStatus::InProgress, @@ -185,6 +186,7 @@ fn complete_plan_turn(app: &mut App) { let thread_id = ThreadId::new().to_string(); let turn = Turn { id: "plan-turn".into(), + root_turn_id: None, items_view: TurnItemsView::Full, items: Vec::new(), status: TurnStatus::InProgress, diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 39febe959d..026fe11eac 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -598,6 +598,7 @@ async fn owned_details_escape_interrupts_work_without_starting_backtrack() -> Re thread_id: thread_id.to_string(), turn: codex_app_server_protocol::Turn { id: "active-turn".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::InProgress, diff --git a/codex-rs/tui/src/app/pending_interactive_replay.rs b/codex-rs/tui/src/app/pending_interactive_replay.rs index 11c39a90e3..cdbc8512fa 100644 --- a/codex-rs/tui/src/app/pending_interactive_replay.rs +++ b/codex-rs/tui/src/app/pending_interactive_replay.rs @@ -675,6 +675,7 @@ mod tests { thread_id: "thread-1".to_string(), turn: Turn { id: turn_id.to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: TurnStatus::Completed, diff --git a/codex-rs/tui/src/app/recap_tests.rs b/codex-rs/tui/src/app/recap_tests.rs index 33fe586b14..437cb66bb8 100644 --- a/codex-rs/tui/src/app/recap_tests.rs +++ b/codex-rs/tui/src/app/recap_tests.rs @@ -45,6 +45,7 @@ use uuid::Uuid; fn turn(status: TurnStatus) -> Turn { Turn { id: "turn".to_string(), + root_turn_id: None, items: Vec::new(), items_view: Default::default(), status, diff --git a/codex-rs/tui/src/app/safety_buffering_tests.rs b/codex-rs/tui/src/app/safety_buffering_tests.rs index 4586e7ab21..2e4bba6120 100644 --- a/codex-rs/tui/src/app/safety_buffering_tests.rs +++ b/codex-rs/tui/src/app/safety_buffering_tests.rs @@ -4,6 +4,7 @@ use codex_app_server_protocol::TurnItemsView; fn turn(id: &str, status: TurnStatus) -> Turn { Turn { id: id.to_string(), + root_turn_id: None, items: Vec::new(), items_view: TurnItemsView::Full, status, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 8ed3f84c85..73bc892cc5 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -7224,6 +7224,7 @@ async fn resizing_empty_transcript_schedules_settled_size_recheck() { fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { Turn { id: turn_id.to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items, status, @@ -8426,6 +8427,7 @@ async fn prompt_edit_reverts_earlier_and_first_visible_prompts_in_place() -> Res completed_at_ms: 0, })), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id: turn_id.to_string(), last_agent_message: None, error: None, @@ -8812,6 +8814,7 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() { turns: vec![ Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "user-1".to_string(), @@ -8829,6 +8832,7 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() { }, Turn { id: "turn-2".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ ThreadItem::UserMessage { diff --git a/codex-rs/tui/src/app/tests/buffered_replay.rs b/codex-rs/tui/src/app/tests/buffered_replay.rs index 708efcacb6..47ea84e91d 100644 --- a/codex-rs/tui/src/app/tests/buffered_replay.rs +++ b/codex-rs/tui/src/app/tests/buffered_replay.rs @@ -304,6 +304,7 @@ fn evicted_voice_delegation_marker_still_suppresses_private_replay() { let mut resumed = ThreadEventStore::new(/*capacity*/ 1); resumed.set_turns(vec![Turn { id: "voice-turn".into(), + root_turn_id: None, items: vec![voice_request], items_view: codex_app_server_protocol::TurnItemsView::Summary, status: TurnStatus::InProgress, @@ -345,6 +346,7 @@ fn saved_voice_turn_suppresses_reasoning_without_hiding_typed_reasoning() { }; let turn = |id: &str, items| Turn { id: id.into(), + root_turn_id: None, items, items_view: codex_app_server_protocol::TurnItemsView::Full, status: TurnStatus::Completed, @@ -390,6 +392,7 @@ fn snapshot_keeps_typed_output_before_voice_handoff_in_the_same_turn() { let mut store = ThreadEventStore::new(/*capacity*/ 8); store.set_turns(vec![Turn { id: "shared".into(), + root_turn_id: None, items: vec![typed.clone(), marker.clone(), private.clone()], items_view: codex_app_server_protocol::TurnItemsView::Full, status: TurnStatus::InProgress, diff --git a/codex-rs/tui/src/app/tests/history_hydration_tests.rs b/codex-rs/tui/src/app/tests/history_hydration_tests.rs index bd976a3dd5..5a65adb334 100644 --- a/codex-rs/tui/src/app/tests/history_hydration_tests.rs +++ b/codex-rs/tui/src/app/tests/history_hydration_tests.rs @@ -80,6 +80,7 @@ async fn history_fixture( })); } events.push(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id, last_agent_message: None, error: None, diff --git a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs index 5698e5d2eb..7cfc88a950 100644 --- a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs +++ b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs @@ -95,6 +95,7 @@ pub(super) async fn completed_history_app( }) })); events.push(EventMsg::TurnComplete(TurnCompleteEvent { + root_turn_id: None, turn_id, last_agent_message: Some(format!("{name} answer")), error: None, diff --git a/codex-rs/tui/src/app/tests/realtime_requests.rs b/codex-rs/tui/src/app/tests/realtime_requests.rs index 632e6bd4a5..cb8c448549 100644 --- a/codex-rs/tui/src/app/tests/realtime_requests.rs +++ b/codex-rs/tui/src/app/tests/realtime_requests.rs @@ -299,6 +299,7 @@ async fn switching_agent_threads_preserves_backend_voice_and_routes_pending_spee thread_id: source.to_string(), turn: Turn { id: turn_id.into(), + root_turn_id: None, items: vec![answer.clone()], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, @@ -665,6 +666,7 @@ async fn replay_reconciles_only_matching_voice_captions_one_for_one() { .insert(source, records); let voice = Turn { id: "voice-turn".into(), + root_turn_id: None, items: vec![ test_user_message( "voice-user", @@ -929,6 +931,7 @@ async fn unrendered_buffered_items_do_not_consume_retained_captions() { thread_id: source.to_string(), turn: Turn { id: turn_id.into(), + root_turn_id: None, items, items_view: TurnItemsView::Summary, status, @@ -1212,6 +1215,7 @@ async fn rejected_realtime_speech_restores_the_delegated_final_answer() -> Resul thread_id: thread_id.to_string(), turn: Turn { id: "rejected-turn".to_string(), + root_turn_id: None, items: vec![answer], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, @@ -1311,6 +1315,7 @@ async fn switching_threads_retains_undelivered_voice_answer_after_replay_evictio thread_id: original.to_string(), turn: Turn { id: "switched-turn".to_string(), + root_turn_id: None, items: vec![answer], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, @@ -1391,6 +1396,7 @@ async fn switching_threads_retains_undelivered_voice_answer_after_replay_evictio session: Some(test_thread_session(original, app.config.cwd.to_path_buf())), turns: vec![Turn { id: "replayed-turn".to_string(), + root_turn_id: None, items: vec![replayed_item], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, @@ -1593,6 +1599,7 @@ async fn delegated_final_speech_reaches_app_server_once_and_stale_speech_is_reje thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), + root_turn_id: None, items: vec![answer], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, @@ -1670,6 +1677,7 @@ async fn delegated_final_speech_reaches_app_server_once_and_stale_speech_is_reje thread_id: thread_id.to_string(), turn: Turn { id: second_turn.to_string(), + root_turn_id: None, items: vec![second_answer], items_view: TurnItemsView::Summary, status: TurnStatus::Completed, diff --git a/codex-rs/tui/src/app/thread_event_buffer_tests.rs b/codex-rs/tui/src/app/thread_event_buffer_tests.rs index bfb61b5184..ec7e1e066b 100644 --- a/codex-rs/tui/src/app/thread_event_buffer_tests.rs +++ b/codex-rs/tui/src/app/thread_event_buffer_tests.rs @@ -18,6 +18,7 @@ fn turn_started_notification(thread_id: ThreadId, turn_id: &str) -> ServerNotifi thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), + root_turn_id: None, items_view: TurnItemsView::Full, items: Vec::new(), status: TurnStatus::InProgress, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 1972b8047c..d7ef858246 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -689,6 +689,7 @@ mod tests { fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { Turn { id: turn_id.to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items, status, diff --git a/codex-rs/tui/src/app/thread_title_tests.rs b/codex-rs/tui/src/app/thread_title_tests.rs index e9b10fbb85..ca73ee8d30 100644 --- a/codex-rs/tui/src/app/thread_title_tests.rs +++ b/codex-rs/tui/src/app/thread_title_tests.rs @@ -269,6 +269,7 @@ async fn check_thread_title_generation(scenario: TitleScenario) -> color_eyre::R .turns .push(Turn { id: "existing-turn".to_string(), + root_turn_id: None, items: vec![title_user_message("user-message", "Fix the login timeout")], items_view: Default::default(), status: TurnStatus::Completed, diff --git a/codex-rs/tui/src/app/transcript_export_tests.rs b/codex-rs/tui/src/app/transcript_export_tests.rs index a0b3a2e9c6..21ee6e4c76 100644 --- a/codex-rs/tui/src/app/transcript_export_tests.rs +++ b/codex-rs/tui/src/app/transcript_export_tests.rs @@ -83,6 +83,7 @@ fn transcript_export_excludes_hidden_review_prompts_and_nested_duplicates() { let duplicate_two = user("duplicate-two", "duplicate review prompt"); let turn = |id: &str, items: Vec, status| Turn { id: id.to_string(), + root_turn_id: None, items, items_view: TurnItemsView::Full, status, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 762cdb0fa6..f55b75c1bb 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -729,6 +729,7 @@ mod tests { fn turn(turn_id: &str, status: TurnStatus, user_messages: usize) -> Turn { Turn { id: turn_id.to_string(), + root_turn_id: None, items: (0..user_messages) .map(|index| ThreadItem::UserMessage { id: format!("user-{index}"), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 475b8a0773..6d15c852f7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1439,6 +1439,8 @@ impl AppServerSession { disabled_plugin_ids: None, thread_id: thread_id.to_string(), turn_trigger: Some("user".to_string()), + parent_turn_id: None, + root_turn_id: None, client_user_message_id: Some(client_user_message_id), input: items, tool_output: None, @@ -4118,6 +4120,7 @@ mod tests { name: None, turns: vec![Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ codex_app_server_protocol::ThreadItem::UserMessage { diff --git a/codex-rs/tui/src/chatwidget/realtime_tests.rs b/codex-rs/tui/src/chatwidget/realtime_tests.rs index 2bb89b585a..78c53630df 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests.rs @@ -126,6 +126,7 @@ fn finish_turn( thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), + root_turn_id: None, items, items_view: TurnItemsView::Summary, status, diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs b/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs index fe1bf60b6c..c5b234de6b 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/caption_replay.rs @@ -10,6 +10,7 @@ async fn replay_preserves_typed_updates_before_voice_steers_the_turn() { chat.replay_thread_turns( vec![Turn { id: "typed-then-voice".into(), + root_turn_id: None, items: vec![ user_item("Typed question"), agent_item( @@ -79,6 +80,7 @@ async fn in_progress_voice_replay_restores_the_late_reasoning_guard() { chat.replay_thread_turns( vec![Turn { id: "saved-voice-turn".into(), + root_turn_id: None, items: vec![voice_request.clone()], items_view: TurnItemsView::Full, status: TurnStatus::InProgress, diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index ccb5b77514..1907577c56 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -126,6 +126,7 @@ impl ChatWidget { } let Turn { id: turn_id, + root_turn_id, items_view: _, items, status, @@ -224,6 +225,7 @@ impl ChatWidget { thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn: Turn { id: turn_id, + root_turn_id, items_view: codex_app_server_protocol::TurnItemsView::NotLoaded, items: Vec::new(), status, diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index c1530e035d..8a4f864aaa 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -162,6 +162,7 @@ fn start_safety_buffering_test_turn( thread_id: thread_id.to_string(), turn: AppServerTurn { id: turn_id.to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, @@ -874,6 +875,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, @@ -921,6 +923,7 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item( thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Summary, items: vec![item], status: AppServerTurnStatus::Completed, @@ -958,6 +961,7 @@ async fn live_app_server_turn_started_sets_feedback_turn_id() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, @@ -1538,6 +1542,7 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, @@ -1573,6 +1578,7 @@ async fn live_app_server_failed_turn_does_not_duplicate_error_history() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::Failed, @@ -1705,6 +1711,7 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, @@ -1782,6 +1789,7 @@ async fn live_app_server_server_overloaded_error_renders_error() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, @@ -1825,6 +1833,7 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 5d0a24a46e..2ccf46ce42 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1131,6 +1131,7 @@ pub(super) fn app_server_turn( ) -> AppServerTurn { AppServerTurn { id: turn_id.to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 25dd196a46..23c08f2271 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -1140,6 +1140,7 @@ async fn replayed_retryable_app_server_error_keeps_turn_running() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, @@ -1475,6 +1476,7 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, @@ -1534,6 +1536,7 @@ async fn live_reasoning_summary_drops_empty_parts_without_losing_content() { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 735f0df413..2b3257073c 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -810,6 +810,7 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { chat.replay_thread_turns( vec![AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![AppServerThreadItem::AgentMessage { id: "msg-plan".to_string(), @@ -850,6 +851,7 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com chat.replay_thread_turns( vec![AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![AppServerThreadItem::AgentMessage { id: "msg-plan-replay".to_string(), @@ -1159,6 +1161,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { thread_id: thread_id.to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, @@ -1204,6 +1207,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { thread_id: thread_id.to_string(), turn: AppServerTurn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::Completed, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 914974aaee..dd8214d013 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -1298,6 +1298,7 @@ async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::InProgress, @@ -1335,6 +1336,7 @@ async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot thread_id: "thread-1".to_string(), turn: codex_app_server_protocol::Turn { id: "turn-1".to_string(), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Interrupted, diff --git a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs index 625818ecf8..64aeca360a 100644 --- a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs +++ b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs @@ -274,6 +274,7 @@ async fn replayed_turn_completions_do_not_start_live_billing_refreshes() { chat.replay_thread_turns( vec![Turn { id: "replayed-turn".to_string(), + root_turn_id: None, items: Vec::new(), items_view: TurnItemsView::NotLoaded, status: status.clone(), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 4d5bf6cb83..40b6d74402 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -6747,6 +6747,7 @@ session_picker_view = "dense" name: None, turns: vec![codex_app_server_protocol::Turn { id: String::from("turn-1"), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ ThreadItem::UserMessage { @@ -6834,6 +6835,7 @@ session_picker_view = "dense" name: None, turns: vec![codex_app_server_protocol::Turn { id: String::from("turn-1"), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::Reasoning { id: String::from("reasoning-1"), @@ -6912,6 +6914,7 @@ session_picker_view = "dense" name: None, turns: vec![codex_app_server_protocol::Turn { id: String::from("turn-1"), + root_turn_id: None, items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::Reasoning { id: String::from("reasoning-1"), diff --git a/codex-rs/tui/src/temporary_structured_request_tests.rs b/codex-rs/tui/src/temporary_structured_request_tests.rs index 88c24a40b1..e86668e5ad 100644 --- a/codex-rs/tui/src/temporary_structured_request_tests.rs +++ b/codex-rs/tui/src/temporary_structured_request_tests.rs @@ -43,6 +43,7 @@ fn turn_completed_notification(turn_id: &str, status: TurnStatus) -> ServerNotif thread_id: "thread-1".to_string(), turn: Turn { id: turn_id.to_string(), + root_turn_id: None, items: Vec::new(), items_view: Default::default(), status, diff --git a/codex-rs/tui/src/thread_transcript/computer_groups_tests.rs b/codex-rs/tui/src/thread_transcript/computer_groups_tests.rs index 7bb259621e..6e24c27ef5 100644 --- a/codex-rs/tui/src/thread_transcript/computer_groups_tests.rs +++ b/codex-rs/tui/src/thread_transcript/computer_groups_tests.rs @@ -23,6 +23,7 @@ fn computer(id: &str) -> ThreadItem { fn turn(id: &str, items: Vec) -> Turn { Turn { id: id.to_string(), + root_turn_id: None, items, items_view: TurnItemsView::Full, status: TurnStatus::Completed, diff --git a/codex-rs/tui/src/transcript_view/mutations_tests.rs b/codex-rs/tui/src/transcript_view/mutations_tests.rs index ad0d244789..25edcdf7b0 100644 --- a/codex-rs/tui/src/transcript_view/mutations_tests.rs +++ b/codex-rs/tui/src/transcript_view/mutations_tests.rs @@ -378,6 +378,7 @@ fn folding_a_reasoning_only_page_retains_its_find_and_copy_revision() { }; let current = Turn { id: "turn".to_owned(), + root_turn_id: None, items: vec![call, reasoning], items_view: TurnItemsView::Full, status: TurnStatus::Completed, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 4a5cd1f191..de694cbfed 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -11672,6 +11672,13 @@ class Turn(BaseModel): description="Describes how much of `items` has been loaded for this turn.", ), ] = "full" + root_turn_id: Annotated[ + str | None, + Field( + alias="rootTurnId", + description="ID of the first turn in the chain of work that led to this turn. Pass this as `rootTurnId` when starting work on behalf of this turn. May be null in older history or a `review/start` response.", + ), + ] = None started_at: Annotated[ int | None, Field(alias="startedAt", description="Unix timestamp (in seconds) when the turn started."), @@ -12555,12 +12562,26 @@ class TurnStartParams(BaseModel): description="Optional JSON Schema used to constrain the final assistant message for this turn.", ), ] = None + parent_turn_id: Annotated[ + str | None, + Field( + alias="parentTurnId", + description="ID of the turn that caused this new turn to start.\n\nSet this when starting work on behalf of another turn, such as delegated work in a different thread. Leave unset for work started directly by the user. Ignored when this request adds input to an active turn.", + ), + ] = None personality: Annotated[ Personality | None, Field( description="@deprecated `friendly` and `pragmatic` no longer select a style. Changing this does not rewrite the thread's existing instructions." ), ] = None + root_turn_id: Annotated[ + str | None, + Field( + alias="rootTurnId", + description="ID of the first turn in the chain of work that led to this new turn.\n\nWhen setting `parentTurnId`, set this to the parent turn's `rootTurnId` when known. This keeps descendant work attributed to the original turn. If omitted, the new turn becomes its own root. Ignored when this request adds input to an active turn.", + ), + ] = None sandbox_policy: Annotated[ SandboxPolicy | None, Field(