gemini_adk_rs/live/replay.rs
1//! Offline session replay — feed a recorded wire log through the **real**
2//! control plane.
3//!
4//! Recording happens at L0 via
5//! [`SessionConfig::record_wire`](gemini_genai_rs::prelude::SessionConfig::record_wire)
6//! (every wire byte, both directions, as [`WireEntry`] JSONL). This module
7//! closes the loop: [`replay_session`] opens a
8//! [`ReplayTransport`] over the log's inbound frames and attaches the same three-lane processor a
9//! live connection would get — phase machine, extractors, watchers, tool
10//! dispatch, flow governance all run for real. Nothing is mocked above the
11//! transport seam.
12//!
13//! What replay does and does not do:
14//!
15//! - **Does**: re-decode every recorded inbound frame, re-drive the L1
16//! processor (events, state writes, tool dispatch through whatever
17//! dispatcher you attach), and collect the outbound frames the processor
18//! regenerates (setup, tool responses) for comparison against the log.
19//! - **Does not**: re-execute the model. The model's outputs are *in* the
20//! recorded inbound frames. User-originated sends (text/audio) are in the
21//! log's outbound entries but are not re-sent — they only ever existed to
22//! provoke the recorded inbound frames.
23//!
24//! ```rust,no_run
25//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
26//! use gemini_adk_rs::live::replay::replay_session;
27//! use gemini_adk_rs::live::LiveSessionBuilder;
28//! use gemini_genai_rs::prelude::SessionConfig;
29//! use gemini_genai_rs::transport::read_wire_log;
30//!
31//! let entries = read_wire_log("session.wire.jsonl")?;
32//! let config = SessionConfig::new("offline");
33//! let builder = LiveSessionBuilder::new(config.clone());
34//! let replay = replay_session(config, builder, &entries).await?;
35//!
36//! let mut events = replay.handle().events();
37//! replay.release(); // start streaming recorded frames
38//! replay.drained().await; // all frames handed to the session loop
39//! # let _ = events.try_recv();
40//! # Ok(())
41//! # }
42//! ```
43
44use std::sync::Arc;
45use std::time::{Duration, UNIX_EPOCH};
46
47use gemini_genai_rs::prelude::{SessionConfig, SessionPhase};
48use gemini_genai_rs::session::{SessionEvent, SessionHandle};
49use gemini_genai_rs::transport::replay::{ReplayControl, ReplayTransport};
50use gemini_genai_rs::transport::{ConnectBuilder, TransportConfig, WireDirection, WireEntry};
51use tokio::sync::broadcast;
52
53use crate::clock::ManualClock;
54
55use crate::error::AgentError;
56
57use super::builder::{LiveSessionBuilder, build_runtime, spawn_lanes};
58use super::events::LiveEvent;
59use super::handle::LiveHandle;
60
61/// Attach the full L1 control plane (three-lane processor, phase machine,
62/// extractors, watchers, tool dispatch, …) to an **already connected** L0
63/// session.
64///
65/// This is the seam that makes replay possible without touching the network:
66/// connect the L0 session over any [`Transport`](gemini_genai_rs::transport::Transport)
67/// (e.g. [`ReplayTransport`] or [`MockTransport`](gemini_genai_rs::transport::MockTransport)), then hand
68/// it here together with a configured [`LiveSessionBuilder`].
69///
70/// Note: the builder's own `SessionConfig` is *not* re-sent — the setup
71/// message was already encoded from the config given to the L0 connect call.
72/// Subscribe to events **after** this returns and only then let the transport
73/// stream (for `ReplayTransport`, call
74/// [`ReplayControl::release`](gemini_genai_rs::transport::replay::ReplayControl::release)),
75/// otherwise early frames race the subscription.
76pub async fn attach_session(
77 builder: LiveSessionBuilder,
78 session: SessionHandle,
79) -> Result<LiveHandle, AgentError> {
80 let plan = builder.into_plan()?;
81 session.wait_for_phase(SessionPhase::Active).await;
82 let runtime = build_runtime(plan, session);
83 spawn_lanes(runtime).await
84}
85
86/// A replayed session: the live handle plus the replay controls.
87pub struct ReplaySession {
88 handle: LiveHandle,
89 control: ReplayControl,
90 clock: Arc<ManualClock>,
91}
92
93impl ReplaySession {
94 /// The live handle — same type a real connection returns. `state()`,
95 /// `events()`, `telemetry()`, `extracted()` all work.
96 pub fn handle(&self) -> &LiveHandle {
97 &self.handle
98 }
99
100 /// Start streaming the recorded inbound frames. Call after subscribing
101 /// to [`LiveHandle::events`].
102 pub fn release(&self) {
103 self.control.release();
104 }
105
106 /// Wait until every recorded inbound frame has been handed to the session
107 /// loop. The last frame's effects may still be propagating through the
108 /// processor — use [`collect_events_until_idle`] (or assert on state) to
109 /// settle.
110 pub async fn drained(&self) {
111 self.control.drained().await;
112 }
113
114 /// Outbound frames the replayed session has sent so far (setup, tool
115 /// responses, …), in send order, for comparison against the recorded log.
116 pub fn outbound_frames(&self) -> Vec<Vec<u8>> {
117 self.control.outbound_frames()
118 }
119
120 /// The replay's clock. It stands at the recorded capture time of the most
121 /// recently delivered frame, measured from the first inbound frame.
122 pub fn clock(&self) -> &Arc<ManualClock> {
123 &self.clock
124 }
125
126 /// Disconnect the replayed session.
127 pub async fn disconnect(&self) -> Result<(), gemini_genai_rs::session::SessionError> {
128 self.handle.disconnect().await
129 }
130}
131
132/// Replay a recorded wire log through the real L1 processor, offline.
133///
134/// - `config` is used to open the replay transport (its re-encoded setup
135/// message becomes the first outbound frame, mirroring the original run).
136/// Use the same configuration as the recorded session for a faithful setup
137/// comparison. No network is touched and no credential is used.
138/// - `builder` supplies the control plane: dispatcher, phases, extractors,
139/// watchers, state, callbacks. Attach the original tool implementations to
140/// re-execute tools deterministically; without a dispatcher, recorded tool
141/// calls surface as events but produce no responses.
142/// - `entries` is the recorded log; only its inbound frames are replayed
143/// (outbound entries are kept in the log purely for comparison/audit).
144///
145/// Frames are delivered as fast as the session consumes them, but the
146/// session reads time from a [`ManualClock`] that moves to each frame's
147/// recorded capture time as it is delivered (this replaces any clock the
148/// builder set). Temporal patterns, phase durations, resolver cache expiry
149/// and the `session:` timing signals therefore see the original gaps between
150/// frames, whatever the replay's own speed.
151///
152/// The session processes frames asynchronously, so before the clock jumps
153/// by [`REPLAY_SYNC_STEP`] or more, the next frame waits until both event
154/// lanes have handled every event of the frames before it (the router runs
155/// in lockstep during a replay). An earlier frame is then evaluated at its
156/// own time, not a later one. Jumps smaller than the step are not waited
157/// for, which keeps dense audio fast and bounds the timing error at the
158/// step. The replay is gated: nothing past
159/// the setup handshake flows until [`ReplaySession::release`] is called, so
160/// subscribe to events first.
161pub async fn replay_session(
162 config: SessionConfig,
163 builder: LiveSessionBuilder,
164 entries: &[WireEntry],
165) -> Result<ReplaySession, AgentError> {
166 let first_ts_ms = entries
167 .iter()
168 .find(|e| e.dir == WireDirection::Inbound)
169 .map_or(0, |e| e.ts_ms);
170 let clock = Arc::new(ManualClock::starting_at(
171 UNIX_EPOCH + Duration::from_millis(first_ts_ms),
172 ));
173 // Once the session exists, the gate counts the events it has emitted
174 // and waits for the lanes to have handled them all (lockstep).
175 let lockstep = Arc::new(super::processor::Lockstep::default());
176 let emitted: Arc<tokio::sync::Mutex<Option<EmittedCount>>> = Arc::default();
177 let synced_ms = Arc::new(std::sync::atomic::AtomicU64::new(first_ts_ms));
178 let gate = {
179 let clock = clock.clone();
180 let emitted = emitted.clone();
181 let lockstep = lockstep.clone();
182 Arc::new(move |ts_ms: u64| {
183 let clock = clock.clone();
184 let emitted = emitted.clone();
185 let lockstep = lockstep.clone();
186 let synced_ms = synced_ms.clone();
187 Box::pin(async move {
188 let step = REPLAY_SYNC_STEP.as_millis() as u64;
189 let synced = synced_ms.load(std::sync::atomic::Ordering::Acquire);
190 if ts_ms.saturating_sub(synced) >= step
191 && let Some((events, count)) = emitted.lock().await.as_mut()
192 {
193 // The session loop takes the next frame only after it has
194 // emitted every event of the previous one, so these are
195 // all the events so far.
196 loop {
197 match events.try_recv() {
198 Ok(_) => *count += 1,
199 Err(broadcast::error::TryRecvError::Lagged(n)) => *count += n,
200 Err(_) => break,
201 }
202 }
203 lockstep.wait_for(*count, Duration::from_secs(5)).await;
204 synced_ms.store(ts_ms, std::sync::atomic::Ordering::Release);
205 }
206 clock.set_elapsed(Duration::from_millis(ts_ms.saturating_sub(first_ts_ms)));
207 }) as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
208 })
209 };
210 let (transport, control) = ReplayTransport::from_wire_log(entries);
211 let transport = transport.with_frame_gate(gate);
212 let builder = builder.clock(clock.clone()).lockstep(lockstep);
213 let transport_config = TransportConfig {
214 max_reconnect_attempts: 0,
215 connect_timeout_secs: 5,
216 setup_timeout_secs: 5,
217 ..TransportConfig::default()
218 };
219 let session = ConnectBuilder::new(config)
220 .transport_config(transport_config)
221 .transport(transport)
222 .connect()
223 .await
224 .map_err(AgentError::Session)?;
225 let handle = attach_session(builder, session).await?;
226 // Count from here, after the router subscribed: it settles everything it
227 // receives, so a count started later can only trail it. Nothing past the
228 // handshake flows before `release`, so no frame's events are missed.
229 *emitted.lock().await = Some((handle.session().subscribe(), 0));
230 Ok(ReplaySession {
231 handle,
232 control,
233 clock,
234 })
235}
236
237/// A subscription to the session's events and how many it has seen.
238type EmittedCount = (broadcast::Receiver<SessionEvent>, u64);
239
240/// The largest clock jump a replay makes without first waiting for the
241/// session to process the frames already delivered. See [`replay_session`].
242pub const REPLAY_SYNC_STEP: Duration = Duration::from_millis(50);
243
244/// Collect [`LiveEvent`]s until the stream stays idle for `idle` (or `max`
245/// elapses). Useful for settling an as-fast-as-possible replay where "done"
246/// means "no more effects are propagating".
247pub async fn collect_events_until_idle(
248 rx: &mut tokio::sync::broadcast::Receiver<LiveEvent>,
249 idle: Duration,
250 max: Duration,
251) -> Vec<LiveEvent> {
252 let mut events = Vec::new();
253 let deadline = tokio::time::Instant::now() + max;
254 loop {
255 let timeout = idle.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
256 if timeout.is_zero() {
257 break;
258 }
259 match tokio::time::timeout(timeout, rx.recv()).await {
260 Ok(Ok(event)) => events.push(event),
261 Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
262 Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => break,
263 Err(_) => break, // idle
264 }
265 }
266 events
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use gemini_genai_rs::prelude::ModelId;
273
274 #[tokio::test]
275 async fn replay_session_reaches_active_and_emits_events() {
276 let entries = vec![
277 WireEntry {
278 seq: 1,
279 dir: WireDirection::Inbound,
280 ts_ms: 1,
281 payload: br#"{"setupComplete":{}}"#.to_vec(),
282 },
283 WireEntry {
284 seq: 2,
285 dir: WireDirection::Inbound,
286 ts_ms: 2,
287 payload:
288 br#"{"serverContent":{"modelTurn":{"parts":[{"text":"Hi"}]},"turnComplete":true}}"#
289 .to_vec(),
290 },
291 ];
292 let config = SessionConfig::new("offline").model(ModelId::LIVE_2_5_FLASH_NATIVE_AUDIO);
293 let builder = LiveSessionBuilder::new(config.clone());
294
295 let replay = replay_session(config, builder, &entries).await.unwrap();
296 let mut events = replay.handle().events();
297 replay.release();
298 replay.drained().await;
299
300 let collected = collect_events_until_idle(
301 &mut events,
302 Duration::from_millis(200),
303 Duration::from_secs(5),
304 )
305 .await;
306
307 assert!(
308 collected
309 .iter()
310 .any(|e| matches!(e, LiveEvent::TextDelta(t) if t == "Hi")),
311 "expected replayed TextDelta, got {collected:?}"
312 );
313 assert!(
314 collected
315 .iter()
316 .any(|e| matches!(e, LiveEvent::TurnComplete))
317 );
318
319 // The clock stands at the last frame's recorded time.
320 assert_eq!(replay.clock().elapsed(), Duration::from_millis(1));
321
322 // The replayed session re-encoded and "sent" the setup message.
323 let outbound = replay.outbound_frames();
324 assert!(!outbound.is_empty());
325 assert!(
326 String::from_utf8(outbound[0].clone())
327 .unwrap()
328 .contains("\"setup\"")
329 );
330
331 replay.disconnect().await.unwrap();
332 }
333
334 /// Timing decisions follow the recording, not the replay's speed: a turn
335 /// recorded ten seconds after the handshake is stamped ten seconds later,
336 /// at the recording's wall time, even though the replay takes
337 /// milliseconds.
338 #[tokio::test]
339 async fn replay_reads_time_from_the_recording() {
340 const T0: u64 = 1_000_000_000_000; // 2001-09-09, well before "now"
341 let entries = vec![
342 WireEntry {
343 seq: 1,
344 dir: WireDirection::Inbound,
345 ts_ms: T0,
346 payload: br#"{"setupComplete":{}}"#.to_vec(),
347 },
348 WireEntry {
349 seq: 2,
350 dir: WireDirection::Inbound,
351 ts_ms: T0 + 10_000,
352 payload:
353 br#"{"serverContent":{"modelTurn":{"parts":[{"text":"Hi"}]},"turnComplete":true}}"#
354 .to_vec(),
355 },
356 ];
357 let config = SessionConfig::new("offline").model(ModelId::LIVE_2_5_FLASH_NATIVE_AUDIO);
358 let replay = replay_session(config.clone(), LiveSessionBuilder::new(config), &entries)
359 .await
360 .unwrap();
361 let mut events = replay.handle().events();
362 replay.release();
363 replay.drained().await;
364 collect_events_until_idle(
365 &mut events,
366 Duration::from_millis(200),
367 Duration::from_secs(5),
368 )
369 .await;
370
371 assert_eq!(replay.clock().elapsed(), Duration::from_secs(10));
372 let mutations = replay.handle().state().recent_mutations();
373 assert!(!mutations.is_empty(), "the turn should write state");
374 let recorded = |ms: u64| UNIX_EPOCH + Duration::from_millis(ms);
375 for m in &mutations {
376 assert!(
377 m.timestamp >= recorded(T0) && m.timestamp <= recorded(T0 + 10_000),
378 "{} stamped outside the recording: {:?}",
379 m.key,
380 m.timestamp
381 );
382 }
383 assert!(
384 mutations
385 .iter()
386 .any(|m| m.timestamp == recorded(T0 + 10_000)),
387 "the turn's writes carry the second frame's recorded time"
388 );
389
390 replay.disconnect().await.unwrap();
391 }
392
393 #[tokio::test]
394 async fn each_frame_is_processed_at_its_own_recorded_time() {
395 const T0: u64 = 1_000_000_000_000;
396 let turn =
397 br#"{"serverContent":{"modelTurn":{"parts":[{"text":"Hi"}]},"turnComplete":true}}"#;
398 let entry = |seq, ts_ms, payload: &[u8]| WireEntry {
399 seq,
400 dir: WireDirection::Inbound,
401 ts_ms,
402 payload: payload.to_vec(),
403 };
404 let entries = vec![
405 entry(1, T0, br#"{"setupComplete":{}}"#),
406 entry(2, T0 + 1_000, turn),
407 entry(3, T0 + 60_000, turn),
408 ];
409 // A slow turn-complete handler: the lane is still on the first turn
410 // when the replay would otherwise deliver the second.
411 let state = crate::state::State::new();
412 let seen = state.clone();
413 let callbacks = crate::live::callbacks::EventCallbacks {
414 on_turn_complete: Some(Arc::new(move || {
415 let seen = seen.clone();
416 Box::pin(async move {
417 tokio::time::sleep(Duration::from_millis(100)).await;
418 let n = seen.get::<u32>("turns_seen").unwrap_or(0);
419 let _ = seen.set("turns_seen", n + 1);
420 })
421 })),
422 ..Default::default()
423 };
424 let config = SessionConfig::new("offline").model(ModelId::LIVE_2_5_FLASH_NATIVE_AUDIO);
425 let builder = LiveSessionBuilder::new(config.clone())
426 .state(state)
427 .callbacks(callbacks);
428 let replay = replay_session(config, builder, &entries).await.unwrap();
429 let mut events = replay.handle().events();
430 replay.release();
431 replay.drained().await;
432 collect_events_until_idle(
433 &mut events,
434 Duration::from_millis(400),
435 Duration::from_secs(5),
436 )
437 .await;
438
439 let recorded = |ms: u64| UNIX_EPOCH + Duration::from_millis(ms);
440 let stamps: Vec<_> = replay
441 .handle()
442 .state()
443 .recent_mutations()
444 .iter()
445 .filter(|m| m.key == "turns_seen")
446 .map(|m| m.timestamp)
447 .collect();
448 assert_eq!(
449 stamps,
450 [recorded(T0 + 1_000), recorded(T0 + 60_000)],
451 "each turn's handler runs at its own recorded time"
452 );
453 replay.disconnect().await.unwrap();
454 }
455}