gemini_genai_rs/session/
state.rs

1//! Session phase finite state machine and shared session state.
2//!
3//! [`SessionPhase`] — lifecycle phase enum with validated transitions.
4//! [`SessionState`] — shared state struct (phase, turns, resume handle).
5//!
6//! Invalid transitions return `Err(SessionError::InvalidTransition)`.
7//! The phase is observable via a `watch::Receiver<SessionPhase>` channel.
8
9use std::fmt;
10use std::time::Instant;
11use tokio::sync::{broadcast, watch};
12
13use super::errors::SessionError;
14use super::events::{SessionEvent, Turn};
15
16/// The lifecycle phase of a Gemini Live session.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum SessionPhase {
19    /// Not connected to the server.
20    Disconnected,
21    /// WebSocket connection in progress.
22    Connecting,
23    /// Setup message sent, awaiting setupComplete.
24    SetupSent,
25    /// Session is active and ready for interaction.
26    Active,
27    /// User is currently speaking (client VAD or server signal).
28    UserSpeaking,
29    /// Model is currently generating a response.
30    ModelSpeaking,
31    /// Model was interrupted by user barge-in.
32    Interrupted,
33    /// Model requested tool calls, awaiting dispatch.
34    ToolCallPending,
35    /// Tool calls are executing.
36    ToolCallExecuting,
37    /// Session is shutting down gracefully.
38    Disconnecting,
39}
40
41impl fmt::Display for SessionPhase {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Disconnected => write!(f, "Disconnected"),
45            Self::Connecting => write!(f, "Connecting"),
46            Self::SetupSent => write!(f, "SetupSent"),
47            Self::Active => write!(f, "Active"),
48            Self::UserSpeaking => write!(f, "UserSpeaking"),
49            Self::ModelSpeaking => write!(f, "ModelSpeaking"),
50            Self::Interrupted => write!(f, "Interrupted"),
51            Self::ToolCallPending => write!(f, "ToolCallPending"),
52            Self::ToolCallExecuting => write!(f, "ToolCallExecuting"),
53            Self::Disconnecting => write!(f, "Disconnecting"),
54        }
55    }
56}
57
58impl SessionPhase {
59    /// Check whether a transition from this phase to `to` is valid.
60    pub fn can_transition_to(&self, to: &SessionPhase) -> bool {
61        matches!(
62            (self, to),
63            // Connection lifecycle
64            (SessionPhase::Disconnected, SessionPhase::Connecting)
65                | (SessionPhase::Connecting, SessionPhase::SetupSent)
66                | (SessionPhase::SetupSent, SessionPhase::Active)
67                // Conversation flow
68                | (SessionPhase::Active, SessionPhase::UserSpeaking)
69                | (SessionPhase::Active, SessionPhase::ModelSpeaking)
70                | (SessionPhase::Active, SessionPhase::ToolCallPending)
71                // User speaking transitions
72                | (SessionPhase::UserSpeaking, SessionPhase::Active)
73                | (SessionPhase::UserSpeaking, SessionPhase::ModelSpeaking)
74                // Model speaking transitions
75                | (SessionPhase::ModelSpeaking, SessionPhase::Active)
76                | (SessionPhase::ModelSpeaking, SessionPhase::Interrupted)
77                | (SessionPhase::ModelSpeaking, SessionPhase::ToolCallPending)
78                // Barge-in recovery
79                | (SessionPhase::Interrupted, SessionPhase::Active)
80                | (SessionPhase::Interrupted, SessionPhase::UserSpeaking)
81                // Tool flow
82                | (SessionPhase::ToolCallPending, SessionPhase::ToolCallExecuting)
83                | (SessionPhase::ToolCallExecuting, SessionPhase::Active)
84                | (SessionPhase::ToolCallExecuting, SessionPhase::ModelSpeaking)
85                // Graceful shutdown
86                | (SessionPhase::Active, SessionPhase::Disconnecting)
87                | (SessionPhase::UserSpeaking, SessionPhase::Disconnecting)
88                | (SessionPhase::ModelSpeaking, SessionPhase::Disconnecting)
89                | (SessionPhase::Interrupted, SessionPhase::Disconnecting)
90                | (SessionPhase::ToolCallPending, SessionPhase::Disconnecting)
91                | (SessionPhase::ToolCallExecuting, SessionPhase::Disconnecting)
92                | (SessionPhase::Disconnecting, SessionPhase::Disconnected)
93                // Force-disconnect from any state
94                | (_, SessionPhase::Disconnected)
95        )
96    }
97}
98
99// ---------------------------------------------------------------------------
100// Session state (shared, read-mostly)
101// ---------------------------------------------------------------------------
102
103/// Shared session state, accessible from the SessionHandle.
104#[derive(Debug)]
105pub struct SessionState {
106    /// Current phase (updated atomically via watch channel).
107    phase_tx: watch::Sender<SessionPhase>,
108    /// Optional broadcast sender to emit `PhaseChanged` events on transitions.
109    event_tx: Option<broadcast::Sender<SessionEvent>>,
110    /// Session ID.
111    pub session_id: String,
112    /// Session resume handle from server.
113    pub resume_handle: parking_lot::Mutex<Option<String>>,
114    /// Turn history.
115    pub turns: parking_lot::Mutex<Vec<Turn>>,
116    /// Current in-progress turn.
117    pub current_turn: parking_lot::Mutex<Option<Turn>>,
118}
119
120impl SessionState {
121    /// Create new session state (no `PhaseChanged` event emission).
122    pub fn new(phase_tx: watch::Sender<SessionPhase>) -> Self {
123        Self {
124            phase_tx,
125            event_tx: None,
126            session_id: uuid::Uuid::new_v4().to_string(),
127            resume_handle: parking_lot::Mutex::new(None),
128            turns: parking_lot::Mutex::new(Vec::new()),
129            current_turn: parking_lot::Mutex::new(None),
130        }
131    }
132
133    /// Create new session state that emits `PhaseChanged` events on transitions.
134    pub fn with_events(
135        phase_tx: watch::Sender<SessionPhase>,
136        event_tx: broadcast::Sender<SessionEvent>,
137    ) -> Self {
138        Self {
139            phase_tx,
140            event_tx: Some(event_tx),
141            session_id: uuid::Uuid::new_v4().to_string(),
142            resume_handle: parking_lot::Mutex::new(None),
143            turns: parking_lot::Mutex::new(Vec::new()),
144            current_turn: parking_lot::Mutex::new(None),
145        }
146    }
147
148    /// Get the current phase.
149    pub fn phase(&self) -> SessionPhase {
150        *self.phase_tx.borrow()
151    }
152
153    /// Attempt a validated phase transition.
154    ///
155    /// If an `event_tx` was provided via [`with_events`](Self::with_events),
156    /// a [`SessionEvent::PhaseChanged`] is broadcast after a successful transition.
157    pub fn transition_to(&self, to: SessionPhase) -> Result<SessionPhase, SessionError> {
158        let from = self.phase();
159        if !from.can_transition_to(&to) {
160            return Err(SessionError::InvalidTransition { from, to });
161        }
162        self.phase_tx.send_replace(to);
163        if let Some(ref tx) = self.event_tx {
164            let _ = tx.send(SessionEvent::PhaseChanged(to));
165        }
166        Ok(to)
167    }
168
169    /// Force transition (bypasses validation — use only for disconnect).
170    pub fn force_phase(&self, phase: SessionPhase) {
171        self.phase_tx.send_replace(phase);
172    }
173
174    /// Start a new turn.
175    pub fn start_turn(&self) {
176        let mut current = self.current_turn.lock();
177        if let Some(prev) = current.take() {
178            self.turns.lock().push(prev);
179        }
180        *current = Some(Turn::new());
181    }
182
183    /// Append text to the current turn.
184    pub fn append_text(&self, text: &str) {
185        if let Some(turn) = self.current_turn.lock().as_mut() {
186            turn.text.push_str(text);
187        }
188    }
189
190    /// Mark audio received in the current turn.
191    pub fn mark_audio(&self) {
192        if let Some(turn) = self.current_turn.lock().as_mut() {
193            turn.has_audio = true;
194        }
195    }
196
197    /// Complete the current turn.
198    pub fn complete_turn(&self) -> Option<Turn> {
199        let mut current = self.current_turn.lock();
200        if let Some(turn) = current.as_mut() {
201            turn.completed_at = Some(Instant::now());
202        }
203        let completed = current.take();
204        if let Some(ref t) = completed {
205            self.turns.lock().push(t.clone());
206        }
207        completed
208    }
209
210    /// Mark the current turn as interrupted.
211    pub fn interrupt_turn(&self) {
212        if let Some(turn) = self.current_turn.lock().as_mut() {
213            turn.interrupted = true;
214            turn.completed_at = Some(Instant::now());
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn valid_connection_lifecycle() {
225        assert!(SessionPhase::Disconnected.can_transition_to(&SessionPhase::Connecting));
226        assert!(SessionPhase::Connecting.can_transition_to(&SessionPhase::SetupSent));
227        assert!(SessionPhase::SetupSent.can_transition_to(&SessionPhase::Active));
228    }
229
230    #[test]
231    fn valid_conversation_flow() {
232        assert!(SessionPhase::Active.can_transition_to(&SessionPhase::UserSpeaking));
233        assert!(SessionPhase::Active.can_transition_to(&SessionPhase::ModelSpeaking));
234        assert!(SessionPhase::UserSpeaking.can_transition_to(&SessionPhase::Active));
235        assert!(SessionPhase::ModelSpeaking.can_transition_to(&SessionPhase::Active));
236    }
237
238    #[test]
239    fn valid_barge_in() {
240        assert!(SessionPhase::ModelSpeaking.can_transition_to(&SessionPhase::Interrupted));
241        assert!(SessionPhase::Interrupted.can_transition_to(&SessionPhase::Active));
242        assert!(SessionPhase::Interrupted.can_transition_to(&SessionPhase::UserSpeaking));
243    }
244
245    #[test]
246    fn valid_tool_flow() {
247        assert!(SessionPhase::Active.can_transition_to(&SessionPhase::ToolCallPending));
248        assert!(SessionPhase::ModelSpeaking.can_transition_to(&SessionPhase::ToolCallPending));
249        assert!(SessionPhase::ToolCallPending.can_transition_to(&SessionPhase::ToolCallExecuting));
250        assert!(SessionPhase::ToolCallExecuting.can_transition_to(&SessionPhase::Active));
251        assert!(SessionPhase::ToolCallExecuting.can_transition_to(&SessionPhase::ModelSpeaking));
252    }
253
254    #[test]
255    fn valid_disconnect_from_any() {
256        let phases = [
257            SessionPhase::Disconnected,
258            SessionPhase::Connecting,
259            SessionPhase::SetupSent,
260            SessionPhase::Active,
261            SessionPhase::UserSpeaking,
262            SessionPhase::ModelSpeaking,
263            SessionPhase::Interrupted,
264            SessionPhase::ToolCallPending,
265            SessionPhase::ToolCallExecuting,
266            SessionPhase::Disconnecting,
267        ];
268
269        for phase in &phases {
270            assert!(
271                phase.can_transition_to(&SessionPhase::Disconnected),
272                "{phase} should be able to force-disconnect"
273            );
274        }
275    }
276
277    #[test]
278    fn invalid_transitions() {
279        assert!(!SessionPhase::Disconnected.can_transition_to(&SessionPhase::Active));
280        assert!(!SessionPhase::Connecting.can_transition_to(&SessionPhase::Active));
281        assert!(!SessionPhase::Active.can_transition_to(&SessionPhase::SetupSent));
282        assert!(!SessionPhase::UserSpeaking.can_transition_to(&SessionPhase::ToolCallExecuting));
283        assert!(!SessionPhase::Disconnecting.can_transition_to(&SessionPhase::Active));
284    }
285
286    #[test]
287    fn display_impl() {
288        assert_eq!(format!("{}", SessionPhase::Active), "Active");
289        assert_eq!(format!("{}", SessionPhase::ModelSpeaking), "ModelSpeaking");
290    }
291}