Persist response token usage in rollout history (#41912)

## Why

Resumed threads need to continue per-turn and per-thread token totals without scanning arbitrarily far beyond the latest compaction checkpoint.

## What changed

- Add durable `TokenUsageRecord` rollout items with response, turn, thread, session, and root-turn attribution.
- Restore accumulated usage on resume and snapshot the latest record plus the compaction response ID in compaction checkpoints.
- Preserve root-turn lineage in persisted turn context, while ensuring forked child threads start with their own usage totals.

## Testing

- Cover usage accumulation across multiple responses and resumed turns.
- Cover local and remote compaction checkpoints, invalid remote compaction output, rollout reconstruction, and fork isolation.

GitOrigin-RevId: ef9e0c4a9102a08a2c382be4cdac68c84353c90a
This commit is contained in:
Owen Lin authored and copyberry committed 2026-08-31 21:15:38 +00:00
1 parent a7913390f7
commit 5f79a92e39
67 files changed
+932 -162

No files matched your search

+2 -13
View File
@@ -35,7 +35,7 @@ impl Session {
}
}
/// Injects hook context while classifying its actual receiving turn atomically.
/// Injects hook context into the running turn atomically.
#[expect(
clippy::await_holding_invalid_type,
reason = "active turn provenance and turn state updates must remain atomic"
@@ -43,19 +43,13 @@ impl Session {
pub(crate) async fn inject_hook_context_if_running(
&self,
input: Vec<ResponseItem>,
source_turn_id: Option<&str>,
) -> Result<(), Vec<ResponseItem>> {
let mut active = self.active_turn.lock().await;
let Some(active_turn) = active.as_mut() else {
return Err(input);
};
let Some(task) = active_turn.task.as_ref() else {
if active_turn.task.is_none() {
return Err(input);
};
if source_turn_id != Some(task.turn_context.sub_id.as_str()) {
task.turn_context
.turn_metadata_state
.mark_root_turn_ambiguous();
}
self.input_queue
.extend_pending_input_and_accept_mailbox_delivery_for_turn_state(
@@ -86,11 +80,6 @@ impl Session {
.collect::<Vec<_>>();
let mut active = self.active_turn.lock().await;
if let Some(active_turn) = active.as_mut() {
if let Some(task) = active_turn.task.as_ref() {
task.turn_context
.turn_metadata_state
.mark_root_turn_ambiguous();
}
self.input_queue
.extend_pending_input_and_accept_mailbox_delivery_for_turn_state(
active_turn.turn_state.as_ref(),
+10 -17
View File
@@ -169,16 +169,16 @@ impl InputQueue {
.and_then(|id| id.filter(|id| !id.trim().is_empty()).map(str::to_string));
start_options.root_turn_id = pending_mails
.iter()
.filter(|mail| mail.communication.trigger_turn)
.map(|mail| {
.find(|mail| mail.communication.trigger_turn)
.and_then(|mail| {
mail.start_options
.parent_turn_id
.as_deref()
.filter(|id| !id.trim().is_empty())
.and(mail.start_options.root_turn_id.as_deref())
.filter(|id| !id.trim().is_empty())
})
.reduce(|expected, candidate| expected.filter(|id| candidate == Some(*id)))
.and_then(|id| id.filter(|id| !id.trim().is_empty()).map(str::to_string));
.map(str::to_string);
let items = pending_mails
.into_iter()
.map(|mail| TurnInput::InterAgentCommunication(mail.communication))
@@ -320,17 +320,10 @@ impl InputQueue {
}
let (mailbox_items, start_options) = self.drain_mailbox_input_items().await;
if let Some(active_turn_metadata) = active_turn_metadata
&& mailbox_items.iter().any(|item| {
matches!(
item,
TurnInput::InterAgentCommunication(communication)
if communication.trigger_turn
)
})
&& (start_options.root_turn_id.is_none()
|| active_turn_metadata.root_turn_id() != start_options.root_turn_id)
&& active_turn_metadata.root_turn_id().is_none()
&& let Some(root_turn_id) = start_options.root_turn_id.as_ref()
{
active_turn_metadata.mark_root_turn_ambiguous();
active_turn_metadata.set_root_turn_id(root_turn_id.clone());
}
if pending_input.is_empty() {
(mailbox_items, start_options)
@@ -561,7 +554,7 @@ mod tests {
}
#[tokio::test]
async fn input_queue_requires_one_unambiguous_trigger_parent() {
async fn input_queue_uses_unambiguous_trigger_parent_and_first_root() {
let (parent, peer, root, root2) = (Some("a"), Some("b"), Some("r"), Some("s"));
for (pending_mails, expected_parent_turn_id, expected_root_turn_id) in [
(Vec::new(), None, None),
@@ -572,8 +565,8 @@ mod tests {
(vec![(true, parent, None)], parent, None),
(vec![(true, parent, Some(""))], parent, None),
(vec![(true, parent, root), (true, peer, root)], None, root),
(vec![(true, parent, root), (true, peer, root2)], None, None),
(vec![(true, parent, root), (true, None, root)], None, None),
(vec![(true, parent, root), (true, peer, root2)], None, root),
(vec![(true, parent, root), (true, None, root)], None, root),
(
vec![(true, parent, root), (true, parent, root)],
parent,
+59
View File
@@ -90,6 +90,7 @@ use codex_network_proxy::normalize_host;
use codex_otel::current_span_trace_id;
use codex_otel::current_span_w3c_trace_context;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::ResponseUsageMetadata;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationRequest;
@@ -349,6 +350,7 @@ use codex_protocol::protocol::ModelVerificationEvent;
use codex_protocol::protocol::NetworkApprovalContext;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RawResponseCompletedEvent;
use codex_protocol::protocol::RequestUserInputEvent;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
@@ -360,6 +362,7 @@ use codex_protocol::protocol::ThreadMemoryMode;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TokenUsageRecord;
use codex_protocol::protocol::TurnModerationMetadataEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::turn_input::TurnInputMode;
@@ -1440,6 +1443,8 @@ impl Session {
let mut state = self.state.lock().await;
state.set_token_info(Some(info));
}
self.state.lock().await.latest_token_usage_record =
Self::last_token_usage_record_from_rollout(&rollout_items);
// Defer seeding the session's initial context until the first turn starts so
// turn/start overrides can be merged before we write to the rollout.
@@ -1460,6 +1465,8 @@ impl Session {
let mut state = self.state.lock().await;
state.set_token_info(Some(info));
}
self.state.lock().await.latest_token_usage_record =
Self::last_token_usage_record_from_rollout(&rollout_items);
let thread_settings_applied =
RolloutItem::EventMsg(thread_settings::applied_event(self).await);
@@ -1624,6 +1631,21 @@ impl Session {
})
}
fn last_token_usage_record_from_rollout(
rollout_items: &[RolloutItem],
) -> Option<TokenUsageRecord> {
for item in rollout_items.iter().rev() {
match item {
RolloutItem::TokenUsageRecord(record) => return Some(record.clone()),
RolloutItem::Compacted(compacted) => {
return compacted.latest_token_usage_record.clone();
}
_ => {}
}
}
None
}
async fn previous_turn_settings(&self) -> Option<PreviousTurnSettings> {
let state = self.state.lock().await;
state.previous_turn_settings()
@@ -3668,6 +3690,8 @@ impl Session {
.previous_window_id
.map(|id| id.to_string()),
window_id: Some(metadata.window_ids.window_id.to_string()),
compaction_response_id: metadata.compaction_response_id,
latest_token_usage_record: self.state.lock().await.latest_token_usage_record.clone(),
};
// Wait for accepted updates to finish persisting, then keep later updates from
// overtaking the current settings snapshot while its checkpoint is written.
@@ -4118,6 +4142,7 @@ impl Session {
message: String::new(),
window_number,
window_ids,
compaction_response_id: None,
},
)
.await;
@@ -4232,6 +4257,40 @@ impl Session {
result
}
pub(crate) async fn record_observed_response_completed(
&self,
turn_context: &TurnContext,
response_id: &str,
usage: Option<&TokenUsage>,
usage_metadata: Option<&ResponseUsageMetadata>,
) {
self.send_event(
turn_context,
EventMsg::RawResponseCompleted(RawResponseCompletedEvent {
response_id: response_id.to_string(),
token_usage: usage.cloned(),
usage_metadata: usage_metadata.cloned(),
}),
)
.await;
let Some(usage) = usage else {
return;
};
let record = self.state.lock().await.record_token_usage(
self.thread_id,
&turn_context.sub_id,
self.session_id(),
turn_context
.turn_metadata_state
.root_turn_id()
.unwrap_or_else(|| turn_context.sub_id.clone()),
response_id.to_string(),
usage,
);
self.persist_rollout_items(&[RolloutItem::TokenUsageRecord(record)])
.await;
}
pub(crate) async fn record_token_usage_info(
&self,
turn_context: &TurnContext,
@@ -294,6 +294,7 @@ impl Session {
| RolloutItem::SessionMeta(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::InterAgentCommunicationMetadata { .. } => {}
}
@@ -385,6 +386,7 @@ impl Session {
| RolloutItem::RealtimeItem(_)
| RolloutItem::WorldState(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::SessionMeta(_) => {}
}
}
@@ -424,6 +426,7 @@ impl Session {
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::TurnContext(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::EventMsg(_) => {
unreachable!("only world-state replay items are collected")
@@ -265,6 +265,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
workspace_roots: None,
@@ -314,6 +315,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
let previous_model = "previous-rollout-model";
let mut previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
workspace_roots: None,
@@ -1064,6 +1066,8 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(
codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 },
@@ -1124,6 +1128,8 @@ async fn record_initial_history_requires_surviving_full_snapshot_without_user_tu
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
],
};
@@ -1156,6 +1162,8 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
];
@@ -1226,6 +1234,8 @@ async fn reconstruct_history_prefers_compacted_window_over_session_meta() {
first_window_id: Some(compacted_first_window_id.to_string()),
previous_window_id: Some(compacted_previous_window_id.to_string()),
window_id: Some(compacted_window_id.to_string()),
compaction_response_id: None,
latest_token_usage_record: None,
}),
];
@@ -1262,6 +1272,8 @@ async fn reconstruct_history_replays_world_state_from_latest_compaction_window()
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
RolloutItem::WorldState(WorldStateItem::full(object!({
"environment": {"status": "starting", "cwd": "/workspace"}
@@ -1324,6 +1336,8 @@ async fn bounded_replay_matches_full_replay_after_empty_turn_compactions() {
first_window_id: Some(window_ids[0].to_string()),
previous_window_id: Some(window_ids[window_number - 1].to_string()),
window_id: Some(window_ids[window_number].to_string()),
compaction_response_id: None,
latest_token_usage_record: None,
}),
RolloutItem::WorldState(WorldStateItem::full(object!({
"environment": {"window": window_number, "status": "starting"}
@@ -1420,6 +1434,8 @@ async fn reconstruct_history_preserves_legacy_compaction_count_with_session_meta
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
];
@@ -1448,6 +1464,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
];
@@ -1484,6 +1502,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
@@ -1532,6 +1552,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: Some("root-turn".to_string()),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
workspace_roots: None,
@@ -1588,6 +1609,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
RolloutItem::TurnContext(previous_context_item),
RolloutItem::EventMsg(EventMsg::TurnComplete(
@@ -1624,6 +1647,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
.expect("serialize seeded reference context item"),
serde_json::to_value(Some(TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: Some("root-turn".to_string()),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
workspace_roots: None,
@@ -1658,6 +1682,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
workspace_roots: None,
@@ -1755,6 +1780,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
];
@@ -1791,6 +1818,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
let unmatched_abort_turn_id = "other-turn".to_string();
let current_context_item = TurnContextItem {
turn_id: Some(current_turn_id.clone()),
root_turn_id: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
workspace_roots: None,
@@ -1920,6 +1948,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
workspace_roots: None,
@@ -2008,6 +2037,8 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
];
@@ -2093,6 +2124,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
let previous_model = "previous-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
workspace_roots: None,
@@ -2182,6 +2214,8 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
// A newer TurnStarted replaces the incomplete compacted turn without a matching
// completion/abort for the old one.
+3 -1
View File
@@ -770,11 +770,12 @@ impl Session {
}
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
};
// Legacy subagent rollouts synthesize session_id from their own thread id.
// Legacy subagent rollouts synthesized session_id from their own thread ID.
let resumed_session_id = resumed_session_id.filter(|session_id| {
!session_configuration.session_source.is_non_root_agent()
|| *session_id != SessionId::from(thread_id)
});
// session_id is equal to the root thread's ID.
let session_id = resumed_session_id.unwrap_or_else(|| {
if session_configuration.session_source.is_non_root_agent() {
agent_control.session_id()
@@ -1357,6 +1358,7 @@ impl Session {
| RolloutItem::TurnContext(_)
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::SecurityRiskScore(_) => {}
}
}
+141 -4
View File
@@ -164,6 +164,7 @@ use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TokenUsageRecord;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
@@ -2227,6 +2228,8 @@ async fn reconstruct_history_uses_replacement_history_verbatim() {
first_window_id: Some(first_window_id.to_string()),
previous_window_id: Some(previous_window_id.to_string()),
window_id: Some(window_id.to_string()),
compaction_response_id: None,
latest_token_usage_record: None,
})];
let reconstructed = session
@@ -2883,6 +2886,49 @@ async fn record_initial_history_seeds_token_info_from_rollout() {
assert_eq!(actual, Some(info2));
}
#[test]
fn latest_token_usage_record_stops_at_compaction_checkpoint() {
let thread_id = ThreadId::new();
let checkpoint_record = TokenUsageRecord {
thread_id,
turn_id: "turn-1".to_string(),
session_id: SessionId::from(thread_id),
root_turn_id: "turn-1".to_string(),
response_id: "response-1".to_string(),
usage: TokenUsage::default(),
turn_token_usage: TokenUsage::default(),
thread_token_usage: TokenUsage::default(),
};
let checkpoint = |latest_token_usage_record| {
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record,
})
};
assert_eq!(
Session::last_token_usage_record_from_rollout(&[
RolloutItem::TokenUsageRecord(checkpoint_record.clone()),
checkpoint(Some(checkpoint_record.clone())),
]),
Some(checkpoint_record.clone())
);
assert_eq!(
Session::last_token_usage_record_from_rollout(&[
RolloutItem::TokenUsageRecord(checkpoint_record),
checkpoint(None),
]),
None
);
}
#[tokio::test]
async fn recompute_token_usage_uses_session_base_instructions() {
let (session, turn_context) = make_session_and_context().await;
@@ -3412,7 +3458,7 @@ async fn record_initial_history_reconstructs_forked_transcript() {
}
#[tokio::test]
async fn start_new_context_window_assigns_and_persists_item_ids() {
async fn start_new_context_window_persists_checkpoint_state() {
let (mut session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx(
CodexAuth::from_api_key("Test API Key"),
Vec::new(),
@@ -3421,6 +3467,18 @@ async fn start_new_context_window_assigns_and_persists_item_ids() {
.await;
let rollout_path =
attach_thread_persistence(Arc::get_mut(&mut session).expect("unique session")).await;
let thread_id = ThreadId::new();
let token_usage_record = TokenUsageRecord {
thread_id,
turn_id: "turn-1".to_string(),
session_id: SessionId::from(thread_id),
root_turn_id: "turn-1".to_string(),
response_id: "response-1".to_string(),
usage: TokenUsage::default(),
turn_token_usage: TokenUsage::default(),
thread_token_usage: TokenUsage::default(),
};
session.state.lock().await.latest_token_usage_record = Some(token_usage_record.clone());
let step_context = session
.capture_step_context(Arc::clone(&turn_context), &CancellationToken::new())
.await
@@ -3447,8 +3505,8 @@ async fn start_new_context_window_assigns_and_persists_item_ids() {
else {
panic!("expected resumed rollout history");
};
let persisted_replacement_history = resumed.history.iter().rev().find_map(|item| match item {
RolloutItem::Compacted(compacted) => compacted.replacement_history.as_ref(),
let persisted_compacted = resumed.history.iter().rev().find_map(|item| match item {
RolloutItem::Compacted(compacted) => Some(compacted),
RolloutItem::SessionMeta(_)
| RolloutItem::ResponseItem(_)
| RolloutItem::InterAgentCommunication(_)
@@ -3456,13 +3514,23 @@ async fn start_new_context_window_assigns_and_persists_item_ids() {
| RolloutItem::TurnContext(_)
| RolloutItem::WorldState(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::EventMsg(_) => None,
});
assert_eq!(
persisted_replacement_history.cloned(),
persisted_compacted.and_then(|compacted| compacted.replacement_history.clone()),
Some(live_history.annotated_items().to_vec())
);
assert_eq!(
persisted_compacted.map(|compacted| {
(
compacted.compaction_response_id.as_deref(),
compacted.latest_token_usage_record.as_ref(),
)
}),
Some((None, Some(&token_usage_record)))
);
}
#[tokio::test]
@@ -3535,6 +3603,7 @@ async fn record_initial_history_assigns_and_persists_id_for_forked_response_item
| RolloutItem::TurnContext(_)
| RolloutItem::WorldState(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::EventMsg(_) => None,
});
@@ -3691,6 +3760,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
let previous_model = "forked-rollout-model";
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
root_turn_id: None,
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
workspace_roots: None,
@@ -4102,6 +4172,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
first_window_id: Some(first_window_id.to_string()),
previous_window_id: Some(previous_window_id.to_string()),
window_id: Some(compacted_window_id.to_string()),
compaction_response_id: None,
latest_token_usage_record: None,
}),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: compact_turn_id,
@@ -5864,6 +5936,7 @@ async fn compaction_checkpoint_waits_for_accepted_settings_persistence() {
message: "summary".to_string(),
window_number,
window_ids,
compaction_response_id: None,
},
),
));
@@ -11907,6 +11980,66 @@ async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
assert!(sess.input_queue.has_trigger_turn_mailbox_items().await);
}
#[tokio::test]
async fn active_turn_keeps_first_root_when_mail_coalesces() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
tc.turn_metadata_state
.set_root_turn_id("root-a".to_string());
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: true,
},
)
.await;
let first = InterAgentCommunication::new(
AgentPath::try_from("/root/worker_a").expect("worker path should parse"),
AgentPath::root(),
Vec::new(),
"first".to_string(),
/*trigger_turn*/ true,
);
let second = InterAgentCommunication::new(
AgentPath::try_from("/root/worker_b").expect("worker path should parse"),
AgentPath::root(),
Vec::new(),
"second".to_string(),
/*trigger_turn*/ true,
);
for (communication, parent_turn_id, root_turn_id) in [
(first.clone(), "parent-a", "root-a"),
(second.clone(), "parent-b", "root-b"),
] {
sess.input_queue
.enqueue_mailbox_communication(
communication,
codex_protocol::turn_input::TurnStartOptions {
parent_turn_id: Some(parent_turn_id.to_string()),
root_turn_id: Some(root_turn_id.to_string()),
..Default::default()
},
)
.await;
}
assert_eq!(
(sess.input_queue.get_pending_input(&sess.active_turn).await).0,
vec![
TurnInput::InterAgentCommunication(first),
TurnInput::InterAgentCommunication(second),
]
);
assert_eq!(
tc.turn_metadata_state.root_turn_id().as_deref(),
Some("root-a")
);
assert!(!sess.input_queue.has_pending_mailbox_items().await);
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
}
#[tokio::test]
async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
@@ -12271,6 +12404,8 @@ async fn sample_rollout(
first_window_id: Some(window_ids.first_window_id.to_string()),
previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()),
window_id: Some(window_ids.window_id.to_string()),
compaction_response_id: None,
latest_token_usage_record: None,
}));
let user2 = user_message("second user");
@@ -12301,6 +12436,8 @@ async fn sample_rollout(
first_window_id: Some(window_ids.first_window_id.to_string()),
previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()),
window_id: Some(window_ids.window_id.to_string()),
compaction_response_id: None,
latest_token_usage_record: None,
}));
let user3 = user_message("third user");
+4 -7
View File
@@ -100,7 +100,6 @@ use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::PlanDeltaEvent;
use codex_protocol::protocol::RawResponseCompletedEvent;
use codex_protocol::protocol::ReasoningContentDeltaEvent;
use codex_protocol::protocol::ReasoningRawContentDeltaEvent;
use codex_protocol::protocol::SafetyBufferingEvent;
@@ -2590,13 +2589,11 @@ async fn try_run_sampling_request(
&mut assistant_message_stream_parsers,
)
.await;
sess.send_event(
sess.record_observed_response_completed(
&turn_context,
EventMsg::RawResponseCompleted(RawResponseCompletedEvent {
response_id,
token_usage: token_usage.clone(),
usage_metadata,
}),
&response_id,
token_usage.as_ref(),
usage_metadata.as_ref(),
)
.await;
let budget_result = sess
@@ -578,6 +578,7 @@ impl TurnContext {
let cwd = self.cwd.clone();
TurnContextItem {
turn_id: Some(self.sub_id.clone()),
root_turn_id: self.turn_metadata_state.root_turn_id(),
cwd,
workspace_roots: (!workspace_roots.is_empty()).then_some(workspace_roots),
current_date: self.current_date.clone(),
+7 -3
View File
@@ -624,13 +624,17 @@ impl Session {
input => pending_turn_input(input.clone()),
};
pending_input.push(input);
if let Some(incoming_root_turn_id) = incoming_root_turn_id
&& active_task.turn_context.turn_metadata_state.root_turn_id() != incoming_root_turn_id
if active_task
.turn_context
.turn_metadata_state
.root_turn_id()
.is_none()
&& let Some(Some(incoming_root_turn_id)) = incoming_root_turn_id
{
active_task
.turn_context
.turn_metadata_state
.mark_root_turn_ambiguous();
.set_root_turn_id(incoming_root_turn_id);
}
self.input_queue
.extend_pending_input_and_accept_mailbox_delivery_for_turn_state(