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