Skip to main content

room_plugin_agent/
lib.rs

1pub mod personalities;
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6use std::process::{Child, Command, Stdio};
7use std::sync::{Arc, Mutex};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use room_protocol::plugin::{
13    BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult,
14};
15use room_protocol::Message;
16
17// ── C ABI entry points for cdylib loading ─────────────────────────────────
18
19/// JSON configuration for the agent plugin when loaded dynamically.
20///
21/// ```json
22/// {
23///   "state_path": "/home/user/.room/state/agents-myroom.json",
24///   "socket_path": "/tmp/room-myroom.sock",
25///   "log_dir": "/home/user/.room/logs"
26/// }
27/// ```
28#[derive(Deserialize)]
29struct AgentConfig {
30    state_path: PathBuf,
31    socket_path: PathBuf,
32    log_dir: PathBuf,
33}
34
35/// Create an [`AgentPlugin`] from a JSON config string.
36///
37/// Falls back to temp-path defaults if config is empty (for testing).
38fn create_agent_from_config(config: &str) -> AgentPlugin {
39    if config.is_empty() {
40        AgentPlugin::new(
41            PathBuf::from("/tmp/room-agents.json"),
42            PathBuf::from("/tmp/room-default.sock"),
43            PathBuf::from("/tmp/room-logs"),
44        )
45    } else {
46        let cfg: AgentConfig =
47            serde_json::from_str(config).expect("invalid agent plugin config JSON");
48        AgentPlugin::new(cfg.state_path, cfg.socket_path, cfg.log_dir)
49    }
50}
51
52room_protocol::declare_plugin!("agent", create_agent_from_config);
53
54/// Grace period in seconds before sending SIGKILL after SIGTERM.
55const STOP_GRACE_PERIOD_SECS: u64 = 5;
56
57/// Default number of log lines to show.
58const DEFAULT_TAIL_LINES: usize = 20;
59
60/// Default threshold in seconds before an agent is considered stale.
61const DEFAULT_STALE_THRESHOLD_SECS: i64 = 300;
62
63/// Health status of a spawned agent.
64#[derive(Debug, Clone, PartialEq)]
65pub enum HealthStatus {
66    /// Agent is active — sent a message recently.
67    Healthy,
68    /// Agent has not sent any message within the stale threshold.
69    Stale,
70    /// Agent process has exited.
71    Exited(Option<i32>),
72}
73
74impl std::fmt::Display for HealthStatus {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            HealthStatus::Healthy => write!(f, "healthy"),
78            HealthStatus::Stale => write!(f, "stale"),
79            HealthStatus::Exited(Some(code)) => write!(f, "exited ({code})"),
80            HealthStatus::Exited(None) => write!(f, "exited (signal)"),
81        }
82    }
83}
84
85/// A spawned agent process tracked by the plugin.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct SpawnedAgent {
88    pub username: String,
89    pub pid: u32,
90    pub model: String,
91    #[serde(default)]
92    pub personality: String,
93    pub spawned_at: DateTime<Utc>,
94    pub log_path: PathBuf,
95    pub room_id: String,
96}
97
98/// Agent spawn/stop/list plugin.
99///
100/// Manages ralph agent processes spawned from within a room. Tracks PIDs,
101/// provides `/agent spawn`, `/agent list`, and `/agent stop` commands.
102pub struct AgentPlugin {
103    /// Running agents keyed by username.
104    agents: Arc<Mutex<HashMap<String, SpawnedAgent>>>,
105    /// Child process handles for exit code tracking (not serialized).
106    children: Arc<Mutex<HashMap<String, Child>>>,
107    /// Recorded exit codes for agents whose Child handles have been reaped.
108    exit_codes: Arc<Mutex<HashMap<String, Option<i32>>>>,
109    /// Last time each tracked agent sent a message, keyed by username.
110    last_seen_at: Arc<Mutex<HashMap<String, DateTime<Utc>>>>,
111    /// Seconds of inactivity before an agent is marked stale.
112    stale_threshold_secs: i64,
113    /// Path to persist agent state (e.g. `~/.room/state/agents-<room>.json`).
114    state_path: PathBuf,
115    /// Socket path to pass to spawned ralph processes.
116    socket_path: PathBuf,
117    /// Directory for agent log files.
118    log_dir: PathBuf,
119}
120
121impl AgentPlugin {
122    /// Create a new agent plugin.
123    ///
124    /// Loads previously persisted agent state and prunes entries whose
125    /// processes are no longer running.
126    pub fn new(state_path: PathBuf, socket_path: PathBuf, log_dir: PathBuf) -> Self {
127        let agents = load_agents(&state_path);
128        // Prune dead agents on startup
129        let agents: HashMap<String, SpawnedAgent> = agents
130            .into_iter()
131            .filter(|(_, a)| is_process_alive(a.pid))
132            .collect();
133        let plugin = Self {
134            agents: Arc::new(Mutex::new(agents)),
135            children: Arc::new(Mutex::new(HashMap::new())),
136            exit_codes: Arc::new(Mutex::new(HashMap::new())),
137            last_seen_at: Arc::new(Mutex::new(HashMap::new())),
138            stale_threshold_secs: DEFAULT_STALE_THRESHOLD_SECS,
139            state_path,
140            socket_path,
141            log_dir,
142        };
143        plugin.persist();
144        plugin
145    }
146
147    /// Returns the command info for the TUI command palette without needing
148    /// an instantiated plugin. Used by `all_known_commands()`.
149    pub fn default_commands() -> Vec<CommandInfo> {
150        vec![
151            CommandInfo {
152                name: "agent".to_owned(),
153                description: "Spawn, list, stop, or tail logs of ralph agents".to_owned(),
154                usage: "/agent <action> [args...]".to_owned(),
155                params: vec![
156                    ParamSchema {
157                        name: "action".to_owned(),
158                        param_type: ParamType::Choice(vec![
159                            "spawn".to_owned(),
160                            "list".to_owned(),
161                            "stop".to_owned(),
162                            "logs".to_owned(),
163                        ]),
164                        required: true,
165                        description: "Subcommand".to_owned(),
166                    },
167                    ParamSchema {
168                        name: "args".to_owned(),
169                        param_type: ParamType::Text,
170                        required: false,
171                        description: "Arguments for the subcommand".to_owned(),
172                    },
173                ],
174            },
175            CommandInfo {
176                name: "spawn".to_owned(),
177                description: "Spawn an agent by personality name".to_owned(),
178                usage: "/spawn <personality> [--name <username>]".to_owned(),
179                params: vec![
180                    ParamSchema {
181                        name: "personality".to_owned(),
182                        param_type: ParamType::Choice(personalities::all_personality_names()),
183                        required: true,
184                        description: "Personality preset name".to_owned(),
185                    },
186                    ParamSchema {
187                        name: "name".to_owned(),
188                        param_type: ParamType::Text,
189                        required: false,
190                        description: "Override auto-generated username".to_owned(),
191                    },
192                ],
193            },
194        ]
195    }
196
197    fn persist(&self) {
198        let agents = self.agents.lock().unwrap();
199        if let Ok(json) = serde_json::to_string_pretty(&*agents) {
200            let _ = fs::write(&self.state_path, json);
201        }
202    }
203
204    fn handle_spawn(&self, ctx: &CommandContext) -> Result<(String, serde_json::Value), String> {
205        let params = &ctx.params;
206        if params.len() < 2 {
207            return Err(
208                "usage: /agent spawn <username> [--model <model>] [--personality <name>] [--issue <N>] [--prompt <text>]"
209                    .to_owned(),
210            );
211        }
212
213        let username = &params[1];
214
215        // Validate username is not empty
216        if username.is_empty() || username.starts_with('-') {
217            return Err("invalid username".to_owned());
218        }
219
220        // Check for collision with online users
221        if ctx
222            .metadata
223            .online_users
224            .iter()
225            .any(|u| u.username == *username)
226        {
227            return Err(format!("username '{username}' is already online"));
228        }
229
230        // Check for collision with already-spawned agents
231        {
232            let agents = self.agents.lock().unwrap();
233            if agents.contains_key(username.as_str()) {
234                return Err(format!(
235                    "agent '{username}' is already running (pid {})",
236                    agents[username.as_str()].pid
237                ));
238            }
239        }
240
241        // Parse optional flags from params[2..]
242        let mut model = "sonnet".to_owned();
243        let mut personality = String::new();
244        let mut issue: Option<String> = None;
245        let mut prompt: Option<String> = None;
246
247        let mut i = 2;
248        while i < params.len() {
249            match params[i].as_str() {
250                "--model" => {
251                    i += 1;
252                    if i < params.len() {
253                        model = params[i].clone();
254                    }
255                }
256                "--personality" => {
257                    i += 1;
258                    if i < params.len() {
259                        personality = params[i].clone();
260                    }
261                }
262                "--issue" => {
263                    i += 1;
264                    if i < params.len() {
265                        issue = Some(params[i].clone());
266                    }
267                }
268                "--prompt" => {
269                    i += 1;
270                    if i < params.len() {
271                        prompt = Some(params[i].clone());
272                    }
273                }
274                _ => {}
275            }
276            i += 1;
277        }
278
279        // Create log directory
280        let _ = fs::create_dir_all(&self.log_dir);
281
282        let ts = Utc::now().format("%Y%m%d-%H%M%S");
283        let log_path = self.log_dir.join(format!("agent-{username}-{ts}.log"));
284
285        let log_file =
286            fs::File::create(&log_path).map_err(|e| format!("failed to create log file: {e}"))?;
287        let stderr_file = log_file
288            .try_clone()
289            .map_err(|e| format!("failed to clone log file handle: {e}"))?;
290
291        // Build the room-ralph command
292        let mut cmd = Command::new("room-ralph");
293        cmd.arg(&ctx.room_id)
294            .arg(username)
295            .arg("--socket")
296            .arg(&self.socket_path)
297            .arg("--model")
298            .arg(&model);
299
300        if let Some(ref iss) = issue {
301            cmd.arg("--issue").arg(iss);
302        }
303        if let Some(ref p) = prompt {
304            cmd.arg("--prompt").arg(p);
305        }
306        if !personality.is_empty() {
307            cmd.arg("--personality").arg(&personality);
308        }
309
310        cmd.stdin(Stdio::null())
311            .stdout(Stdio::from(log_file))
312            .stderr(Stdio::from(stderr_file));
313
314        let child = cmd
315            .spawn()
316            .map_err(|e| format!("failed to spawn room-ralph: {e}"))?;
317
318        let pid = child.id();
319
320        let agent = SpawnedAgent {
321            username: username.clone(),
322            pid,
323            model: model.clone(),
324            personality: personality.clone(),
325            spawned_at: Utc::now(),
326            log_path: log_path.clone(),
327            room_id: ctx.room_id.clone(),
328        };
329
330        {
331            let mut agents = self.agents.lock().unwrap();
332            agents.insert(username.clone(), agent);
333        }
334        {
335            let mut children = self.children.lock().unwrap();
336            children.insert(username.clone(), child);
337        }
338        self.persist();
339
340        let personality_info = if personality.is_empty() {
341            String::new()
342        } else {
343            format!(", personality: {personality}")
344        };
345        let text =
346            format!("agent {username} spawned (pid {pid}, model: {model}{personality_info})");
347        let data = serde_json::json!({
348            "action": "spawn",
349            "username": username,
350            "pid": pid,
351            "model": model,
352            "personality": personality,
353            "log_path": log_path.to_string_lossy(),
354        });
355        Ok((text, data))
356    }
357
358    /// Compute the health status for a given agent.
359    fn compute_health(
360        &self,
361        agent: &SpawnedAgent,
362        exit_codes: &HashMap<String, Option<i32>>,
363        now: DateTime<Utc>,
364    ) -> HealthStatus {
365        if !is_process_alive(agent.pid) {
366            let code = exit_codes.get(&agent.username).copied().unwrap_or(None);
367            return HealthStatus::Exited(code);
368        }
369        let last_seen = self.last_seen_at.lock().unwrap();
370        if let Some(&ts) = last_seen.get(&agent.username) {
371            let elapsed = (now - ts).num_seconds();
372            if elapsed > self.stale_threshold_secs {
373                return HealthStatus::Stale;
374            }
375        }
376        // No message tracked yet but process is alive — healthy (just spawned).
377        HealthStatus::Healthy
378    }
379
380    fn handle_list(&self) -> (String, serde_json::Value) {
381        let agents = self.agents.lock().unwrap();
382        if agents.is_empty() {
383            let data = serde_json::json!({ "action": "list", "agents": [] });
384            return ("no agents spawned".to_owned(), data);
385        }
386
387        let mut lines = vec![
388            "username     | pid   | personality | model  | uptime  | health  | status".to_owned(),
389        ];
390
391        // Try to reap exit codes from child handles.
392        {
393            let mut children = self.children.lock().unwrap();
394            let mut exit_codes = self.exit_codes.lock().unwrap();
395            let usernames: Vec<String> = children.keys().cloned().collect();
396            for name in usernames {
397                if let Some(child) = children.get_mut(&name) {
398                    if let Ok(Some(status)) = child.try_wait() {
399                        exit_codes.insert(name.clone(), status.code());
400                        children.remove(&name);
401                    }
402                }
403            }
404        }
405
406        let exit_codes = self.exit_codes.lock().unwrap();
407        let now = Utc::now();
408        let mut entries: Vec<_> = agents.values().collect();
409        entries.sort_by_key(|a| a.spawned_at);
410        let mut agent_data: Vec<serde_json::Value> = Vec::new();
411
412        for agent in entries {
413            let uptime = format_duration(now - agent.spawned_at);
414            let health = self.compute_health(agent, &exit_codes, now);
415            let status = if is_process_alive(agent.pid) {
416                "running".to_owned()
417            } else if let Some(code) = exit_codes.get(&agent.username) {
418                match code {
419                    Some(c) => format!("exited ({c})"),
420                    None => "exited (signal)".to_owned(),
421                }
422            } else {
423                "exited (unknown)".to_owned()
424            };
425            let personality_display = if agent.personality.is_empty() {
426                "-"
427            } else {
428                &agent.personality
429            };
430            let health_str = health.to_string();
431            lines.push(format!(
432                "{:<12} | {:<5} | {:<11} | {:<6} | {:<7} | {:<7} | {}",
433                agent.username,
434                agent.pid,
435                personality_display,
436                agent.model,
437                uptime,
438                health_str,
439                status,
440            ));
441            agent_data.push(serde_json::json!({
442                "username": agent.username,
443                "pid": agent.pid,
444                "model": agent.model,
445                "personality": agent.personality,
446                "uptime_secs": (now - agent.spawned_at).num_seconds(),
447                "health": health_str,
448                "status": status,
449            }));
450        }
451
452        let data = serde_json::json!({ "action": "list", "agents": agent_data });
453        (lines.join("\n"), data)
454    }
455
456    /// Handle `/spawn <personality> [--name <username>]`.
457    ///
458    /// Resolves the personality from the registry (user TOML overrides then
459    /// built-in defaults), generates a username from the name pool, and
460    /// spawns room-ralph with the personality's model, tool restrictions,
461    /// and prompt.
462    fn handle_spawn_personality(&self, ctx: &CommandContext) -> Result<String, String> {
463        if ctx.params.is_empty() {
464            return Err("usage: /spawn <personality> [--name <username>]".to_owned());
465        }
466
467        let personality_name = &ctx.params[0];
468
469        let personality = personalities::resolve_personality(personality_name)
470            .map_err(|e| format!("failed to load personality '{personality_name}': {e}"))?
471            .ok_or_else(|| {
472                let available = personalities::all_personality_names().join(", ");
473                format!("unknown personality '{personality_name}'. available: {available}")
474            })?;
475
476        // Parse --name flag
477        let mut explicit_name: Option<String> = None;
478        let mut i = 1;
479        while i < ctx.params.len() {
480            if ctx.params[i] == "--name" {
481                i += 1;
482                if i < ctx.params.len() {
483                    explicit_name = Some(ctx.params[i].clone());
484                }
485            }
486            i += 1;
487        }
488
489        // Determine username
490        let used_names: Vec<String> = {
491            let agents = self.agents.lock().unwrap();
492            let mut names: Vec<String> = agents.keys().cloned().collect();
493            names.extend(ctx.metadata.online_users.iter().map(|u| u.username.clone()));
494            names
495        };
496
497        let username = if let Some(name) = explicit_name {
498            name
499        } else {
500            personality.generate_username(&used_names)
501        };
502
503        // Validate username
504        if username.is_empty() || username.starts_with('-') {
505            return Err("invalid username".to_owned());
506        }
507
508        // Check collisions
509        if ctx
510            .metadata
511            .online_users
512            .iter()
513            .any(|u| u.username == username)
514        {
515            return Err(format!("username '{username}' is already online"));
516        }
517        {
518            let agents = self.agents.lock().unwrap();
519            if agents.contains_key(username.as_str()) {
520                return Err(format!(
521                    "agent '{username}' is already running (pid {})",
522                    agents[username.as_str()].pid
523                ));
524            }
525        }
526
527        // Create log directory
528        let _ = fs::create_dir_all(&self.log_dir);
529
530        let ts = Utc::now().format("%Y%m%d-%H%M%S");
531        let log_path = self.log_dir.join(format!("agent-{username}-{ts}.log"));
532
533        let log_file =
534            fs::File::create(&log_path).map_err(|e| format!("failed to create log file: {e}"))?;
535        let stderr_file = log_file
536            .try_clone()
537            .map_err(|e| format!("failed to clone log file handle: {e}"))?;
538
539        // Build the room-ralph command from personality config
540        let model = &personality.personality.model;
541        let mut cmd = Command::new("room-ralph");
542        cmd.arg(&ctx.room_id)
543            .arg(&username)
544            .arg("--socket")
545            .arg(&self.socket_path)
546            .arg("--model")
547            .arg(model);
548
549        // Apply tool restrictions
550        if personality.tools.allow_all {
551            cmd.arg("--allow-all");
552        } else {
553            if !personality.tools.disallow.is_empty() {
554                cmd.arg("--disallow-tools")
555                    .arg(personality.tools.disallow.join(","));
556            }
557            if !personality.tools.allow.is_empty() {
558                cmd.arg("--allow-tools")
559                    .arg(personality.tools.allow.join(","));
560            }
561        }
562
563        // Apply prompt
564        if !personality.prompt.template.is_empty() {
565            cmd.arg("--prompt").arg(&personality.prompt.template);
566        }
567
568        cmd.stdin(Stdio::null())
569            .stdout(Stdio::from(log_file))
570            .stderr(Stdio::from(stderr_file));
571
572        let child = cmd
573            .spawn()
574            .map_err(|e| format!("failed to spawn room-ralph: {e}"))?;
575
576        let pid = child.id();
577
578        let agent = SpawnedAgent {
579            username: username.clone(),
580            pid,
581            model: model.clone(),
582            personality: personality_name.to_owned(),
583            spawned_at: Utc::now(),
584            log_path,
585            room_id: ctx.room_id.clone(),
586        };
587
588        {
589            let mut agents = self.agents.lock().unwrap();
590            agents.insert(username.clone(), agent);
591        }
592        {
593            let mut children = self.children.lock().unwrap();
594            children.insert(username.clone(), child);
595        }
596        self.persist();
597
598        Ok(format!(
599            "agent {username} spawned via /spawn {personality_name} (pid {pid}, model: {model})"
600        ))
601    }
602
603    fn handle_stop(&self, ctx: &CommandContext) -> Result<(String, serde_json::Value), String> {
604        if ctx.params.len() < 2 {
605            return Err("usage: /agent stop <username>".to_owned());
606        }
607
608        // Host-only permission check
609        if let Some(ref host) = ctx.metadata.host {
610            if ctx.sender != *host {
611                return Err("permission denied: only the host can stop agents".to_owned());
612            }
613        }
614
615        let username = &ctx.params[1];
616
617        let agent = {
618            let agents = self.agents.lock().unwrap();
619            agents.get(username.as_str()).cloned()
620        };
621
622        let Some(agent) = agent else {
623            return Err(format!("no agent named '{username}'"));
624        };
625
626        // Check if already exited before attempting to stop
627        let was_alive = is_process_alive(agent.pid);
628        if was_alive {
629            // Try to use stored Child handle for clean shutdown, fall back to PID signal.
630            let mut child = {
631                let mut children = self.children.lock().unwrap();
632                children.remove(username.as_str())
633            };
634            if let Some(ref mut child) = child {
635                let _ = child.kill();
636                let _ = child.wait();
637            } else {
638                stop_process(agent.pid, STOP_GRACE_PERIOD_SECS);
639            }
640        }
641
642        {
643            let mut agents = self.agents.lock().unwrap();
644            agents.remove(username.as_str());
645        }
646        {
647            let mut exit_codes = self.exit_codes.lock().unwrap();
648            exit_codes.remove(username.as_str());
649        }
650        self.persist();
651
652        let data = serde_json::json!({
653            "action": "stop",
654            "username": username,
655            "pid": agent.pid,
656            "was_alive": was_alive,
657            "stopped_by": ctx.sender,
658        });
659        if was_alive {
660            Ok((
661                format!(
662                    "agent {} stopped by {} (was pid {})",
663                    username, ctx.sender, agent.pid
664                ),
665                data,
666            ))
667        } else {
668            Ok((
669                format!(
670                    "agent {} removed (already exited, was pid {})",
671                    username, agent.pid
672                ),
673                data,
674            ))
675        }
676    }
677
678    fn handle_logs(&self, ctx: &CommandContext) -> Result<String, String> {
679        if ctx.params.len() < 2 {
680            return Err("usage: /agent logs <username> [--tail <N>]".to_owned());
681        }
682
683        let username = &ctx.params[1];
684
685        // Parse optional --tail flag
686        let mut tail_lines = DEFAULT_TAIL_LINES;
687        let mut i = 2;
688        while i < ctx.params.len() {
689            if ctx.params[i] == "--tail" {
690                i += 1;
691                if i < ctx.params.len() {
692                    tail_lines = ctx.params[i]
693                        .parse::<usize>()
694                        .map_err(|_| format!("invalid --tail value: {}", ctx.params[i]))?;
695                    if tail_lines == 0 {
696                        return Err("--tail must be at least 1".to_owned());
697                    }
698                }
699            }
700            i += 1;
701        }
702
703        // Look up the agent
704        let agent = {
705            let agents = self.agents.lock().unwrap();
706            agents.get(username.as_str()).cloned()
707        };
708
709        let Some(agent) = agent else {
710            return Err(format!("no agent named '{username}'"));
711        };
712
713        // Read the log file
714        let content = fs::read_to_string(&agent.log_path)
715            .map_err(|e| format!("cannot read log file {}: {e}", agent.log_path.display()))?;
716
717        if content.is_empty() {
718            return Ok(format!("agent {username}: log file is empty"));
719        }
720
721        // Take last N lines
722        let lines: Vec<&str> = content.lines().collect();
723        let start = lines.len().saturating_sub(tail_lines);
724        let tail: Vec<&str> = lines[start..].to_vec();
725
726        let header = format!(
727            "agent {username} logs (last {} of {} lines):",
728            tail.len(),
729            lines.len()
730        );
731        Ok(format!("{header}\n{}", tail.join("\n")))
732    }
733}
734
735impl Plugin for AgentPlugin {
736    fn name(&self) -> &str {
737        "agent"
738    }
739
740    fn version(&self) -> &str {
741        env!("CARGO_PKG_VERSION")
742    }
743
744    fn commands(&self) -> Vec<CommandInfo> {
745        Self::default_commands()
746    }
747
748    fn on_message(&self, msg: &Message) {
749        let user = msg.user();
750        let agents = self.agents.lock().unwrap();
751        if agents.contains_key(user) {
752            drop(agents);
753            let now = Utc::now();
754            let mut last_seen = self.last_seen_at.lock().unwrap();
755            last_seen.insert(user.to_owned(), now);
756        }
757    }
758
759    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
760        Box::pin(async move {
761            // `/spawn <personality>` is dispatched here with command == "spawn".
762            if ctx.command == "spawn" {
763                return match self.handle_spawn_personality(&ctx) {
764                    Ok(msg) => Ok(PluginResult::Broadcast(msg, None)),
765                    Err(e) => Ok(PluginResult::Reply(e, None)),
766                };
767            }
768
769            // `/agent <action> [args...]`
770            let action = ctx.params.first().map(|s| s.as_str()).unwrap_or("");
771
772            match action {
773                "spawn" => match self.handle_spawn(&ctx) {
774                    Ok((msg, data)) => Ok(PluginResult::Broadcast(msg, Some(data))),
775                    Err(e) => Ok(PluginResult::Reply(e, None)),
776                },
777                "list" => {
778                    let (text, data) = self.handle_list();
779                    Ok(PluginResult::Reply(text, Some(data)))
780                }
781                "stop" => match self.handle_stop(&ctx) {
782                    Ok((msg, data)) => Ok(PluginResult::Broadcast(msg, Some(data))),
783                    Err(e) => Ok(PluginResult::Reply(e, None)),
784                },
785                "logs" => match self.handle_logs(&ctx) {
786                    Ok(msg) => Ok(PluginResult::Reply(msg, None)),
787                    Err(e) => Ok(PluginResult::Reply(e, None)),
788                },
789                _ => Ok(PluginResult::Reply(
790                    "unknown action. usage: /agent spawn|list|stop|logs".to_owned(),
791                    None,
792                )),
793            }
794        })
795    }
796}
797
798/// Check whether a process with the given PID is still running.
799fn is_process_alive(pid: u32) -> bool {
800    #[cfg(unix)]
801    {
802        // kill(pid, 0) checks existence without sending a signal
803        unsafe { libc::kill(pid as i32, 0) == 0 }
804    }
805    #[cfg(not(unix))]
806    {
807        let _ = pid;
808        false
809    }
810}
811
812/// Send SIGTERM to a process, wait `grace_secs`, then SIGKILL if still alive.
813fn stop_process(pid: u32, grace_secs: u64) {
814    #[cfg(unix)]
815    {
816        unsafe {
817            libc::kill(pid as i32, libc::SIGTERM);
818        }
819        std::thread::sleep(std::time::Duration::from_secs(grace_secs));
820        if is_process_alive(pid) {
821            unsafe {
822                libc::kill(pid as i32, libc::SIGKILL);
823            }
824        }
825    }
826    #[cfg(not(unix))]
827    {
828        let _ = (pid, grace_secs);
829    }
830}
831
832/// Format a chrono Duration as a human-readable string (e.g. "14m", "2h").
833fn format_duration(d: chrono::Duration) -> String {
834    let secs = d.num_seconds();
835    if secs < 60 {
836        format!("{secs}s")
837    } else if secs < 3600 {
838        format!("{}m", secs / 60)
839    } else {
840        format!("{}h", secs / 3600)
841    }
842}
843
844/// Load agent state from a JSON file, returning an empty map on error.
845fn load_agents(path: &std::path::Path) -> HashMap<String, SpawnedAgent> {
846    match fs::read_to_string(path) {
847        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
848        Err(_) => HashMap::new(),
849    }
850}
851
852// ── Tests ─────────────────────────────────────────────────────────────────────
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use room_protocol::plugin::{RoomMetadata, UserInfo};
858
859    fn test_plugin(dir: &std::path::Path) -> AgentPlugin {
860        AgentPlugin::new(
861            dir.join("agents.json"),
862            dir.join("room.sock"),
863            dir.join("logs"),
864        )
865    }
866
867    fn make_ctx(_plugin: &AgentPlugin, params: Vec<&str>, online: Vec<&str>) -> CommandContext {
868        CommandContext {
869            command: "agent".to_owned(),
870            params: params.into_iter().map(|s| s.to_owned()).collect(),
871            sender: "host".to_owned(),
872            room_id: "test-room".to_owned(),
873            message_id: "msg-1".to_owned(),
874            timestamp: Utc::now(),
875            history: Box::new(NoopHistory),
876            writer: Box::new(NoopWriter),
877            metadata: RoomMetadata {
878                online_users: online
879                    .into_iter()
880                    .map(|u| UserInfo {
881                        username: u.to_owned(),
882                        status: String::new(),
883                    })
884                    .collect(),
885                host: Some("host".to_owned()),
886                message_count: 0,
887            },
888            available_commands: vec![],
889            team_access: None,
890        }
891    }
892
893    // Noop implementations for test contexts
894    struct NoopHistory;
895    impl room_protocol::plugin::HistoryAccess for NoopHistory {
896        fn all(&self) -> BoxFuture<'_, anyhow::Result<Vec<room_protocol::Message>>> {
897            Box::pin(async { Ok(vec![]) })
898        }
899        fn tail(&self, _n: usize) -> BoxFuture<'_, anyhow::Result<Vec<room_protocol::Message>>> {
900            Box::pin(async { Ok(vec![]) })
901        }
902        fn since(
903            &self,
904            _message_id: &str,
905        ) -> BoxFuture<'_, anyhow::Result<Vec<room_protocol::Message>>> {
906            Box::pin(async { Ok(vec![]) })
907        }
908        fn count(&self) -> BoxFuture<'_, anyhow::Result<usize>> {
909            Box::pin(async { Ok(0) })
910        }
911    }
912
913    struct NoopWriter;
914    impl room_protocol::plugin::MessageWriter for NoopWriter {
915        fn broadcast(&self, _content: &str) -> BoxFuture<'_, anyhow::Result<()>> {
916            Box::pin(async { Ok(()) })
917        }
918        fn reply_to(&self, _user: &str, _content: &str) -> BoxFuture<'_, anyhow::Result<()>> {
919            Box::pin(async { Ok(()) })
920        }
921        fn emit_event(
922            &self,
923            _event_type: room_protocol::EventType,
924            _content: &str,
925            _params: Option<serde_json::Value>,
926        ) -> BoxFuture<'_, anyhow::Result<()>> {
927            Box::pin(async { Ok(()) })
928        }
929    }
930
931    #[test]
932    fn spawn_missing_username() {
933        let dir = tempfile::tempdir().unwrap();
934        let plugin = test_plugin(dir.path());
935        let ctx = make_ctx(&plugin, vec!["spawn"], vec![]);
936        let result = plugin.handle_spawn(&ctx);
937        assert!(result.is_err());
938        assert!(result.unwrap_err().contains("usage"));
939    }
940
941    #[test]
942    fn spawn_invalid_username() {
943        let dir = tempfile::tempdir().unwrap();
944        let plugin = test_plugin(dir.path());
945        let ctx = make_ctx(&plugin, vec!["spawn", "--badname"], vec![]);
946        let result = plugin.handle_spawn(&ctx);
947        assert!(result.is_err());
948        assert!(result.unwrap_err().contains("invalid username"));
949    }
950
951    #[test]
952    fn spawn_username_collision_with_online_user() {
953        let dir = tempfile::tempdir().unwrap();
954        let plugin = test_plugin(dir.path());
955        let ctx = make_ctx(&plugin, vec!["spawn", "alice"], vec!["alice", "bob"]);
956        let result = plugin.handle_spawn(&ctx);
957        assert!(result.is_err());
958        assert!(result.unwrap_err().contains("already online"));
959    }
960
961    #[test]
962    fn spawn_username_collision_with_running_agent() {
963        let dir = tempfile::tempdir().unwrap();
964        let plugin = test_plugin(dir.path());
965
966        // Manually insert a fake running agent (use our own PID so it appears alive)
967        {
968            let mut agents = plugin.agents.lock().unwrap();
969            agents.insert(
970                "bot1".to_owned(),
971                SpawnedAgent {
972                    username: "bot1".to_owned(),
973                    pid: std::process::id(),
974                    model: "sonnet".to_owned(),
975                    personality: String::new(),
976                    spawned_at: Utc::now(),
977                    log_path: PathBuf::from("/tmp/test.log"),
978                    room_id: "test-room".to_owned(),
979                },
980            );
981        }
982
983        let ctx = make_ctx(&plugin, vec!["spawn", "bot1"], vec![]);
984        let result = plugin.handle_spawn(&ctx);
985        assert!(result.is_err());
986        assert!(result.unwrap_err().contains("already running"));
987    }
988
989    #[test]
990    fn list_empty() {
991        let dir = tempfile::tempdir().unwrap();
992        let plugin = test_plugin(dir.path());
993        assert_eq!(plugin.handle_list().0, "no agents spawned");
994    }
995
996    #[test]
997    fn list_with_agents() {
998        let dir = tempfile::tempdir().unwrap();
999        let plugin = test_plugin(dir.path());
1000
1001        {
1002            let mut agents = plugin.agents.lock().unwrap();
1003            agents.insert(
1004                "bot1".to_owned(),
1005                SpawnedAgent {
1006                    username: "bot1".to_owned(),
1007                    pid: 99999,
1008                    model: "opus".to_owned(),
1009                    personality: String::new(),
1010                    spawned_at: Utc::now(),
1011                    log_path: PathBuf::from("/tmp/test.log"),
1012                    room_id: "test-room".to_owned(),
1013                },
1014            );
1015        }
1016
1017        let (output, _data) = plugin.handle_list();
1018        assert!(output.contains("bot1"));
1019        assert!(output.contains("opus"));
1020        assert!(output.contains("99999"));
1021    }
1022
1023    #[test]
1024    fn stop_missing_username() {
1025        let dir = tempfile::tempdir().unwrap();
1026        let plugin = test_plugin(dir.path());
1027        let ctx = make_ctx(&plugin, vec!["stop"], vec![]);
1028        let result = plugin.handle_stop(&ctx);
1029        assert!(result.is_err());
1030        assert!(result.unwrap_err().contains("usage"));
1031    }
1032
1033    #[test]
1034    fn stop_unknown_agent() {
1035        let dir = tempfile::tempdir().unwrap();
1036        let plugin = test_plugin(dir.path());
1037        let ctx = make_ctx(&plugin, vec!["stop", "nobody"], vec![]);
1038        let result = plugin.handle_stop(&ctx);
1039        assert!(result.is_err());
1040        assert!(result.unwrap_err().contains("no agent named"));
1041    }
1042
1043    #[test]
1044    fn stop_non_host_denied() {
1045        let dir = tempfile::tempdir().unwrap();
1046        let plugin = test_plugin(dir.path());
1047
1048        // Insert a fake agent
1049        {
1050            let mut agents = plugin.agents.lock().unwrap();
1051            agents.insert(
1052                "bot1".to_owned(),
1053                SpawnedAgent {
1054                    username: "bot1".to_owned(),
1055                    pid: std::process::id(),
1056                    model: "sonnet".to_owned(),
1057                    personality: String::new(),
1058                    spawned_at: Utc::now(),
1059                    log_path: PathBuf::from("/tmp/test.log"),
1060                    room_id: "test-room".to_owned(),
1061                },
1062            );
1063        }
1064
1065        // Create context with sender != host
1066        let mut ctx = make_ctx(&plugin, vec!["stop", "bot1"], vec![]);
1067        ctx.sender = "not-host".to_owned();
1068        let result = plugin.handle_stop(&ctx);
1069        assert!(result.is_err());
1070        assert!(result.unwrap_err().contains("permission denied"));
1071    }
1072
1073    #[test]
1074    fn stop_already_exited_agent() {
1075        let dir = tempfile::tempdir().unwrap();
1076        let plugin = test_plugin(dir.path());
1077
1078        // Insert an agent with a dead PID
1079        {
1080            let mut agents = plugin.agents.lock().unwrap();
1081            agents.insert(
1082                "dead-bot".to_owned(),
1083                SpawnedAgent {
1084                    username: "dead-bot".to_owned(),
1085                    pid: 999_999_999,
1086                    model: "haiku".to_owned(),
1087                    personality: String::new(),
1088                    spawned_at: Utc::now(),
1089                    log_path: PathBuf::from("/tmp/test.log"),
1090                    room_id: "test-room".to_owned(),
1091                },
1092            );
1093        }
1094
1095        let ctx = make_ctx(&plugin, vec!["stop", "dead-bot"], vec![]);
1096        let result = plugin.handle_stop(&ctx);
1097        assert!(result.is_ok());
1098        let (msg, _data) = result.unwrap();
1099        assert!(msg.contains("already exited"));
1100        assert!(msg.contains("removed"));
1101
1102        // Agent should be removed from tracking
1103        let agents = plugin.agents.lock().unwrap();
1104        assert!(!agents.contains_key("dead-bot"));
1105    }
1106
1107    #[test]
1108    fn stop_host_can_stop_agent() {
1109        let dir = tempfile::tempdir().unwrap();
1110        let plugin = test_plugin(dir.path());
1111
1112        // Insert an agent with a dead PID (safe to "stop")
1113        {
1114            let mut agents = plugin.agents.lock().unwrap();
1115            agents.insert(
1116                "bot1".to_owned(),
1117                SpawnedAgent {
1118                    username: "bot1".to_owned(),
1119                    pid: 999_999_999,
1120                    model: "sonnet".to_owned(),
1121                    personality: String::new(),
1122                    spawned_at: Utc::now(),
1123                    log_path: PathBuf::from("/tmp/test.log"),
1124                    room_id: "test-room".to_owned(),
1125                },
1126            );
1127        }
1128
1129        // Host (default sender) should be able to stop
1130        let ctx = make_ctx(&plugin, vec!["stop", "bot1"], vec![]);
1131        let result = plugin.handle_stop(&ctx);
1132        assert!(result.is_ok());
1133
1134        let agents = plugin.agents.lock().unwrap();
1135        assert!(!agents.contains_key("bot1"));
1136    }
1137
1138    #[test]
1139    fn persist_and_load_roundtrip() {
1140        let dir = tempfile::tempdir().unwrap();
1141        let state_path = dir.path().join("agents.json");
1142
1143        // Create plugin and add an agent
1144        let plugin = AgentPlugin::new(
1145            state_path.clone(),
1146            dir.path().join("room.sock"),
1147            dir.path().join("logs"),
1148        );
1149        {
1150            let mut agents = plugin.agents.lock().unwrap();
1151            agents.insert(
1152                "bot1".to_owned(),
1153                SpawnedAgent {
1154                    username: "bot1".to_owned(),
1155                    pid: std::process::id(), // use own PID so it appears alive
1156                    model: "sonnet".to_owned(),
1157                    personality: String::new(),
1158                    spawned_at: Utc::now(),
1159                    log_path: PathBuf::from("/tmp/test.log"),
1160                    room_id: "test-room".to_owned(),
1161                },
1162            );
1163        }
1164        plugin.persist();
1165
1166        // Load a new plugin from same state — should find the agent
1167        let plugin2 = AgentPlugin::new(
1168            state_path,
1169            dir.path().join("room.sock"),
1170            dir.path().join("logs"),
1171        );
1172        let agents = plugin2.agents.lock().unwrap();
1173        assert!(agents.contains_key("bot1"));
1174    }
1175
1176    #[test]
1177    fn prune_dead_agents_on_load() {
1178        let dir = tempfile::tempdir().unwrap();
1179        let state_path = dir.path().join("agents.json");
1180
1181        // Write a state file with a dead PID
1182        let mut agents = HashMap::new();
1183        agents.insert(
1184            "dead-bot".to_owned(),
1185            SpawnedAgent {
1186                username: "dead-bot".to_owned(),
1187                pid: 999_999_999, // very unlikely to be alive
1188                model: "haiku".to_owned(),
1189                personality: String::new(),
1190                spawned_at: Utc::now(),
1191                log_path: PathBuf::from("/tmp/test.log"),
1192                room_id: "test-room".to_owned(),
1193            },
1194        );
1195        fs::write(&state_path, serde_json::to_string(&agents).unwrap()).unwrap();
1196
1197        // New plugin should prune the dead agent
1198        let plugin = AgentPlugin::new(
1199            state_path,
1200            dir.path().join("room.sock"),
1201            dir.path().join("logs"),
1202        );
1203        let agents = plugin.agents.lock().unwrap();
1204        assert!(agents.is_empty(), "dead agents should be pruned on load");
1205    }
1206
1207    // ── /spawn command schema tests ─────────────────────────────────────
1208
1209    #[test]
1210    fn default_commands_includes_spawn() {
1211        let cmds = AgentPlugin::default_commands();
1212        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
1213        assert!(
1214            names.contains(&"spawn"),
1215            "default_commands must include spawn"
1216        );
1217    }
1218
1219    #[test]
1220    fn spawn_command_has_personality_choice_param() {
1221        let cmds = AgentPlugin::default_commands();
1222        let spawn = cmds.iter().find(|c| c.name == "spawn").unwrap();
1223        assert_eq!(spawn.params.len(), 2);
1224        match &spawn.params[0].param_type {
1225            ParamType::Choice(values) => {
1226                assert!(values.contains(&"coder".to_owned()));
1227                assert!(values.contains(&"reviewer".to_owned()));
1228                assert!(values.contains(&"scout".to_owned()));
1229                assert!(values.contains(&"qa".to_owned()));
1230                assert!(values.contains(&"coordinator".to_owned()));
1231                assert_eq!(values.len(), 5);
1232            }
1233            other => panic!("expected Choice, got {:?}", other),
1234        }
1235    }
1236
1237    #[test]
1238    fn spawn_personality_unknown_returns_error() {
1239        let dir = tempfile::tempdir().unwrap();
1240        let plugin = test_plugin(dir.path());
1241        let mut ctx = make_ctx(&plugin, vec!["hacker"], vec![]);
1242        ctx.command = "spawn".to_owned();
1243        let result = plugin.handle_spawn_personality(&ctx);
1244        assert!(result.is_err());
1245        assert!(result.unwrap_err().contains("unknown personality"));
1246    }
1247
1248    #[test]
1249    fn spawn_personality_missing_returns_usage() {
1250        let dir = tempfile::tempdir().unwrap();
1251        let plugin = test_plugin(dir.path());
1252        let mut ctx = make_ctx(&plugin, vec![] as Vec<&str>, vec![]);
1253        ctx.command = "spawn".to_owned();
1254        let result = plugin.handle_spawn_personality(&ctx);
1255        assert!(result.is_err());
1256        assert!(result.unwrap_err().contains("usage"));
1257    }
1258
1259    #[test]
1260    fn spawn_personality_collision_with_online_user() {
1261        let dir = tempfile::tempdir().unwrap();
1262        let plugin = test_plugin(dir.path());
1263        let mut ctx = make_ctx(&plugin, vec!["coder", "--name", "alice"], vec!["alice"]);
1264        ctx.command = "spawn".to_owned();
1265        let result = plugin.handle_spawn_personality(&ctx);
1266        assert!(result.is_err());
1267        assert!(result.unwrap_err().contains("already online"));
1268    }
1269
1270    #[test]
1271    fn spawn_personality_auto_name_skips_used() {
1272        let dir = tempfile::tempdir().unwrap();
1273        let plugin = test_plugin(dir.path());
1274
1275        // Pre-insert agents with the first pool names to force later picks
1276        let coder = personalities::resolve_personality("coder")
1277            .unwrap()
1278            .unwrap();
1279        let first_name = format!("coder-{}", coder.naming.name_pool[0]);
1280        {
1281            let mut agents = plugin.agents.lock().unwrap();
1282            agents.insert(
1283                first_name.clone(),
1284                SpawnedAgent {
1285                    username: first_name.clone(),
1286                    pid: std::process::id(),
1287                    model: "opus".to_owned(),
1288                    personality: "coder".to_owned(),
1289                    spawned_at: Utc::now(),
1290                    log_path: PathBuf::from("/tmp/test.log"),
1291                    room_id: "test-room".to_owned(),
1292                },
1293            );
1294        }
1295
1296        // The name pool should skip the first name and pick the second
1297        let used: Vec<String> = {
1298            let agents = plugin.agents.lock().unwrap();
1299            agents.keys().cloned().collect()
1300        };
1301        let generated = coder.generate_username(&used);
1302        assert_ne!(generated, first_name);
1303        assert!(generated.starts_with("coder-"));
1304    }
1305
1306    #[test]
1307    fn logs_missing_username() {
1308        let dir = tempfile::tempdir().unwrap();
1309        let plugin = test_plugin(dir.path());
1310        let ctx = make_ctx(&plugin, vec!["logs"], vec![]);
1311        let result = plugin.handle_logs(&ctx);
1312        assert!(result.is_err());
1313        assert!(result.unwrap_err().contains("usage"));
1314    }
1315
1316    #[test]
1317    fn logs_unknown_agent() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let plugin = test_plugin(dir.path());
1320        let ctx = make_ctx(&plugin, vec!["logs", "nobody"], vec![]);
1321        let result = plugin.handle_logs(&ctx);
1322        assert!(result.is_err());
1323        assert!(result.unwrap_err().contains("no agent named"));
1324    }
1325
1326    #[test]
1327    fn logs_empty_file() {
1328        let dir = tempfile::tempdir().unwrap();
1329        let plugin = test_plugin(dir.path());
1330        let log_path = dir.path().join("empty.log");
1331        fs::write(&log_path, "").unwrap();
1332
1333        {
1334            let mut agents = plugin.agents.lock().unwrap();
1335            agents.insert(
1336                "bot1".to_owned(),
1337                SpawnedAgent {
1338                    username: "bot1".to_owned(),
1339                    pid: std::process::id(),
1340                    model: "sonnet".to_owned(),
1341                    personality: String::new(),
1342                    spawned_at: Utc::now(),
1343                    log_path: log_path.clone(),
1344                    room_id: "test-room".to_owned(),
1345                },
1346            );
1347        }
1348
1349        let ctx = make_ctx(&plugin, vec!["logs", "bot1"], vec![]);
1350        let result = plugin.handle_logs(&ctx).unwrap();
1351        assert!(result.contains("empty"));
1352    }
1353
1354    #[test]
1355    fn logs_default_tail() {
1356        let dir = tempfile::tempdir().unwrap();
1357        let plugin = test_plugin(dir.path());
1358        let log_path = dir.path().join("agent.log");
1359
1360        // Write 30 lines
1361        let lines: Vec<String> = (1..=30).map(|i| format!("line {i}")).collect();
1362        fs::write(&log_path, lines.join("\n")).unwrap();
1363
1364        {
1365            let mut agents = plugin.agents.lock().unwrap();
1366            agents.insert(
1367                "bot1".to_owned(),
1368                SpawnedAgent {
1369                    username: "bot1".to_owned(),
1370                    pid: std::process::id(),
1371                    model: "sonnet".to_owned(),
1372                    personality: String::new(),
1373                    spawned_at: Utc::now(),
1374                    log_path: log_path.clone(),
1375                    room_id: "test-room".to_owned(),
1376                },
1377            );
1378        }
1379
1380        let ctx = make_ctx(&plugin, vec!["logs", "bot1"], vec![]);
1381        let result = plugin.handle_logs(&ctx).unwrap();
1382        assert!(result.contains("last 20 of 30 lines"));
1383        assert!(result.contains("line 11"));
1384        assert!(result.contains("line 30"));
1385        assert!(!result.contains("line 10\n"));
1386    }
1387
1388    #[test]
1389    fn logs_custom_tail() {
1390        let dir = tempfile::tempdir().unwrap();
1391        let plugin = test_plugin(dir.path());
1392        let log_path = dir.path().join("agent.log");
1393
1394        let lines: Vec<String> = (1..=10).map(|i| format!("line {i}")).collect();
1395        fs::write(&log_path, lines.join("\n")).unwrap();
1396
1397        {
1398            let mut agents = plugin.agents.lock().unwrap();
1399            agents.insert(
1400                "bot1".to_owned(),
1401                SpawnedAgent {
1402                    username: "bot1".to_owned(),
1403                    pid: std::process::id(),
1404                    model: "sonnet".to_owned(),
1405                    personality: String::new(),
1406                    spawned_at: Utc::now(),
1407                    log_path: log_path.clone(),
1408                    room_id: "test-room".to_owned(),
1409                },
1410            );
1411        }
1412
1413        let ctx = make_ctx(&plugin, vec!["logs", "bot1", "--tail", "3"], vec![]);
1414        let result = plugin.handle_logs(&ctx).unwrap();
1415        assert!(result.contains("last 3 of 10 lines"));
1416        assert!(result.contains("line 8"));
1417        assert!(result.contains("line 10"));
1418        assert!(!result.contains("line 7\n"));
1419    }
1420
1421    #[test]
1422    fn logs_tail_larger_than_file() {
1423        let dir = tempfile::tempdir().unwrap();
1424        let plugin = test_plugin(dir.path());
1425        let log_path = dir.path().join("agent.log");
1426
1427        fs::write(&log_path, "only one line").unwrap();
1428
1429        {
1430            let mut agents = plugin.agents.lock().unwrap();
1431            agents.insert(
1432                "bot1".to_owned(),
1433                SpawnedAgent {
1434                    username: "bot1".to_owned(),
1435                    pid: std::process::id(),
1436                    model: "sonnet".to_owned(),
1437                    personality: String::new(),
1438                    spawned_at: Utc::now(),
1439                    log_path: log_path.clone(),
1440                    room_id: "test-room".to_owned(),
1441                },
1442            );
1443        }
1444
1445        let ctx = make_ctx(&plugin, vec!["logs", "bot1", "--tail", "50"], vec![]);
1446        let result = plugin.handle_logs(&ctx).unwrap();
1447        assert!(result.contains("last 1 of 1 lines"));
1448        assert!(result.contains("only one line"));
1449    }
1450
1451    #[test]
1452    fn logs_missing_log_file() {
1453        let dir = tempfile::tempdir().unwrap();
1454        let plugin = test_plugin(dir.path());
1455
1456        {
1457            let mut agents = plugin.agents.lock().unwrap();
1458            agents.insert(
1459                "bot1".to_owned(),
1460                SpawnedAgent {
1461                    username: "bot1".to_owned(),
1462                    pid: std::process::id(),
1463                    model: "sonnet".to_owned(),
1464                    personality: String::new(),
1465                    spawned_at: Utc::now(),
1466                    log_path: PathBuf::from("/nonexistent/path/agent.log"),
1467                    room_id: "test-room".to_owned(),
1468                },
1469            );
1470        }
1471
1472        let ctx = make_ctx(&plugin, vec!["logs", "bot1"], vec![]);
1473        let result = plugin.handle_logs(&ctx);
1474        assert!(result.is_err());
1475        assert!(result.unwrap_err().contains("cannot read log file"));
1476    }
1477
1478    #[test]
1479    fn logs_invalid_tail_value() {
1480        let dir = tempfile::tempdir().unwrap();
1481        let plugin = test_plugin(dir.path());
1482
1483        {
1484            let mut agents = plugin.agents.lock().unwrap();
1485            agents.insert(
1486                "bot1".to_owned(),
1487                SpawnedAgent {
1488                    username: "bot1".to_owned(),
1489                    pid: std::process::id(),
1490                    model: "sonnet".to_owned(),
1491                    personality: String::new(),
1492                    spawned_at: Utc::now(),
1493                    log_path: PathBuf::from("/tmp/test.log"),
1494                    room_id: "test-room".to_owned(),
1495                },
1496            );
1497        }
1498
1499        let ctx = make_ctx(&plugin, vec!["logs", "bot1", "--tail", "abc"], vec![]);
1500        let result = plugin.handle_logs(&ctx);
1501        assert!(result.is_err());
1502        assert!(result.unwrap_err().contains("invalid --tail value"));
1503    }
1504
1505    #[test]
1506    fn logs_zero_tail_rejected() {
1507        let dir = tempfile::tempdir().unwrap();
1508        let plugin = test_plugin(dir.path());
1509
1510        {
1511            let mut agents = plugin.agents.lock().unwrap();
1512            agents.insert(
1513                "bot1".to_owned(),
1514                SpawnedAgent {
1515                    username: "bot1".to_owned(),
1516                    pid: std::process::id(),
1517                    model: "sonnet".to_owned(),
1518                    personality: String::new(),
1519                    spawned_at: Utc::now(),
1520                    log_path: PathBuf::from("/tmp/test.log"),
1521                    room_id: "test-room".to_owned(),
1522                },
1523            );
1524        }
1525
1526        let ctx = make_ctx(&plugin, vec!["logs", "bot1", "--tail", "0"], vec![]);
1527        let result = plugin.handle_logs(&ctx);
1528        assert!(result.is_err());
1529        assert!(result.unwrap_err().contains("--tail must be at least 1"));
1530    }
1531
1532    #[test]
1533    fn unknown_action_returns_usage() {
1534        let dir = tempfile::tempdir().unwrap();
1535        let plugin = test_plugin(dir.path());
1536        let ctx = make_ctx(&plugin, vec!["frobnicate"], vec![]);
1537
1538        let rt = tokio::runtime::Builder::new_current_thread()
1539            .enable_all()
1540            .build()
1541            .unwrap();
1542        let result = rt.block_on(plugin.handle(ctx)).unwrap();
1543        match result {
1544            PluginResult::Reply(msg, _) => assert!(msg.contains("unknown action")),
1545            PluginResult::Broadcast(..) => panic!("expected Reply, got Broadcast"),
1546            PluginResult::Handled => panic!("expected Reply, got Handled"),
1547        }
1548    }
1549
1550    // ── /agent list tests (#689) ──────────────────────────────────────────
1551
1552    #[test]
1553    fn list_header_includes_personality_column() {
1554        let dir = tempfile::tempdir().unwrap();
1555        let plugin = test_plugin(dir.path());
1556
1557        {
1558            let mut agents = plugin.agents.lock().unwrap();
1559            agents.insert(
1560                "bot1".to_owned(),
1561                SpawnedAgent {
1562                    username: "bot1".to_owned(),
1563                    pid: std::process::id(),
1564                    model: "sonnet".to_owned(),
1565                    personality: "coder".to_owned(),
1566                    spawned_at: Utc::now(),
1567                    log_path: PathBuf::from("/tmp/test.log"),
1568                    room_id: "test-room".to_owned(),
1569                },
1570            );
1571        }
1572
1573        let (output, _data) = plugin.handle_list();
1574        let header = output.lines().next().unwrap();
1575        assert!(
1576            header.contains("personality"),
1577            "header must include personality column"
1578        );
1579        assert!(output.contains("coder"), "personality value must appear");
1580    }
1581
1582    #[test]
1583    fn list_shows_dash_for_empty_personality() {
1584        let dir = tempfile::tempdir().unwrap();
1585        let plugin = test_plugin(dir.path());
1586
1587        {
1588            let mut agents = plugin.agents.lock().unwrap();
1589            agents.insert(
1590                "bot1".to_owned(),
1591                SpawnedAgent {
1592                    username: "bot1".to_owned(),
1593                    pid: std::process::id(),
1594                    model: "opus".to_owned(),
1595                    personality: String::new(),
1596                    spawned_at: Utc::now(),
1597                    log_path: PathBuf::from("/tmp/test.log"),
1598                    room_id: "test-room".to_owned(),
1599                },
1600            );
1601        }
1602
1603        let (output, _data) = plugin.handle_list();
1604        // The personality column should show "-" for empty personality.
1605        let data_line = output.lines().nth(1).unwrap();
1606        assert!(
1607            data_line.contains("| -"),
1608            "empty personality should show '-'"
1609        );
1610    }
1611
1612    #[test]
1613    fn list_shows_running_for_alive_process() {
1614        let dir = tempfile::tempdir().unwrap();
1615        let plugin = test_plugin(dir.path());
1616
1617        {
1618            let mut agents = plugin.agents.lock().unwrap();
1619            agents.insert(
1620                "bot1".to_owned(),
1621                SpawnedAgent {
1622                    username: "bot1".to_owned(),
1623                    pid: std::process::id(), // our own PID — always alive
1624                    model: "sonnet".to_owned(),
1625                    personality: String::new(),
1626                    spawned_at: Utc::now(),
1627                    log_path: PathBuf::from("/tmp/test.log"),
1628                    room_id: "test-room".to_owned(),
1629                },
1630            );
1631        }
1632
1633        let (output, _data) = plugin.handle_list();
1634        assert!(
1635            output.contains("running"),
1636            "alive process should show 'running'"
1637        );
1638    }
1639
1640    #[test]
1641    fn list_shows_exited_unknown_for_dead_process_without_child() {
1642        let dir = tempfile::tempdir().unwrap();
1643        let plugin = test_plugin(dir.path());
1644
1645        {
1646            let mut agents = plugin.agents.lock().unwrap();
1647            agents.insert(
1648                "bot1".to_owned(),
1649                SpawnedAgent {
1650                    username: "bot1".to_owned(),
1651                    pid: 999_999_999, // not alive
1652                    model: "haiku".to_owned(),
1653                    personality: "scout".to_owned(),
1654                    spawned_at: Utc::now(),
1655                    log_path: PathBuf::from("/tmp/test.log"),
1656                    room_id: "test-room".to_owned(),
1657                },
1658            );
1659        }
1660
1661        let (output, _data) = plugin.handle_list();
1662        assert!(
1663            output.contains("exited (unknown)"),
1664            "dead process without child handle should show 'exited (unknown)'"
1665        );
1666    }
1667
1668    #[test]
1669    fn list_shows_exit_code_when_recorded() {
1670        let dir = tempfile::tempdir().unwrap();
1671        let plugin = test_plugin(dir.path());
1672
1673        {
1674            let mut agents = plugin.agents.lock().unwrap();
1675            agents.insert(
1676                "bot1".to_owned(),
1677                SpawnedAgent {
1678                    username: "bot1".to_owned(),
1679                    pid: 999_999_999,
1680                    model: "sonnet".to_owned(),
1681                    personality: "coder".to_owned(),
1682                    spawned_at: Utc::now(),
1683                    log_path: PathBuf::from("/tmp/test.log"),
1684                    room_id: "test-room".to_owned(),
1685                },
1686            );
1687        }
1688        {
1689            let mut exit_codes = plugin.exit_codes.lock().unwrap();
1690            exit_codes.insert("bot1".to_owned(), Some(0));
1691        }
1692
1693        let (output, _data) = plugin.handle_list();
1694        assert!(
1695            output.contains("exited (0)"),
1696            "recorded exit code should appear in output"
1697        );
1698    }
1699
1700    #[test]
1701    fn list_shows_signal_when_no_exit_code() {
1702        let dir = tempfile::tempdir().unwrap();
1703        let plugin = test_plugin(dir.path());
1704
1705        {
1706            let mut agents = plugin.agents.lock().unwrap();
1707            agents.insert(
1708                "bot1".to_owned(),
1709                SpawnedAgent {
1710                    username: "bot1".to_owned(),
1711                    pid: 999_999_999,
1712                    model: "sonnet".to_owned(),
1713                    personality: String::new(),
1714                    spawned_at: Utc::now(),
1715                    log_path: PathBuf::from("/tmp/test.log"),
1716                    room_id: "test-room".to_owned(),
1717                },
1718            );
1719        }
1720        {
1721            // None exit code = killed by signal
1722            let mut exit_codes = plugin.exit_codes.lock().unwrap();
1723            exit_codes.insert("bot1".to_owned(), None);
1724        }
1725
1726        let (output, _data) = plugin.handle_list();
1727        assert!(
1728            output.contains("exited (signal)"),
1729            "signal death should show 'exited (signal)'"
1730        );
1731    }
1732
1733    #[test]
1734    fn list_sorts_by_spawn_time() {
1735        let dir = tempfile::tempdir().unwrap();
1736        let plugin = test_plugin(dir.path());
1737        let now = Utc::now();
1738
1739        {
1740            let mut agents = plugin.agents.lock().unwrap();
1741            agents.insert(
1742                "second".to_owned(),
1743                SpawnedAgent {
1744                    username: "second".to_owned(),
1745                    pid: std::process::id(),
1746                    model: "opus".to_owned(),
1747                    personality: String::new(),
1748                    spawned_at: now,
1749                    log_path: PathBuf::from("/tmp/test.log"),
1750                    room_id: "test-room".to_owned(),
1751                },
1752            );
1753            agents.insert(
1754                "first".to_owned(),
1755                SpawnedAgent {
1756                    username: "first".to_owned(),
1757                    pid: std::process::id(),
1758                    model: "sonnet".to_owned(),
1759                    personality: String::new(),
1760                    spawned_at: now - chrono::Duration::minutes(5),
1761                    log_path: PathBuf::from("/tmp/test.log"),
1762                    room_id: "test-room".to_owned(),
1763                },
1764            );
1765        }
1766
1767        let (output, _data) = plugin.handle_list();
1768        let lines: Vec<&str> = output.lines().collect();
1769        // Skip header (line 0), first data line should be "first", second "second".
1770        assert!(
1771            lines[1].contains("first"),
1772            "older agent should appear first"
1773        );
1774        assert!(
1775            lines[2].contains("second"),
1776            "newer agent should appear second"
1777        );
1778    }
1779
1780    #[test]
1781    fn list_with_personality_and_exit_code_full_row() {
1782        let dir = tempfile::tempdir().unwrap();
1783        let plugin = test_plugin(dir.path());
1784
1785        {
1786            let mut agents = plugin.agents.lock().unwrap();
1787            agents.insert(
1788                "reviewer-a1".to_owned(),
1789                SpawnedAgent {
1790                    username: "reviewer-a1".to_owned(),
1791                    pid: 999_999_999,
1792                    model: "sonnet".to_owned(),
1793                    personality: "reviewer".to_owned(),
1794                    spawned_at: Utc::now(),
1795                    log_path: PathBuf::from("/tmp/test.log"),
1796                    room_id: "test-room".to_owned(),
1797                },
1798            );
1799        }
1800        {
1801            let mut exit_codes = plugin.exit_codes.lock().unwrap();
1802            exit_codes.insert("reviewer-a1".to_owned(), Some(0));
1803        }
1804
1805        let (output, _data) = plugin.handle_list();
1806        assert!(output.contains("reviewer-a1"));
1807        assert!(output.contains("reviewer"));
1808        assert!(output.contains("sonnet"));
1809        assert!(output.contains("exited (0)"));
1810    }
1811
1812    #[test]
1813    fn persist_roundtrip_with_personality() {
1814        let dir = tempfile::tempdir().unwrap();
1815        let state_path = dir.path().join("agents.json");
1816
1817        let plugin = AgentPlugin::new(
1818            state_path.clone(),
1819            dir.path().join("room.sock"),
1820            dir.path().join("logs"),
1821        );
1822        {
1823            let mut agents = plugin.agents.lock().unwrap();
1824            agents.insert(
1825                "bot1".to_owned(),
1826                SpawnedAgent {
1827                    username: "bot1".to_owned(),
1828                    pid: std::process::id(),
1829                    model: "sonnet".to_owned(),
1830                    personality: "coder".to_owned(),
1831                    spawned_at: Utc::now(),
1832                    log_path: PathBuf::from("/tmp/test.log"),
1833                    room_id: "test-room".to_owned(),
1834                },
1835            );
1836        }
1837        plugin.persist();
1838
1839        // Reload — personality should survive roundtrip.
1840        let plugin2 = AgentPlugin::new(
1841            state_path,
1842            dir.path().join("room.sock"),
1843            dir.path().join("logs"),
1844        );
1845        let agents = plugin2.agents.lock().unwrap();
1846        assert_eq!(agents["bot1"].personality, "coder");
1847    }
1848
1849    // ── structured data tests (#697) ─────────────────────────────────────
1850
1851    #[test]
1852    fn list_data_contains_agents_array() {
1853        let dir = tempfile::tempdir().unwrap();
1854        let plugin = test_plugin(dir.path());
1855
1856        {
1857            let mut agents = plugin.agents.lock().unwrap();
1858            agents.insert(
1859                "bot1".to_owned(),
1860                SpawnedAgent {
1861                    username: "bot1".to_owned(),
1862                    pid: std::process::id(),
1863                    model: "opus".to_owned(),
1864                    personality: "coder".to_owned(),
1865                    spawned_at: Utc::now(),
1866                    log_path: PathBuf::from("/tmp/test.log"),
1867                    room_id: "test-room".to_owned(),
1868                },
1869            );
1870        }
1871
1872        let (_text, data) = plugin.handle_list();
1873        assert_eq!(data["action"], "list");
1874        let agents = data["agents"].as_array().expect("agents should be array");
1875        assert_eq!(agents.len(), 1);
1876        assert_eq!(agents[0]["username"], "bot1");
1877        assert_eq!(agents[0]["model"], "opus");
1878        assert_eq!(agents[0]["personality"], "coder");
1879        assert_eq!(agents[0]["status"], "running");
1880    }
1881
1882    #[test]
1883    fn list_empty_data_has_empty_agents() {
1884        let dir = tempfile::tempdir().unwrap();
1885        let plugin = test_plugin(dir.path());
1886
1887        let (_text, data) = plugin.handle_list();
1888        assert_eq!(data["action"], "list");
1889        let agents = data["agents"].as_array().expect("agents should be array");
1890        assert!(agents.is_empty());
1891    }
1892
1893    #[test]
1894    fn stop_data_includes_action_and_username() {
1895        let dir = tempfile::tempdir().unwrap();
1896        let plugin = test_plugin(dir.path());
1897
1898        {
1899            let mut agents = plugin.agents.lock().unwrap();
1900            agents.insert(
1901                "bot1".to_owned(),
1902                SpawnedAgent {
1903                    username: "bot1".to_owned(),
1904                    pid: 999_999_999,
1905                    model: "sonnet".to_owned(),
1906                    personality: String::new(),
1907                    spawned_at: Utc::now(),
1908                    log_path: PathBuf::from("/tmp/test.log"),
1909                    room_id: "test-room".to_owned(),
1910                },
1911            );
1912        }
1913
1914        let ctx = make_ctx(&plugin, vec!["stop", "bot1"], vec![]);
1915        let (text, data) = plugin.handle_stop(&ctx).unwrap();
1916        assert!(text.contains("bot1"));
1917        assert_eq!(data["action"], "stop");
1918        assert_eq!(data["username"], "bot1");
1919        assert_eq!(data["was_alive"], false);
1920    }
1921
1922    // ── ABI entry point tests ────────────────────────────────────────────
1923
1924    #[test]
1925    fn abi_declaration_matches_plugin() {
1926        let decl = &ROOM_PLUGIN_DECLARATION;
1927        assert_eq!(decl.api_version, room_protocol::plugin::PLUGIN_API_VERSION);
1928        unsafe {
1929            assert_eq!(decl.name().unwrap(), "agent");
1930            assert_eq!(decl.version().unwrap(), env!("CARGO_PKG_VERSION"));
1931            assert_eq!(decl.min_protocol().unwrap(), "0.0.0");
1932        }
1933    }
1934
1935    #[test]
1936    fn abi_create_with_empty_config() {
1937        let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
1938        assert!(!plugin_ptr.is_null());
1939        let plugin = unsafe { Box::from_raw(plugin_ptr) };
1940        assert_eq!(plugin.name(), "agent");
1941        assert_eq!(plugin.version(), env!("CARGO_PKG_VERSION"));
1942    }
1943
1944    #[test]
1945    fn abi_create_with_json_config() {
1946        let dir = tempfile::tempdir().unwrap();
1947        let config = format!(
1948            r#"{{"state_path":"{}","socket_path":"{}","log_dir":"{}"}}"#,
1949            dir.path().join("agents.json").display(),
1950            dir.path().join("room.sock").display(),
1951            dir.path().join("logs").display()
1952        );
1953        let plugin_ptr = unsafe { room_plugin_create(config.as_ptr(), config.len()) };
1954        assert!(!plugin_ptr.is_null());
1955        let plugin = unsafe { Box::from_raw(plugin_ptr) };
1956        assert_eq!(plugin.name(), "agent");
1957    }
1958
1959    #[test]
1960    fn abi_destroy_frees_plugin() {
1961        let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
1962        assert!(!plugin_ptr.is_null());
1963        unsafe { room_plugin_destroy(plugin_ptr) };
1964    }
1965
1966    #[test]
1967    fn abi_destroy_null_is_safe() {
1968        unsafe { room_plugin_destroy(std::ptr::null_mut()) };
1969    }
1970
1971    // ── HealthStatus tests ───────────────────────────────────────────────
1972
1973    #[test]
1974    fn health_status_display_healthy() {
1975        assert_eq!(HealthStatus::Healthy.to_string(), "healthy");
1976    }
1977
1978    #[test]
1979    fn health_status_display_stale() {
1980        assert_eq!(HealthStatus::Stale.to_string(), "stale");
1981    }
1982
1983    #[test]
1984    fn health_status_display_exited_code() {
1985        assert_eq!(HealthStatus::Exited(Some(0)).to_string(), "exited (0)");
1986        assert_eq!(HealthStatus::Exited(Some(1)).to_string(), "exited (1)");
1987    }
1988
1989    #[test]
1990    fn health_status_display_exited_signal() {
1991        assert_eq!(HealthStatus::Exited(None).to_string(), "exited (signal)");
1992    }
1993
1994    #[test]
1995    fn health_status_equality() {
1996        assert_eq!(HealthStatus::Healthy, HealthStatus::Healthy);
1997        assert_eq!(HealthStatus::Stale, HealthStatus::Stale);
1998        assert_ne!(HealthStatus::Healthy, HealthStatus::Stale);
1999        assert_ne!(HealthStatus::Exited(Some(0)), HealthStatus::Exited(Some(1)));
2000        assert_eq!(HealthStatus::Exited(None), HealthStatus::Exited(None));
2001    }
2002
2003    #[test]
2004    fn compute_health_exited_process() {
2005        let dir = tempfile::tempdir().unwrap();
2006        let plugin = test_plugin(dir.path());
2007
2008        let agent = SpawnedAgent {
2009            username: "dead-bot".to_owned(),
2010            pid: 999_999_999, // non-existent PID
2011            model: "sonnet".to_owned(),
2012            personality: "coder".to_owned(),
2013            spawned_at: Utc::now() - chrono::Duration::minutes(10),
2014            log_path: dir.path().join("dead-bot.log"),
2015            room_id: "test-room".to_owned(),
2016        };
2017
2018        let exit_codes = HashMap::new();
2019        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2020        assert_eq!(health, HealthStatus::Exited(None));
2021    }
2022
2023    #[test]
2024    fn compute_health_exited_with_code() {
2025        let dir = tempfile::tempdir().unwrap();
2026        let plugin = test_plugin(dir.path());
2027
2028        let agent = SpawnedAgent {
2029            username: "dead-bot".to_owned(),
2030            pid: 999_999_999,
2031            model: "sonnet".to_owned(),
2032            personality: "coder".to_owned(),
2033            spawned_at: Utc::now() - chrono::Duration::minutes(10),
2034            log_path: dir.path().join("dead-bot.log"),
2035            room_id: "test-room".to_owned(),
2036        };
2037
2038        let mut exit_codes = HashMap::new();
2039        exit_codes.insert("dead-bot".to_owned(), Some(1));
2040        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2041        assert_eq!(health, HealthStatus::Exited(Some(1)));
2042    }
2043
2044    #[test]
2045    fn on_message_updates_last_seen() {
2046        let dir = tempfile::tempdir().unwrap();
2047        let plugin = test_plugin(dir.path());
2048
2049        // Insert a tracked agent.
2050        {
2051            let mut agents = plugin.agents.lock().unwrap();
2052            agents.insert(
2053                "tracked-bot".to_owned(),
2054                SpawnedAgent {
2055                    username: "tracked-bot".to_owned(),
2056                    pid: std::process::id(),
2057                    model: "sonnet".to_owned(),
2058                    personality: "coder".to_owned(),
2059                    spawned_at: Utc::now(),
2060                    log_path: dir.path().join("bot.log"),
2061                    room_id: "test-room".to_owned(),
2062                },
2063            );
2064        }
2065
2066        // Before any message, last_seen should be empty.
2067        assert!(plugin.last_seen_at.lock().unwrap().is_empty());
2068
2069        // Simulate a message from the tracked agent.
2070        let msg = room_protocol::make_message("test-room", "tracked-bot", "hello");
2071        plugin.on_message(&msg);
2072
2073        let last_seen = plugin.last_seen_at.lock().unwrap();
2074        assert!(last_seen.contains_key("tracked-bot"));
2075    }
2076
2077    #[test]
2078    fn on_message_ignores_untracked_users() {
2079        let dir = tempfile::tempdir().unwrap();
2080        let plugin = test_plugin(dir.path());
2081
2082        // No agents registered — message from random user should be ignored.
2083        let msg = room_protocol::make_message("test-room", "random-user", "hello");
2084        plugin.on_message(&msg);
2085
2086        assert!(plugin.last_seen_at.lock().unwrap().is_empty());
2087    }
2088
2089    #[test]
2090    fn stale_threshold_default_is_five_minutes() {
2091        let dir = tempfile::tempdir().unwrap();
2092        let plugin = test_plugin(dir.path());
2093        assert_eq!(plugin.stale_threshold_secs, 300);
2094    }
2095
2096    #[test]
2097    fn health_stale_when_last_seen_exceeds_threshold() {
2098        let dir = tempfile::tempdir().unwrap();
2099        let mut plugin = test_plugin(dir.path());
2100        plugin.stale_threshold_secs = 60; // 1 minute for test
2101
2102        let agent = SpawnedAgent {
2103            username: "stale-bot".to_owned(),
2104            pid: std::process::id(), // current process = alive
2105            model: "sonnet".to_owned(),
2106            personality: "coder".to_owned(),
2107            spawned_at: Utc::now() - chrono::Duration::minutes(10),
2108            log_path: dir.path().join("stale-bot.log"),
2109            room_id: "test-room".to_owned(),
2110        };
2111
2112        // Set last_seen to 2 minutes ago (exceeds 1 minute threshold).
2113        {
2114            let mut last_seen = plugin.last_seen_at.lock().unwrap();
2115            last_seen.insert(
2116                "stale-bot".to_owned(),
2117                Utc::now() - chrono::Duration::seconds(120),
2118            );
2119        }
2120
2121        let exit_codes = HashMap::new();
2122        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2123        assert_eq!(health, HealthStatus::Stale);
2124    }
2125
2126    #[test]
2127    fn health_healthy_when_recently_seen() {
2128        let dir = tempfile::tempdir().unwrap();
2129        let mut plugin = test_plugin(dir.path());
2130        plugin.stale_threshold_secs = 60;
2131
2132        let agent = SpawnedAgent {
2133            username: "active-bot".to_owned(),
2134            pid: std::process::id(),
2135            model: "sonnet".to_owned(),
2136            personality: "coder".to_owned(),
2137            spawned_at: Utc::now() - chrono::Duration::minutes(10),
2138            log_path: dir.path().join("active-bot.log"),
2139            room_id: "test-room".to_owned(),
2140        };
2141
2142        // Set last_seen to 30 seconds ago (within 1 minute threshold).
2143        {
2144            let mut last_seen = plugin.last_seen_at.lock().unwrap();
2145            last_seen.insert(
2146                "active-bot".to_owned(),
2147                Utc::now() - chrono::Duration::seconds(30),
2148            );
2149        }
2150
2151        let exit_codes = HashMap::new();
2152        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2153        assert_eq!(health, HealthStatus::Healthy);
2154    }
2155
2156    #[test]
2157    fn health_healthy_when_never_seen_but_alive() {
2158        let dir = tempfile::tempdir().unwrap();
2159        let plugin = test_plugin(dir.path());
2160
2161        let agent = SpawnedAgent {
2162            username: "new-bot".to_owned(),
2163            pid: std::process::id(), // current process = alive
2164            model: "sonnet".to_owned(),
2165            personality: "coder".to_owned(),
2166            spawned_at: Utc::now(),
2167            log_path: dir.path().join("new-bot.log"),
2168            room_id: "test-room".to_owned(),
2169        };
2170
2171        let exit_codes = HashMap::new();
2172        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2173        assert_eq!(health, HealthStatus::Healthy);
2174    }
2175
2176    #[test]
2177    fn handle_list_includes_health_column() {
2178        let dir = tempfile::tempdir().unwrap();
2179        let plugin = test_plugin(dir.path());
2180
2181        // Insert an agent with the current PID so it shows as alive.
2182        {
2183            let mut agents = plugin.agents.lock().unwrap();
2184            agents.insert(
2185                "test-bot".to_owned(),
2186                SpawnedAgent {
2187                    username: "test-bot".to_owned(),
2188                    pid: std::process::id(),
2189                    model: "sonnet".to_owned(),
2190                    personality: "coder".to_owned(),
2191                    spawned_at: Utc::now(),
2192                    log_path: dir.path().join("test-bot.log"),
2193                    room_id: "test-room".to_owned(),
2194                },
2195            );
2196        }
2197
2198        let (text, data) = plugin.handle_list();
2199        // Header should include health column.
2200        assert!(text.contains("health"));
2201        // Agent data should include health field.
2202        let agents = data["agents"].as_array().unwrap();
2203        assert_eq!(agents.len(), 1);
2204        assert_eq!(agents[0]["health"], "healthy");
2205    }
2206}