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
+17 -1
View File
@@ -29,7 +29,7 @@ code, message and binary details are available explicitly; credentials, event
and error diagnostics are redacted. Item lifecycle updates report supported
message, command, and file-change summaries. Agent message summaries retain
structured questions even when their text is empty. Unsupported item kinds stay opaque.
History, approval replies and reconnect are subsequent slices.
Approval replies and reconnect are subsequent slices.
The minimal envelopes in `wire.rs` and `ResumeRequest` mirror ThreadService field
numbers; no monorepo checkout or protobuf compiler is required to build this crate.
@@ -43,3 +43,19 @@ cargo run -p codex-cloud-client --example attach -- "$CODEX_CLOUD_GRPC_ENDPOINT"
Use an existing authorized thread and native gRPC origin. No production URL is
assumed. Public native ingress is a rollout assumption, not proved by fixture
tests. This change does not publish a package to a registry.
History reads use native `ListTurns` (stored turn metadata) and `ListItems` (one
turn or the whole thread). Calls return one page with forward/backward opaque
cursors, preserve server order and retain raw responses and rows. Reuse a cursor
with the same account, thread and filters. Keep the sort order for `next_cursor`
and reverse it for `backwards_cursor`. Calls never paginate or retry automatically.
Items with an unknown variant or command source remain in the page with no typed summary.
History pages and Attach have no shared revision or replay cursor. A page is not
an atomic history/live boundary, and timestamps are not event offsets. Do not
append buffered deltas to a fetched snapshot: it may already include that text.
Full item snapshots can replace matching account/host/thread/turn/item identities;
they must not create duplicate transcript rows. Disconnects, untyped recovery
notifications and uncertain overlap require authoritative history reload. This
crate exposes the read primitives; a coordinated resync/display state machine is
still required before claiming lossless resume or live/history reconciliation.
+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;