mirror of
https://github.com/openai/codex.git
synced 2026-10-29 00:06:55 +00:00
Avoid cloning thread history for token usage replay (#34361)
## What changed - Resolve the turn ID for restored token usage before sending resume and fork responses, avoiding a clone of the full thread history. - Add `ThreadHistoryBuilder::active_turn_id` so attribution can inspect the active turn without materializing its items. - Stop history reconstruction at the latest persisted `TokenCount` and verify that a later tail turn does not receive the replayed usage. GitOrigin-RevId: 69c4a9fe04770d1a938470afc933431e61670094
This commit is contained in:
@@ -274,6 +274,14 @@ impl ThreadHistoryBuilder {
|
||||
.or_else(|| self.turns.last().cloned())
|
||||
}
|
||||
|
||||
/// Returns the id of the active turn without materializing its items.
|
||||
pub fn active_turn_id(&self) -> Option<&str> {
|
||||
self.current_turn
|
||||
.as_ref()
|
||||
.map(|turn| turn.id.as_str())
|
||||
.or_else(|| self.turns.last().map(|turn| turn.id.as_str()))
|
||||
}
|
||||
|
||||
pub fn turn_snapshot(&self, turn_id: &str) -> Option<Turn> {
|
||||
self.current_turn
|
||||
.as_ref()
|
||||
|
||||
@@ -570,7 +570,7 @@ use crate::thread_state::ConnectionCapabilities;
|
||||
use crate::thread_state::ThreadListenerCommand;
|
||||
use crate::thread_state::ThreadState;
|
||||
use crate::thread_state::ThreadStateManager;
|
||||
use token_usage_replay::latest_token_usage_turn_id_from_rollout_items;
|
||||
use token_usage_replay::restored_token_usage_turn_id;
|
||||
use token_usage_replay::send_thread_token_usage_update_to_connection;
|
||||
|
||||
fn resolve_request_cwd(cwd: Option<PathBuf>) -> Result<Option<AbsolutePathBuf>, JSONRPCErrorError> {
|
||||
|
||||
@@ -577,7 +577,9 @@ pub(super) async fn handle_pending_thread_resume_request(
|
||||
thread_status.clone(),
|
||||
has_live_in_progress_turn,
|
||||
);
|
||||
let token_usage_thread = pending.include_turns.then(|| thread.clone());
|
||||
let token_usage_turn_id = pending
|
||||
.include_turns
|
||||
.then(|| restored_token_usage_turn_id(&pending.history_items, &thread));
|
||||
let mut initial_turns_page = if let Some(mut page) = pending.paginated_initial_turns_page.take()
|
||||
{
|
||||
if let (Some(active_turn), Some(params)) =
|
||||
@@ -714,18 +716,13 @@ pub(super) async fn handle_pending_thread_resume_request(
|
||||
.await;
|
||||
// Match cold resume: metadata-only resume should attach the listener without
|
||||
// paying the cost of turn reconstruction for historical usage replay.
|
||||
if let Some(token_usage_thread) = token_usage_thread {
|
||||
let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items(
|
||||
&pending.history_items,
|
||||
token_usage_thread.turns.as_slice(),
|
||||
);
|
||||
if let Some(token_usage_turn_id) = token_usage_turn_id {
|
||||
// Rejoining a loaded thread has the same UI contract as a cold resume, but
|
||||
// uses the live conversation state instead of reconstructing a new session.
|
||||
send_thread_token_usage_update_to_connection(
|
||||
outgoing,
|
||||
connection_id,
|
||||
conversation_id,
|
||||
&token_usage_thread,
|
||||
conversation.as_ref(),
|
||||
token_usage_turn_id,
|
||||
)
|
||||
|
||||
@@ -3316,7 +3316,9 @@ impl ThreadRequestProcessor {
|
||||
let active_permission_profile = thread_response_active_permission_profile(
|
||||
config_snapshot.active_permission_profile,
|
||||
);
|
||||
let token_usage_thread = include_turns.then(|| thread.clone());
|
||||
let token_usage_turn_id = include_turns.then(|| {
|
||||
restored_token_usage_turn_id(response_history.get_rollout_items(), &thread)
|
||||
});
|
||||
let mut initial_turns_page = if let Some(params) = initial_turns_page.as_ref() {
|
||||
let initial_turns_page_result = if paginated_resume {
|
||||
self.paginated_resume_initial_turns_page(thread_id, params)
|
||||
@@ -3373,11 +3375,7 @@ impl ThreadRequestProcessor {
|
||||
.await;
|
||||
// `excludeTurns` is explicitly the cheap resume path, so avoid
|
||||
// rebuilding history only to attribute a replayed usage update.
|
||||
if let Some(token_usage_thread) = token_usage_thread {
|
||||
let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items(
|
||||
response_history.get_rollout_items(),
|
||||
token_usage_thread.turns.as_slice(),
|
||||
);
|
||||
if let Some(token_usage_turn_id) = token_usage_turn_id {
|
||||
// The client needs restored usage before it starts another turn.
|
||||
// Sending after the response preserves JSON-RPC request ordering while
|
||||
// still filling the status line before the next turn lifecycle begins.
|
||||
@@ -3385,7 +3383,6 @@ impl ThreadRequestProcessor {
|
||||
&self.outgoing,
|
||||
connection_id,
|
||||
thread_id,
|
||||
&token_usage_thread,
|
||||
codex_thread.as_ref(),
|
||||
token_usage_turn_id,
|
||||
)
|
||||
@@ -4263,24 +4260,20 @@ impl ThreadRequestProcessor {
|
||||
|
||||
let notif = thread_started_notification(thread);
|
||||
let connection_id = request_id.connection_id;
|
||||
let token_usage_thread = include_turns.then(|| response.thread.clone());
|
||||
let token_usage_turn_id =
|
||||
include_turns.then(|| restored_token_usage_turn_id(&history_items, &response.thread));
|
||||
self.outgoing
|
||||
.send_response_with_thread_originator(request_id, response, thread_originator)
|
||||
.await;
|
||||
// `excludeTurns` is the cheap fork path, so skip restored usage replay
|
||||
// instead of rebuilding history only to attribute a historical update.
|
||||
if let Some(token_usage_thread) = token_usage_thread {
|
||||
let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items(
|
||||
&history_items,
|
||||
token_usage_thread.turns.as_slice(),
|
||||
);
|
||||
if let Some(token_usage_turn_id) = token_usage_turn_id {
|
||||
// Mirror the resume contract for forks: the new thread is usable as soon
|
||||
// as the response arrives, so restored usage must follow immediately.
|
||||
send_thread_token_usage_update_to_connection(
|
||||
&self.outgoing,
|
||||
connection_id,
|
||||
thread_id,
|
||||
&token_usage_thread,
|
||||
forked_thread.as_ref(),
|
||||
token_usage_turn_id,
|
||||
)
|
||||
|
||||
@@ -37,16 +37,15 @@ pub(super) async fn send_thread_token_usage_update_to_connection(
|
||||
outgoing: &Arc<OutgoingMessageSender>,
|
||||
connection_id: ConnectionId,
|
||||
thread_id: ThreadId,
|
||||
thread: &Thread,
|
||||
conversation: &CodexThread,
|
||||
token_usage_turn_id: Option<String>,
|
||||
token_usage_turn_id: String,
|
||||
) {
|
||||
let Some(info) = conversation.token_usage_info().await else {
|
||||
return;
|
||||
};
|
||||
let notification = ThreadTokenUsageUpdatedNotification {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: token_usage_turn_id.unwrap_or_else(|| latest_token_usage_turn_id(thread)),
|
||||
turn_id: token_usage_turn_id,
|
||||
token_usage: ThreadTokenUsage::from(info),
|
||||
};
|
||||
outgoing
|
||||
@@ -57,41 +56,36 @@ pub(super) async fn send_thread_token_usage_update_to_connection(
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Identifies the turn that was active when a `TokenCount` record appeared.
|
||||
pub(super) fn restored_token_usage_turn_id(
|
||||
rollout_items: &[RolloutItem],
|
||||
thread: &Thread,
|
||||
) -> String {
|
||||
latest_token_usage_turn_id_from_rollout_items(rollout_items, thread.turns.as_slice())
|
||||
.unwrap_or_else(|| latest_token_usage_turn_id(thread))
|
||||
}
|
||||
|
||||
/// Identifies the turn that was active when the latest `TokenCount` record appeared.
|
||||
///
|
||||
/// The id is preferred when it still appears in the rebuilt thread. The position is a
|
||||
/// fallback for histories whose implicit turn ids are regenerated during reconstruction.
|
||||
struct TokenUsageTurnOwner {
|
||||
id: String,
|
||||
position: Option<usize>,
|
||||
}
|
||||
|
||||
pub(super) fn latest_token_usage_turn_id_from_rollout_items(
|
||||
fn latest_token_usage_turn_id_from_rollout_items(
|
||||
rollout_items: &[RolloutItem],
|
||||
turns: &[Turn],
|
||||
) -> Option<String> {
|
||||
let token_count_index = rollout_items
|
||||
.iter()
|
||||
.rposition(|item| matches!(item, RolloutItem::EventMsg(EventMsg::TokenCount(_))))?;
|
||||
let mut builder = ThreadHistoryBuilder::new();
|
||||
let mut token_usage_turn_owner = None;
|
||||
|
||||
for item in rollout_items {
|
||||
if matches!(item, RolloutItem::EventMsg(EventMsg::TokenCount(_))) {
|
||||
token_usage_turn_owner =
|
||||
builder
|
||||
.active_turn_snapshot()
|
||||
.map(|turn| TokenUsageTurnOwner {
|
||||
id: turn.id,
|
||||
position: builder.active_turn_position(),
|
||||
});
|
||||
}
|
||||
for item in &rollout_items[..token_count_index] {
|
||||
builder.handle_rollout_item(item);
|
||||
}
|
||||
|
||||
let owner = token_usage_turn_owner?;
|
||||
if turns.iter().any(|turn| turn.id == owner.id) {
|
||||
Some(owner.id)
|
||||
let active_turn_id = builder.active_turn_id()?;
|
||||
if turns.iter().any(|turn| turn.id == active_turn_id) {
|
||||
Some(active_turn_id.to_string())
|
||||
} else {
|
||||
owner
|
||||
.position
|
||||
builder
|
||||
.active_turn_position()
|
||||
.and_then(|position| turns.get(position))
|
||||
.map(|turn| turn.id.clone())
|
||||
}
|
||||
@@ -145,6 +139,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_attribution_uses_latest_token_count_and_ignores_tail_turn() {
|
||||
let mut rollout_items = token_usage_history();
|
||||
rollout_items.extend(token_usage_history());
|
||||
let turns = build_turns_from_rollout_items(&rollout_items);
|
||||
|
||||
assert_eq!(
|
||||
latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns.as_slice()),
|
||||
Some(turns[2].id.clone())
|
||||
);
|
||||
}
|
||||
|
||||
fn token_usage_history() -> Vec<RolloutItem> {
|
||||
vec![
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
|
||||
|
||||
Reference in New Issue
Block a user