Skip to main content

zeph_subagent/
command.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Typed parsers for `/agent` and `/agents` slash commands.
5//!
6//! [`AgentCommand`] handles runtime operations on running agents (spawn, cancel, etc.)
7//! and `@agent_name` mention syntax.
8//!
9//! [`AgentsCommand`] handles definition CRUD operations (`/agents list`, `/agents create`, …).
10
11use super::error::SubAgentError;
12
13/// Typed representation of a parsed `/agents` command for definition CRUD operations.
14///
15/// Separate from [`AgentCommand`] (runtime operations like spawn/cancel) to avoid
16/// namespace collision between running-agent management and definition management.
17///
18/// # Examples
19///
20/// ```rust
21/// use zeph_subagent::AgentsCommand;
22///
23/// let cmd = AgentsCommand::parse("/agents list").unwrap();
24/// assert_eq!(cmd, AgentsCommand::List);
25///
26/// let cmd = AgentsCommand::parse("/agents show reviewer").unwrap();
27/// assert_eq!(cmd, AgentsCommand::Show { name: "reviewer".to_owned() });
28/// ```
29#[derive(Debug, PartialEq)]
30pub enum AgentsCommand {
31    /// List all discovered sub-agent definitions.
32    List,
33    /// Show full details of a definition.
34    Show { name: String },
35    /// Create a new definition.
36    Create { name: String },
37    /// Edit an existing definition.
38    Edit { name: String },
39    /// Delete a definition.
40    Delete { name: String },
41}
42
43impl AgentsCommand {
44    /// Parse from raw input text starting with `/agents`.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`SubAgentError::InvalidCommand`] if parsing fails.
49    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/// Typed representation of a parsed `/agent` CLI command or `@agent` mention.
115///
116/// # Examples
117///
118/// ```rust
119/// use zeph_subagent::AgentCommand;
120///
121/// let cmd = AgentCommand::parse("/agent spawn helper fix the bug", &[]).unwrap();
122/// assert_eq!(cmd, AgentCommand::Spawn {
123///     name: "helper".to_owned(),
124///     prompt: "fix the bug".to_owned(),
125/// });
126///
127/// // @mention syntax routes to known agents.
128/// let known = vec!["reviewer".to_owned()];
129/// let cmd = AgentCommand::parse("@reviewer check the PR", &known).unwrap();
130/// assert_eq!(cmd, AgentCommand::Mention {
131///     agent: "reviewer".to_owned(),
132///     prompt: "check the PR".to_owned(),
133/// });
134/// ```
135#[derive(Debug, PartialEq)]
136pub enum AgentCommand {
137    /// List all running sub-agent tasks.
138    List,
139    /// Spawn a foreground sub-agent and block until it completes.
140    Spawn { name: String, prompt: String },
141    /// Spawn a background sub-agent that runs independently.
142    Background { name: String, prompt: String },
143    /// Show a brief status summary of all running agents.
144    Status,
145    /// Cancel a running agent by task ID.
146    Cancel { id: String },
147    /// Approve a pending vault secret request for a running agent.
148    Approve { id: String },
149    /// Deny a pending vault secret request for a running agent.
150    Deny { id: String },
151    /// Foreground spawn triggered by `@agent_name <prompt>` mention syntax.
152    Mention { agent: String, prompt: String },
153    /// Resume a previously completed sub-agent session by ID prefix.
154    Resume { id: String, prompt: String },
155}
156
157impl AgentCommand {
158    /// Parse from raw input text.
159    ///
160    /// The input must start with `/agent`. Everything after that prefix is
161    /// interpreted as `<subcommand> [args]`.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`SubAgentError::InvalidCommand`] if:
166    /// - `input` does not start with `/agent`
167    /// - the subcommand is missing (empty after prefix)
168    /// - required arguments are missing
169    /// - the subcommand is not recognised
170    ///
171    /// Also handles `@agent_name prompt` mention syntax when `known_agents`
172    /// contains a match. If `@` prefix is present but the agent is unknown,
173    /// returns `Err` so the caller can fall back to file-reference handling.
174    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                // Require at least 4 characters to prevent accidental mass-match or session
263                // enumeration via very short prefixes.
264                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    /// Parse an `@agent_name <prompt>` mention from raw input.
283    ///
284    /// Returns `Ok(Mention { agent, prompt })` if `input` starts with `@` and the
285    /// token after `@` matches one of `known_agents`. Returns
286    /// [`SubAgentError::InvalidCommand`] if:
287    /// - `input` does not start with `@`
288    /// - the agent name token is empty (bare `@`)
289    /// - the named agent is not in `known_agents` — caller should fall back to
290    ///   other `@` handling such as file references
291    ///
292    /// # Errors
293    ///
294    /// Returns [`SubAgentError::InvalidCommand`] on any parse failure.
295    ///
296    /// # Examples
297    ///
298    /// ```rust
299    /// use zeph_subagent::AgentCommand;
300    ///
301    /// let known = vec!["helper".to_owned()];
302    /// let cmd = AgentCommand::parse_mention("@helper fix this", &known).unwrap();
303    /// assert_eq!(cmd, AgentCommand::Mention {
304    ///     agent: "helper".to_owned(),
305    ///     prompt: "fix this".to_owned(),
306    /// });
307    ///
308    /// // Unknown agents are rejected so callers can fall back to file-reference handling.
309    /// assert!(AgentCommand::parse_mention("@unknown do work", &known).is_err());
310    /// ```
311    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        // Extra spaces around subcommand and args should be handled gracefully.
472        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    // ── parse_mention() tests ─────────────────────────────────────────────────
498
499    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    // ── parse() unified entry point with @ ──────────────────────────────────
558
559    #[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    // ── parse resume ─────────────────────────────────────────────────────────
584
585    #[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        // id "abc" has only 3 chars — below the 4-char minimum.
638        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        // After split_once, prompt is "   " which trims to "".
660        let err = AgentCommand::parse("/agent resume deadbeef    ", &[]).unwrap_err();
661        // Either split_once returns None (no space after id) or prompt trims to empty.
662        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
663    }
664
665    // ── AgentsCommand (definition CRUD) ────────────────────────────────────
666
667    #[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}