mirror of
https://github.com/openai/codex.git
synced 2026-11-11 21:10:39 +00:00
Release superseded assistant text from live paginated history (#58751)
## Why Live paginated history retains completed assistant text even when summaries only need the latest message. Release older text for threads with persisted history while preserving full resume responses. ## What changed - Evict superseded assistant text from live turn state while preserving corrected items and the latest assistant message. - Restore evicted text for full resume responses from prepared history pages, fetching remaining items by ID in bounded batches. Return an error if saved text is missing. - Only load the active turn when it belongs in the requested initial page, and use the item ID index for targeted history reads. ## Testing Add reducer coverage for text eviction and corrected messages. Extend running-thread resume coverage to compare full and summary items with live notifications, exercise restoration when the prepared page lacks the active turn, and check missing-text errors. GitOrigin-RevId: 3229c0cea820f117dc6331312f1e6056a8d1d52c
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
use super::thread_processor::THREAD_ITEMS_MAX_LIMIT;
|
||||
use super::thread_processor::thread_turns_page_size;
|
||||
use super::*;
|
||||
use crate::extensions::send_thread_warning;
|
||||
use crate::thread_state::ListenerStateCandidate;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadQueueChangedNotification;
|
||||
use codex_core::ThreadEvictionOutcome;
|
||||
use codex_core::ThreadRegistration;
|
||||
@@ -8,6 +11,7 @@ use codex_extension_api::ThreadIdleCause;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thread_listener_shutdown_tests.rs"]
|
||||
@@ -1027,20 +1031,49 @@ pub(super) async fn handle_pending_thread_resume_request(
|
||||
pending_thread_unloads: &PendingThreadUnloads,
|
||||
mut pending: crate::thread_state::PendingThreadResumeRequest,
|
||||
) {
|
||||
let (active_turn_metadata, active_turn) = {
|
||||
let (active_turn_metadata, mut active_turn, active_page_params) = {
|
||||
let state = thread_state.lock().await;
|
||||
let active_page_params = pending.initial_turns_page.as_ref().filter(|params| {
|
||||
pending
|
||||
.paginated_initial_turns_page
|
||||
.as_ref()
|
||||
.is_none_or(|page| {
|
||||
params.sort_direction != Some(SortDirection::Asc)
|
||||
|| (page.next_cursor.is_none()
|
||||
&& page.data.len() < thread_turns_page_size(params.limit))
|
||||
|| state.active_turn_id().is_some_and(|active_turn_id| {
|
||||
page.data.iter().any(|turn| turn.id == active_turn_id)
|
||||
})
|
||||
})
|
||||
});
|
||||
let items_view = if pending.include_turns {
|
||||
Some(TurnItemsView::Full)
|
||||
} else {
|
||||
pending
|
||||
.initial_turns_page
|
||||
.as_ref()
|
||||
.map(|page| page.items_view.unwrap_or(TurnItemsView::Summary))
|
||||
active_page_params.map(|params| params.items_view.unwrap_or(TurnItemsView::Summary))
|
||||
};
|
||||
let active_turn =
|
||||
items_view.and_then(|view| state.active_turn_snapshot_with_items_view(view));
|
||||
(state.active_turn_metadata_snapshot(), active_turn)
|
||||
(
|
||||
state.active_turn_metadata_snapshot(),
|
||||
active_turn,
|
||||
active_page_params,
|
||||
)
|
||||
};
|
||||
if let Some(store) = pending.resume_cursor_store.as_deref()
|
||||
&& let Some(turn) = &mut active_turn
|
||||
&& turn.items_view == TurnItemsView::Full
|
||||
{
|
||||
let stored = pending.paginated_turns.iter_mut().flatten().chain(
|
||||
pending
|
||||
.paginated_initial_turns_page
|
||||
.iter_mut()
|
||||
.flat_map(|page| &mut page.data),
|
||||
);
|
||||
if let Err(error) = restore_evicted_agent_text(store, conversation_id, turn, stored).await {
|
||||
outgoing.send_error(pending.request_id, error).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
tracing::debug!(
|
||||
thread_id = %conversation_id,
|
||||
request_id = ?pending.request_id,
|
||||
@@ -1082,16 +1115,9 @@ pub(super) async fn handle_pending_thread_resume_request(
|
||||
thread_status.clone(),
|
||||
has_live_in_progress_turn,
|
||||
);
|
||||
let active_turn = if pending.initial_turns_page.is_some() {
|
||||
active_turn.or_else(|| active_turn_metadata.map(Turn::from))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut initial_turns_page = if let Some(mut page) = pending.paginated_initial_turns_page.take()
|
||||
{
|
||||
if let (Some(active_turn), Some(params)) =
|
||||
(active_turn, pending.initial_turns_page.as_ref())
|
||||
{
|
||||
if let (Some(active_turn), Some(params)) = (active_turn, active_page_params) {
|
||||
let sort_direction = params.sort_direction.unwrap_or(SortDirection::Desc);
|
||||
let active_turn_is_in_page = page.data.iter().any(|turn| turn.id == active_turn.id);
|
||||
if matches!(sort_direction, SortDirection::Desc)
|
||||
@@ -1328,6 +1354,70 @@ pub(super) async fn resolve_pending_server_request(
|
||||
outgoing.resolve_server_request(request_id).await;
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, Hash, PartialEq)]
|
||||
struct HistoryItemId<'a>(Cow<'a, str>);
|
||||
|
||||
async fn restore_evicted_agent_text<'a>(
|
||||
store: &dyn ThreadStore,
|
||||
thread_id: ThreadId,
|
||||
turn: &mut Turn,
|
||||
stored: impl Iterator<Item = &'a mut Turn>,
|
||||
) -> Result<(), JSONRPCErrorError> {
|
||||
// Live history skips empty assistant completions, so every empty slot was evicted.
|
||||
let mut missing: HashMap<_, _> = turn
|
||||
.items
|
||||
.iter_mut()
|
||||
.filter_map(|item| match &mut item.payload {
|
||||
ThreadItemPayload::AgentMessage { id, text, .. } if text.is_empty() => {
|
||||
Some((HistoryItemId(Cow::Borrowed(id)), text))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for stored in stored.filter(|stored| stored.id == turn.id) {
|
||||
fill_agent_text(&mut missing, std::mem::take(&mut stored.items));
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// The prepared page can predate some of the listener's items. Fetch only those gaps.
|
||||
let ids: Vec<_> = missing.keys().cloned().collect();
|
||||
for chunk in ids.chunks(THREAD_ITEMS_MAX_LIMIT) {
|
||||
let params = ThreadItemsReadParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: turn.id.clone(),
|
||||
item_ids: chunk.iter().map(|id| id.0.to_string()).collect(),
|
||||
};
|
||||
let saved = ThreadRequestProcessor::read_thread_items(store, params).await?;
|
||||
fill_agent_text(&mut missing, saved.data.into_iter().map(|entry| entry.item));
|
||||
}
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(internal_error(format!(
|
||||
"saved history is missing completed assistant items for turn {}",
|
||||
turn.id
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_agent_text(
|
||||
missing: &mut HashMap<HistoryItemId<'_>, &mut String>,
|
||||
items: impl IntoIterator<Item = ThreadItem>,
|
||||
) {
|
||||
for item in items {
|
||||
if let ThreadItemPayload::AgentMessage { id, text, .. } = item.payload
|
||||
&& !text.is_empty()
|
||||
&& let Some(slot) = missing.remove(&HistoryItemId(Cow::Owned(id)))
|
||||
{
|
||||
*slot = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn merge_turn_history_with_active_turn(turns: &mut Vec<Turn>, active_turn: Turn) {
|
||||
turns.retain(|turn| turn.id != active_turn.id);
|
||||
turns.push(active_turn);
|
||||
@@ -1343,17 +1433,9 @@ fn merge_active_turn_into_page(
|
||||
params.items_view.unwrap_or(TurnItemsView::Summary),
|
||||
);
|
||||
let sort_direction = params.sort_direction.unwrap_or(SortDirection::Desc);
|
||||
let page_size = super::thread_processor::thread_turns_page_size(params.limit);
|
||||
let active_turn_is_in_page = page.data.iter().any(|turn| turn.id == active_turn.id);
|
||||
page.data.retain(|turn| turn.id != active_turn.id);
|
||||
match sort_direction {
|
||||
SortDirection::Asc
|
||||
if active_turn_is_in_page
|
||||
|| (page.data.len() < page_size && page.next_cursor.is_none()) =>
|
||||
{
|
||||
page.data.push(active_turn);
|
||||
}
|
||||
SortDirection::Asc => {}
|
||||
SortDirection::Asc => page.data.push(active_turn),
|
||||
SortDirection::Desc => page.data.insert(0, active_turn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,7 +1007,7 @@ impl ThreadRequestProcessor {
|
||||
&self,
|
||||
params: ThreadItemsReadParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.thread_items_read_response_inner(params)
|
||||
Self::read_thread_items(self.thread_store.as_ref(), params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
@@ -3762,11 +3762,11 @@ impl ThreadRequestProcessor {
|
||||
})
|
||||
}
|
||||
|
||||
async fn thread_items_read_response_inner(
|
||||
&self,
|
||||
pub(super) async fn read_thread_items(
|
||||
store: &dyn ThreadStore,
|
||||
params: ThreadItemsReadParams,
|
||||
) -> Result<ThreadItemsReadResponse, JSONRPCErrorError> {
|
||||
if !(1..=100).contains(¶ms.item_ids.len()) {
|
||||
if !(1..=THREAD_ITEMS_MAX_LIMIT).contains(¶ms.item_ids.len()) {
|
||||
return Err(invalid_request(
|
||||
"itemIds must contain 1 to 100 IDs".to_string(),
|
||||
));
|
||||
@@ -3779,8 +3779,7 @@ impl ThreadRequestProcessor {
|
||||
let thread_id = ThreadId::from_string(&thread_id)
|
||||
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
|
||||
let page_size = item_ids.len();
|
||||
let page = self
|
||||
.thread_store
|
||||
let page = store
|
||||
.list_items(StoreListItemsParams {
|
||||
item_ids: Some(item_ids),
|
||||
thread_id,
|
||||
@@ -6117,7 +6116,7 @@ fn xcode_26_4_mcp_elicitations_auto_deny(
|
||||
const THREAD_TURNS_DEFAULT_LIMIT: usize = 25;
|
||||
const THREAD_TURNS_MAX_LIMIT: usize = 100;
|
||||
const THREAD_ITEMS_DEFAULT_LIMIT: usize = 25;
|
||||
const THREAD_ITEMS_MAX_LIMIT: usize = 100;
|
||||
pub(super) const THREAD_ITEMS_MAX_LIMIT: usize = 100;
|
||||
const THREAD_SEARCH_OCCURRENCES_DEFAULT_LIMIT: usize = 50;
|
||||
const THREAD_SEARCH_OCCURRENCES_MAX_LIMIT: usize = 250;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user