mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
## 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
221 lines
6.8 KiB
Rust
221 lines
6.8 KiB
Rust
//! 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,
|
|
})
|
|
}
|