Skip to main content

adk_managed/
session_loop.rs

1//! Supervised session loop for the managed agent runtime.
2//!
3//! The [`SessionLoop`] is the core execution engine. It runs as a
4//! `tokio::spawn`ed background task, dequeues [`UserEvent`]s from an
5//! mpsc channel, processes each turn, and broadcasts [`SessionEvent`]s
6//! to stream subscribers.
7//!
8//! # Architecture
9//!
10//! The loop composes:
11//! - [`SequenceCounter`] — assigns monotonically increasing `seq` to each event
12//! - [`ToolParkingLot`] — parks on `custom_tool_use` until client delivers a result
13//! - [`CheckpointManager`] — atomic checkpoint after each event
14//! - `tokio::broadcast` — fan-out to stream subscribers
15//! - [`Runner`] — drives the agent through the real LLM
16//! - [`SessionUsageTracker`] — tracks per-turn and cumulative token usage
17//!
18//! # Control Flow
19//!
20//! ```text
21//! Dequeue UserEvent → emit status.running → invoke Runner
22//!   → for each output event: classify, map, assign seq, checkpoint, broadcast
23//!   → if custom tool call: park, wait for result, resume
24//!   → track usage → emit status.idle → loop
25//! ```
26//!
27//! # Interrupt and Pause
28//!
29//! - **Interrupt**: A [`CancellationToken`] signals the loop to stop at the next
30//!   boundary. On interrupt, the loop emits `status.idle` and exits.
31//! - **Pause/Resume**: A pause flag + [`Notify`] allow the loop to park until
32//!   resumed.
33
34use std::sync::Arc;
35
36use futures::StreamExt;
37use tokio::sync::{Mutex, Notify, RwLock, broadcast, mpsc};
38use tokio_util::sync::CancellationToken;
39use tracing::{debug, info, warn};
40
41#[cfg(feature = "memory")]
42use adk_core::Memory;
43use adk_core::{Agent, Content, Event, Part};
44use adk_runner::Runner;
45use adk_session::service::SessionService;
46
47use crate::checkpoint::{CheckpointManager, RunState};
48use crate::event_mapping::{RunnerOutput, custom_tool_use_id, map_runner_output, requires_parking};
49use crate::parking::ToolParkingLot;
50use crate::sequence::SequenceCounter;
51use crate::types::{
52    ContentBlock, RuntimeError, SessionEvent, SessionStatus, StopReason, UserEvent,
53};
54use crate::usage::{SessionUsageTracker, UsageReport};
55
56/// Supervised session loop — one per active session.
57///
58/// Runs as a background `tokio::spawn`ed task. Receives user events via an
59/// mpsc channel, processes each turn through the real Runner, and broadcasts
60/// session events via a `tokio::broadcast` channel.
61///
62/// # Example
63///
64/// ```rust,ignore
65/// use std::sync::Arc;
66/// use std::time::Duration;
67/// use tokio::sync::{broadcast, mpsc, Mutex, Notify};
68/// use tokio_util::sync::CancellationToken;
69/// use adk_managed::session_loop::SessionLoop;
70/// use adk_managed::parking::ToolParkingLot;
71///
72/// let (event_tx, event_rx) = mpsc::channel(64);
73/// let (broadcast_tx, _) = broadcast::channel(256);
74/// let cancel = CancellationToken::new();
75/// let parking = Arc::new(ToolParkingLot::new(Duration::from_secs(300)));
76///
77/// let loop_handle = SessionLoop::new(
78///     "session_001".to_string(),
79///     event_rx,
80///     broadcast_tx,
81///     parking,
82///     cancel.clone(),
83///     agent,
84///     session_service,
85/// );
86///
87/// let handle = tokio::spawn(loop_handle.run());
88/// // Send events via event_tx...
89/// ```
90pub struct SessionLoop {
91    /// Session identifier.
92    session_id: String,
93    /// Input channel for user events.
94    event_rx: mpsc::Receiver<UserEvent>,
95    /// Broadcast channel for session events (fan-out to subscribers).
96    event_tx: broadcast::Sender<SessionEvent>,
97    /// Monotonic sequence counter.
98    seq: SequenceCounter,
99    /// Custom tool parking lot.
100    parking: Arc<ToolParkingLot>,
101    /// Checkpoint manager for durable state (shared with ActiveSession for replay).
102    checkpoint: Arc<RwLock<CheckpointManager>>,
103    /// Cancellation token for interrupt handling.
104    cancel_token: CancellationToken,
105    /// Pause flag — when true, the loop parks until resumed.
106    pause_flag: Arc<Mutex<bool>>,
107    /// Notify used to wake the loop after resume.
108    pause_notify: Arc<Notify>,
109    /// The owner this session's Runner calls are made under.
110    ///
111    /// Defaults to the historical `managed` / `managed_user` constants so an existing loop
112    /// keeps working, and is replaced by [`SessionLoop::with_owner`] when the runtime knows
113    /// who the session belongs to. Hardcoding the constants is what put every managed session
114    /// in one namespace.
115    owner: (String, String),
116    /// Current session status.
117    ///
118    /// Shared with the public session handle when the runtime installs its own via
119    /// [`SessionLoop::with_shared_status`]. Without that, the handle reported `Queued` for
120    /// the whole life of a session because the loop wrote to a field the handle never read.
121    status: Arc<RwLock<SessionStatus>>,
122    /// The agent driving this session.
123    agent: Arc<dyn Agent>,
124    /// Session persistence backend (needed by the Runner).
125    session_service: Arc<dyn SessionService>,
126    /// Optional memory service for cross-session RAG injection.
127    #[cfg(feature = "memory")]
128    memory: Option<Arc<dyn Memory>>,
129    /// Accumulated usage tracking across all turns.
130    usage_tracker: SessionUsageTracker,
131}
132
133impl SessionLoop {
134    /// Create a new session loop.
135    ///
136    /// # Arguments
137    ///
138    /// * `session_id` - The session this loop operates on.
139    /// * `event_rx` - Receiver for incoming user events.
140    /// * `event_tx` - Broadcast sender for outgoing session events.
141    /// * `parking` - Shared parking lot for custom tool calls.
142    /// * `cancel_token` - Token to signal interrupt/shutdown.
143    /// * `agent` - The built agent to drive through the Runner.
144    /// * `session_service` - Session persistence for the Runner.
145    pub fn new(
146        session_id: String,
147        event_rx: mpsc::Receiver<UserEvent>,
148        event_tx: broadcast::Sender<SessionEvent>,
149        parking: Arc<ToolParkingLot>,
150        cancel_token: CancellationToken,
151        agent: Arc<dyn Agent>,
152        session_service: Arc<dyn SessionService>,
153    ) -> Self {
154        let checkpoint = Arc::new(RwLock::new(CheckpointManager::new(session_id.clone())));
155        Self {
156            session_id,
157            event_rx,
158            event_tx,
159            seq: SequenceCounter::default(),
160            parking,
161            checkpoint,
162            cancel_token,
163            pause_flag: Arc::new(Mutex::new(false)),
164            pause_notify: Arc::new(Notify::new()),
165            owner: ("managed".to_string(), "managed_user".to_string()),
166            status: Arc::new(RwLock::new(SessionStatus::Queued)),
167            agent,
168            session_service,
169            #[cfg(feature = "memory")]
170            memory: None,
171            usage_tracker: SessionUsageTracker::new(),
172        }
173    }
174
175    /// Create a session loop with custom pause controls (for external pause/resume).
176    ///
177    /// This allows the runtime to share the pause flag, notify, and checkpoint
178    /// with the session handle so that `pause()`, `resume()`, and `stream_events()`
179    /// (replay) work correctly against the same state the loop writes to.
180    #[cfg(feature = "memory")]
181    #[allow(clippy::too_many_arguments)]
182    pub fn with_pause_controls(
183        session_id: String,
184        event_rx: mpsc::Receiver<UserEvent>,
185        event_tx: broadcast::Sender<SessionEvent>,
186        parking: Arc<ToolParkingLot>,
187        cancel_token: CancellationToken,
188        pause_flag: Arc<Mutex<bool>>,
189        pause_notify: Arc<Notify>,
190        checkpoint: Arc<RwLock<CheckpointManager>>,
191        agent: Arc<dyn Agent>,
192        session_service: Arc<dyn SessionService>,
193        memory: Option<Arc<dyn Memory>>,
194    ) -> Self {
195        Self {
196            session_id,
197            event_rx,
198            event_tx,
199            seq: SequenceCounter::default(),
200            parking,
201            checkpoint,
202            cancel_token,
203            pause_flag,
204            pause_notify,
205            owner: ("managed".to_string(), "managed_user".to_string()),
206            status: Arc::new(RwLock::new(SessionStatus::Queued)),
207            agent,
208            session_service,
209            memory,
210            usage_tracker: SessionUsageTracker::new(),
211        }
212    }
213
214    /// Create a session loop with custom pause controls (for external pause/resume).
215    ///
216    /// See the `memory`-enabled variant for full documentation.
217    #[cfg(not(feature = "memory"))]
218    #[allow(clippy::too_many_arguments)]
219    pub fn with_pause_controls(
220        session_id: String,
221        event_rx: mpsc::Receiver<UserEvent>,
222        event_tx: broadcast::Sender<SessionEvent>,
223        parking: Arc<ToolParkingLot>,
224        cancel_token: CancellationToken,
225        pause_flag: Arc<Mutex<bool>>,
226        pause_notify: Arc<Notify>,
227        checkpoint: Arc<RwLock<CheckpointManager>>,
228        agent: Arc<dyn Agent>,
229        session_service: Arc<dyn SessionService>,
230    ) -> Self {
231        Self {
232            session_id,
233            event_rx,
234            event_tx,
235            seq: SequenceCounter::default(),
236            parking,
237            checkpoint,
238            cancel_token,
239            pause_flag,
240            pause_notify,
241            owner: ("managed".to_string(), "managed_user".to_string()),
242            status: Arc::new(RwLock::new(SessionStatus::Queued)),
243            agent,
244            session_service,
245            usage_tracker: SessionUsageTracker::new(),
246        }
247    }
248
249    /// Makes this loop's Runner calls under `owner`.
250    ///
251    /// Without this the loop used the constants `managed` / `managed_user`, so every managed
252    /// session shared one logical namespace and no session could be attributed to a caller.
253    ///
254    /// # Example
255    ///
256    /// ```rust,ignore
257    /// let session_loop = SessionLoop::with_pause_controls(/* ... */)
258    ///     .with_owner(owner.app_name(), owner.user_id());
259    /// ```
260    pub fn with_owner(mut self, app_name: impl Into<String>, user_id: impl Into<String>) -> Self {
261        self.owner = (app_name.into(), user_id.into());
262        self
263    }
264
265    /// Reports status into the handle the caller already holds.
266    ///
267    /// The runtime's `ActiveSession` owns the status a caller observes through
268    /// `ManagedAgentRuntime::status`. Installing it here is what makes normal
269    /// queued → running → idle transitions visible; without it the loop wrote to its own
270    /// field and the public handle stayed `Queued` through an entire session.
271    ///
272    /// # Example
273    ///
274    /// ```rust,ignore
275    /// let session_loop = SessionLoop::with_pause_controls(/* ... */)
276    ///     .with_shared_status(Arc::clone(&active.status));
277    /// ```
278    pub fn with_shared_status(mut self, status: Arc<RwLock<SessionStatus>>) -> Self {
279        self.status = status;
280        self
281    }
282
283    /// Get a clone of the pause flag for external control.
284    pub fn pause_flag(&self) -> Arc<Mutex<bool>> {
285        Arc::clone(&self.pause_flag)
286    }
287
288    /// Get a clone of the pause notify for external control.
289    pub fn pause_notify(&self) -> Arc<Notify> {
290        Arc::clone(&self.pause_notify)
291    }
292
293    /// Run the session loop (consumes self).
294    ///
295    /// This is the main loop body, designed to be `tokio::spawn`ed. It runs
296    /// until the input channel is closed or the cancellation token is triggered.
297    ///
298    /// # Returns
299    ///
300    /// Returns `Ok(())` on graceful shutdown, or `Err(RuntimeError)` if an
301    /// unrecoverable error occurs.
302    pub async fn run(mut self) -> Result<(), RuntimeError> {
303        info!(session_id = %self.session_id, "session loop started");
304
305        loop {
306            // Check for interrupt before waiting for the next event.
307            if self.cancel_token.is_cancelled() {
308                debug!(session_id = %self.session_id, "interrupt detected, shutting down");
309                self.emit_idle(Some(StopReason::EndTurn), None).await;
310                break;
311            }
312
313            // Check for pause.
314            self.check_pause().await;
315
316            // Wait for next event or cancellation.
317            let event = tokio::select! {
318                biased;
319                _ = self.cancel_token.cancelled() => {
320                    debug!(session_id = %self.session_id, "interrupted while waiting for event");
321                    self.emit_idle(Some(StopReason::EndTurn), None).await;
322                    break;
323                }
324                ev = self.event_rx.recv() => {
325                    match ev {
326                        Some(event) => event,
327                        None => {
328                            debug!(session_id = %self.session_id, "event channel closed, shutting down");
329                            break;
330                        }
331                    }
332                }
333            };
334
335            // Dispatch based on event type.
336            match event {
337                UserEvent::Message { content } => {
338                    self.process_turn(content).await?;
339                }
340                UserEvent::Interrupt {} => {
341                    debug!(session_id = %self.session_id, "user.interrupt received");
342                    self.emit_idle(Some(StopReason::EndTurn), None).await;
343                    break;
344                }
345                UserEvent::CustomToolResult { custom_tool_use_id, content } => {
346                    debug!(
347                        session_id = %self.session_id,
348                        tool_use_id = %custom_tool_use_id,
349                        "delivering custom tool result"
350                    );
351                    if let Err(e) = self.parking.deliver(&custom_tool_use_id, content).await {
352                        warn!(
353                            session_id = %self.session_id,
354                            error = %e,
355                            "failed to deliver custom tool result"
356                        );
357                    }
358                }
359                UserEvent::ToolConfirmation { tool_use_id, result, deny_message } => {
360                    debug!(
361                        session_id = %self.session_id,
362                        tool_use_id = %tool_use_id,
363                        result = ?result,
364                        "tool confirmation received, delivering to parking lot"
365                    );
366                    // Tool confirmation decisions are delivered via the parking lot.
367                    // The session loop parks on tool_use_id when a confirmation is
368                    // required (emitted as RequiresAction). The client sends back
369                    // Allow/Deny which we convert to a ContentBlock result.
370                    let content = match result {
371                        crate::types::ConfirmationResult::Allow => {
372                            vec![ContentBlock::Text {
373                                text: serde_json::json!({
374                                    "confirmation": "approved",
375                                    "tool_use_id": tool_use_id
376                                })
377                                .to_string(),
378                            }]
379                        }
380                        crate::types::ConfirmationResult::Deny => {
381                            let message = deny_message
382                                .unwrap_or_else(|| "Tool execution denied by user".to_string());
383                            vec![ContentBlock::Text {
384                                text: serde_json::json!({
385                                    "confirmation": "denied",
386                                    "tool_use_id": tool_use_id,
387                                    "reason": message
388                                })
389                                .to_string(),
390                            }]
391                        }
392                    };
393                    if let Err(e) = self.parking.deliver(&tool_use_id, content).await {
394                        warn!(
395                            session_id = %self.session_id,
396                            error = %e,
397                            "failed to deliver tool confirmation"
398                        );
399                    }
400                }
401                UserEvent::ToolResult { tool_use_id, .. } => {
402                    debug!(
403                        session_id = %self.session_id,
404                        tool_use_id = %tool_use_id,
405                        "tool result received (self-hosted only, not yet wired)"
406                    );
407                }
408                UserEvent::DefineOutcome { criteria } => {
409                    debug!(
410                        session_id = %self.session_id,
411                        criteria = %criteria,
412                        "outcome criteria defined"
413                    );
414                    // Stored for future use — outcome evaluation is a later task.
415                }
416            }
417        }
418
419        info!(session_id = %self.session_id, "session loop exited");
420        Ok(())
421    }
422
423    /// Process a single turn: emit status.running, invoke Runner, emit events, emit status.idle.
424    async fn process_turn(&mut self, content: Vec<ContentBlock>) -> Result<(), RuntimeError> {
425        // 1. Emit status.running
426        *self.status.write().await = SessionStatus::Running;
427        let running_event = SessionEvent::StatusRunning { seq: self.seq.next() };
428        self.emit_event(running_event).await;
429
430        // 2. Check interrupt before processing.
431        if self.check_interrupt() {
432            self.emit_idle(Some(StopReason::EndTurn), None).await;
433            return Ok(());
434        }
435
436        // 3. Build user Content from ContentBlocks
437        let user_content = self.build_user_content(&content);
438
439        // 4. Build and invoke the Runner
440        let runner = self.build_runner()?;
441
442        let event_stream = runner
443            .run_str(&self.owner.1, &self.session_id, user_content)
444            .await
445            .map_err(|e| RuntimeError::internal(format!("runner invocation failed: {e}")))?;
446
447        // 5. Consume event stream, mapping each event to SessionEvents
448        let mut turn_usage = UsageReport::default();
449        let mut custom_tool_ids = Vec::new();
450
451        futures::pin_mut!(event_stream);
452
453        while let Some(event_result) = event_stream.next().await {
454            // Check interrupt between events
455            if self.check_interrupt() {
456                self.emit_idle(Some(StopReason::EndTurn), None).await;
457                return Ok(());
458            }
459
460            match event_result {
461                Ok(event) => {
462                    self.process_runner_event(&event, &mut turn_usage, &mut custom_tool_ids).await;
463                }
464                Err(e) => {
465                    warn!(
466                        session_id = %self.session_id,
467                        error = %e,
468                        "runner event stream error"
469                    );
470                    let error_event = SessionEvent::Error {
471                        code: "runner_error".to_string(),
472                        message: e.to_string(),
473                        seq: self.seq.next(),
474                    };
475                    self.emit_event(error_event).await;
476                }
477            }
478        }
479
480        // 6. Track usage
481        // 6. Track usage
482        let turn_usage_report = if !turn_usage.is_empty() {
483            self.usage_tracker.record_turn(turn_usage.clone());
484            Some(turn_usage)
485        } else {
486            None
487        };
488
489        // 7. Determine stop reason
490        let stop_reason = if custom_tool_ids.is_empty() {
491            Some(StopReason::EndTurn)
492        } else {
493            Some(StopReason::RequiresAction { event_ids: custom_tool_ids })
494        };
495
496        // 8. Emit status.idle with usage from this turn
497        self.emit_idle(stop_reason, turn_usage_report).await;
498
499        Ok(())
500    }
501
502    /// Build a Runner instance for this turn.
503    fn build_runner(&self) -> Result<Runner, RuntimeError> {
504        #[allow(unused_mut)]
505        let mut builder = Runner::builder()
506            .app_name(self.owner.0.as_str())
507            .agent(Arc::clone(&self.agent))
508            .session_service(Arc::clone(&self.session_service))
509            .cancellation_token(self.cancel_token.clone());
510
511        #[cfg(feature = "memory")]
512        if let Some(ref memory) = self.memory {
513            builder = builder.memory_service(Arc::clone(memory));
514        }
515
516        builder.build().map_err(|e| RuntimeError::internal(format!("failed to build runner: {e}")))
517    }
518
519    /// Convert managed ContentBlocks into an adk-core Content for the Runner.
520    fn build_user_content(&self, blocks: &[ContentBlock]) -> Content {
521        let mut parts = Vec::new();
522        for block in blocks {
523            match block {
524                ContentBlock::Text { text } => {
525                    parts.push(Part::Text { text: text.clone() });
526                }
527                ContentBlock::Image { source } => {
528                    // Convert image block to inline data or file reference
529                    if let Some(url) = source.get("url").and_then(|v| v.as_str()) {
530                        parts.push(Part::FileData {
531                            mime_type: source
532                                .get("media_type")
533                                .and_then(|v| v.as_str())
534                                .unwrap_or("image/png")
535                                .to_string(),
536                            file_uri: url.to_string(),
537                            annotations: None,
538                        });
539                    }
540                }
541                ContentBlock::File { file_id } => {
542                    parts.push(Part::FileData {
543                        mime_type: "application/octet-stream".to_string(),
544                        file_uri: file_id.clone(),
545                        annotations: None,
546                    });
547                }
548            }
549        }
550
551        Content { role: "user".to_string(), parts }
552    }
553
554    /// Process a single Runner event, mapping it to SessionEvents and tracking usage.
555    async fn process_runner_event(
556        &mut self,
557        event: &Event,
558        turn_usage: &mut UsageReport,
559        custom_tool_ids: &mut Vec<String>,
560    ) {
561        // Extract usage metadata from the LLM response
562        if let Some(ref usage_meta) = event.llm_response.usage_metadata {
563            let report = UsageReport::from_usage_metadata(usage_meta);
564            turn_usage.accumulate(&report);
565        }
566
567        // Skip partial streaming chunks — we only emit complete events
568        if event.llm_response.partial {
569            return;
570        }
571
572        // Extract content from the LLM response
573        if let Some(ref content) = event.llm_response.content {
574            for part in &content.parts {
575                match part {
576                    Part::Text { text } => {
577                        if text.is_empty() {
578                            continue;
579                        }
580                        let output = RunnerOutput::TextContent { text: text.clone() };
581                        let session_event = map_runner_output(output, self.seq.next());
582                        self.emit_event(session_event).await;
583                    }
584                    Part::FunctionCall { name, args, id, .. } => {
585                        let tool_use_id =
586                            id.clone().unwrap_or_else(|| format!("tu_{}", uuid::Uuid::new_v4()));
587
588                        // Classify the tool call
589                        let tool_kind = self.classify_tool(name);
590
591                        let output = match tool_kind {
592                            ToolKind::Custom => {
593                                let ctu_id = format!("ctu_{}", uuid::Uuid::new_v4());
594                                custom_tool_ids.push(ctu_id.clone());
595                                RunnerOutput::CustomToolCall {
596                                    custom_tool_use_id: ctu_id,
597                                    name: name.clone(),
598                                    input: args.clone(),
599                                }
600                            }
601                            ToolKind::Builtin => RunnerOutput::BuiltinToolCall {
602                                tool_use_id,
603                                name: name.clone(),
604                                input: args.clone(),
605                            },
606                            ToolKind::Mcp => RunnerOutput::McpToolCall {
607                                tool_use_id,
608                                name: name.clone(),
609                                input: args.clone(),
610                            },
611                        };
612
613                        let session_event = map_runner_output(output.clone(), self.seq.next());
614                        self.emit_event(session_event).await;
615
616                        // If custom tool, park and wait for client result
617                        if requires_parking(&output)
618                            && let Some(ctu_id) = custom_tool_use_id(&output)
619                        {
620                            let ctu_id_owned = ctu_id.to_string();
621                            debug!(
622                                session_id = %self.session_id,
623                                custom_tool_use_id = %ctu_id_owned,
624                                "parking for custom tool result"
625                            );
626                            match self.parking.park(&ctu_id_owned).await {
627                                Ok(_result_blocks) => {
628                                    debug!(
629                                        session_id = %self.session_id,
630                                        custom_tool_use_id = %ctu_id_owned,
631                                        "custom tool result delivered"
632                                    );
633                                }
634                                Err(e) => {
635                                    warn!(
636                                        session_id = %self.session_id,
637                                        error = %e,
638                                        "custom tool park failed or timed out"
639                                    );
640                                }
641                            }
642                        }
643                    }
644                    // Skip FunctionResponse, Thinking, and other part types
645                    _ => {}
646                }
647            }
648        }
649    }
650
651    /// Classify a tool call by name to determine which RunnerOutput variant to use.
652    fn classify_tool(&self, name: &str) -> ToolKind {
653        // Known built-in tools execute server-side
654        const BUILTIN_TOOLS: &[&str] =
655            &["bash", "filesystem", "web_search", "web_fetch", "code_execution"];
656
657        if BUILTIN_TOOLS.contains(&name) {
658            ToolKind::Builtin
659        } else if name.starts_with("mcp_") || name.contains("::") {
660            ToolKind::Mcp
661        } else {
662            // All other tools are custom (client-executed)
663            ToolKind::Custom
664        }
665    }
666
667    /// Emit a session event: assign to checkpoint and broadcast.
668    async fn emit_event(&mut self, event: SessionEvent) {
669        // Checkpoint atomically via the shared manager.
670        let run_state = RunState {
671            seq: self.seq.current(),
672            pending_tool_ids: Vec::new(),
673            status: *self.status.read().await,
674        };
675        self.checkpoint.write().await.checkpoint(event.clone(), run_state);
676
677        // Broadcast to subscribers (ignore if no receivers).
678        let _ = self.event_tx.send(event);
679    }
680
681    /// Emit a `status.idle` event and update internal status.
682    async fn emit_idle(&mut self, stop_reason: Option<StopReason>, usage: Option<UsageReport>) {
683        *self.status.write().await = SessionStatus::Idle;
684        let idle_event = SessionEvent::StatusIdle { seq: self.seq.next(), stop_reason, usage };
685        self.emit_event(idle_event).await;
686    }
687
688    /// Check if the cancellation token has been triggered.
689    ///
690    /// Returns `true` if interrupted.
691    fn check_interrupt(&self) -> bool {
692        self.cancel_token.is_cancelled()
693    }
694
695    /// Check and handle pause state. If paused, blocks until resumed.
696    async fn check_pause(&self) {
697        loop {
698            let is_paused = *self.pause_flag.lock().await;
699            if !is_paused {
700                break;
701            }
702            debug!(session_id = %self.session_id, "session loop paused, waiting for resume");
703            self.pause_notify.notified().await;
704        }
705    }
706}
707
708/// Tool classification used internally by the session loop.
709///
710/// Re-exported from [`crate::event_mapping::ToolKind`] for internal use.
711use crate::event_mapping::ToolKind;
712
713#[cfg(test)]
714mod tests {
715    use std::time::Duration;
716
717    use super::*;
718    use adk_core::{FinishReason, Llm, LlmRequest, LlmResponse, LlmResponseStream};
719    use async_stream::stream;
720    use async_trait::async_trait;
721
722    /// Mock LLM that returns a configurable response.
723    struct TestLlm {
724        response_text: String,
725    }
726
727    impl TestLlm {
728        fn new(text: &str) -> Self {
729            Self { response_text: text.to_string() }
730        }
731    }
732
733    #[async_trait]
734    impl Llm for TestLlm {
735        fn name(&self) -> &str {
736            "test-llm"
737        }
738
739        async fn generate_content(
740            &self,
741            _request: LlmRequest,
742            _stream: bool,
743        ) -> adk_core::Result<LlmResponseStream> {
744            let text = self.response_text.clone();
745            let s = stream! {
746                yield Ok(LlmResponse {
747                    content: Some(Content::new("model").with_text(&text)),
748                    partial: false,
749                    turn_complete: true,
750                    finish_reason: Some(FinishReason::Stop),
751                    ..Default::default()
752                });
753            };
754            Ok(Box::pin(s))
755        }
756    }
757
758    /// Build a test agent with the given LLM.
759    fn build_test_agent(llm: impl Llm + 'static) -> Arc<dyn Agent> {
760        let agent =
761            adk_agent::LlmAgentBuilder::new("test-agent").model(Arc::new(llm)).build().unwrap();
762        Arc::new(agent)
763    }
764
765    /// Helper to create a session loop with default test configuration.
766    fn create_test_loop()
767    -> (mpsc::Sender<UserEvent>, broadcast::Receiver<SessionEvent>, CancellationToken, SessionLoop)
768    {
769        let (event_tx, event_rx) = mpsc::channel(64);
770        let (broadcast_tx, broadcast_rx) = broadcast::channel(256);
771        let cancel = CancellationToken::new();
772        let parking = Arc::new(ToolParkingLot::new(Duration::from_secs(5)));
773        let agent = build_test_agent(TestLlm::new("Hello from the agent"));
774        let session_service: Arc<dyn SessionService> =
775            Arc::new(adk_session::InMemorySessionService::new());
776
777        let session_loop = SessionLoop::new(
778            "test_session".to_string(),
779            event_rx,
780            broadcast_tx,
781            parking,
782            cancel.clone(),
783            agent,
784            session_service,
785        );
786
787        (event_tx, broadcast_rx, cancel, session_loop)
788    }
789
790    #[tokio::test]
791    async fn test_basic_message_flow() {
792        let (event_tx, mut broadcast_rx, _cancel, session_loop) = create_test_loop();
793
794        let handle = tokio::spawn(session_loop.run());
795
796        // Send a message.
797        event_tx
798            .send(UserEvent::Message {
799                content: vec![ContentBlock::Text { text: "Hello".to_string() }],
800            })
801            .await
802            .unwrap();
803
804        // Expect: status.running, then agent response events, then status.idle
805        let ev1 = broadcast_rx.recv().await.unwrap();
806        match ev1 {
807            SessionEvent::StatusRunning { seq } => assert_eq!(seq, 0),
808            other => panic!("expected StatusRunning, got: {other:?}"),
809        }
810
811        // Collect remaining events until we get StatusIdle
812        let mut got_message = false;
813        let mut got_idle = false;
814        for _ in 0..10 {
815            match tokio::time::timeout(Duration::from_secs(5), broadcast_rx.recv()).await {
816                Ok(Ok(SessionEvent::Message { content, .. })) => {
817                    assert!(!content.is_empty());
818                    got_message = true;
819                }
820                Ok(Ok(SessionEvent::StatusIdle { stop_reason, .. })) => {
821                    assert!(matches!(stop_reason, Some(StopReason::EndTurn)));
822                    got_idle = true;
823                    break;
824                }
825                Ok(Ok(SessionEvent::Error { message, .. })) => {
826                    // In test environments without a real model, errors are acceptable
827                    debug!("got error event: {message}");
828                }
829                Ok(Ok(other)) => {
830                    debug!("got other event: {other:?}");
831                }
832                Ok(Err(_)) => break,
833                Err(_) => break,
834            }
835        }
836
837        // We must at least get status.idle (the turn completes regardless)
838        assert!(got_idle, "expected StatusIdle event");
839
840        // Close the channel to stop the loop.
841        drop(event_tx);
842        let result = handle.await.unwrap();
843        assert!(result.is_ok());
844
845        // Note: got_message depends on whether the Runner successfully invoked
846        // the mock LLM. In unit tests, InMemorySessionService may not have the
847        // session pre-created so the Runner creates one — either way the flow
848        // should complete without panics.
849        let _ = got_message;
850    }
851
852    #[tokio::test]
853    async fn test_seq_monotonically_increases() {
854        let (event_tx, mut broadcast_rx, _cancel, session_loop) = create_test_loop();
855
856        let handle = tokio::spawn(session_loop.run());
857
858        // Send a message
859        event_tx
860            .send(UserEvent::Message {
861                content: vec![ContentBlock::Text { text: "First".to_string() }],
862            })
863            .await
864            .unwrap();
865
866        // Collect events from the turn
867        let mut seqs = Vec::new();
868        for _ in 0..10 {
869            match tokio::time::timeout(Duration::from_secs(5), broadcast_rx.recv()).await {
870                Ok(Ok(ev)) => {
871                    let seq = match &ev {
872                        SessionEvent::StatusRunning { seq } => *seq,
873                        SessionEvent::Message { seq, .. } => *seq,
874                        SessionEvent::StatusIdle { seq, .. } => *seq,
875                        SessionEvent::ToolUse { seq, .. } => *seq,
876                        SessionEvent::CustomToolUse { seq, .. } => *seq,
877                        SessionEvent::McpToolUse { seq, .. } => *seq,
878                        SessionEvent::Error { seq, .. } => *seq,
879                    };
880                    seqs.push(seq);
881                    if matches!(ev, SessionEvent::StatusIdle { .. }) {
882                        break;
883                    }
884                }
885                _ => break,
886            }
887        }
888
889        // Verify strict monotonic increase.
890        assert!(seqs.len() >= 2, "expected at least 2 events");
891        for window in seqs.windows(2) {
892            assert!(
893                window[1] > window[0],
894                "seq must be strictly increasing: {} should be > {}",
895                window[1],
896                window[0]
897            );
898        }
899
900        drop(event_tx);
901        handle.await.unwrap().unwrap();
902    }
903
904    #[tokio::test]
905    async fn test_interrupt_stops_loop() {
906        let (event_tx, mut broadcast_rx, cancel, session_loop) = create_test_loop();
907
908        let handle = tokio::spawn(session_loop.run());
909
910        // Give the loop a moment to start waiting.
911        tokio::time::sleep(Duration::from_millis(10)).await;
912
913        // Trigger interrupt.
914        cancel.cancel();
915
916        // Should emit status.idle on interrupt.
917        let ev = broadcast_rx.recv().await.unwrap();
918        match ev {
919            SessionEvent::StatusIdle { stop_reason, .. } => {
920                assert!(matches!(stop_reason, Some(StopReason::EndTurn)));
921            }
922            other => panic!("expected StatusIdle on interrupt, got: {other:?}"),
923        }
924
925        // The loop should exit cleanly.
926        let result = handle.await.unwrap();
927        assert!(result.is_ok());
928
929        drop(event_tx);
930    }
931
932    #[tokio::test]
933    async fn test_user_interrupt_event_stops_loop() {
934        let (event_tx, mut broadcast_rx, _cancel, session_loop) = create_test_loop();
935
936        let handle = tokio::spawn(session_loop.run());
937
938        // Send an interrupt event.
939        event_tx.send(UserEvent::Interrupt {}).await.unwrap();
940
941        // Should emit status.idle.
942        let ev = broadcast_rx.recv().await.unwrap();
943        match ev {
944            SessionEvent::StatusIdle { stop_reason, .. } => {
945                assert!(matches!(stop_reason, Some(StopReason::EndTurn)));
946            }
947            other => panic!("expected StatusIdle, got: {other:?}"),
948        }
949
950        let result = handle.await.unwrap();
951        assert!(result.is_ok());
952
953        drop(event_tx);
954    }
955
956    #[tokio::test]
957    async fn test_pause_and_resume() {
958        let (event_tx, event_rx) = mpsc::channel(64);
959        let (broadcast_tx, mut broadcast_rx) = broadcast::channel(256);
960        let cancel = CancellationToken::new();
961        let parking = Arc::new(ToolParkingLot::new(Duration::from_secs(5)));
962        let pause_flag = Arc::new(Mutex::new(false));
963        let pause_notify = Arc::new(Notify::new());
964        let agent = build_test_agent(TestLlm::new("resumed response"));
965        let session_service: Arc<dyn SessionService> =
966            Arc::new(adk_session::InMemorySessionService::new());
967
968        #[cfg(feature = "memory")]
969        let session_loop = SessionLoop::with_pause_controls(
970            "pause_test".to_string(),
971            event_rx,
972            broadcast_tx,
973            parking,
974            cancel.clone(),
975            Arc::clone(&pause_flag),
976            Arc::clone(&pause_notify),
977            Arc::new(RwLock::new(CheckpointManager::new("pause_test".to_string()))),
978            agent,
979            session_service,
980            None,
981        );
982        #[cfg(not(feature = "memory"))]
983        let session_loop = SessionLoop::with_pause_controls(
984            "pause_test".to_string(),
985            event_rx,
986            broadcast_tx,
987            parking,
988            cancel.clone(),
989            Arc::clone(&pause_flag),
990            Arc::clone(&pause_notify),
991            Arc::new(RwLock::new(CheckpointManager::new("pause_test".to_string()))),
992            agent,
993            session_service,
994        );
995
996        let handle = tokio::spawn(session_loop.run());
997
998        // Pause the loop.
999        *pause_flag.lock().await = true;
1000
1001        // Send a message — should not be processed while paused.
1002        event_tx
1003            .send(UserEvent::Message {
1004                content: vec![ContentBlock::Text { text: "While paused".to_string() }],
1005            })
1006            .await
1007            .unwrap();
1008
1009        // Give the loop time to potentially process (it shouldn't).
1010        tokio::time::sleep(Duration::from_millis(50)).await;
1011
1012        // Verify nothing was broadcast yet (try_recv should fail).
1013        assert!(broadcast_rx.try_recv().is_err());
1014
1015        // Resume.
1016        *pause_flag.lock().await = false;
1017        pause_notify.notify_one();
1018
1019        // Now the message should be processed.
1020        let ev1 = tokio::time::timeout(Duration::from_secs(2), broadcast_rx.recv())
1021            .await
1022            .expect("timed out waiting for event after resume")
1023            .unwrap();
1024
1025        match ev1 {
1026            SessionEvent::StatusRunning { .. } => {}
1027            other => panic!("expected StatusRunning after resume, got: {other:?}"),
1028        }
1029
1030        // Clean up.
1031        drop(event_tx);
1032        handle.await.unwrap().unwrap();
1033    }
1034
1035    #[tokio::test]
1036    async fn test_channel_close_stops_loop() {
1037        let (event_tx, event_rx) = mpsc::channel(64);
1038        let (broadcast_tx, _broadcast_rx) = broadcast::channel(256);
1039        let cancel = CancellationToken::new();
1040        let parking = Arc::new(ToolParkingLot::new(Duration::from_secs(5)));
1041        let agent = build_test_agent(TestLlm::new("test"));
1042        let session_service: Arc<dyn SessionService> =
1043            Arc::new(adk_session::InMemorySessionService::new());
1044
1045        let session_loop = SessionLoop::new(
1046            "close_test".to_string(),
1047            event_rx,
1048            broadcast_tx,
1049            parking,
1050            cancel,
1051            agent,
1052            session_service,
1053        );
1054
1055        let handle = tokio::spawn(session_loop.run());
1056
1057        // Drop the sender — closes the channel.
1058        drop(event_tx);
1059
1060        // Loop should exit cleanly.
1061        let result = handle.await.unwrap();
1062        assert!(result.is_ok());
1063    }
1064
1065    #[tokio::test]
1066    async fn test_custom_tool_result_delivery() {
1067        let (event_tx, event_rx) = mpsc::channel(64);
1068        let (broadcast_tx, _broadcast_rx) = broadcast::channel(256);
1069        let cancel = CancellationToken::new();
1070        let parking = Arc::new(ToolParkingLot::new(Duration::from_secs(5)));
1071        let parking_clone = Arc::clone(&parking);
1072        let agent = build_test_agent(TestLlm::new("test"));
1073        let session_service: Arc<dyn SessionService> =
1074            Arc::new(adk_session::InMemorySessionService::new());
1075
1076        let session_loop = SessionLoop::new(
1077            "parking_test".to_string(),
1078            event_rx,
1079            broadcast_tx,
1080            parking_clone,
1081            cancel,
1082            agent,
1083            session_service,
1084        );
1085
1086        let handle = tokio::spawn(session_loop.run());
1087
1088        // Park a tool call from another task.
1089        let parking_for_park = Arc::clone(&parking);
1090        let park_handle = tokio::spawn(async move { parking_for_park.park("ctu_test_001").await });
1091
1092        // Give the park a moment to register.
1093        tokio::time::sleep(Duration::from_millis(10)).await;
1094
1095        // Send custom tool result via the session loop.
1096        event_tx
1097            .send(UserEvent::CustomToolResult {
1098                custom_tool_use_id: "ctu_test_001".to_string(),
1099                content: vec![ContentBlock::Text { text: "tool output".to_string() }],
1100            })
1101            .await
1102            .unwrap();
1103
1104        // The parked task should receive the result.
1105        let result = tokio::time::timeout(Duration::from_secs(2), park_handle)
1106            .await
1107            .expect("park timed out")
1108            .unwrap()
1109            .unwrap();
1110
1111        assert_eq!(result.len(), 1);
1112        match &result[0] {
1113            ContentBlock::Text { text } => assert_eq!(text, "tool output"),
1114            _ => panic!("expected Text"),
1115        }
1116
1117        // Clean up.
1118        drop(event_tx);
1119        handle.await.unwrap().unwrap();
1120    }
1121
1122    #[tokio::test]
1123    async fn test_tool_classification() {
1124        let (event_tx, event_rx) = mpsc::channel(64);
1125        let (broadcast_tx, _) = broadcast::channel(256);
1126        let cancel = CancellationToken::new();
1127        let parking = Arc::new(ToolParkingLot::new(Duration::from_secs(5)));
1128        let agent = build_test_agent(TestLlm::new("test"));
1129        let session_service: Arc<dyn SessionService> =
1130            Arc::new(adk_session::InMemorySessionService::new());
1131
1132        let session_loop = SessionLoop::new(
1133            "classify_test".to_string(),
1134            event_rx,
1135            broadcast_tx,
1136            parking,
1137            cancel,
1138            agent,
1139            session_service,
1140        );
1141
1142        // Test builtin tools
1143        assert!(matches!(session_loop.classify_tool("bash"), ToolKind::Builtin));
1144        assert!(matches!(session_loop.classify_tool("filesystem"), ToolKind::Builtin));
1145        assert!(matches!(session_loop.classify_tool("web_search"), ToolKind::Builtin));
1146        assert!(matches!(session_loop.classify_tool("web_fetch"), ToolKind::Builtin));
1147        assert!(matches!(session_loop.classify_tool("code_execution"), ToolKind::Builtin));
1148
1149        // Test MCP tools
1150        assert!(matches!(session_loop.classify_tool("mcp_file_read"), ToolKind::Mcp));
1151        assert!(matches!(session_loop.classify_tool("server::tool"), ToolKind::Mcp));
1152
1153        // Test custom tools
1154        assert!(matches!(session_loop.classify_tool("get_weather"), ToolKind::Custom));
1155        assert!(matches!(session_loop.classify_tool("deploy"), ToolKind::Custom));
1156
1157        drop(event_tx);
1158    }
1159}