Support includeTurns reads for paginated threads (#36993)

## Why

Clients using `thread/read` with `includeTurns: true` need the legacy full-history
view even when a thread stores its history in paginated form.

## What changed

- Reconstruct full projected turns from paginated history for both stored and
  loaded threads.
- Keep `thread/turns/list` and `thread/items/list` as the preferred incremental
  history APIs.

## Testing

- Cover `thread/read(includeTurns=true)` before and after a paginated thread is
  loaded.

GitOrigin-RevId: 6efdc04e5ed3cad2ff2078d8103933082c7921db
This commit is contained in:
Owen Lin
2026-08-04 23:19:41 +00:00
committed by copyberry
parent 44cb66e4ed
commit e87e2b495b
3 changed files with 62 additions and 35 deletions
@@ -2375,15 +2375,22 @@ impl ThreadRequestProcessor {
include_turns: bool,
) -> Result<Option<Thread>, ThreadReadViewError> {
let fallback_provider = self.config.model_provider_id.as_str();
if include_turns
&& self
if include_turns {
let Some(stored_thread) = self
.read_stored_thread_for_read(thread_id, /*include_history*/ false)
.await?
.is_some_and(|thread| matches!(thread.history_mode, ThreadHistoryMode::Paginated))
{
return Err(ThreadReadViewError::InvalidRequest(
"paginated threads do not support thread/read(includeTurns=true)".to_string(),
));
else {
return Ok(None);
};
if matches!(stored_thread.history_mode, ThreadHistoryMode::Paginated) {
let (mut thread, _) =
thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd);
thread.turns = self
.paginated_thread_full_turns(thread_id)
.await
.map_err(ThreadReadViewError::JsonRpc)?;
return Ok(Some(thread));
}
}
let Some(stored_thread) = self
.read_stored_thread_for_read(thread_id, /*include_history*/ include_turns)
@@ -2448,11 +2455,6 @@ impl ThreadRequestProcessor {
"ephemeral threads do not support includeTurns".to_string(),
));
}
if include_turns && matches!(config_snapshot.history_mode, ThreadHistoryMode::Paginated) {
return Err(ThreadReadViewError::InvalidRequest(
"paginated threads do not support thread/read(includeTurns=true)".to_string(),
));
}
let fallback_thread =
build_thread_from_loaded_snapshot(thread_id, &config_snapshot, loaded_thread);
let mut thread = if let Some(mut thread) = persisted_thread {
@@ -2481,6 +2483,20 @@ impl ThreadRequestProcessor {
self.attach_thread_name(thread_id, thread).await;
if include_turns {
if matches!(
thread.history_mode,
codex_app_server_protocol::ThreadHistoryMode::Paginated
) {
self.thread_store
.persist_thread(thread_id)
.await
.map_err(|err| thread_read_history_load_error(thread_id, err))?;
thread.turns = self
.paginated_thread_full_turns(thread_id)
.await
.map_err(ThreadReadViewError::JsonRpc)?;
return Ok(());
}
let history = loaded_thread
.load_history(/*include_archived*/ true)
.await
@@ -2746,8 +2762,8 @@ impl ThreadRequestProcessor {
}
}
// Older clients omit `excludeTurns` and expect full `thread.turns` on resume.
// Remove this slow path once all clients use paginated resume bootstrap.
// Older clients expect full `thread.turns` from resume and `thread/read(includeTurns=true)`.
// Keep this slow compatibility path until all clients page history directly.
async fn paginated_thread_full_turns(
&self,
thread_id: ThreadId,
@@ -4904,6 +4920,7 @@ enum ThreadReadViewError {
InvalidRequest(String),
Unsupported(&'static str),
Internal(String),
JsonRpc(JSONRPCErrorError),
}
fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError {
@@ -4913,6 +4930,7 @@ fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError {
unsupported_thread_store_operation(operation)
}
ThreadReadViewError::Internal(message) => internal_error(message),
ThreadReadViewError::JsonRpc(error) => error,
}
}