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:
Brent Traut
2026-11-01 20:10:33 +00:00
committed by copyberry
parent 4b4a5cf25a
commit fe16bc0a97
6 changed files with 252 additions and 65 deletions
@@ -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(&params.item_ids.len()) {
if !(1..=THREAD_ITEMS_MAX_LIMIT).contains(&params.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;