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
@@ -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());
}