1use super::error::SubAgentError;
12
13#[derive(Debug, PartialEq)]
30pub enum AgentsCommand {
31 List,
33 Show { name: String },
35 Create { name: String },
37 Edit { name: String },
39 Delete { name: String },
41}
42
43impl AgentsCommand {
44 pub fn parse(input: &str) -> Result<Self, SubAgentError> {
50 let rest = input
51 .strip_prefix("/agents")
52 .ok_or_else(|| SubAgentError::InvalidCommand("input must start with /agents".into()))?
53 .trim();
54
55 if rest.is_empty() {
56 return Err(SubAgentError::InvalidCommand(
57 "usage: /agents <list|show|create|edit|delete> [args]".into(),
58 ));
59 }
60
61 let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
62 let cmd = cmd.trim();
63 let args = args.trim();
64
65 match cmd {
66 "list" => Ok(Self::List),
67 "show" => {
68 if args.is_empty() {
69 return Err(SubAgentError::InvalidCommand(
70 "usage: /agents show <name>".into(),
71 ));
72 }
73 Ok(Self::Show {
74 name: args.to_owned(),
75 })
76 }
77 "create" => {
78 if args.is_empty() {
79 return Err(SubAgentError::InvalidCommand(
80 "usage: /agents create <name>".into(),
81 ));
82 }
83 Ok(Self::Create {
84 name: args.to_owned(),
85 })
86 }
87 "edit" => {
88 if args.is_empty() {
89 return Err(SubAgentError::InvalidCommand(
90 "usage: /agents edit <name>".into(),
91 ));
92 }
93 Ok(Self::Edit {
94 name: args.to_owned(),
95 })
96 }
97 "delete" => {
98 if args.is_empty() {
99 return Err(SubAgentError::InvalidCommand(
100 "usage: /agents delete <name>".into(),
101 ));
102 }
103 Ok(Self::Delete {
104 name: args.to_owned(),
105 })
106 }
107 other => Err(SubAgentError::InvalidCommand(format!(
108 "unknown subcommand '{other}'; try: list, show, create, edit, delete"
109 ))),
110 }
111 }
112}
113
114#[derive(Debug, PartialEq)]
136pub enum AgentCommand {
137 List,
139 Spawn { name: String, prompt: String },
141 Background { name: String, prompt: String },
143 Status,
145 Cancel { id: String },
147 Approve { id: String },
149 Deny { id: String },
151 Mention { agent: String, prompt: String },
153 Resume { id: String, prompt: String },
155}
156
157impl AgentCommand {
158 pub fn parse(input: &str, known_agents: &[String]) -> Result<Self, SubAgentError> {
175 if input.starts_with('@') {
176 return Self::parse_mention(input, known_agents);
177 }
178
179 let rest = input
180 .strip_prefix("/agent")
181 .ok_or_else(|| {
182 SubAgentError::InvalidCommand("input must start with /agent or @".into())
183 })?
184 .trim();
185
186 if rest.is_empty() {
187 return Err(SubAgentError::InvalidCommand(
188 "usage: /agent <list|spawn|bg|resume|status|cancel|approve|deny> [args]".into(),
189 ));
190 }
191
192 let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
193 let cmd = cmd.trim();
194 let args = args.trim();
195
196 match cmd {
197 "list" => Ok(Self::List),
198 "status" => Ok(Self::Status),
199 "spawn" | "bg" => {
200 let (name, prompt) = args.split_once(' ').ok_or_else(|| {
201 SubAgentError::InvalidCommand(format!("usage: /agent {cmd} <name> <prompt>"))
202 })?;
203 let name = name.trim().to_owned();
204 let prompt = prompt.trim().to_owned();
205 if name.is_empty() {
206 return Err(SubAgentError::InvalidCommand(
207 "sub-agent name must not be empty".into(),
208 ));
209 }
210 if prompt.is_empty() {
211 return Err(SubAgentError::InvalidCommand(
212 "prompt must not be empty".into(),
213 ));
214 }
215 if cmd == "bg" {
216 Ok(Self::Background { name, prompt })
217 } else {
218 Ok(Self::Spawn { name, prompt })
219 }
220 }
221 "cancel" => {
222 if args.is_empty() {
223 return Err(SubAgentError::InvalidCommand(
224 "usage: /agent cancel <id>".into(),
225 ));
226 }
227 Ok(Self::Cancel {
228 id: args.to_owned(),
229 })
230 }
231 "approve" => {
232 if args.is_empty() {
233 return Err(SubAgentError::InvalidCommand(
234 "usage: /agent approve <id>".into(),
235 ));
236 }
237 Ok(Self::Approve {
238 id: args.to_owned(),
239 })
240 }
241 "deny" => {
242 if args.is_empty() {
243 return Err(SubAgentError::InvalidCommand(
244 "usage: /agent deny <id>".into(),
245 ));
246 }
247 Ok(Self::Deny {
248 id: args.to_owned(),
249 })
250 }
251 "resume" => {
252 let (id, prompt) = args.split_once(' ').ok_or_else(|| {
253 SubAgentError::InvalidCommand("usage: /agent resume <id> <prompt>".into())
254 })?;
255 let id = id.trim().to_owned();
256 let prompt = prompt.trim().to_owned();
257 if id.is_empty() {
258 return Err(SubAgentError::InvalidCommand(
259 "agent id must not be empty".into(),
260 ));
261 }
262 if id.len() < 4 {
265 return Err(SubAgentError::InvalidCommand(
266 "agent id prefix must be at least 4 characters".into(),
267 ));
268 }
269 if prompt.is_empty() {
270 return Err(SubAgentError::InvalidCommand(
271 "prompt must not be empty".into(),
272 ));
273 }
274 Ok(Self::Resume { id, prompt })
275 }
276 other => Err(SubAgentError::InvalidCommand(format!(
277 "unknown subcommand '{other}'; try: list, spawn, bg, resume, status, cancel, approve, deny"
278 ))),
279 }
280 }
281
282 pub fn parse_mention(input: &str, known_agents: &[String]) -> Result<Self, SubAgentError> {
312 let rest = input
313 .strip_prefix('@')
314 .ok_or_else(|| SubAgentError::InvalidCommand("input must start with @".into()))?;
315
316 if rest.is_empty() || rest.starts_with(' ') {
317 return Err(SubAgentError::InvalidCommand(
318 "bare '@' is not a valid agent mention".into(),
319 ));
320 }
321
322 let (agent_token, prompt) = rest.split_once(' ').unwrap_or((rest, ""));
323 let agent = agent_token.trim().to_owned();
324
325 if !known_agents.iter().any(|n| n == &agent) {
326 return Err(SubAgentError::InvalidCommand(format!(
327 "@{agent} is not a known sub-agent"
328 )));
329 }
330
331 Ok(Self::Mention {
332 agent,
333 prompt: prompt.trim().to_owned(),
334 })
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn parse_list() {
344 assert_eq!(
345 AgentCommand::parse("/agent list", &[]).unwrap(),
346 AgentCommand::List
347 );
348 }
349
350 #[test]
351 fn parse_status() {
352 assert_eq!(
353 AgentCommand::parse("/agent status", &[]).unwrap(),
354 AgentCommand::Status
355 );
356 }
357
358 #[test]
359 fn parse_spawn() {
360 let cmd = AgentCommand::parse("/agent spawn helper do something useful", &[]).unwrap();
361 assert_eq!(
362 cmd,
363 AgentCommand::Spawn {
364 name: "helper".into(),
365 prompt: "do something useful".into(),
366 }
367 );
368 }
369
370 #[test]
371 fn parse_bg() {
372 let cmd = AgentCommand::parse("/agent bg reviewer check the code", &[]).unwrap();
373 assert_eq!(
374 cmd,
375 AgentCommand::Background {
376 name: "reviewer".into(),
377 prompt: "check the code".into(),
378 }
379 );
380 }
381
382 #[test]
383 fn parse_cancel() {
384 let cmd = AgentCommand::parse("/agent cancel abc123", &[]).unwrap();
385 assert_eq!(
386 cmd,
387 AgentCommand::Cancel {
388 id: "abc123".into()
389 }
390 );
391 }
392
393 #[test]
394 fn parse_approve() {
395 let cmd = AgentCommand::parse("/agent approve task-1", &[]).unwrap();
396 assert_eq!(
397 cmd,
398 AgentCommand::Approve {
399 id: "task-1".into()
400 }
401 );
402 }
403
404 #[test]
405 fn parse_deny() {
406 let cmd = AgentCommand::parse("/agent deny task-2", &[]).unwrap();
407 assert_eq!(
408 cmd,
409 AgentCommand::Deny {
410 id: "task-2".into()
411 }
412 );
413 }
414
415 #[test]
416 fn parse_wrong_prefix_returns_error() {
417 let err = AgentCommand::parse("/foo list", &[]).unwrap_err();
418 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
419 }
420
421 #[test]
422 fn parse_empty_after_prefix_returns_usage() {
423 let err = AgentCommand::parse("/agent", &[]).unwrap_err();
424 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
425 }
426
427 #[test]
428 fn parse_whitespace_only_after_prefix_returns_usage() {
429 let err = AgentCommand::parse("/agent ", &[]).unwrap_err();
430 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
431 }
432
433 #[test]
434 fn parse_unknown_subcommand_returns_error() {
435 let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
436 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate")));
437 }
438
439 #[test]
440 fn parse_spawn_missing_prompt_returns_error() {
441 let err = AgentCommand::parse("/agent spawn helper", &[]).unwrap_err();
442 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
443 }
444
445 #[test]
446 fn parse_spawn_missing_name_and_prompt_returns_error() {
447 let err = AgentCommand::parse("/agent spawn", &[]).unwrap_err();
448 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
449 }
450
451 #[test]
452 fn parse_cancel_missing_id_returns_error() {
453 let err = AgentCommand::parse("/agent cancel", &[]).unwrap_err();
454 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
455 }
456
457 #[test]
458 fn parse_approve_missing_id_returns_error() {
459 let err = AgentCommand::parse("/agent approve", &[]).unwrap_err();
460 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
461 }
462
463 #[test]
464 fn parse_deny_missing_id_returns_error() {
465 let err = AgentCommand::parse("/agent deny", &[]).unwrap_err();
466 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
467 }
468
469 #[test]
470 fn parse_extra_whitespace_trimmed() {
471 let cmd = AgentCommand::parse("/agent cancel deadbeef", &[]).unwrap();
473 assert_eq!(
474 cmd,
475 AgentCommand::Cancel {
476 id: "deadbeef".into()
477 }
478 );
479 }
480
481 #[test]
482 fn parse_spawn_prompt_with_spaces_preserved() {
483 let cmd = AgentCommand::parse(
484 "/agent spawn bot review the PR and suggest improvements",
485 &[],
486 )
487 .unwrap();
488 assert_eq!(
489 cmd,
490 AgentCommand::Spawn {
491 name: "bot".into(),
492 prompt: "review the PR and suggest improvements".into(),
493 }
494 );
495 }
496
497 fn known() -> Vec<String> {
500 vec!["reviewer".into(), "helper".into()]
501 }
502
503 #[test]
504 fn mention_known_agent_with_prompt() {
505 let cmd = AgentCommand::parse_mention("@reviewer review this PR", &known()).unwrap();
506 assert_eq!(
507 cmd,
508 AgentCommand::Mention {
509 agent: "reviewer".into(),
510 prompt: "review this PR".into(),
511 }
512 );
513 }
514
515 #[test]
516 fn mention_known_agent_without_prompt() {
517 let cmd = AgentCommand::parse_mention("@helper", &known()).unwrap();
518 assert_eq!(
519 cmd,
520 AgentCommand::Mention {
521 agent: "helper".into(),
522 prompt: String::new(),
523 }
524 );
525 }
526
527 #[test]
528 fn mention_unknown_agent_returns_error() {
529 let err = AgentCommand::parse_mention("@unknown-thing do work", &known()).unwrap_err();
530 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("unknown-thing")));
531 }
532
533 #[test]
534 fn mention_bare_at_returns_error() {
535 let err = AgentCommand::parse_mention("@", &known()).unwrap_err();
536 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
537 }
538
539 #[test]
540 fn mention_at_with_space_returns_error() {
541 let err = AgentCommand::parse_mention("@ something", &known()).unwrap_err();
542 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
543 }
544
545 #[test]
546 fn mention_wrong_prefix_returns_error() {
547 let err = AgentCommand::parse_mention("reviewer do work", &known()).unwrap_err();
548 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
549 }
550
551 #[test]
552 fn mention_empty_known_agents_always_fails() {
553 let err = AgentCommand::parse_mention("@reviewer do work", &[]).unwrap_err();
554 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
555 }
556
557 #[test]
560 fn parse_dispatches_at_mention_to_parse_mention() {
561 let cmd = AgentCommand::parse("@reviewer review this PR", &known()).unwrap();
562 assert_eq!(
563 cmd,
564 AgentCommand::Mention {
565 agent: "reviewer".into(),
566 prompt: "review this PR".into(),
567 }
568 );
569 }
570
571 #[test]
572 fn parse_at_unknown_agent_returns_error() {
573 let err = AgentCommand::parse("@unknown test", &known()).unwrap_err();
574 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
575 }
576
577 #[test]
578 fn parse_at_with_empty_known_returns_error() {
579 let err = AgentCommand::parse("@reviewer test", &[]).unwrap_err();
580 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
581 }
582
583 #[test]
586 fn parse_resume() {
587 let cmd = AgentCommand::parse("/agent resume deadbeef continue the analysis", &[]).unwrap();
588 assert_eq!(
589 cmd,
590 AgentCommand::Resume {
591 id: "deadbeef".into(),
592 prompt: "continue the analysis".into(),
593 }
594 );
595 }
596
597 #[test]
598 fn parse_resume_missing_prompt_returns_error() {
599 let err = AgentCommand::parse("/agent resume deadbeef", &[]).unwrap_err();
600 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
601 }
602
603 #[test]
604 fn parse_resume_missing_id_and_prompt_returns_error() {
605 let err = AgentCommand::parse("/agent resume", &[]).unwrap_err();
606 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
607 }
608
609 #[test]
610 fn parse_resume_unknown_subcommand_hint() {
611 let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
612 if let SubAgentError::InvalidCommand(msg) = err {
613 assert!(
614 msg.contains("resume"),
615 "hint should mention 'resume': {msg}"
616 );
617 } else {
618 panic!("expected InvalidCommand");
619 }
620 }
621
622 #[test]
623 fn parse_resume_prompt_with_spaces_preserved() {
624 let cmd = AgentCommand::parse("/agent resume abc123 do more work and fix the issue", &[])
625 .unwrap();
626 assert_eq!(
627 cmd,
628 AgentCommand::Resume {
629 id: "abc123".into(),
630 prompt: "do more work and fix the issue".into(),
631 }
632 );
633 }
634
635 #[test]
636 fn parse_resume_id_too_short_returns_error() {
637 let err = AgentCommand::parse("/agent resume abc continue", &[]).unwrap_err();
639 assert!(
640 matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("4 characters")),
641 "expected min-length error, got: {err:?}"
642 );
643 }
644
645 #[test]
646 fn parse_resume_id_exactly_four_chars_is_accepted() {
647 let cmd = AgentCommand::parse("/agent resume abcd continue the work", &[]).unwrap();
648 assert_eq!(
649 cmd,
650 AgentCommand::Resume {
651 id: "abcd".into(),
652 prompt: "continue the work".into(),
653 }
654 );
655 }
656
657 #[test]
658 fn parse_resume_whitespace_only_prompt_returns_error() {
659 let err = AgentCommand::parse("/agent resume deadbeef ", &[]).unwrap_err();
661 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
663 }
664
665 #[test]
668 fn agents_parse_list() {
669 assert_eq!(
670 AgentsCommand::parse("/agents list").unwrap(),
671 AgentsCommand::List
672 );
673 }
674
675 #[test]
676 fn agents_parse_show() {
677 let cmd = AgentsCommand::parse("/agents show code-reviewer").unwrap();
678 assert_eq!(
679 cmd,
680 AgentsCommand::Show {
681 name: "code-reviewer".into()
682 }
683 );
684 }
685
686 #[test]
687 fn agents_parse_create() {
688 let cmd = AgentsCommand::parse("/agents create my-agent").unwrap();
689 assert_eq!(
690 cmd,
691 AgentsCommand::Create {
692 name: "my-agent".into()
693 }
694 );
695 }
696
697 #[test]
698 fn agents_parse_edit() {
699 let cmd = AgentsCommand::parse("/agents edit reviewer").unwrap();
700 assert_eq!(
701 cmd,
702 AgentsCommand::Edit {
703 name: "reviewer".into()
704 }
705 );
706 }
707
708 #[test]
709 fn agents_parse_delete() {
710 let cmd = AgentsCommand::parse("/agents delete reviewer").unwrap();
711 assert_eq!(
712 cmd,
713 AgentsCommand::Delete {
714 name: "reviewer".into()
715 }
716 );
717 }
718
719 #[test]
720 fn agents_parse_missing_subcommand_returns_usage() {
721 let err = AgentsCommand::parse("/agents").unwrap_err();
722 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
723 }
724
725 #[test]
726 fn agents_parse_show_missing_name_returns_usage() {
727 let err = AgentsCommand::parse("/agents show").unwrap_err();
728 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
729 }
730
731 #[test]
732 fn agents_parse_unknown_subcommand_returns_error() {
733 let err = AgentsCommand::parse("/agents frobnicate").unwrap_err();
734 assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate")));
735 }
736
737 #[test]
738 fn agents_parse_wrong_prefix_returns_error() {
739 let err = AgentsCommand::parse("/agent list").unwrap_err();
740 assert!(matches!(err, SubAgentError::InvalidCommand(_)));
741 }
742}