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