Decode live transcript updates in the cloud client (#59047)

## Why

Attach notifications previously exposed only opaque protobuf bytes. Typed transcript events let callers access live text, command output, file changes, and turn state directly.

## What changed

- Add `Event::Transcript` with typed agent-message, command-output, and file-change deltas; patch updates; and turn start/completion events.
- Expose patch kinds and move paths, turn status and failure details, and server-provided timestamps and duration.
- Validate transcript thread IDs. Preserve unsupported or incomplete notifications and other threads' turn lifecycle events as opaque payloads; reject deltas for another thread.
- Keep transcript payloads redacted in `Debug` output.

## Testing

Add independent protobuf fixtures covering Attach decoding, opaque fallback and stream continuation, patch ordering, turn timing and failures, thread validation, repeated oneof variants, unknown fields, and debug redaction.

GitOrigin-RevId: f26b8d4a5b355bb12159136e83310cb57c1546ca
This commit is contained in:
Benjamin Carlsson
2026-11-03 02:49:40 +00:00
committed by copyberry
parent 7e8143a897
commit fbf5f0d3a7
7 changed files with 717 additions and 9 deletions
+8 -5
View File
@@ -20,11 +20,14 @@ stopping the thread or answering approvals. Streams end after their first error.
Resume and Attach setup have a 150-second local deadline; live streams have no idle
deadline. Setup does not send a gRPC deadline that would expire a healthy stream.
Event variants distinguish notifications and server requests; payloads remain the
complete nested protobuf message bytes, preserving unknown fields. These are not
ProtoJSON or app-server JSON-RPC. Native status code, message and binary details
are available explicitly; credentials, event and error diagnostics are redacted.
Full payload typing, history, approval replies and reconnect are subsequent slices.
`Event::Transcript` decodes selected text, command output, patch, and turn updates.
It validates the thread ID and exposes the decoded fields. Turn updates include
start time, completion time, and duration when the server provides them.
Other notifications and server requests remain opaque protobuf payloads, preserving
unknown fields. These are not ProtoJSON or app-server JSON-RPC. Native status
code, message and binary details are available explicitly; credentials, event
and error diagnostics are redacted. History, 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.
@@ -346,3 +346,5 @@ async fn https_enforces_unmanaged_endpoint_restrictions_before_connecting() {
.await
.unwrap();
}
#[path = "transcript_tests.rs"]
mod transcript;
+18 -1
View File
@@ -4,9 +4,20 @@
//! 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 transcript;
mod transcript_wire;
mod types;
mod wire;
pub use transcript::FileUpdate;
pub use transcript::ItemDelta;
pub use transcript::PatchKind;
pub use transcript::PatchUpdate;
pub use transcript::TranscriptEvent;
pub use transcript::TranscriptUpdate;
pub use transcript::TurnFailure;
pub use transcript::TurnStatus;
pub use transcript::TurnSummary;
pub use types::Credentials;
pub use types::Error;
pub use types::Event;
@@ -160,10 +171,16 @@ impl Client {
.map_err(|_| Error::Timeout)?
.map_err(Error::from)?;
let mut stream = response.into_inner();
let thread_id = thread_id.to_owned();
Ok(async_stream::try_stream! {
while let Some(message) = stream.message().await.map_err(Error::from)? {
yield match message.event.ok_or(Error::InvalidResponse)? {
wire::Payload::Notification(bytes) => Event::Notification(bytes),
wire::Payload::Notification(bytes) => {
match TranscriptEvent::decode(&thread_id, &bytes)? {
Some(event) => Event::Transcript(Box::new(event)),
None => Event::Notification(bytes),
}
},
wire::Payload::ServerRequest(bytes) => Event::ServerRequest(bytes),
};
}
+241
View File
@@ -0,0 +1,241 @@
//! 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 {
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;
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;
enum DecodeFailure {
MissingField,
InvalidResponse,
}
impl From<prost::DecodeError> for DecodeFailure {
fn from(_: prost::DecodeError) -> Self {
Self::InvalidResponse
}
}
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_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(|change| {
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(FileUpdate {
path: required(change.path)?,
diff: required(change.diff)?,
kind,
})
})
.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 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 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,
}))
}
}
@@ -0,0 +1,290 @@
//! Independent protobuf fixtures cover the public transcript contract.
use super::*;
use pretty_assertions::assert_eq;
fn message(tag: u32, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
prost::encoding::encode_key(tag, prost::encoding::WireType::LengthDelimited, &mut out);
prost::encoding::encode_varint(payload.len() as u64, &mut out);
out.extend_from_slice(payload);
out
}
fn notification(tag: u32, params: &[u8]) -> Vec<u8> {
message(tag, &message(/*tag*/ 2, params))
}
const DELTA: &[u8] = b"\x0a\x01d\x12\x01i\x1a\x01t\x22\x01u";
#[tokio::test]
async fn attach_decodes_live_deltas_with_unknown_fields() {
let fixture = fixture(response(
[32, 37, 39]
.into_iter()
.map(|tag| {
let mut payload = notification(tag, DELTA);
payload.extend([0xa0, 0x06, 1]); // unknown field 100
frame(&message(/*tag*/ 1, &payload))
})
.collect(),
tonic::Status::ok(""),
))
.await;
let mut stream = fixture.client.attach("t").await.unwrap();
for expected in [
TranscriptUpdate::AgentMessageDelta as fn(ItemDelta) -> TranscriptUpdate,
TranscriptUpdate::CommandOutputDelta,
TranscriptUpdate::FileChangeOutputDelta,
] {
let Event::Transcript(event) = stream.next().await.unwrap().unwrap() else {
panic!("expected transcript event")
};
assert_eq!(format!("{event:?}"), "TranscriptEvent([redacted])");
assert_eq!(event.thread_id, "t");
assert_eq!(event.emitted_at_ms, None);
assert_eq!(
event.update,
expected(ItemDelta {
item_id: "i".into(),
turn_id: "u".into(),
delta: "d".into(),
})
);
}
assert!(stream.next().await.is_none());
}
#[test]
fn patches_preserve_order_kinds_and_move_paths() {
let mut changes = Vec::new();
for kind in [
b"\x0a\x00".as_slice(),
b"\x12\x00",
b"\x1a\x03\x0a\x01m",
b"\x22\x00",
] {
changes.extend(message(
/*tag*/ 1,
&[
b"\x0a\x01d".to_vec(),
message(/*tag*/ 2, kind),
b"\x1a\x01p".to_vec(),
]
.concat(),
));
}
let params = [message(/*tag*/ 1, &changes), DELTA[3..].to_vec()].concat();
let payload = notification(/*tag*/ 40, &params);
let event = TranscriptEvent::decode("t", &payload).unwrap().unwrap();
assert_eq!(
event.update,
TranscriptUpdate::FileChangePatchUpdated(PatchUpdate {
item_id: "i".into(),
turn_id: "u".into(),
changes: [
PatchKind::Add,
PatchKind::Delete,
PatchKind::Update {
move_path: Some("m".into())
},
PatchKind::Unknown,
]
.into_iter()
.map(|kind| FileUpdate {
path: "p".into(),
diff: "d".into(),
kind,
})
.collect(),
})
);
}
#[test]
fn turn_events_preserve_status_failure_and_timing() {
for (tag, status, expected) in [
(23, 1, TurnStatus::Completed),
(23, 2, TurnStatus::Interrupted),
(21, 4, TurnStatus::InProgress),
(23, 3, TurnStatus::Failed),
(23, 99, TurnStatus::Unknown(99)),
] {
let (timing, started_at, completed_at, duration_ms) = match expected {
TurnStatus::InProgress => (b"\x38\x00".as_slice(), Some(0), None, None),
TurnStatus::Completed => (
b"\x08\x0d\x10\xf4\x03\x38\x0b".as_slice(),
Some(11),
Some(13),
Some(500),
),
_ => (&[][..], None, None, None),
};
let turn = [
b"\x1a\x06\x0a\x01a\x1a\x01e\x22\x01u\x2a\x00\x40".to_vec(),
vec![status],
timing.to_vec(),
]
.concat();
let params = [b"\x0a\x01t".to_vec(), message(/*tag*/ 2, &turn)].concat();
let event = TranscriptEvent::decode(
"t",
&message(tag, &[vec![8, 7], message(/*tag*/ 2, &params)].concat()),
)
.unwrap()
.unwrap();
let turn = TurnSummary {
id: "u".into(),
status: expected,
started_at,
completed_at,
duration_ms,
error: Some(TurnFailure {
message: "e".into(),
additional_details: Some("a".into()),
}),
};
assert_eq!(event.emitted_at_ms, Some(7));
assert_eq!(
event.update,
if tag == 21 {
TranscriptUpdate::TurnStarted(turn)
} else {
TranscriptUpdate::TurnCompleted(turn)
}
);
}
}
#[tokio::test]
async fn attach_preserves_opaque_notifications_and_continues() {
let foreign_turn = b"\x0a\x01x\x12\x05\x22\x01u\x40\x01";
let opaque = [
message(/*tag*/ 100, b"unknown notification"),
notification(/*tag*/ 21, foreign_turn),
notification(/*tag*/ 23, foreign_turn),
notification(/*tag*/ 32, &DELTA[..9]), // missing turn ID
];
let valid = notification(/*tag*/ 32, DELTA);
let fixture = fixture(response(
opaque
.iter()
.chain([&valid])
.map(|payload| frame(&message(/*tag*/ 1, payload)))
.collect(),
tonic::Status::ok(""),
))
.await;
let mut stream = fixture.client.attach("t").await.unwrap();
for payload in opaque {
let Some(Ok(Event::Notification(bytes))) = stream.next().await else {
panic!("expected opaque notification");
};
assert_eq!(bytes.as_ref(), payload.as_slice());
}
let Some(Ok(Event::Transcript(event))) = stream.next().await else {
panic!("expected transcript after opaque notifications");
};
assert_eq!(event.thread_id, "t");
assert!(stream.next().await.is_none());
}
#[test]
fn decoder_keeps_incomplete_events_opaque_and_rejects_foreign_deltas() {
let mut wrong_owner = DELTA.to_vec();
wrong_owner[8] = b'x';
for payload in [
notification(/*tag*/ 32, &DELTA[3..]),
notification(/*tag*/ 40, &DELTA[3..]),
notification(/*tag*/ 23, b"\x0a\x01t"),
] {
assert!(TranscriptEvent::decode("t", &payload).unwrap().is_none());
}
assert!(TranscriptEvent::decode("t", &notification(/*tag*/ 32, &wrong_owner)).is_err());
assert!(
TranscriptEvent::decode("t", &message(/*tag*/ 100, b""))
.unwrap()
.is_none()
);
}
#[test]
fn notification_selection_merges_only_repeated_variants() {
let partial = notification(/*tag*/ 32, &DELTA[..6]);
let tail = notification(/*tag*/ 32, &DELTA[6..]);
assert!(
TranscriptEvent::decode("t", &[partial.clone(), tail.clone()].concat())
.unwrap()
.is_some()
);
assert!(
TranscriptEvent::decode("t", &[partial, message(/*tag*/ 85, b""), tail].concat())
.unwrap()
.is_none()
);
assert!(
TranscriptEvent::decode(
"t",
&[notification(/*tag*/ 32, DELTA), message(/*tag*/ 89, b"")].concat()
)
.unwrap()
.is_none()
);
let envelope = message(/*tag*/ 2, DELTA);
let split_inside_params = [
message(/*tag*/ 32, &envelope[..4]),
message(/*tag*/ 32, &envelope[4..]),
]
.concat();
assert!(TranscriptEvent::decode("t", &split_inside_params).is_err());
}
#[test]
fn public_payload_debug_is_redacted() {
for formatted in [
format!(
"{:?}",
FileUpdate {
path: "secret".into(),
diff: "secret".into(),
kind: PatchKind::Update {
move_path: Some("secret".into()),
},
}
),
format!(
"{:?}",
TurnFailure {
message: "secret".into(),
additional_details: Some("secret".into()),
}
),
] {
assert!(formatted.ends_with("([redacted])"));
assert!(!formatted.contains("secret"));
}
}
#[test]
fn notification_selection_handles_many_fragments_and_obsolete_malformed_messages() {
let mut payload = message(/*tag*/ 32, b"\xff");
payload.extend(message(/*tag*/ 85, b""));
for _ in 0..100_000 {
payload.extend(message(/*tag*/ 32, b""));
}
payload.extend(notification(/*tag*/ 32, &DELTA[..6]));
payload.extend(message(/*tag*/ 100, b"\xff"));
payload.extend(notification(/*tag*/ 32, &DELTA[6..]));
let event = TranscriptEvent::decode("t", &payload).unwrap().unwrap();
assert_eq!(
event.update,
TranscriptUpdate::AgentMessageDelta(ItemDelta {
item_id: "i".into(),
turn_id: "u".into(),
delta: "d".into(),
})
);
payload.extend(message(/*tag*/ 89, b"\xff"));
assert!(TranscriptEvent::decode("t", &payload).unwrap().is_none());
}
@@ -0,0 +1,154 @@
//! Mirrors the ASB notification fields exposed by this client so it can build without
//! the monorepo's generated bindings. Keep the tag numbers in sync with `Notification.value`
//! in `native_notification.proto`. Only the final oneof variant is decoded on a second pass.
use prost::Message;
type SelectedNotification<'a> = (u32, SelectedMessages<'a>);
pub(super) struct SelectedMessages<'a> {
tag: u32,
input: &'a [u8],
}
// Known oneof members in native_notification.proto replace earlier variants, even
// when unsupported. Fields outside that schema leave the selected variant intact.
pub(super) fn notification(
input: &[u8],
) -> Result<Option<SelectedNotification<'_>>, prost::DecodeError> {
let mut cursor = input;
let mut selected = None;
while !cursor.is_empty() {
let start = input.len() - cursor.len();
let (tag, kind) = prost::encoding::decode_key(&mut cursor)?;
if (1..=89).contains(&tag) {
prost::encoding::check_wire_type(prost::encoding::WireType::LengthDelimited, kind)?;
if selected.is_none_or(|(previous, _)| previous != tag) {
selected = Some((tag, start));
}
}
prost::encoding::skip_field(kind, tag, &mut cursor, Default::default())?;
}
Ok(selected.map(|(tag, start)| {
(
tag,
SelectedMessages {
tag,
input: &input[start..],
},
)
}))
}
pub(super) fn merge_messages<T: Message + Default>(
values: SelectedMessages<'_>,
) -> Result<T, prost::DecodeError> {
let mut message = T::default();
let mut input = values.input;
while !input.is_empty() {
let (tag, kind) = prost::encoding::decode_key(&mut input)?;
if tag == values.tag {
// The first pass validated the wire type and bounded every length.
let length = prost::encoding::decode_varint(&mut input)? as usize;
let (value, tail) = input.split_at(length);
message.merge(value)?;
input = tail;
} else {
prost::encoding::skip_field(kind, tag, &mut input, Default::default())?;
}
}
Ok(message)
}
#[derive(Clone, Message)]
pub(super) struct Envelope<T: Message + Default + std::fmt::Debug> {
#[prost(int64, optional, tag = "1")]
pub emitted_at_ms: Option<i64>,
#[prost(message, optional, tag = "2")]
pub params: Option<T>,
}
#[derive(Clone, Message)]
pub(super) struct PatchUpdate {
#[prost(message, optional, tag = "1")]
pub changes: Option<Changes>,
#[prost(string, optional, tag = "2")]
pub item_id: Option<String>,
#[prost(string, optional, tag = "3")]
pub thread_id: Option<String>,
#[prost(string, optional, tag = "4")]
pub turn_id: Option<String>,
}
#[derive(Clone, Message)]
pub(super) struct Delta {
#[prost(string, optional, tag = "1")]
pub delta: Option<String>,
#[prost(string, optional, tag = "2")]
pub item_id: Option<String>,
#[prost(string, optional, tag = "3")]
pub thread_id: Option<String>,
#[prost(string, optional, tag = "4")]
pub turn_id: Option<String>,
}
#[derive(Clone, Message)]
pub(super) struct Changes {
#[prost(message, repeated, tag = "1")]
pub values: Vec<Change>,
}
#[derive(Clone, Message)]
pub(super) struct Change {
#[prost(string, optional, tag = "1")]
pub diff: Option<String>,
#[prost(message, optional, tag = "2")]
pub kind: Option<ChangeKind>,
#[prost(string, optional, tag = "3")]
pub path: Option<String>,
}
#[derive(Clone, Message)]
pub(super) struct ChangeKind {
#[prost(oneof = "Kind", tags = "1, 2, 3")]
pub value: Option<Kind>,
}
#[derive(Clone, prost::Oneof)]
pub(super) enum Kind {
#[prost(message, tag = "1")]
Add(Empty),
#[prost(message, tag = "2")]
Delete(Empty),
#[prost(message, tag = "3")]
Update(Move),
}
#[derive(Clone, Message)]
pub(super) struct Empty {}
#[derive(Clone, Message)]
pub(super) struct Move {
#[prost(string, optional, tag = "1")]
pub move_path: Option<String>,
}
#[derive(Clone, Message)]
pub(super) struct TurnUpdate {
#[prost(string, optional, tag = "1")]
pub thread_id: Option<String>,
#[prost(message, optional, tag = "2")]
pub turn: Option<Turn>,
}
#[derive(Clone, Message)]
pub(super) struct Turn {
#[prost(int64, optional, tag = "1")]
pub completed_at: Option<i64>,
#[prost(int64, optional, tag = "2")]
pub duration_ms: Option<i64>,
#[prost(message, optional, tag = "3")]
pub error: Option<TurnError>,
#[prost(string, optional, tag = "4")]
pub id: Option<String>,
#[prost(int64, optional, tag = "7")]
pub started_at: Option<i64>,
#[prost(int32, optional, tag = "8")]
pub status: Option<i32>,
}
#[derive(Clone, Message)]
pub(super) struct TurnError {
#[prost(string, optional, tag = "1")]
pub additional_details: Option<String>,
#[prost(string, optional, tag = "3")]
pub message: Option<String>,
}
+4 -3
View File
@@ -46,11 +46,11 @@ pub struct ResumeRequest {
pub shared_access: bool,
}
/// An Attach event containing the complete nested protobuf message bytes.
/// Payloads are native Notification/ServerRequest messages, not JSON-RPC or ProtoJSON.
/// Keeping them opaque preserves unknown fields until full payload typing is added.
/// An Attach event with selected typed transcript updates and otherwise opaque payloads.
/// The opaque variants carry the original native Notification or ServerRequest protobuf bytes.
pub enum Event {
Notification(Bytes),
Transcript(Box<crate::TranscriptEvent>),
ServerRequest(Bytes),
}
@@ -58,6 +58,7 @@ impl fmt::Debug for Event {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Notification(_) => "Notification([redacted])",
Self::Transcript(_) => "Transcript([redacted])",
Self::ServerRequest(_) => "ServerRequest([redacted])",
})
}