Preserve root turn attribution in turn-start events (#44611)

## Why

Persisted turn-start events lack the originating root turn ID, so they cannot directly associate child turns with the root turn that initiated them.

## What changed

- Add optional `root_turn_id` to `TurnStartedEvent`. Use the inherited root turn ID when available, otherwise the turn's own ID.
- Centralize turn-start emission for regular turns, shell commands, and manual compaction so they record attribution consistently.
- Carry attribution into `ThreadHistoryTurnChange` when processing turn-start events and retain it through completion in the history builder.
- Keep older records compatible by defaulting missing attribution to `None`.

## Testing

Add coverage for root and child turn attribution, persistence when tool collisions fail a turn before sampling, history change propagation, and deserialization of older turn-start records.

GitOrigin-RevId: 54a80b828ca7d1c4c19f13d468c01d052e1b9130
This commit is contained in:
Owen Lin authored and copyberry committed 2026-09-10 18:24:13 +00:00
1 parent 242c5ce01c
commit 196964ef10
40 files changed
+252 -55

No files matched your search

@@ -1102,6 +1102,7 @@ async fn thread_fork_can_cut_before_unfinished_stored_turn() -> Result<()> {
&source_path,
&RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: unfinished_turn_id.to_string(),
root_turn_id: None,
trace_id: None,
started_at: None,
model_context_window: None,
@@ -1376,6 +1377,7 @@ async fn thread_fork_creates_reference_backed_paginated_thread() -> Result<()> {
for item in [
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
root_turn_id: None,
trace_id: None,
started_at: Some(10),
model_context_window: None,
@@ -1705,6 +1707,7 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted(
source_path.as_path(),
&RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "active-turn".to_string(),
root_turn_id: None,
trace_id: None,
started_at: Some(10),
model_context_window: None,
@@ -2222,6 +2222,7 @@ async fn read_items_page(
fn paginated_turn_started(turn_id: &str) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
started_at: Some(10),
model_context_window: None,
@@ -3866,6 +3866,7 @@ async fn cold_paginated_resume_restores_usage_without_loading_turns() -> Result<
&path,
&RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: canonical_turn_id.to_string(),
root_turn_id: None,
trace_id: None,
started_at: None,
model_context_window: None,
@@ -3977,6 +3978,7 @@ async fn cold_paginated_resume_omits_usage_when_its_turn_is_ambiguous() -> Resul
&path,
&RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: interrupted_turn_id.to_string(),
root_turn_id: None,
trace_id: None,
started_at: None,
model_context_window: None,
@@ -4119,6 +4121,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn()
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: stale_turn_id.to_string(),
root_turn_id: None,
trace_id: None,
started_at: None,
model_context_window: None,
@@ -4207,6 +4210,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: interrupted_turn_id.to_string(),
root_turn_id: None,
trace_id: None,
started_at: None,
model_context_window: None,
@@ -4520,6 +4524,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
root_turn_id: None,
trace_id: None,
started_at: None,
model_context_window: None,
@@ -97,6 +97,7 @@ async fn timeline_pages_mix_items_and_resolve_the_opening_realtime_session() ->
}),
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
root_turn_id: None,
trace_id: None,
started_at: Some(10),
model_context_window: None,