1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::time::Instant;
15
16use crate::clock::SharedClock;
17
18use gemini_genai_rs::prelude::{SessionEvent, SessionPhase};
19use parking_lot::Mutex;
20
21use crate::state::State;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SessionType {
30 AudioOnly,
32 AudioVideo,
34}
35
36pub struct SessionSignals {
50 state: State,
51 start: Instant,
53 clock: SharedClock,
55 connected_at_ns: AtomicU64,
57 is_connected: AtomicBool,
59 last_activity_ns: AtomicU64,
61 has_video: AtomicBool,
63 go_away_at: Mutex<Option<Instant>>,
65 latest_resume_handle: Mutex<Option<String>>,
67}
68
69impl SessionSignals {
70 pub fn new(state: State) -> Self {
72 let clock = state.clock();
73 Self {
74 state,
75 start: clock.now(),
76 clock,
77 connected_at_ns: AtomicU64::new(0),
78 is_connected: AtomicBool::new(false),
79 last_activity_ns: AtomicU64::new(0),
80 has_video: AtomicBool::new(false),
81 go_away_at: Mutex::new(None),
82 latest_resume_handle: Mutex::new(None),
83 }
84 }
85
86 pub fn on_event(&self, event: &SessionEvent) {
93 match event {
94 SessionEvent::Connected => {
95 let now_ns = self.elapsed_ns();
96 self.connected_at_ns.store(now_ns, Ordering::Relaxed);
97 self.is_connected.store(true, Ordering::Relaxed);
98 self.last_activity_ns.store(now_ns, Ordering::Relaxed);
99 let _ = self.state.session().set("connected_at_ms", 0u64);
100 let _ = self.state.session().set("interrupt_count", 0u64);
101 let _ = self.state.session().set("error_count", 0u64);
102 let _ = self.state.session().set("is_user_speaking", false);
103 let _ = self.state.session().set("is_model_speaking", false);
104 let _ = self.state.session().set("go_away_received", false);
105 let _ = self.state.session().set("resumable", false);
106 let _ = self.state.session().set("session_type", "audio_only");
107 }
108
109 SessionEvent::VoiceActivityStart => {
110 let _ = self.state.session().set("is_user_speaking", true);
111 self.touch_activity();
112 }
113
114 SessionEvent::VoiceActivityEnd => {
115 let _ = self.state.session().set("is_user_speaking", false);
116 self.touch_activity();
117 }
118
119 SessionEvent::Interrupted => {
120 let count: u64 = self.state.session().get("interrupt_count").unwrap_or(0);
121 let _ = self.state.session().set("interrupt_count", count + 1);
122 self.touch_activity();
123 }
124
125 SessionEvent::Error(msg) => {
126 let count: u64 = self.state.session().get("error_count").unwrap_or(0);
127 let _ = self.state.session().set("error_count", count + 1);
128 let _ = self.state.session().set("last_error", msg.to_string());
129 }
130
131 SessionEvent::PhaseChanged(phase) => {
132 let _ = self
133 .state
134 .session()
135 .set("is_model_speaking", *phase == SessionPhase::ModelSpeaking);
136 let _ = self.state.session().set("phase", phase.to_string());
137 self.touch_activity();
138 }
139
140 SessionEvent::GoAway(time_left) => {
141 let _ = self.state.session().set("go_away_received", true);
142 if let Some(tl) = time_left {
143 *self.go_away_at.lock() = Some(self.clock.now() + *tl);
144 let _ = self
145 .state
146 .session()
147 .set("go_away_time_left_ms", tl.as_millis() as u64);
148 }
149 }
150
151 SessionEvent::SessionResumeUpdate(info) => {
152 *self.latest_resume_handle.lock() = Some(info.handle.clone());
153 let _ = self.state.session().set("resumable", info.resumable);
154 if let Some(ref idx) = info.last_consumed_index {
155 let _ = self
156 .state
157 .session()
158 .set("last_consumed_client_index", idx.clone());
159 }
160 }
161
162 SessionEvent::Usage(usage) => {
163 if let Some(total) = usage.total_token_count {
164 let _ = self.state.session().set("total_token_count", total);
165 }
166 if let Some(prompt) = usage.prompt_token_count {
167 let _ = self.state.session().set("prompt_token_count", prompt);
168 }
169 if let Some(response) = usage.response_token_count {
170 let _ = self.state.session().set("response_token_count", response);
171 }
172 if let Some(cached) = usage.cached_content_token_count {
173 let _ = self
174 .state
175 .session()
176 .set("cached_content_token_count", cached);
177 }
178 if let Some(thoughts) = usage.thoughts_token_count {
179 let _ = self.state.session().set("thoughts_token_count", thoughts);
180 }
181 }
182
183 SessionEvent::GenerationComplete => {
184 }
186
187 SessionEvent::InputTranscription(text) => {
188 let _ = self
189 .state
190 .session()
191 .set("last_input_transcription", text.clone());
192 self.touch_activity();
193 }
194
195 SessionEvent::OutputTranscription(text) => {
196 let _ = self
197 .state
198 .session()
199 .set("last_output_transcription", text.clone());
200 self.touch_activity();
201 }
202
203 SessionEvent::AudioData(_)
204 | SessionEvent::TextDelta(_)
205 | SessionEvent::TextComplete(_) => {
206 self.touch_activity();
209 }
210
211 SessionEvent::TurnComplete => {
212 self.touch_activity();
213 }
214
215 SessionEvent::Disconnected(_reason) => {
216 self.is_connected.store(false, Ordering::Relaxed);
217 let _ = self.state.session().set("disconnected", true);
218 }
219
220 _ => {}
221 }
222 }
223
224 pub fn flush_timing(&self) {
230 let last_activity = self.last_activity_ns.load(Ordering::Relaxed);
231 if last_activity > 0 {
232 let now_ns = self.elapsed_ns();
233 let silence_ms = now_ns.saturating_sub(last_activity) / 1_000_000;
234 let _ = self.state.session().set("silence_ms", silence_ms);
235 }
236
237 if self.is_connected.load(Ordering::Relaxed) {
238 let connected_ns = self.connected_at_ns.load(Ordering::Relaxed);
239 let now_ns = self.elapsed_ns();
240 let elapsed_ms = now_ns.saturating_sub(connected_ns) / 1_000_000;
241 let _ = self.state.session().set("elapsed_ms", elapsed_ms);
242
243 let limit_ms: u64 = match self.session_type() {
244 SessionType::AudioOnly => 15 * 60 * 1000,
245 SessionType::AudioVideo => 2 * 60 * 1000,
246 };
247 let remaining = limit_ms.saturating_sub(elapsed_ms);
248 let _ = self.state.session().set("remaining_budget_ms", remaining);
249 }
250 }
251
252 #[inline]
253 pub(crate) fn touch_activity(&self) {
254 self.last_activity_ns
255 .store(self.elapsed_ns(), Ordering::Relaxed);
256 }
257
258 #[inline]
259 fn elapsed_ns(&self) -> u64 {
260 self.clock.since(self.start).as_nanos() as u64
261 }
262
263 pub fn record_response_latency(&self, latency: std::time::Duration) {
276 let _ = self
277 .state
278 .session()
279 .set("last_response_latency_ms", latency.as_millis() as u64);
280 self.touch_activity();
281 }
282
283 pub fn session_type(&self) -> SessionType {
285 if self.has_video.load(Ordering::Relaxed) {
286 SessionType::AudioVideo
287 } else {
288 SessionType::AudioOnly
289 }
290 }
291
292 pub fn latest_resume_handle(&self) -> Option<String> {
294 self.latest_resume_handle.lock().clone()
295 }
296
297 pub fn mark_video_sent(&self) {
299 if !self.has_video.swap(true, Ordering::Relaxed) {
300 let _ = self.state.session().set("session_type", "audio_video");
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use bytes::Bytes;
309 use gemini_genai_rs::prelude::SessionEvent;
310
311 fn signals() -> SessionSignals {
312 SessionSignals::new(State::new())
313 }
314
315 #[test]
316 fn connected_initializes_state() {
317 let s = signals();
318 s.on_event(&SessionEvent::Connected);
319
320 assert_eq!(s.state.session().get::<u64>("connected_at_ms"), Some(0));
321 assert_eq!(s.state.session().get::<u64>("interrupt_count"), Some(0));
322 assert_eq!(s.state.session().get::<u64>("error_count"), Some(0));
323 assert_eq!(
324 s.state.session().get::<bool>("is_user_speaking"),
325 Some(false)
326 );
327 assert_eq!(
328 s.state.session().get::<bool>("is_model_speaking"),
329 Some(false)
330 );
331 assert_eq!(
332 s.state.session().get::<bool>("go_away_received"),
333 Some(false)
334 );
335 assert_eq!(s.state.session().get::<bool>("resumable"), Some(false));
336 assert_eq!(
337 s.state.session().get::<String>("session_type"),
338 Some("audio_only".to_string())
339 );
340 assert!(s.is_connected.load(Ordering::Relaxed));
341 }
342
343 #[test]
344 fn voice_activity_toggles_user_speaking() {
345 let s = signals();
346 s.on_event(&SessionEvent::Connected);
347 s.on_event(&SessionEvent::VoiceActivityStart);
348 assert_eq!(
349 s.state.session().get::<bool>("is_user_speaking"),
350 Some(true)
351 );
352 s.on_event(&SessionEvent::VoiceActivityEnd);
353 assert_eq!(
354 s.state.session().get::<bool>("is_user_speaking"),
355 Some(false)
356 );
357 }
358
359 #[test]
360 fn interrupted_increments_count() {
361 let s = signals();
362 s.on_event(&SessionEvent::Connected);
363 s.on_event(&SessionEvent::Interrupted);
364 assert_eq!(s.state.session().get::<u64>("interrupt_count"), Some(1));
365 s.on_event(&SessionEvent::Interrupted);
366 assert_eq!(s.state.session().get::<u64>("interrupt_count"), Some(2));
367 s.on_event(&SessionEvent::Interrupted);
368 assert_eq!(s.state.session().get::<u64>("interrupt_count"), Some(3));
369 }
370
371 #[test]
372 fn error_increments_count() {
373 let s = signals();
374 s.on_event(&SessionEvent::Connected);
375 let err = |m: &str| {
376 SessionEvent::Error(gemini_genai_rs::session::SessionError::WebSocket(
377 gemini_genai_rs::session::WebSocketError::ProtocolError(m.into()),
378 ))
379 };
380 s.on_event(&err("oops"));
381 assert_eq!(s.state.session().get::<u64>("error_count"), Some(1));
382 let last: String = s.state.session().get("last_error").unwrap();
383 assert!(last.contains("oops"), "{last}");
384 s.on_event(&err("oops2"));
385 assert_eq!(s.state.session().get::<u64>("error_count"), Some(2));
386 let last: String = s.state.session().get("last_error").unwrap();
387 assert!(last.contains("oops2"), "{last}");
388 }
389
390 #[test]
391 fn phase_changed_sets_model_speaking() {
392 let s = signals();
393 s.on_event(&SessionEvent::Connected);
394 s.on_event(&SessionEvent::PhaseChanged(SessionPhase::ModelSpeaking));
395 assert_eq!(
396 s.state.session().get::<bool>("is_model_speaking"),
397 Some(true)
398 );
399 assert_eq!(
400 s.state.session().get::<String>("phase"),
401 Some("ModelSpeaking".into())
402 );
403 s.on_event(&SessionEvent::PhaseChanged(SessionPhase::Active));
404 assert_eq!(
405 s.state.session().get::<bool>("is_model_speaking"),
406 Some(false)
407 );
408 assert_eq!(
409 s.state.session().get::<String>("phase"),
410 Some("Active".into())
411 );
412 }
413
414 #[test]
415 fn go_away_sets_state() {
416 let s = signals();
417 s.on_event(&SessionEvent::Connected);
418 s.on_event(&SessionEvent::GoAway(Some(std::time::Duration::from_secs(
419 60,
420 ))));
421 assert_eq!(
422 s.state.session().get::<bool>("go_away_received"),
423 Some(true)
424 );
425 assert_eq!(
426 s.state.session().get::<u64>("go_away_time_left_ms"),
427 Some(60_000)
428 );
429 assert!(s.go_away_at.lock().is_some());
430 }
431
432 #[test]
433 fn go_away_without_time_left() {
434 let s = signals();
435 s.on_event(&SessionEvent::Connected);
436 s.on_event(&SessionEvent::GoAway(None));
437 assert_eq!(
438 s.state.session().get::<bool>("go_away_received"),
439 Some(true)
440 );
441 assert_eq!(s.state.session().get::<u64>("go_away_time_left_ms"), None);
442 assert!(s.go_away_at.lock().is_none());
443 }
444
445 #[test]
446 fn session_resume_handle_stored() {
447 let s = signals();
448 s.on_event(&SessionEvent::Connected);
449 s.on_event(&SessionEvent::SessionResumeUpdate(
450 gemini_genai_rs::session::ResumeInfo {
451 handle: "handle-abc".into(),
452 resumable: true,
453 last_consumed_index: None,
454 },
455 ));
456 assert_eq!(s.state.session().get::<bool>("resumable"), Some(true));
457 assert_eq!(s.latest_resume_handle(), Some("handle-abc".to_string()));
458 }
459
460 #[test]
461 fn transcription_stores_last() {
462 let s = signals();
463 s.on_event(&SessionEvent::Connected);
464 s.on_event(&SessionEvent::InputTranscription("hello".into()));
465 assert_eq!(
466 s.state.session().get::<String>("last_input_transcription"),
467 Some("hello".into())
468 );
469 s.on_event(&SessionEvent::OutputTranscription("hi there".into()));
470 assert_eq!(
471 s.state.session().get::<String>("last_output_transcription"),
472 Some("hi there".into())
473 );
474 s.on_event(&SessionEvent::InputTranscription("bye".into()));
475 assert_eq!(
476 s.state.session().get::<String>("last_input_transcription"),
477 Some("bye".into())
478 );
479 }
480
481 #[test]
482 fn session_type_defaults_to_audio_only() {
483 let s = signals();
484 assert_eq!(s.session_type(), SessionType::AudioOnly);
485 }
486
487 #[test]
488 fn mark_video_sent_changes_session_type() {
489 let s = signals();
490 s.on_event(&SessionEvent::Connected);
491 assert_eq!(s.session_type(), SessionType::AudioOnly);
492 s.mark_video_sent();
493 assert_eq!(s.session_type(), SessionType::AudioVideo);
494 assert_eq!(
495 s.state.session().get::<String>("session_type"),
496 Some("audio_video".into())
497 );
498 }
499
500 #[test]
501 fn mark_video_sent_idempotent() {
502 let s = signals();
503 s.on_event(&SessionEvent::Connected);
504 s.mark_video_sent();
505 s.mark_video_sent();
506 assert_eq!(s.session_type(), SessionType::AudioVideo);
507 }
508
509 #[test]
510 fn flush_timing_after_connected() {
511 let s = signals();
512 s.on_event(&SessionEvent::Connected);
513 s.flush_timing();
514 let elapsed: u64 = s.state.session().get("elapsed_ms").unwrap_or(0);
515 assert!(elapsed < 100, "elapsed should be near zero, got {elapsed}");
516 let remaining: u64 = s.state.session().get("remaining_budget_ms").unwrap();
517 let limit = 15 * 60 * 1000u64;
518 assert!(
519 remaining > limit - 1000,
520 "remaining should be near limit, got {remaining}"
521 );
522 }
523
524 #[test]
525 fn flush_timing_respects_video_budget() {
526 let s = signals();
527 s.on_event(&SessionEvent::Connected);
528 s.flush_timing();
529 let remaining_audio: u64 = s.state.session().get("remaining_budget_ms").unwrap();
530 assert!(remaining_audio > 14 * 60 * 1000);
531 s.mark_video_sent();
532 s.flush_timing();
533 let remaining_video: u64 = s.state.session().get("remaining_budget_ms").unwrap();
534 assert!(
535 remaining_video <= 2 * 60 * 1000,
536 "video remaining should be <= 120_000, got {remaining_video}"
537 );
538 }
539
540 #[test]
541 fn latest_resume_handle_initially_none() {
542 let s = signals();
543 assert_eq!(s.latest_resume_handle(), None);
544 }
545
546 #[test]
547 fn latest_resume_handle_updates() {
548 let s = signals();
549 s.on_event(&SessionEvent::SessionResumeUpdate(
550 gemini_genai_rs::session::ResumeInfo {
551 handle: "h1".into(),
552 resumable: true,
553 last_consumed_index: None,
554 },
555 ));
556 assert_eq!(s.latest_resume_handle(), Some("h1".to_string()));
557 s.on_event(&SessionEvent::SessionResumeUpdate(
558 gemini_genai_rs::session::ResumeInfo {
559 handle: "h2".into(),
560 resumable: true,
561 last_consumed_index: Some("5".into()),
562 },
563 ));
564 assert_eq!(s.latest_resume_handle(), Some("h2".to_string()));
565 }
566
567 #[test]
568 fn silence_ms_tracked() {
569 let s = signals();
570 s.on_event(&SessionEvent::Connected);
571 s.flush_timing();
572 let silence: u64 = s.state.session().get("silence_ms").unwrap_or(u64::MAX);
573 assert!(silence < 100, "silence should be near zero, got {silence}");
574 }
575
576 #[test]
577 fn audio_data_updates_activity() {
578 let s = signals();
579 s.on_event(&SessionEvent::Connected);
580 s.on_event(&SessionEvent::AudioData(Bytes::from_static(b"pcm")));
581 s.flush_timing();
582 let silence: u64 = s.state.session().get("silence_ms").unwrap_or(u64::MAX);
583 assert!(silence < 100);
584 }
585
586 #[test]
587 fn turn_complete_updates_activity() {
588 let s = signals();
589 s.on_event(&SessionEvent::Connected);
590 s.on_event(&SessionEvent::TurnComplete);
591 s.flush_timing();
592 let silence: u64 = s.state.session().get("silence_ms").unwrap_or(u64::MAX);
593 assert!(silence < 100);
594 }
595
596 #[test]
597 fn text_complete_updates_activity() {
598 let s = signals();
599 s.on_event(&SessionEvent::Connected);
600 s.on_event(&SessionEvent::TextComplete("done".into()));
601 s.flush_timing();
602 let silence: u64 = s.state.session().get("silence_ms").unwrap_or(u64::MAX);
603 assert!(silence < 100);
604 }
605
606 #[test]
607 fn disconnected_clears_connected_and_sets_flag() {
608 let s = signals();
609 s.on_event(&SessionEvent::Connected);
610 assert!(s.is_connected.load(Ordering::Relaxed));
611 s.on_event(&SessionEvent::Disconnected(Some("server closed".into())));
612 assert!(!s.is_connected.load(Ordering::Relaxed));
613 assert_eq!(s.state.session().get::<bool>("disconnected"), Some(true));
614 }
615
616 #[test]
617 fn disconnected_without_reason() {
618 let s = signals();
619 s.on_event(&SessionEvent::Connected);
620 s.on_event(&SessionEvent::Disconnected(None));
621 assert!(!s.is_connected.load(Ordering::Relaxed));
622 assert_eq!(s.state.session().get::<bool>("disconnected"), Some(true));
623 }
624}