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#[derive(Deserialize)]
29struct AgentConfig {
30 state_path: PathBuf,
31 socket_path: PathBuf,
32 log_dir: PathBuf,
33}
34
35fn 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
54const STOP_GRACE_PERIOD_SECS: u64 = 5;
56
57const DEFAULT_TAIL_LINES: usize = 20;
59
60const DEFAULT_STALE_THRESHOLD_SECS: i64 = 300;
62
63#[derive(Debug, Clone, PartialEq)]
65pub enum HealthStatus {
66 Healthy,
68 Stale,
70 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#[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
98pub struct AgentPlugin {
103 agents: Arc<Mutex<HashMap<String, SpawnedAgent>>>,
105 children: Arc<Mutex<HashMap<String, Child>>>,
107 exit_codes: Arc<Mutex<HashMap<String, Option<i32>>>>,
109 last_seen_at: Arc<Mutex<HashMap<String, DateTime<Utc>>>>,
111 stale_threshold_secs: i64,
113 state_path: PathBuf,
115 socket_path: PathBuf,
117 log_dir: PathBuf,
119}
120
121impl AgentPlugin {
122 pub fn new(state_path: PathBuf, socket_path: PathBuf, log_dir: PathBuf) -> Self {
127 let agents = load_agents(&state_path);
128 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 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 = ¶ms[1];
214
215 if username.is_empty() || username.starts_with('-') {
217 return Err("invalid username".to_owned());
218 }
219
220 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 {
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 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 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 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 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 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 {
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 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 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 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 if username.is_empty() || username.starts_with('-') {
505 return Err("invalid username".to_owned());
506 }
507
508 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 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 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 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 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 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 let was_alive = is_process_alive(agent.pid);
628 if was_alive {
629 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 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 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 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 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 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 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
798fn is_process_alive(pid: u32) -> bool {
800 #[cfg(unix)]
801 {
802 unsafe { libc::kill(pid as i32, 0) == 0 }
804 }
805 #[cfg(not(unix))]
806 {
807 let _ = pid;
808 false
809 }
810}
811
812fn 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
832fn 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
844fn 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#[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 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 {
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 {
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 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 {
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 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 {
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 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 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(), 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 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 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, 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 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 #[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 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 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 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 #[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 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(), 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, 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 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 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 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 #[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 #[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 #[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, 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 {
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 assert!(plugin.last_seen_at.lock().unwrap().is_empty());
2068
2069 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 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; let agent = SpawnedAgent {
2103 username: "stale-bot".to_owned(),
2104 pid: std::process::id(), 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 {
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 {
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(), 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 {
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 assert!(text.contains("health"));
2201 let agents = data["agents"].as_array().unwrap();
2203 assert_eq!(agents.len(), 1);
2204 assert_eq!(agents[0]["health"], "healthy");
2205 }
2206}