Skip to main content

gemini_adk_rs/live/
context_writer.rs

1//! Deferred context delivery — flush pending context alongside user content.
2//!
3//! When the control plane produces model-role context turns (tool advisory,
4//! repair nudge, steering modifiers, phase instructions, on_enter_context),
5//! they can be queued in a [`PendingContext`] buffer instead of sent immediately.
6//!
7//! [`DeferredWriter`] wraps any [`SessionWriter`] and transparently drains the
8//! pending queue before forwarding user-initiated sends (`send_audio`,
9//! `send_text`, `send_video`).  This ensures context arrives in the same burst
10//! as user content rather than as isolated WebSocket frames that can confuse
11//! the model or clash with concurrent user input.
12//!
13//! # Architecture
14//!
15//! ```text
16//!   Control lane (lifecycle)         User code (LiveHandle)
17//!          |                                |
18//!   push context to                  send_audio / send_text
19//!   PendingContext                          |
20//!          |                         DeferredWriter
21//!          v                          1. drain PendingContext
22//!   +---------------+                2. send_client_content(drained, false)
23//!   | PendingContext | <-- drain ---  3. forward original send
24//!   +---------------+
25//! ```
26//!
27//! The queue uses `parking_lot::Mutex` for fast, uncontested locking — the
28//! control lane pushes once per turn, and user sends drain before each frame.
29
30use std::sync::Arc;
31
32use async_trait::async_trait;
33use parking_lot::Mutex;
34
35use gemini_genai_rs::prelude::{Content, FunctionResponse};
36use gemini_genai_rs::protocol::ContextUpdate;
37use gemini_genai_rs::session::{SessionError, SessionWriter};
38
39/// Thread-safe buffer for pending context turns awaiting delivery.
40///
41/// Context is queued by the control plane (lifecycle steps 7d/7e/7f/12/13)
42/// and drained by [`DeferredWriter`] before the next user interaction.
43///
44/// # Thread safety
45///
46/// Uses `parking_lot::Mutex` — fast uncontested locking, no poisoning.
47/// The control lane pushes once per turn; user sends drain once per frame.
48/// Contention is near-zero.
49pub struct PendingContext {
50    buffer: Mutex<Vec<Content>>,
51    /// Whether a prompt (turnComplete:true) should be sent after flushing.
52    prompt: Mutex<bool>,
53}
54
55impl PendingContext {
56    /// Create an empty pending context buffer.
57    pub fn new() -> Self {
58        Self {
59            buffer: Mutex::new(Vec::new()),
60            prompt: Mutex::new(false),
61        }
62    }
63
64    /// Push a single context turn into the buffer.
65    pub fn push(&self, content: Content) {
66        self.buffer.lock().push(content);
67    }
68
69    /// Push multiple context turns into the buffer.
70    pub fn extend(&self, contents: Vec<Content>) {
71        if !contents.is_empty() {
72            self.buffer.lock().extend(contents);
73        }
74    }
75
76    /// Mark that a prompt (turnComplete:true) should follow the next flush.
77    pub fn set_prompt(&self) {
78        *self.prompt.lock() = true;
79    }
80
81    /// Drain all pending context, returning the contents and whether to prompt.
82    ///
83    /// After this call, the buffer is empty and the prompt flag is cleared.
84    pub fn drain(&self) -> (Vec<Content>, bool) {
85        let contents = self.drain_context();
86        let prompt = self.take_prompt();
87        (contents, prompt)
88    }
89
90    /// Drain only context turns, leaving any pending prompt armed.
91    pub fn drain_context(&self) -> Vec<Content> {
92        {
93            let mut buf = self.buffer.lock();
94            std::mem::take(&mut *buf)
95        }
96    }
97
98    /// Take and clear the pending prompt flag without touching queued context.
99    pub fn take_prompt(&self) -> bool {
100        let mut p = self.prompt.lock();
101        std::mem::replace(&mut *p, false)
102    }
103
104    /// Clear any armed prompt without touching queued context.
105    pub fn clear_prompt(&self) {
106        *self.prompt.lock() = false;
107    }
108
109    /// Return whether a prompt is currently armed.
110    pub fn has_prompt(&self) -> bool {
111        *self.prompt.lock()
112    }
113
114    /// Check if the buffer is empty (no pending context or prompt).
115    pub fn is_empty(&self) -> bool {
116        self.buffer.lock().is_empty() && !*self.prompt.lock()
117    }
118}
119
120impl Default for PendingContext {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126/// A [`SessionWriter`] wrapper that flushes pending context before user content.
127///
128/// Wraps an inner writer and drains a shared [`PendingContext`] buffer before
129/// forwarding `send_audio`, `send_text`, or `send_video` calls.  This ensures
130/// model-role context turns arrive in the same burst as user content.
131///
132/// # When context is flushed
133///
134/// - **`send_audio`**: Context is flushed as `send_client_content(drained, false)`
135///   immediately before the audio frame.  Audio goes via `realtimeInput` (different
136///   wire message), so they are two frames — but sent back-to-back with no gap.
137///
138/// - **`send_text`**: Context is flushed, then user text is sent.  Both go via
139///   `clientContent`, but as separate messages since the user text needs
140///   `turn_complete: true` to trigger a model response.
141///
142/// - **`send_video`**: Same as audio — flush then forward.
143///
144/// # When context is NOT flushed
145///
146/// `send_tool_response`, `update_instruction`, `send_client_content`,
147/// `signal_activity_start/end`, and `disconnect` do NOT trigger a flush.
148/// These are either internal SDK operations or explicit user control — flushing
149/// context before them would be surprising.
150pub struct DeferredWriter {
151    inner: Arc<dyn SessionWriter>,
152    pending: Arc<PendingContext>,
153}
154
155impl DeferredWriter {
156    /// Create a new deferred writer wrapping the given writer.
157    pub fn new(inner: Arc<dyn SessionWriter>, pending: Arc<PendingContext>) -> Self {
158        Self { inner, pending }
159    }
160
161    /// Flush any pending context to the wire without triggering a model prompt.
162    ///
163    /// User sends (audio/text/video) use this context-only flush so queued
164    /// phase prompts cannot make the model speak while the user is speaking.
165    async fn flush_context(&self) -> Result<(), SessionError> {
166        let contents = self.pending.drain_context();
167        if !contents.is_empty() {
168            self.inner.send_client_content(contents, false).await?;
169        }
170        Ok(())
171    }
172
173    /// Get a reference to the shared pending context buffer.
174    pub fn pending(&self) -> &Arc<PendingContext> {
175        &self.pending
176    }
177}
178
179#[async_trait]
180impl SessionWriter for DeferredWriter {
181    async fn send_audio(&self, data: bytes::Bytes) -> Result<(), SessionError> {
182        self.flush_context().await?;
183        self.inner.send_audio(data).await
184    }
185
186    async fn send_text(&self, text: String) -> Result<(), SessionError> {
187        self.flush_context().await?;
188        self.inner.send_text(text).await
189    }
190
191    async fn send_tool_response(
192        &self,
193        responses: Vec<FunctionResponse>,
194    ) -> Result<(), SessionError> {
195        // Tool responses are SDK-internal — don't flush context here.
196        self.inner.send_tool_response(responses).await
197    }
198
199    async fn send_client_content(
200        &self,
201        turns: Vec<Content>,
202        turn_complete: bool,
203    ) -> Result<(), SessionError> {
204        // Explicit client content calls pass through unchanged.
205        // The caller knows what they're doing.
206        self.inner.send_client_content(turns, turn_complete).await
207    }
208
209    async fn send_video(&self, jpeg_data: bytes::Bytes) -> Result<(), SessionError> {
210        self.flush_context().await?;
211        self.inner.send_video(jpeg_data).await
212    }
213
214    async fn update_instruction(&self, instruction: String) -> Result<(), SessionError> {
215        // Instruction updates are SDK-internal — don't flush context here.
216        self.inner.update_instruction(instruction).await
217    }
218
219    async fn update_context(&self, update: ContextUpdate) -> Result<(), SessionError> {
220        // Like an instruction update: SDK-internal, so no flush.
221        self.inner.update_context(update).await
222    }
223
224    async fn signal_activity_start(&self) -> Result<(), SessionError> {
225        self.inner.signal_activity_start().await
226    }
227
228    async fn signal_activity_end(&self) -> Result<(), SessionError> {
229        self.inner.signal_activity_end().await
230    }
231
232    async fn disconnect(&self) -> Result<(), SessionError> {
233        // Flush any remaining context before disconnecting so it's not lost.
234        let _ = self.flush_context().await;
235        self.inner.disconnect().await
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use std::sync::atomic::{AtomicUsize, Ordering};
243
244    /// Minimal writer that counts calls by type.
245    struct CountingWriter {
246        audio_count: AtomicUsize,
247        text_count: AtomicUsize,
248        client_content_count: AtomicUsize,
249        video_count: AtomicUsize,
250    }
251
252    impl CountingWriter {
253        fn new() -> Self {
254            Self {
255                audio_count: AtomicUsize::new(0),
256                text_count: AtomicUsize::new(0),
257                client_content_count: AtomicUsize::new(0),
258                video_count: AtomicUsize::new(0),
259            }
260        }
261    }
262
263    #[async_trait]
264    impl SessionWriter for CountingWriter {
265        async fn send_audio(&self, _: bytes::Bytes) -> Result<(), SessionError> {
266            self.audio_count.fetch_add(1, Ordering::SeqCst);
267            Ok(())
268        }
269        async fn send_text(&self, _: String) -> Result<(), SessionError> {
270            self.text_count.fetch_add(1, Ordering::SeqCst);
271            Ok(())
272        }
273        async fn send_tool_response(&self, _: Vec<FunctionResponse>) -> Result<(), SessionError> {
274            Ok(())
275        }
276        async fn send_client_content(&self, _: Vec<Content>, _: bool) -> Result<(), SessionError> {
277            self.client_content_count.fetch_add(1, Ordering::SeqCst);
278            Ok(())
279        }
280        async fn send_video(&self, _: bytes::Bytes) -> Result<(), SessionError> {
281            self.video_count.fetch_add(1, Ordering::SeqCst);
282            Ok(())
283        }
284        async fn update_instruction(&self, _: String) -> Result<(), SessionError> {
285            Ok(())
286        }
287        async fn signal_activity_start(&self) -> Result<(), SessionError> {
288            Ok(())
289        }
290        async fn signal_activity_end(&self) -> Result<(), SessionError> {
291            Ok(())
292        }
293        async fn disconnect(&self) -> Result<(), SessionError> {
294            Ok(())
295        }
296    }
297
298    #[test]
299    fn pending_context_push_and_drain() {
300        let pc = PendingContext::new();
301        assert!(pc.is_empty());
302
303        pc.push(Content::model("context 1"));
304        pc.push(Content::model("context 2"));
305        assert!(!pc.is_empty());
306
307        let (contents, prompt) = pc.drain();
308        assert_eq!(contents.len(), 2);
309        assert!(!prompt);
310        assert!(pc.is_empty());
311    }
312
313    #[test]
314    fn pending_context_extend() {
315        let pc = PendingContext::new();
316        pc.extend(vec![
317            Content::model("a"),
318            Content::model("b"),
319            Content::model("c"),
320        ]);
321        let (contents, _) = pc.drain();
322        assert_eq!(contents.len(), 3);
323    }
324
325    #[test]
326    fn pending_context_prompt_flag() {
327        let pc = PendingContext::new();
328        pc.push(Content::model("ctx"));
329        pc.set_prompt();
330        assert!(!pc.is_empty());
331
332        let (contents, prompt) = pc.drain();
333        assert_eq!(contents.len(), 1);
334        assert!(prompt);
335        assert!(pc.is_empty());
336    }
337
338    #[test]
339    fn pending_context_drain_clears() {
340        let pc = PendingContext::new();
341        pc.push(Content::model("a"));
342        pc.set_prompt();
343        let _ = pc.drain();
344
345        // Second drain should be empty
346        let (contents, prompt) = pc.drain();
347        assert!(contents.is_empty());
348        assert!(!prompt);
349    }
350
351    #[tokio::test]
352    async fn deferred_writer_flushes_on_send_audio() {
353        let inner = Arc::new(CountingWriter::new());
354        let pending = Arc::new(PendingContext::new());
355        let writer = DeferredWriter::new(inner.clone(), pending.clone());
356
357        pending.push(Content::model("steering context"));
358        pending.push(Content::model("phase instruction"));
359
360        writer.send_audio(vec![0u8; 100].into()).await.unwrap();
361
362        // Should have flushed: 1 client_content + 1 audio
363        assert_eq!(inner.client_content_count.load(Ordering::SeqCst), 1);
364        assert_eq!(inner.audio_count.load(Ordering::SeqCst), 1);
365        assert!(pending.is_empty());
366    }
367
368    #[tokio::test]
369    async fn deferred_writer_flushes_on_send_text() {
370        let inner = Arc::new(CountingWriter::new());
371        let pending = Arc::new(PendingContext::new());
372        let writer = DeferredWriter::new(inner.clone(), pending.clone());
373
374        pending.push(Content::model("context"));
375
376        writer.send_text("hello".into()).await.unwrap();
377
378        assert_eq!(inner.client_content_count.load(Ordering::SeqCst), 1);
379        assert_eq!(inner.text_count.load(Ordering::SeqCst), 1);
380    }
381
382    #[tokio::test]
383    async fn deferred_writer_flushes_on_send_video() {
384        let inner = Arc::new(CountingWriter::new());
385        let pending = Arc::new(PendingContext::new());
386        let writer = DeferredWriter::new(inner.clone(), pending.clone());
387
388        pending.push(Content::model("context"));
389
390        writer.send_video(vec![0xFFu8; 50].into()).await.unwrap();
391
392        assert_eq!(inner.client_content_count.load(Ordering::SeqCst), 1);
393        assert_eq!(inner.video_count.load(Ordering::SeqCst), 1);
394    }
395
396    #[tokio::test]
397    async fn deferred_writer_no_flush_when_empty() {
398        let inner = Arc::new(CountingWriter::new());
399        let pending = Arc::new(PendingContext::new());
400        let writer = DeferredWriter::new(inner.clone(), pending.clone());
401
402        // No pending context — should just send audio, no client_content
403        writer.send_audio(vec![0u8; 100].into()).await.unwrap();
404
405        assert_eq!(inner.client_content_count.load(Ordering::SeqCst), 0);
406        assert_eq!(inner.audio_count.load(Ordering::SeqCst), 1);
407    }
408
409    #[tokio::test]
410    async fn deferred_writer_keeps_prompt_pending_on_user_audio() {
411        let inner = Arc::new(CountingWriter::new());
412        let pending = Arc::new(PendingContext::new());
413        let writer = DeferredWriter::new(inner.clone(), pending.clone());
414
415        pending.push(Content::model("repair nudge"));
416        pending.set_prompt();
417
418        writer.send_audio(vec![0u8; 100].into()).await.unwrap();
419
420        // User audio only flushes context. Prompt remains armed until an
421        // explicit idle/playback-drained flush.
422        assert_eq!(inner.client_content_count.load(Ordering::SeqCst), 1);
423        assert_eq!(inner.audio_count.load(Ordering::SeqCst), 1);
424        assert!(!pending.is_empty());
425        assert!(pending.take_prompt());
426    }
427
428    #[tokio::test]
429    async fn deferred_writer_does_not_flush_on_tool_response() {
430        let inner = Arc::new(CountingWriter::new());
431        let pending = Arc::new(PendingContext::new());
432        let writer = DeferredWriter::new(inner.clone(), pending.clone());
433
434        pending.push(Content::model("context"));
435
436        writer.send_tool_response(vec![]).await.unwrap();
437
438        // Tool response should NOT flush — context still pending
439        assert_eq!(inner.client_content_count.load(Ordering::SeqCst), 0);
440        assert!(!pending.is_empty());
441    }
442
443    #[tokio::test]
444    async fn deferred_writer_client_content_passes_through() {
445        let inner = Arc::new(CountingWriter::new());
446        let pending = Arc::new(PendingContext::new());
447        let writer = DeferredWriter::new(inner.clone(), pending.clone());
448
449        pending.push(Content::model("queued context"));
450
451        // Explicit client_content should pass through without flushing
452        writer
453            .send_client_content(vec![Content::user("explicit")], true)
454            .await
455            .unwrap();
456
457        assert_eq!(inner.client_content_count.load(Ordering::SeqCst), 1);
458        // Queued context still pending
459        assert!(!pending.is_empty());
460    }
461}