Record & Replay
The determinism spine: record every byte that crosses the wire and every state mutation, then replay any session offline through the real control plane — phase machine, extractors, watchers, and tool dispatch all run for real. Nothing is mocked above the transport seam.
Recording a session
Wire log — record_wire
One builder call taps both directions of the WebSocket:
#![allow(unused)] fn main() { use gemini_adk_fluent_rs::prelude::*; let handle = Live::builder() .model(GeminiModel::Gemini2_0FlashLive) .instruction("You are a weather assistant") .tools(dispatcher) .record_wire("/var/log/sessions/user-123.wire.jsonl") .connect_from_env() .await?; }
Every outbound frame (setup, user sends, tool responses) and every inbound frame (model audio/text, tool calls, turn completes) is appended to a JSONL log. Each entry carries a monotonic sequence number, a direction, an epoch-millis timestamp, and the base64-encoded raw payload:
{"seq":1,"dir":"out","ts_ms":1718000000000,"payload_b64":"eyJzZXR1cCI6ey4uLn19"}
{"seq":2,"dir":"in","ts_ms":1718000000123,"payload_b64":"eyJzZXR1cENvbXBsZXRlIjp7fX0="}
Recording is synchronous, buffered (flushed every second and on drop), and infallible by contract — an I/O error is logged, never surfaced into the live session.
At lower layers the same knob is SessionConfig::record_wire(recorder) or
ConnectBuilder::record_wire(recorder); custom backends implement the
WireRecorder trait (one sync record(&self, entry: WireEntry) method).
MemoryWireRecorder collects entries in memory for tests.
Mutation journal — JournalSink
The in-memory mutation journal is a bounded ring (1024 entries); a two-hour
call loses most of its history. A JournalSink receives every mutation as
it is recorded:
#![allow(unused)] fn main() { use std::sync::Arc; use gemini_adk_fluent_rs::prelude::*; use gemini_adk_fluent_rs::state::FileJournalSink; let state = State::new(); state.set_journal_sink(Arc::new(FileJournalSink::create( "/var/log/sessions/user-123.journal.jsonl", )?)); // hand `state` to the session: Live::builder()… / LiveSessionBuilder::with_state(state) }
The sink is shared with all clones and delta views of the State and is
invoked synchronously on the write path — keep it cheap (FileJournalSink
buffers and flushes periodically). The ring stays in place for
recent_mutations() / evidence(); the sink adds unbounded durability.
Journal entries are serde round-trippable StateMutation values:
{"sequence":7,"key":"app:last_city","old":null,"new":"London","origin":"set","timestamp_ms":1718000000456,"delta":false}
Replaying a session
CLI: adk session replay
$ adk session replay user-123.wire.jsonl --journal user-123.journal.jsonl
ADK Session Replay — user-123.wire.jsonl
Wire log: 7 entries (4 inbound, 3 outbound), 12.3s recorded
Mode: offline — recorded frames only, no LLM re-execution,
no tool re-execution (the CLI has no tool implementations)
Turn-by-turn:
turn 1 [text×1, text_complete×1, turn_complete×1]
“Hello! Ask me about the weather.”
turn 2 [text×1, tool_call×1, turn_complete×1]
“It is 22C in London.”
tool: get_weather({"city":"London"})
Final state (5 keys):
session:phase = "Disconnected"
session:turn_count = 2
…
Journal diff — user-123.journal.jsonl (27 mutations, 5 keys):
DRIFT — 1 key(s) diverged:
- app:last_city: recorded "London", missing on replay
The replay feeds the recorded inbound frames through the real L1 processor
(default callbacks, no tools) and prints a turn-by-turn summary. With
--journal it diffs the recorded journal's per-key final values against the
replayed final state and reports CLEAN or DRIFT (non-zero exit on
drift). Being honest about scope: replay only re-processes recorded frames —
the model is never re-executed (its outputs are in the inbound frames), and
the CLI has no access to your tool implementations, so tool-written state keys
are expected to drift. Wall-clock-derived keys (session:elapsed_ms,
session:silence_ms, session:remaining_budget_ms) are excluded from the
diff.
Programmatic: the replay harness
To re-execute your actual tools (and assert full determinism), replay in-process with the original dispatcher attached:
#![allow(unused)] fn main() { use gemini_adk_fluent_rs::live::{collect_events_until_idle, replay_session}; use gemini_adk_rs::live::LiveSessionBuilder; use gemini_genai_rs::prelude::{read_wire_log, SessionConfig}; let entries = read_wire_log("user-123.wire.jsonl")?; let state = State::new(); let config = SessionConfig::new("offline"); // no network, no credentials used let builder = LiveSessionBuilder::new(config.clone()) .dispatcher(weather_dispatcher(state.clone())) .with_state(state.clone()); let replay = replay_session(config, builder, &entries).await?; let mut events = replay.handle().events(); replay.release(); // start streaming recorded frames replay.drained().await; // every frame handed to the session loop let events = collect_events_until_idle( &mut events, std::time::Duration::from_millis(300), std::time::Duration::from_secs(30), ).await; let outbound = replay.outbound_frames(); // setup + regenerated tool responses replay.disconnect().await?; }
The replay transport is gated: only the setup handshake flows until
release() is called, so subscribe to events first and lose nothing. Frames
are delivered as fast as the processor consumes them (no original-timing
pacing). attach_session(builder, session) is the underlying seam — it bolts
the full three-lane processor onto any pre-connected L0 session
(ReplayTransport, MockTransport, or a real socket).
What matches, what doesn't
Replaying the same log with the same tools through a fresh State reproduces:
- the per-lane
LiveEventsequences (fast lane and control lane each in order; cross-lane interleaving is scheduler-dependent, in production too), - the final state and the journal's per-key final values (minus the wall-clock keys above),
- byte-identical setup and tool-response frames.
User-originated outbound frames (text/audio you sent) are recorded for audit
but not re-sent — they only ever existed to provoke the recorded inbound
frames. Timer-driven events (Telemetry, TurnMetrics) are not replayed
deterministically.