Skip to main content

bridge/
agent_pool.rs

1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use std::process::Stdio;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
8use tokio::process::{Child, Command};
9use tokio::sync::{broadcast, mpsc, RwLock};
10use tracing::{debug, error, info, warn};
11
12use crate::push::PushRelayClient;
13
14/// Configuration for the agent pool
15#[derive(Debug, Clone)]
16pub struct PoolConfig {
17    /// How long to keep idle agents alive (no client connected)
18    pub idle_timeout: Duration,
19    /// Maximum number of concurrent agent processes
20    pub max_agents: usize,
21    /// Whether to buffer agent messages while client is disconnected
22    pub buffer_messages: bool,
23    /// Maximum number of buffered messages per agent
24    pub max_buffer_size: usize,
25}
26
27impl Default for PoolConfig {
28    fn default() -> Self {
29        Self {
30            idle_timeout: Duration::from_secs(1800),
31            max_agents: 10,
32            buffer_messages: true,
33            max_buffer_size: 10_000,
34        }
35    }
36}
37
38/// A pooled agent process with its I/O handles
39pub struct PooledAgent {
40    /// The spawned child process
41    process: Child,
42    /// Sender for messages going to the agent (from WebSocket to stdin)
43    pub ws_to_agent_tx: mpsc::Sender<String>,
44    /// Broadcast sender for messages from agent stdout.
45    /// Each new connection subscribes via .subscribe()
46    pub agent_to_ws_tx: broadcast::Sender<String>,
47    /// Whether a client is currently connected
48    pub connected: bool,
49    /// When the client last disconnected (for idle timeout)
50    pub disconnected_at: Option<Instant>,
51    /// Buffered messages from agent while client was disconnected
52    pub message_buffer: Vec<String>,
53    /// Cached `initialize` response from the agent (raw JSON-RPC result).
54    /// On reconnect we intercept the client's `initialize` request and reply
55    /// with this cached response instead of forwarding to the agent.
56    pub cached_init_response: Option<String>,
57    /// Cached `createSession` response from the agent (raw JSON-RPC result).
58    /// On reconnect we intercept the client's `createSession` request and reply
59    /// with this cached response, preserving the same session ID so the agent
60    /// keeps its conversation history.
61    pub cached_session_response: Option<String>,
62    /// The agent command used to spawn this agent
63    #[allow(dead_code)]
64    pub agent_command: String,
65}
66
67impl PooledAgent {
68    /// Check if this agent's process is still running
69    pub fn is_alive(&mut self) -> bool {
70        match self.process.try_wait() {
71            Ok(Some(_)) => false,
72            Ok(None) => true,
73            Err(_) => false,
74        }
75    }
76
77    /// Kill the agent process gracefully
78    pub async fn kill(&mut self) {
79        info!("Killing pooled agent process");
80        if let Err(e) = self.process.kill().await {
81            warn!("Failed to kill agent process: {}", e);
82        }
83    }
84
85    /// Subscribe to agent stdout messages
86    pub fn subscribe(&self) -> broadcast::Receiver<String> {
87        self.agent_to_ws_tx.subscribe()
88    }
89}
90
91/// Manages a pool of long-lived agent processes keyed by auth token
92pub struct AgentPool {
93    pub(crate) agents: HashMap<String, PooledAgent>,
94    config: PoolConfig,
95    push_relay: Option<Arc<PushRelayClient>>,
96    working_dir: PathBuf,
97}
98
99impl AgentPool {
100    pub fn new(config: PoolConfig) -> Self {
101        Self {
102            agents: HashMap::new(),
103            config,
104            push_relay: None,
105            working_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
106        }
107    }
108
109    /// Set the working directory for spawned agent processes.
110    pub fn with_working_dir(mut self, dir: PathBuf) -> Self {
111        self.working_dir = dir;
112        self
113    }
114
115    /// Set the push relay client for sending notifications
116    pub fn with_push_relay(mut self, push_relay: Arc<PushRelayClient>) -> Self {
117        self.push_relay = Some(push_relay);
118        self
119    }
120
121    /// Get an existing agent or spawn a new one for the given token.
122    /// Returns (ws_to_agent_tx, agent_to_ws_rx, buffered_messages, was_reused, cached_init_response, cached_session_response, broadcast_tx)
123    pub async fn get_or_spawn(
124        &mut self,
125        token: &str,
126        agent_command: &str,
127    ) -> Result<(mpsc::Sender<String>, broadcast::Receiver<String>, Vec<String>, bool, Option<String>, Option<String>, broadcast::Sender<String>)> {
128        // Check if we have an existing agent for this token
129        if let Some(agent) = self.agents.get_mut(token) {
130            if agent.is_alive() {
131                info!("Reusing existing agent for token (keep-alive)");
132                agent.connected = true;
133                agent.disconnected_at = None;
134
135                let buffered = std::mem::take(&mut agent.message_buffer);
136                if !buffered.is_empty() {
137                    info!("Replaying {} buffered messages", buffered.len());
138                }
139
140                let tx = agent.ws_to_agent_tx.clone();
141                let rx = agent.subscribe();
142                let cached_init = agent.cached_init_response.clone();
143                let cached_session = agent.cached_session_response.clone();
144                let broadcast_tx = agent.agent_to_ws_tx.clone();
145
146                return Ok((tx, rx, buffered, true, cached_init, cached_session, broadcast_tx));
147            } else {
148                info!("Agent process died, removing from pool");
149                self.agents.remove(token);
150            }
151        }
152
153        // Check max agents limit
154        if self.agents.len() >= self.config.max_agents {
155            let oldest_idle = self
156                .agents
157                .iter()
158                .filter(|(_, a)| !a.connected)
159                .min_by_key(|(_, a)| a.disconnected_at)
160                .map(|(k, _)| k.clone());
161
162            if let Some(key) = oldest_idle {
163                info!("Evicting oldest idle agent to make room");
164                if let Some(mut agent) = self.agents.remove(&key) {
165                    agent.kill().await;
166                }
167            } else {
168                anyhow::bail!(
169                    "Agent pool is full ({} agents, all connected). Cannot spawn new agent.",
170                    self.config.max_agents
171                );
172            }
173        }
174
175        // Spawn a new agent
176        info!("Spawning new pooled agent");
177        self.spawn_agent(token, agent_command).await
178    }
179
180    /// Spawn a new agent process and set up I/O channels
181    async fn spawn_agent(
182        &mut self,
183        token: &str,
184        agent_command: &str,
185    ) -> Result<(mpsc::Sender<String>, broadcast::Receiver<String>, Vec<String>, bool, Option<String>, Option<String>, broadcast::Sender<String>)> {
186        let parts: Vec<&str> = agent_command.split_whitespace().collect();
187        if parts.is_empty() {
188            anyhow::bail!("Empty agent command");
189        }
190
191        let command = parts[0];
192        let args = &parts[1..];
193
194        info!("🚀 Spawning pooled agent: {} {:?} (cwd: {})", command, args, self.working_dir.display());
195
196        let mut child = Command::new(command)
197            .args(args)
198            .current_dir(&self.working_dir)
199            .stdin(Stdio::piped())
200            .stdout(Stdio::piped())
201            .stderr(Stdio::piped())
202            .kill_on_drop(false)
203            .spawn()
204            .context(format!("Failed to spawn agent command: {}", agent_command))?;
205
206        let stdin = child.stdin.take().context("Failed to open agent stdin")?;
207        let stdout = child.stdout.take().context("Failed to open agent stdout")?;
208        let stderr = child.stderr.take().context("Failed to open agent stderr")?;
209
210        // Channel: WebSocket messages to agent stdin (mpsc)
211        let (ws_to_agent_tx, mut ws_to_agent_rx) = mpsc::channel::<String>(100);
212
213        // Channel: agent stdout to WebSocket (broadcast, supports reconnection)
214        let (agent_to_ws_tx, agent_to_ws_rx) = broadcast::channel::<String>(256);
215
216        // Background task: forward ws_to_agent_rx to agent stdin
217        let mut stdin_writer = stdin;
218        tokio::spawn(async move {
219            while let Some(msg) = ws_to_agent_rx.recv().await {
220                if let Err(e) = stdin_writer.write_all(msg.as_bytes()).await {
221                    error!("Failed to write to pooled agent stdin: {}", e);
222                    break;
223                }
224                if let Err(e) = stdin_writer.write_all(b"\n").await {
225                    error!("Failed to write newline to pooled agent stdin: {}", e);
226                    break;
227                }
228                if let Err(e) = stdin_writer.flush().await {
229                    error!("Failed to flush pooled agent stdin: {}", e);
230                    break;
231                }
232            }
233            debug!("Pooled agent stdin writer task ended");
234        });
235
236        // Background task: forward agent stdout to broadcast channel
237        let stdout_tx = agent_to_ws_tx.clone();
238        let stdout_reader = BufReader::new(stdout);
239        let push_relay_for_stdout: Option<Arc<PushRelayClient>> = self.push_relay.clone();
240        let agent_token_for_push = token.to_string();
241        tokio::spawn(async move {
242            let mut lines = stdout_reader.lines();
243            while let Ok(Some(line)) = lines.next_line().await {
244                debug!(
245                    "Pooled agent stdout ({} bytes): {}",
246                    line.len(),
247                    line.chars().take(200).collect::<String>()
248                );
249                
250                // Attempt to send to broadcast channel
251                match stdout_tx.send(line) {
252                    Ok(_) => {
253                        // Message was sent successfully (has at least one receiver)
254                        debug!("Agent output sent to connected client");
255                    }
256                    Err(_) => {
257                        // No receivers = no WebSocket client connected
258                        if let Some(ref push_relay) = push_relay_for_stdout {
259                            debug!("Agent output with no connected client, triggering push notification");
260                            if let Err(e) = push_relay.notify(&agent_token_for_push).await {
261                                warn!("Failed to send push notification: {}", e);
262                            }
263                        }
264                    }
265                }
266            }
267            debug!("Pooled agent stdout reader task ended");
268        });
269
270        // Background task: log stderr
271        let stderr_reader = BufReader::new(stderr);
272        tokio::spawn(async move {
273            let mut lines = stderr_reader.lines();
274            while let Ok(Some(line)) = lines.next_line().await {
275                warn!("Pooled agent stderr: {}", line);
276            }
277            debug!("Pooled agent stderr reader task ended");
278        });
279
280        let pooled = PooledAgent {
281            process: child,
282            ws_to_agent_tx: ws_to_agent_tx.clone(),
283            agent_to_ws_tx,
284            connected: true,
285            disconnected_at: None,
286            message_buffer: Vec::new(),
287            cached_init_response: None,
288            cached_session_response: None,
289            agent_command: agent_command.to_string(),
290        };
291
292        self.agents.insert(token.to_string(), pooled);
293
294        let broadcast_tx = self.agents.get(token).unwrap().agent_to_ws_tx.clone();
295
296        Ok((ws_to_agent_tx, agent_to_ws_rx, Vec::new(), false, None, None, broadcast_tx))
297    }
298
299    /// Mark a client as disconnected. The agent stays alive for idle_timeout.
300    pub fn mark_disconnected(&mut self, token: &str) {
301        if let Some(agent) = self.agents.get_mut(token) {
302            info!("Client disconnected, agent entering idle state (keep-alive)");
303            agent.connected = false;
304            agent.disconnected_at = Some(Instant::now());
305        }
306    }
307
308    /// Cache the agent's `initialize` response so reconnections can skip re-initialization
309    pub fn cache_init_response(&mut self, token: &str, response: String) {
310        if let Some(agent) = self.agents.get_mut(token) {
311            info!("Cached initialize response for agent (keep-alive)");
312            agent.cached_init_response = Some(response);
313        }
314    }
315
316    /// Cache the agent's `createSession` response so reconnections reuse the same session ID
317    pub fn cache_session_response(&mut self, token: &str, response: String) {
318        if let Some(agent) = self.agents.get_mut(token) {
319            info!("Cached createSession response for agent (keep-alive)");
320            agent.cached_session_response = Some(response);
321        }
322    }
323
324    /// Clear the cached session response (e.g., when agent reports "Session not found")
325    pub fn clear_session_response(&mut self, token: &str) {
326        if let Some(agent) = self.agents.get_mut(token) {
327            if agent.cached_session_response.is_some() {
328                info!("Cleared cached session response for agent (session invalidated)");
329                agent.cached_session_response = None;
330            }
331        }
332    }
333
334    /// Remove and kill an agent
335    #[allow(dead_code)]
336    pub async fn remove_agent(&mut self, token: &str) {
337        if let Some(mut agent) = self.agents.remove(token) {
338            agent.kill().await;
339        }
340    }
341
342    /// Check for idle agents that have exceeded the timeout and kill them
343    pub async fn reap_idle_agents(&mut self) {
344        let timeout = self.config.idle_timeout;
345        let mut to_remove = Vec::new();
346
347        for (token, agent) in self.agents.iter_mut() {
348            if !agent.is_alive() {
349                info!("Agent for token {}... died, removing", &token[..8.min(token.len())]);
350                to_remove.push(token.clone());
351                continue;
352            }
353
354            if !agent.connected {
355                if let Some(disconnected_at) = agent.disconnected_at {
356                    if disconnected_at.elapsed() > timeout {
357                        info!(
358                            "Agent for token {}... idle for {:?}, terminating",
359                            &token[..8.min(token.len())],
360                            disconnected_at.elapsed()
361                        );
362                        to_remove.push(token.clone());
363                    }
364                }
365            }
366        }
367
368        for token in to_remove {
369            if let Some(mut agent) = self.agents.remove(&token) {
370                agent.kill().await;
371            }
372        }
373    }
374
375    /// Get pool statistics
376    pub fn stats(&self) -> PoolStats {
377        let total = self.agents.len();
378        let connected = self.agents.values().filter(|a| a.connected).count();
379        let idle = total - connected;
380        PoolStats {
381            total,
382            connected,
383            idle,
384            max: self.config.max_agents,
385        }
386    }
387
388    /// Check if the pool contains an agent for the given token
389    #[allow(dead_code)]
390    pub fn contains(&self, token: &str) -> bool {
391        self.agents.contains_key(token)
392    }
393
394    /// Kill a specific agent's process (for testing).
395    /// Returns true if the agent existed.
396    #[allow(dead_code)]
397    pub async fn kill_agent(&mut self, token: &str) -> bool {
398        if let Some(agent) = self.agents.get_mut(token) {
399            agent.kill().await;
400            true
401        } else {
402            false
403        }
404    }
405
406    /// Buffer a message for a disconnected agent
407    pub fn buffer_message(&mut self, token: &str, message: String) {
408        if !self.config.buffer_messages {
409            return;
410        }
411        if let Some(agent) = self.agents.get_mut(token) {
412            if agent.message_buffer.len() < self.config.max_buffer_size {
413                agent.message_buffer.push(message);
414            } else {
415                warn!("Message buffer full for agent, dropping message");
416            }
417        }
418    }
419
420    /// Shut down all agents in the pool
421    #[allow(dead_code)]
422    pub async fn shutdown_all(&mut self) {
423        info!("Shutting down all pooled agents ({} total)", self.agents.len());
424        let tokens: Vec<String> = self.agents.keys().cloned().collect();
425        for token in tokens {
426            if let Some(mut agent) = self.agents.remove(&token) {
427                agent.kill().await;
428            }
429        }
430    }
431}
432
433/// Pool statistics
434#[derive(Debug)]
435pub struct PoolStats {
436    pub total: usize,
437    pub connected: usize,
438    pub idle: usize,
439    pub max: usize,
440}
441
442impl std::fmt::Display for PoolStats {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        write!(
445            f,
446            "AgentPool: {}/{} agents ({} connected, {} idle)",
447            self.total, self.max, self.connected, self.idle
448        )
449    }
450}
451
452/// Start the background reaper task that periodically checks for idle agents
453pub fn start_reaper(pool: Arc<RwLock<AgentPool>>, check_interval: Duration) -> tokio::task::JoinHandle<()> {
454    tokio::spawn(async move {
455        let mut interval = tokio::time::interval(check_interval);
456        loop {
457            interval.tick().await;
458            let mut pool = pool.write().await;
459            pool.reap_idle_agents().await;
460            let stats = pool.stats();
461            if stats.total > 0 {
462                debug!("AgentPool stats: {}", stats);
463            }
464        }
465    })
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    fn test_config() -> PoolConfig {
473        PoolConfig {
474            idle_timeout: Duration::from_secs(2),
475            max_agents: 3,
476            buffer_messages: true,
477            max_buffer_size: 5,
478        }
479    }
480
481    // ── PoolConfig defaults ──────────────────────────────────────────
482
483    #[test]
484    fn pool_config_default_values() {
485        let cfg = PoolConfig::default();
486        assert_eq!(cfg.idle_timeout, Duration::from_secs(1800));
487        assert_eq!(cfg.max_agents, 10);
488        assert!(cfg.buffer_messages);
489        assert_eq!(cfg.max_buffer_size, 10_000);
490    }
491
492    // ── AgentPool::new ───────────────────────────────────────────────
493
494    #[test]
495    fn new_pool_is_empty() {
496        let pool = AgentPool::new(test_config());
497        let stats = pool.stats();
498        assert_eq!(stats.total, 0);
499        assert_eq!(stats.connected, 0);
500        assert_eq!(stats.idle, 0);
501        assert_eq!(stats.max, 3);
502    }
503
504    // ── get_or_spawn ─────────────────────────────────────────────────
505
506    #[tokio::test]
507    async fn spawn_new_agent_with_cat() {
508        let mut pool = AgentPool::new(test_config());
509        let result = pool.get_or_spawn("token_a", "cat").await;
510        assert!(result.is_ok());
511
512        let (_tx, _rx, buffered, was_reused, cached_init, _cached_session, _) = result.unwrap();
513        assert!(!was_reused, "first spawn should not be reused");
514        assert!(buffered.is_empty(), "first spawn should have no buffered msgs");
515        assert!(cached_init.is_none(), "first spawn should have no cached init");
516
517        let stats = pool.stats();
518        assert_eq!(stats.total, 1);
519        assert_eq!(stats.connected, 1);
520
521        pool.shutdown_all().await;
522    }
523
524    #[tokio::test]
525    async fn reuse_existing_agent() {
526        let mut pool = AgentPool::new(test_config());
527
528        // First spawn
529        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
530        pool.mark_disconnected("token_a");
531
532        // Reconnect
533        let (_tx, _rx, _buf, was_reused, _cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
534        assert!(was_reused, "second call should reuse the agent");
535        assert_eq!(pool.stats().total, 1);
536
537        pool.shutdown_all().await;
538    }
539
540    #[tokio::test]
541    async fn spawn_different_tokens() {
542        let mut pool = AgentPool::new(test_config());
543        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
544        let _ = pool.get_or_spawn("token_b", "cat").await.unwrap();
545
546        assert_eq!(pool.stats().total, 2);
547        assert_eq!(pool.stats().connected, 2);
548
549        pool.shutdown_all().await;
550    }
551
552    #[tokio::test]
553    async fn spawn_with_invalid_command_fails() {
554        let mut pool = AgentPool::new(test_config());
555        let result = pool.get_or_spawn("token_a", "nonexistent_binary_xyz_42").await;
556        assert!(result.is_err());
557    }
558
559    #[tokio::test]
560    async fn spawn_with_empty_command_fails() {
561        let mut pool = AgentPool::new(test_config());
562        let result = pool.get_or_spawn("token_a", "").await;
563        assert!(result.is_err());
564    }
565
566    // ── mark_disconnected / mark_connected ───────────────────────────
567
568    #[tokio::test]
569    async fn mark_disconnected_updates_state() {
570        let mut pool = AgentPool::new(test_config());
571        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
572
573        assert!(pool.agents.get("token_a").unwrap().connected);
574
575        pool.mark_disconnected("token_a");
576
577        let agent = pool.agents.get("token_a").unwrap();
578        assert!(!agent.connected);
579        assert!(agent.disconnected_at.is_some());
580
581        let stats = pool.stats();
582        assert_eq!(stats.connected, 0);
583        assert_eq!(stats.idle, 1);
584
585        pool.shutdown_all().await;
586    }
587
588    #[tokio::test]
589    async fn reconnect_clears_disconnected_state() {
590        let mut pool = AgentPool::new(test_config());
591        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
592        pool.mark_disconnected("token_a");
593
594        // Reconnect
595        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
596        let agent = pool.agents.get("token_a").unwrap();
597        assert!(agent.connected);
598        assert!(agent.disconnected_at.is_none());
599
600        pool.shutdown_all().await;
601    }
602
603    // ── max-agents limit ─────────────────────────────────────────────
604
605    #[tokio::test]
606    async fn max_agents_evicts_idle() {
607        let mut pool = AgentPool::new(test_config()); // max_agents = 3
608
609        let _ = pool.get_or_spawn("t1", "cat").await.unwrap();
610        let _ = pool.get_or_spawn("t2", "cat").await.unwrap();
611        let _ = pool.get_or_spawn("t3", "cat").await.unwrap();
612        assert_eq!(pool.stats().total, 3);
613
614        // Disconnect one to make it evictable
615        pool.mark_disconnected("t1");
616
617        // 4th spawn should evict the idle agent
618        let _ = pool.get_or_spawn("t4", "cat").await.unwrap();
619        assert_eq!(pool.stats().total, 3);
620        assert!(!pool.agents.contains_key("t1"), "idle agent t1 should be evicted");
621    }
622
623    #[tokio::test]
624    async fn max_agents_all_connected_fails() {
625        let mut pool = AgentPool::new(test_config()); // max_agents = 3
626
627        let _ = pool.get_or_spawn("t1", "cat").await.unwrap();
628        let _ = pool.get_or_spawn("t2", "cat").await.unwrap();
629        let _ = pool.get_or_spawn("t3", "cat").await.unwrap();
630
631        // All are connected, so 4th should fail
632        let result = pool.get_or_spawn("t4", "cat").await;
633        assert!(result.is_err());
634        assert!(result.unwrap_err().to_string().contains("Agent pool is full"));
635
636        pool.shutdown_all().await;
637    }
638
639    // ── idle timeout / reap ──────────────────────────────────────────
640
641    #[tokio::test]
642    async fn reap_removes_timed_out_agents() {
643        let cfg = PoolConfig {
644            idle_timeout: Duration::from_millis(50),
645            max_agents: 10,
646            buffer_messages: false,
647            max_buffer_size: 100,
648        };
649        let mut pool = AgentPool::new(cfg);
650
651        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
652        pool.mark_disconnected("token_a");
653
654        // Wait for timeout
655        tokio::time::sleep(Duration::from_millis(100)).await;
656
657        pool.reap_idle_agents().await;
658        assert_eq!(pool.stats().total, 0, "timed-out agent should be reaped");
659    }
660
661    #[tokio::test]
662    async fn reap_keeps_connected_agents() {
663        let cfg = PoolConfig {
664            idle_timeout: Duration::from_millis(50),
665            max_agents: 10,
666            buffer_messages: false,
667            max_buffer_size: 100,
668        };
669        let mut pool = AgentPool::new(cfg);
670
671        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
672        // Don't disconnect — stays connected
673
674        tokio::time::sleep(Duration::from_millis(100)).await;
675
676        pool.reap_idle_agents().await;
677        assert_eq!(pool.stats().total, 1, "connected agent should survive reaping");
678
679        pool.shutdown_all().await;
680    }
681
682    #[tokio::test]
683    async fn reap_keeps_recently_disconnected() {
684        let cfg = PoolConfig {
685            idle_timeout: Duration::from_secs(60),
686            max_agents: 10,
687            buffer_messages: false,
688            max_buffer_size: 100,
689        };
690        let mut pool = AgentPool::new(cfg);
691
692        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
693        pool.mark_disconnected("token_a");
694
695        // Not enough time for timeout
696        pool.reap_idle_agents().await;
697        assert_eq!(pool.stats().total, 1, "recently-disconnected agent should survive");
698
699        pool.shutdown_all().await;
700    }
701
702    // ── message buffering ────────────────────────────────────────────
703
704    #[tokio::test]
705    async fn buffer_message_stores_messages() {
706        let mut pool = AgentPool::new(test_config()); // buffer_messages = true, max_buffer_size = 5
707        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
708        pool.mark_disconnected("token_a");
709
710        pool.buffer_message("token_a", "msg1".into());
711        pool.buffer_message("token_a", "msg2".into());
712
713        let agent = pool.agents.get("token_a").unwrap();
714        assert_eq!(agent.message_buffer.len(), 2);
715        assert_eq!(agent.message_buffer[0], "msg1");
716        assert_eq!(agent.message_buffer[1], "msg2");
717
718        pool.shutdown_all().await;
719    }
720
721    #[tokio::test]
722    async fn buffer_message_respects_max_size() {
723        let mut pool = AgentPool::new(test_config()); // max_buffer_size = 5
724        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
725
726        for i in 0..10 {
727            pool.buffer_message("token_a", format!("msg{}", i));
728        }
729
730        let agent = pool.agents.get("token_a").unwrap();
731        assert_eq!(agent.message_buffer.len(), 5, "should cap at max_buffer_size");
732
733        pool.shutdown_all().await;
734    }
735
736    #[tokio::test]
737    async fn buffer_disabled_drops_messages() {
738        let cfg = PoolConfig {
739            buffer_messages: false,
740            ..test_config()
741        };
742        let mut pool = AgentPool::new(cfg);
743        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
744
745        pool.buffer_message("token_a", "msg1".into());
746
747        let agent = pool.agents.get("token_a").unwrap();
748        assert!(agent.message_buffer.is_empty(), "buffering disabled, should drop");
749
750        pool.shutdown_all().await;
751    }
752
753    #[tokio::test]
754    async fn reconnect_drains_buffer() {
755        let mut pool = AgentPool::new(test_config());
756        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
757        pool.mark_disconnected("token_a");
758
759        pool.buffer_message("token_a", "buffered1".into());
760        pool.buffer_message("token_a", "buffered2".into());
761
762        // Reconnect — get_or_spawn returns the buffered messages
763        let (_tx, _rx, buffered, was_reused, _cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
764        assert!(was_reused);
765        assert_eq!(buffered.len(), 2);
766        assert_eq!(buffered[0], "buffered1");
767        assert_eq!(buffered[1], "buffered2");
768
769        // Buffer should be drained
770        let agent = pool.agents.get("token_a").unwrap();
771        assert!(agent.message_buffer.is_empty());
772
773        pool.shutdown_all().await;
774    }
775
776    // ── remove_agent / shutdown_all ──────────────────────────────────
777
778    #[tokio::test]
779    async fn remove_agent_kills_and_removes() {
780        let mut pool = AgentPool::new(test_config());
781        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
782        assert_eq!(pool.stats().total, 1);
783
784        pool.remove_agent("token_a").await;
785        assert_eq!(pool.stats().total, 0);
786    }
787
788    #[tokio::test]
789    async fn shutdown_all_clears_pool() {
790        let mut pool = AgentPool::new(test_config());
791        let _ = pool.get_or_spawn("t1", "cat").await.unwrap();
792        let _ = pool.get_or_spawn("t2", "cat").await.unwrap();
793        let _ = pool.get_or_spawn("t3", "cat").await.unwrap();
794        assert_eq!(pool.stats().total, 3);
795
796        pool.shutdown_all().await;
797        assert_eq!(pool.stats().total, 0);
798    }
799
800    // ── stats ────────────────────────────────────────────────────────
801
802    #[tokio::test]
803    async fn stats_reflect_pool_state() {
804        let mut pool = AgentPool::new(test_config());
805        let _ = pool.get_or_spawn("t1", "cat").await.unwrap();
806        let _ = pool.get_or_spawn("t2", "cat").await.unwrap();
807        pool.mark_disconnected("t2");
808
809        let s = pool.stats();
810        assert_eq!(s.total, 2);
811        assert_eq!(s.connected, 1);
812        assert_eq!(s.idle, 1);
813        assert_eq!(s.max, 3);
814        assert!(format!("{}", s).contains("2/3 agents"));
815
816        pool.shutdown_all().await;
817    }
818
819    // ── is_alive ─────────────────────────────────────────────────────
820
821    #[tokio::test]
822    async fn dead_agent_is_replaced_on_reconnect() {
823        let mut pool = AgentPool::new(test_config());
824        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
825
826        // Kill the agent manually
827        pool.agents.get_mut("token_a").unwrap().kill().await;
828        // Give the process a moment to exit
829        tokio::time::sleep(Duration::from_millis(50)).await;
830
831        // Reconnect should spawn fresh
832        let (_tx, _rx, _buf, was_reused, _cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
833        assert!(!was_reused, "dead agent should be replaced, not reused");
834
835        pool.shutdown_all().await;
836    }
837
838    // ── start_reaper ─────────────────────────────────────────────────
839
840    #[tokio::test]
841    async fn reaper_task_cleans_up() {
842        let cfg = PoolConfig {
843            idle_timeout: Duration::from_millis(50),
844            max_agents: 10,
845            buffer_messages: false,
846            max_buffer_size: 100,
847        };
848        let pool = Arc::new(RwLock::new(AgentPool::new(cfg)));
849
850        // Spawn and disconnect an agent
851        {
852            let mut p = pool.write().await;
853            let _ = p.get_or_spawn("token_a", "cat").await.unwrap();
854            p.mark_disconnected("token_a");
855        }
856
857        // Start reaper with short interval
858        let handle = start_reaper(Arc::clone(&pool), Duration::from_millis(30));
859
860        // Wait for reaper to run at least once past the idle timeout
861        tokio::time::sleep(Duration::from_millis(200)).await;
862
863        let stats = pool.read().await.stats();
864        assert_eq!(stats.total, 0, "reaper should have cleaned up the idle agent");
865
866        handle.abort();
867    }
868
869    // ── cached initialize response ───────────────────────────────────
870
871    #[tokio::test]
872    async fn cache_init_response_stores_and_returns() {
873        let mut pool = AgentPool::new(test_config());
874        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
875
876        // No cached response initially
877        let agent = pool.agents.get("token_a").unwrap();
878        assert!(agent.cached_init_response.is_none());
879
880        // Cache a response
881        let fake_init = r#"{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}"#.to_string();
882        pool.cache_init_response("token_a", fake_init.clone());
883
884        let agent = pool.agents.get("token_a").unwrap();
885        assert_eq!(agent.cached_init_response.as_deref(), Some(fake_init.as_str()));
886
887        // Disconnect and reconnect — cached response should be returned
888        pool.mark_disconnected("token_a");
889        let (_tx, _rx, _buf, was_reused, cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
890        assert!(was_reused);
891        assert_eq!(cached.as_deref(), Some(fake_init.as_str()));
892
893        pool.shutdown_all().await;
894    }
895
896    #[tokio::test]
897    async fn no_cached_init_for_fresh_spawn() {
898        let mut pool = AgentPool::new(test_config());
899        let (_tx, _rx, _buf, was_reused, cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
900        assert!(!was_reused);
901        assert!(cached.is_none(), "fresh spawn should have no cached init");
902
903        pool.shutdown_all().await;
904    }
905
906    #[tokio::test]
907    async fn dead_agent_loses_cached_init() {
908        let mut pool = AgentPool::new(test_config());
909        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
910
911        pool.cache_init_response(
912            "token_a",
913            r#"{"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}"#.to_string(),
914        );
915
916        // Kill the agent
917        pool.agents.get_mut("token_a").unwrap().kill().await;
918        tokio::time::sleep(Duration::from_millis(50)).await;
919
920        // Reconnect — dead agent is replaced, so cached init is gone
921        let (_tx, _rx, _buf, was_reused, cached, _, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
922        assert!(!was_reused, "dead agent should be replaced");
923        assert!(cached.is_none(), "dead agent's cached init should not carry over");
924
925        pool.shutdown_all().await;
926    }
927
928    // ── cached session response ──────────────────────────────────────
929
930    #[tokio::test]
931    async fn cache_session_response_stores_and_returns() {
932        let mut pool = AgentPool::new(test_config());
933        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
934
935        // No cached session response initially
936        let agent = pool.agents.get("token_a").unwrap();
937        assert!(agent.cached_session_response.is_none());
938
939        // Cache a session response
940        let fake_session = r#"{"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses-abc-123"}}"#.to_string();
941        pool.cache_session_response("token_a", fake_session.clone());
942
943        let agent = pool.agents.get("token_a").unwrap();
944        assert_eq!(agent.cached_session_response.as_deref(), Some(fake_session.as_str()));
945
946        // Disconnect and reconnect — cached session response should be returned
947        pool.mark_disconnected("token_a");
948        let (_tx, _rx, _buf, was_reused, _cached_init, cached_session, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
949        assert!(was_reused);
950        assert_eq!(cached_session.as_deref(), Some(fake_session.as_str()));
951
952        pool.shutdown_all().await;
953    }
954
955    #[tokio::test]
956    async fn no_cached_session_for_fresh_spawn() {
957        let mut pool = AgentPool::new(test_config());
958        let (_tx, _rx, _buf, was_reused, _cached_init, cached_session, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
959        assert!(!was_reused);
960        assert!(cached_session.is_none(), "fresh spawn should have no cached session");
961
962        pool.shutdown_all().await;
963    }
964
965    #[tokio::test]
966    async fn dead_agent_loses_cached_session() {
967        let mut pool = AgentPool::new(test_config());
968        let _ = pool.get_or_spawn("token_a", "cat").await.unwrap();
969
970        pool.cache_session_response(
971            "token_a",
972            r#"{"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses-abc-123"}}"#.to_string(),
973        );
974
975        // Kill the agent
976        pool.agents.get_mut("token_a").unwrap().kill().await;
977        tokio::time::sleep(Duration::from_millis(50)).await;
978
979        // Reconnect — dead agent is replaced, so cached session is gone
980        let (_tx, _rx, _buf, was_reused, _cached_init, cached_session, _) = pool.get_or_spawn("token_a", "cat").await.unwrap();
981        assert!(!was_reused, "dead agent should be replaced");
982        assert!(cached_session.is_none(), "dead agent's cached session should not carry over");
983
984        pool.shutdown_all().await;
985    }
986}