Files
codex/codex-rs/cloud-client/src/transcript.rs
T
Benjamin Carlsson bc4116e4cd 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
2026-11-03 06:34:51 +00:00

263 lines
8.6 KiB
Rust

//! Typed live transcript updates for the attached thread.
use crate::Error;
use crate::transcript_wire as wire;
use std::fmt;
/// A live update, not a history snapshot or a replay guarantee.
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct TranscriptEvent {
pub thread_id: String,
pub emitted_at_ms: Option<i64>,
pub update: TranscriptUpdate,
}
#[derive(Clone, PartialEq, Eq)]
pub enum TranscriptUpdate {
ItemStarted(crate::ItemLifecycle),
ItemCompleted(crate::ItemLifecycle),
AgentMessageDelta(ItemDelta),
CommandOutputDelta(ItemDelta),
FileChangeOutputDelta(ItemDelta),
FileChangePatchUpdated(PatchUpdate),
TurnStarted(TurnSummary),
TurnCompleted(TurnSummary),
}
#[derive(Clone, PartialEq, Eq)]
pub struct ItemDelta {
pub item_id: String,
pub turn_id: String,
pub delta: String,
}
#[derive(Clone, PartialEq, Eq)]
pub struct PatchUpdate {
pub item_id: String,
pub turn_id: String,
pub changes: Vec<FileUpdate>,
}
#[derive(Clone, PartialEq, Eq)]
pub struct FileUpdate {
pub path: String,
pub diff: String,
pub kind: PatchKind,
}
#[derive(Clone, PartialEq, Eq)]
pub enum PatchKind {
Add,
Delete,
Update {
move_path: Option<String>,
},
/// The server reported an unrecognized change kind.
Unknown,
}
/// Status, timing, and failure details for a turn.
#[derive(Clone, PartialEq, Eq)]
pub struct TurnSummary {
pub id: String,
pub status: TurnStatus,
/// Turn start time in Unix seconds, if reported by the server.
pub started_at: Option<i64>,
/// Turn completion time in Unix seconds, if reported by the server.
pub completed_at: Option<i64>,
/// Turn duration in milliseconds, if reported by the server.
pub duration_ms: Option<i64>,
pub error: Option<TurnFailure>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TurnStatus {
Completed,
Interrupted,
Failed,
InProgress,
Unknown(i32),
}
#[derive(Clone, PartialEq, Eq)]
pub struct TurnFailure {
pub message: String,
pub additional_details: Option<String>,
}
// Each public payload type is safe to format independently, not just through Event.
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!(
TranscriptEvent,
TranscriptUpdate,
ItemDelta,
PatchUpdate,
FileUpdate,
PatchKind,
TurnSummary,
TurnFailure
);
// Field numbers in the ASB Notification.value oneof.
const TURN_STARTED: u32 = 21;
const TURN_COMPLETED: u32 = 23;
pub(super) const ITEM_STARTED: u32 = 27;
const ITEM_COMPLETED: u32 = 31;
const ITEM_AGENT_MESSAGE_DELTA: u32 = 32;
const ITEM_COMMAND_EXECUTION_OUTPUT_DELTA: u32 = 37;
const ITEM_FILE_CHANGE_OUTPUT_DELTA: u32 = 39;
const ITEM_FILE_CHANGE_PATCH_UPDATED: u32 = 40;
pub(super) enum DecodeFailure {
MissingField,
InvalidResponse,
}
impl From<prost::DecodeError> for DecodeFailure {
fn from(_: prost::DecodeError) -> Self {
Self::InvalidResponse
}
}
pub(super) fn required<T>(value: Option<T>) -> Result<T, DecodeFailure> {
value.ok_or(DecodeFailure::MissingField)
}
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) => Ok(None),
Err(DecodeFailure::InvalidResponse) => Err(Error::InvalidResponse),
}
}
fn decode_present_fields(
thread_id: &str,
payload: &[u8],
) -> Result<Option<Self>, DecodeFailure> {
let Some((tag, values)) = wire::notification(payload)? else {
return Ok(None);
};
let (owner, emitted_at_ms, update) = match tag {
ITEM_STARTED | ITEM_COMPLETED => match crate::items::decode(tag, values)? {
Some(decoded) => decoded,
None => return Ok(None),
},
ITEM_AGENT_MESSAGE_DELTA
| ITEM_COMMAND_EXECUTION_OUTPUT_DELTA
| ITEM_FILE_CHANGE_OUTPUT_DELTA => {
let envelope = wire::merge_messages::<wire::Envelope<wire::Delta>>(values)?;
let params = required(envelope.params)?;
let delta = ItemDelta {
item_id: required(params.item_id)?,
turn_id: required(params.turn_id)?,
delta: required(params.delta)?,
};
let update = match tag {
ITEM_AGENT_MESSAGE_DELTA => TranscriptUpdate::AgentMessageDelta(delta),
ITEM_COMMAND_EXECUTION_OUTPUT_DELTA => {
TranscriptUpdate::CommandOutputDelta(delta)
}
ITEM_FILE_CHANGE_OUTPUT_DELTA => TranscriptUpdate::FileChangeOutputDelta(delta),
_ => return Ok(None),
};
(required(params.thread_id)?, envelope.emitted_at_ms, update)
}
ITEM_FILE_CHANGE_PATCH_UPDATED => {
let envelope = wire::merge_messages::<wire::Envelope<wire::PatchUpdate>>(values)?;
let params = required(envelope.params)?;
let changes = required(params.changes)?
.values
.into_iter()
.map(FileUpdate::try_from)
.collect::<Result<_, DecodeFailure>>()?;
let update = PatchUpdate {
item_id: required(params.item_id)?,
turn_id: required(params.turn_id)?,
changes,
};
(
required(params.thread_id)?,
envelope.emitted_at_ms,
TranscriptUpdate::FileChangePatchUpdated(update),
)
}
TURN_STARTED | TURN_COMPLETED => {
let envelope = wire::merge_messages::<wire::Envelope<wire::TurnUpdate>>(values)?;
let params = required(envelope.params)?;
let summary = TurnSummary::try_from(required(params.turn)?)?;
let update = if tag == TURN_STARTED {
TranscriptUpdate::TurnStarted(summary)
} else {
TranscriptUpdate::TurnCompleted(summary)
};
(required(params.thread_id)?, envelope.emitted_at_ms, update)
}
_ => return Ok(None),
};
if owner != thread_id {
// Attach also carries account-wide lifecycle notifications. Preserve them
// as opaque events rather than ending this thread's transcript stream.
return if matches!(tag, TURN_STARTED | TURN_COMPLETED) {
Ok(None)
} else {
Err(DecodeFailure::InvalidResponse)
};
}
Ok(Some(Self {
thread_id: owner,
emitted_at_ms,
update,
}))
}
}
impl TryFrom<wire::Change> for FileUpdate {
type Error = DecodeFailure;
fn try_from(change: wire::Change) -> Result<Self, DecodeFailure> {
let kind = match required(change.kind)?.value {
Some(wire::Kind::Add(_)) => PatchKind::Add,
Some(wire::Kind::Delete(_)) => PatchKind::Delete,
Some(wire::Kind::Update(value)) => PatchKind::Update {
move_path: value.move_path,
},
None => PatchKind::Unknown,
};
Ok(Self {
path: required(change.path)?,
diff: required(change.diff)?,
kind,
})
}
}
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,
})
}
}