Add paginated history reads to the cloud client (#59075)

## Why

`Attach` delivers live events only. Clients also need access to stored turn metadata and item content.

## What changed

- Add `Client::list_turns` and `Client::list_items` using native gRPC history endpoints, with optional turn filtering for items.
- Expose page limits, sort order, access mode, and forward/backward cursors. Each call returns one page in server order without automatic pagination or retries.
- Preserve raw response and row payloads alongside typed summaries and item timestamps. Retain unknown item variants and command sources without typed summaries.
- Document cursor reuse and history/live overlap: history pages do not provide an atomic boundary with `Attach`, and coordinated reconciliation still requires caller-managed state.

## Testing

Add wire-fixture tests for request encoding, explicit pagination, raw payload preservation, unknown data, malformed rows, empty pages, RPC errors without retries, and diagnostic redaction.

GitOrigin-RevId: f507530b8e431236bc7e19d867517af56f74644f
This commit is contained in:
Benjamin Carlsson
2026-11-03 06:34:51 +00:00
committed by copyberry
parent f9e7b19df8
commit bc4116e4cd
9 changed files with 623 additions and 32 deletions
+7 -2
View File
@@ -22,15 +22,20 @@ impl Drop for Fixture {
}
}
async fn fixture(response: http::Response<Body>) -> Fixture {
fixture_responses(vec![response]).await
}
async fn fixture_responses(responses: Vec<http::Response<Body>>) -> Fixture {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let (sender, requests) = mpsc::unbounded_channel();
let response = std::sync::Arc::new(std::sync::Mutex::new(Some(response)));
let responses = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::from(
responses,
)));
let task = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let service = service_fn(move |request: http::Request<hyper::body::Incoming>| {
let sender = sender.clone();
let response = response.lock().unwrap().take().unwrap();
let response = responses.lock().unwrap().pop_front().unwrap();
async move {
let (parts, body) = request.into_parts();
sender
+220
View File
@@ -0,0 +1,220 @@
//! Bounded history page reads. Pagination cursors are not live-event replay offsets.
use crate::Client;
use crate::Error;
use crate::ItemSummary;
use crate::TurnSummary;
use crate::history_wire as wire;
use crate::transcript::DecodeFailure;
use crate::transcript::required;
use bytes::Bytes;
use prost::Message;
use std::fmt;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HistoryOrder {
Ascending,
#[default]
Descending,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HistoryAccess {
#[default]
Default,
Shared,
}
/// A cursor belongs to its original thread and filters. Keep the order for
/// `next_cursor` and reverse it for `backwards_cursor`.
#[derive(Clone, Default)]
pub struct HistoryQuery {
pub cursor: Option<String>,
pub limit: Option<u32>,
pub order: HistoryOrder,
pub access: HistoryAccess,
}
#[derive(Clone)]
pub struct ListTurnsRequest {
pub thread_id: String,
pub page: HistoryQuery,
}
#[derive(Clone)]
pub struct ListItemsRequest {
pub thread_id: String,
pub turn_id: Option<String>,
pub page: HistoryQuery,
}
/// One server page in server order. No automatic pagination, deduplication or retries.
pub struct HistoryPage<T> {
pub thread_id: String,
pub data: Vec<T>,
pub next_cursor: Option<String>,
pub backwards_cursor: Option<String>,
payload: Bytes,
}
impl<T> HistoryPage<T> {
pub fn payload(&self) -> &Bytes {
&self.payload
}
}
pub struct HistoryTurn {
pub summary: TurnSummary,
payload: Bytes,
}
impl HistoryTurn {
/// Original Turn protobuf; includes metadata omitted from the summary.
pub fn payload(&self) -> &Bytes {
&self.payload
}
}
pub struct HistoryItem {
pub turn_id: String,
/// Unsupported items and command sources have no summary and retain their original entry.
pub summary: Option<ItemSummary>,
pub started_at_ms: Option<i64>,
pub completed_at_ms: Option<i64>,
payload: Bytes,
}
impl HistoryItem {
/// Original ThreadItemEntry protobuf, including the item and untyped metadata.
pub fn payload(&self) -> &Bytes {
&self.payload
}
}
macro_rules! redacted_debug {
($($ty:ty),*) => {$(impl fmt::Debug for $ty {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(concat!(stringify!($ty), "([redacted])")) }
})*};
}
redacted_debug!(
HistoryQuery,
ListTurnsRequest,
ListItemsRequest,
HistoryTurn,
HistoryItem
);
impl<T> fmt::Debug for HistoryPage<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("HistoryPage([redacted])")
}
}
impl HistoryQuery {
fn order(&self) -> Option<i32> {
Some(match self.order {
HistoryOrder::Ascending => 1,
HistoryOrder::Descending => 2,
})
}
fn access(&self) -> Option<i32> {
match self.access {
HistoryAccess::Default => None,
HistoryAccess::Shared => Some(1),
}
}
}
impl Client {
/// Reads stored turn metadata; use `list_items` for paginated item content.
pub async fn list_turns(
&self,
request: &ListTurnsRequest,
) -> Result<HistoryPage<HistoryTurn>, Error> {
let payload = self
.history_rpc(
wire::TurnsRequest {
thread_id: request.thread_id.clone(),
cursor: request.page.cursor.clone(),
limit: request.page.limit,
order: request.page.order(),
access: request.page.access(),
},
"/oaiproto.codex_app_server_backend.ThreadService/ListTurns",
)
.await?;
decode_page(&request.thread_id, payload, |payload| {
let turn = crate::transcript_wire::Turn::decode(payload.clone())?;
Ok(HistoryTurn {
summary: turn.try_into()?,
payload,
})
})
.map_err(|_| Error::InvalidResponse)
}
/// Reads one page, across the thread or restricted to one turn. This is not an
/// atomic snapshot synchronized with Attach; do not append buffered deltas to it.
pub async fn list_items(
&self,
request: &ListItemsRequest,
) -> Result<HistoryPage<HistoryItem>, Error> {
let payload = self
.history_rpc(
wire::ItemsRequest {
thread_id: request.thread_id.clone(),
turn_id: request.turn_id.clone(),
cursor: request.page.cursor.clone(),
limit: request.page.limit,
order: request.page.order(),
access: request.page.access(),
},
"/oaiproto.codex_app_server_backend.ThreadService/ListItems",
)
.await?;
decode_page(&request.thread_id, payload, |payload| {
let entry = wire::ItemEntry::decode(payload.clone())?;
let turn_id = required(entry.turn_id)?;
if request
.turn_id
.as_ref()
.is_some_and(|expected| expected != &turn_id)
{
return Err(DecodeFailure::InvalidResponse);
}
Ok(HistoryItem {
turn_id,
summary: crate::items::summary(required(entry.item)?)?,
started_at_ms: entry.started_at_ms,
completed_at_ms: entry.completed_at_ms,
payload,
})
})
.map_err(|_| Error::InvalidResponse)
}
async fn history_rpc<T: Message + Send + 'static>(
&self,
value: T,
path: &'static str,
) -> Result<Bytes, Error> {
let mut grpc = self.grpc.clone();
let mut request = tonic::Request::new(value);
*request.metadata_mut() =
tonic::metadata::MetadataMap::from_headers(self.credentials.headers.clone());
let response = tokio::time::timeout(
crate::REQUEST_TIMEOUT,
grpc.unary(
request,
http::uri::PathAndQuery::from_static(path),
wire::RawResponseCodec::<T>(std::marker::PhantomData),
),
)
.await
.map_err(|_| Error::Timeout)?
.map_err(Error::from)?;
Ok(response.into_inner())
}
}
fn decode_page<T>(
thread_id: &str,
payload: Bytes,
decode: impl Fn(Bytes) -> Result<T, DecodeFailure>,
) -> Result<HistoryPage<T>, DecodeFailure> {
let page = wire::Page::decode(payload.clone())?;
let data = required(page.data)?
.values
.into_iter()
.map(decode)
.collect::<Result<_, _>>()?;
Ok(HistoryPage {
thread_id: thread_id.to_owned(),
data,
next_cursor: page.next_cursor,
backwards_cursor: page.backwards_cursor,
payload,
})
}
+245
View File
@@ -0,0 +1,245 @@
//! Independent wire fixtures cover page requests, presence, provenance and unknown data.
use super::*;
use pretty_assertions::assert_eq;
fn page(rows: &[&[u8]]) -> Vec<u8> {
message(
/*tag*/ 1,
&rows
.iter()
.flat_map(|row| message(/*tag*/ 1, row))
.collect::<Vec<_>>(),
)
}
#[tokio::test]
async fn item_history_paginates_with_explicit_filters_and_preserves_rows() {
let entry =
b"\x0a\x01u\x12\x0f\x1a\x0d\x12\x01i\x32\x01d\x2a\x05\x0a\x03\x12\x01Q\x18\x65\x20\x67";
// A future command source alongside a known agent message.
let command = b"\x12\x01c\x1a\x00\x22\x01w\x3a\x01i\x58\x63\x60\x03";
let item = message(/*tag*/ 7, command);
let opaque = [message(/*tag*/ 1, b"u"), message(/*tag*/ 2, &item)].concat();
let mut first = page(&[entry, &opaque]);
first.extend(b"\x12\x01n\x1a\x01b\xa0\x06\x01");
let unknown = b"\x0a\x01u\x12\x03\xa2\x01\x00";
let second = page(&[unknown]);
let mut fixture = fixture_responses(vec![
response(vec![frame(&first)], tonic::Status::ok("")),
response(vec![frame(&second)], tonic::Status::ok("")),
])
.await;
let mut request = ListItemsRequest {
thread_id: "t".into(),
turn_id: Some("u".into()),
page: HistoryQuery {
cursor: None,
limit: Some(2),
order: HistoryOrder::Ascending,
access: HistoryAccess::Shared,
},
};
let first_page = fixture.client.list_items(&request).await.unwrap();
assert_request(
&mut fixture,
"ListItems",
b"\x0a\x01t\x12\x01u\x20\x02\x28\x01\x30\x01",
)
.await;
assert_eq!(
(
&first_page.thread_id,
&first_page.next_cursor,
&first_page.backwards_cursor,
first_page.payload()
),
(
&"t".to_owned(),
&Some("n".into()),
&Some("b".into()),
&Bytes::from(first)
)
);
let [item, unknown_source] = first_page.data.as_slice() else {
panic!("expected two items")
};
assert_eq!(unknown_source.summary, None);
assert_eq!(unknown_source.payload().as_ref(), opaque.as_slice());
assert_eq!(
(
&item.turn_id,
&item.summary,
item.started_at_ms,
item.completed_at_ms,
item.payload()
),
(
&"u".to_owned(),
&Some(ItemSummary::AgentMessage {
id: "i".into(),
text: "d".into(),
questions: Some(vec![AgentQuestion {
title: "Q".into(),
options: None
}]),
}),
Some(101),
Some(103),
&Bytes::from_static(entry)
)
);
request.page.cursor = first_page.next_cursor;
let second_page = fixture.client.list_items(&request).await.unwrap();
assert_request(
&mut fixture,
"ListItems",
b"\x0a\x01t\x12\x01u\x1a\x01n\x20\x02\x28\x01\x30\x01",
)
.await;
assert_eq!(
(second_page.next_cursor, second_page.backwards_cursor),
(None, None)
);
assert_eq!(second_page.data.len(), 1);
assert_eq!(
(
&second_page.data[0].summary,
second_page.data[0].started_at_ms,
second_page.data[0].payload()
),
(&None, None, &Bytes::from_static(unknown))
);
}
#[tokio::test]
async fn turn_history_reuses_summary_and_retains_omitted_metadata() {
let turn = b"\x08\x65\x10\x02\x22\x01u\x2a\x00\x38\x63\x40\x03";
let payload = page(&[turn]);
let mut fixture = fixture(response(vec![frame(&payload)], tonic::Status::ok(""))).await;
let result = fixture
.client
.list_turns(&ListTurnsRequest {
thread_id: "t".into(),
page: HistoryQuery {
cursor: Some("c".into()),
..Default::default()
},
})
.await
.unwrap();
assert_request(&mut fixture, "ListTurns", b"\x0a\x01t\x12\x01c\x20\x02").await;
assert_eq!(result.data.len(), 1);
assert_eq!(
(
&result.thread_id,
&result.data[0].summary,
result.data[0].payload(),
result.payload()
),
(
&"t".to_owned(),
&TurnSummary {
id: "u".into(),
status: TurnStatus::Failed,
started_at: Some(99),
completed_at: Some(101),
duration_ms: Some(2),
error: None
},
&Bytes::from_static(turn),
&Bytes::from(payload)
)
);
}
#[tokio::test]
async fn history_distinguishes_empty_pages_from_missing_wrappers_and_bad_rows() {
let valid_empty = page(&[]);
let fixture = fixture(response(vec![frame(&valid_empty)], tonic::Status::ok(""))).await;
let request = ListItemsRequest {
thread_id: "t".into(),
turn_id: Some("u".into()),
page: HistoryQuery::default(),
};
assert!(
fixture
.client
.list_items(&request)
.await
.unwrap()
.data
.is_empty()
);
for payload in [
vec![],
page(&[b"\x0a\x01u"]),
page(&[b"\x12\x00"]),
page(&[b"\x0a\x01u\x12\x05\x1a\x03\x12\x01i"]), // Agent message without text.
page(&[b"\x0a\x01x\x12\x00"]),
// Unknown command source and missing required status.
page(&[b"\x0a\x01u\x12\x0f\x3a\x0d\x12\x01c\x1a\x00\x22\x01w\x3a\x01i\x58\x63"]),
vec![10, 127],
] {
let server = super::fixture(response(vec![frame(&payload)], tonic::Status::ok(""))).await;
assert!(matches!(
server.client.list_items(&request).await,
Err(Error::InvalidResponse)
));
}
for turn in [b"\x22\x01u".as_slice(), b"\x40\x01"] {
let server =
super::fixture(response(vec![frame(&page(&[turn]))], tonic::Status::ok(""))).await;
assert!(matches!(
server
.client
.list_turns(&ListTurnsRequest {
thread_id: "t".into(),
page: HistoryQuery::default()
})
.await,
Err(Error::InvalidResponse)
));
}
}
#[tokio::test]
async fn history_errors_preserve_status_without_retrying() {
let mut fixture = fixture(response(
vec![],
tonic::Status::permission_denied("private error"),
))
.await;
let error = fixture
.client
.list_items(&ListItemsRequest {
thread_id: "t".into(),
turn_id: None,
page: HistoryQuery::default(),
})
.await
.unwrap_err();
assert!(!format!("{error:?}").contains("private error"));
assert!(matches!(error, Error::Rpc(status) if status.code == 7));
assert_request(&mut fixture, "ListItems", b"\x0a\x01t\x28\x02").await;
assert!(fixture.requests.try_recv().is_err());
}
#[test]
fn history_diagnostics_redact_each_public_payload_type() {
let query = HistoryQuery {
cursor: Some("secret".into()),
..Default::default()
};
let items = ListItemsRequest {
thread_id: "secret".into(),
turn_id: Some("secret".into()),
page: query.clone(),
};
let turns = ListTurnsRequest {
thread_id: "secret".into(),
page: query.clone(),
};
for value in [
format!("{query:?}"),
format!("{items:?}"),
format!("{turns:?}"),
] {
assert!(value.ends_with("([redacted])"));
assert!(!value.contains("secret"));
}
}
+86
View File
@@ -0,0 +1,86 @@
//! Native paginated history envelopes. Raw rows and unary responses retain unknown fields.
use bytes::Buf;
use bytes::Bytes;
use prost::Message;
use std::marker::PhantomData;
use tonic::codec::Codec;
#[derive(Clone, Message)]
pub(super) struct TurnsRequest {
#[prost(string, tag = "1")]
pub thread_id: String,
#[prost(string, optional, tag = "2")]
pub cursor: Option<String>,
#[prost(uint32, optional, tag = "3")]
pub limit: Option<u32>,
#[prost(int32, optional, tag = "4")]
pub order: Option<i32>,
#[prost(int32, optional, tag = "6")]
pub access: Option<i32>,
}
#[derive(Clone, Message)]
pub(super) struct ItemsRequest {
#[prost(string, tag = "1")]
pub thread_id: String,
#[prost(string, optional, tag = "2")]
pub turn_id: Option<String>,
#[prost(string, optional, tag = "3")]
pub cursor: Option<String>,
#[prost(uint32, optional, tag = "4")]
pub limit: Option<u32>,
#[prost(int32, optional, tag = "5")]
pub order: Option<i32>,
#[prost(int32, optional, tag = "6")]
pub access: Option<i32>,
}
#[derive(Clone, Message)]
pub(super) struct Page {
#[prost(message, optional, tag = "1")]
pub data: Option<Rows>,
#[prost(string, optional, tag = "2")]
pub next_cursor: Option<String>,
#[prost(string, optional, tag = "3")]
pub backwards_cursor: Option<String>,
}
#[derive(Clone, Message)]
pub(super) struct Rows {
#[prost(bytes = "bytes", repeated, tag = "1")]
pub values: Vec<Bytes>,
}
#[derive(Clone, Message)]
pub(super) struct ItemEntry {
#[prost(string, optional, tag = "1")]
pub turn_id: Option<String>,
#[prost(message, optional, tag = "2")]
pub item: Option<crate::item_wire::Item>,
#[prost(int64, optional, tag = "3")]
pub started_at_ms: Option<i64>,
#[prost(int64, optional, tag = "4")]
pub completed_at_ms: Option<i64>,
}
// Tonic still enforces message bounds and status trailers. Only payload decoding
// is delayed so callers can retain the original complete protobuf response.
pub(super) struct RawResponseCodec<T>(pub PhantomData<T>);
impl<T: Message + Send + 'static> Codec for RawResponseCodec<T> {
type Encode = T;
type Decode = Bytes;
type Encoder = tonic_prost::ProstEncoder<T>;
type Decoder = RawDecoder;
fn encoder(&mut self) -> Self::Encoder {
tonic_prost::ProstCodec::<T, Page>::default().encoder()
}
fn decoder(&mut self) -> Self::Decoder {
RawDecoder
}
}
pub(super) struct RawDecoder;
impl tonic::codec::Decoder for RawDecoder {
type Item = Bytes;
type Error = tonic::Status;
fn decode(
&mut self,
buffer: &mut tonic::codec::DecodeBuf<'_>,
) -> Result<Option<Bytes>, tonic::Status> {
Ok(Some(buffer.copy_to_bytes(buffer.remaining())))
}
}
+2 -2
View File
@@ -215,17 +215,17 @@ pub(crate) fn summary(item: wire::Item) -> Result<Option<ItemSummary>, DecodeFai
.into_iter()
.map(command_action)
.collect::<Result<_, _>>()?,
status: required(value.status)?.into(),
source: match value.source {
None | Some(1) => CommandExecutionSource::Agent,
Some(2) => CommandExecutionSource::UserShell,
Some(3) => CommandExecutionSource::UnifiedExecStartup,
Some(4) => CommandExecutionSource::UnifiedExecInteraction,
Some(_) => return Err(DecodeFailure::UnsupportedCommandSource),
Some(_) => return Ok(None),
},
aggregated_output: value.output,
duration_ms: value.duration_ms,
exit_code: value.exit_code,
status: required(value.status)?.into(),
}),
Some(wire::Value::FileChange(value)) => ItemSummary::FileChange {
id: required(value.id)?,
+10
View File
@@ -4,6 +4,8 @@
//! a timeout or disconnect can leave Resume admitted. Attach delivers live events only;
//! dropping its stream detaches without interrupting the thread or answering approvals.
mod history;
mod history_wire;
mod item_wire;
mod items;
mod transcript;
@@ -12,6 +14,14 @@ mod turns_wire;
mod types;
mod wire;
pub use history::HistoryAccess;
pub use history::HistoryItem;
pub use history::HistoryOrder;
pub use history::HistoryPage;
pub use history::HistoryQuery;
pub use history::HistoryTurn;
pub use history::ListItemsRequest;
pub use history::ListTurnsRequest;
pub use items::AgentQuestion;
pub use items::CommandAction;
pub use items::CommandExecutionSource;
+33 -27
View File
@@ -108,7 +108,6 @@ const ITEM_FILE_CHANGE_PATCH_UPDATED: u32 = 40;
pub(super) enum DecodeFailure {
MissingField,
UnsupportedCommandSource,
InvalidResponse,
}
@@ -125,7 +124,7 @@ impl TranscriptEvent {
pub(crate) fn decode(thread_id: &str, payload: &[u8]) -> Result<Option<Self>, Error> {
match Self::decode_present_fields(thread_id, payload) {
Ok(event) => Ok(event),
Err(DecodeFailure::MissingField | DecodeFailure::UnsupportedCommandSource) => Ok(None),
Err(DecodeFailure::MissingField) => Ok(None),
Err(DecodeFailure::InvalidResponse) => Err(Error::InvalidResponse),
}
}
@@ -185,31 +184,7 @@ impl TranscriptEvent {
TURN_STARTED | TURN_COMPLETED => {
let envelope = wire::merge_messages::<wire::Envelope<wire::TurnUpdate>>(values)?;
let params = required(envelope.params)?;
let turn = required(params.turn)?;
let status = match required(turn.status)? {
1 => TurnStatus::Completed,
2 => TurnStatus::Interrupted,
3 => TurnStatus::Failed,
4 => TurnStatus::InProgress,
value => TurnStatus::Unknown(value),
};
let error = turn
.error
.map(|error| {
Ok::<_, DecodeFailure>(TurnFailure {
message: required(error.message)?,
additional_details: error.additional_details,
})
})
.transpose()?;
let summary = TurnSummary {
id: required(turn.id)?,
status,
started_at: turn.started_at,
completed_at: turn.completed_at,
duration_ms: turn.duration_ms,
error,
};
let summary = TurnSummary::try_from(required(params.turn)?)?;
let update = if tag == TURN_STARTED {
TranscriptUpdate::TurnStarted(summary)
} else {
@@ -254,3 +229,34 @@ impl TryFrom<wire::Change> for FileUpdate {
})
}
}
impl TryFrom<wire::Turn> for TurnSummary {
type Error = DecodeFailure;
fn try_from(turn: wire::Turn) -> Result<Self, DecodeFailure> {
let status = match required(turn.status)? {
1 => TurnStatus::Completed,
2 => TurnStatus::Interrupted,
3 => TurnStatus::Failed,
4 => TurnStatus::InProgress,
value => TurnStatus::Unknown(value),
};
let error = turn
.error
.map(|error| {
Ok::<_, DecodeFailure>(TurnFailure {
message: required(error.message)?,
additional_details: error.additional_details,
})
})
.transpose()?;
Ok(Self {
id: required(turn.id)?,
status,
started_at: turn.started_at,
completed_at: turn.completed_at,
duration_ms: turn.duration_ms,
error,
})
}
}
@@ -295,3 +295,6 @@ fn notification_selection_handles_many_fragments_and_obsolete_malformed_messages
#[path = "item_tests.rs"]
mod items;
#[path = "history_tests.rs"]
mod history;