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