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#[non_exhaustive]
30#[derive(Debug, PartialEq)]
31pub enum AgentsCommand {
32    /// List all discovered sub-agent definitions.
33    List,
34    /// Show full details of a definition.
35    Show { name: String },
36    /// Create a new definition.
37    Create { name: String },
38    /// Edit an existing definition.
39    Edit { name: String },
40    /// Delete a definition.
41    Delete { name: String },
42}
43
44impl AgentsCommand {
45    /// Parse from raw input text starting with `/agents`.
46    ///
47    /// # Errors
48    ///
49    /// Returns [`SubAgentError::InvalidCommand`] if parsing fails.
50    pub fn parse(input: &str) -> Result<Self, SubAgentError> {
51        let rest = input
52            .strip_prefix("/agents")
53            .ok_or_else(|| SubAgentError::InvalidCommand("input must start with /agents".into()))?
54            .trim();
55
56        if rest.is_empty() {
57            return Err(SubAgentError::InvalidCommand(
58                "usage: /agents <list|show|create|edit|delete> [args]".into(),
59            ));
60        }
61
62        let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
63        let cmd = cmd.trim();
64        let args = args.trim();
65
66        match cmd {
67            "list" => Ok(Self::List),
68            "show" => {
69                if args.is_empty() {
70                    return Err(SubAgentError::InvalidCommand(
71                        "usage: /agents show <name>".into(),
72                    ));
73                }
74                Ok(Self::Show {
75                    name: args.to_owned(),
76                })
77            }
78            "create" => {
79                if args.is_empty() {
80                    return Err(SubAgentError::InvalidCommand(
81                        "usage: /agents create <name>".into(),
82                    ));
83                }
84                Ok(Self::Create {
85                    name: args.to_owned(),
86                })
87            }
88            "edit" => {
89                if args.is_empty() {
90                    return Err(SubAgentError::InvalidCommand(
91                        "usage: /agents edit <name>".into(),
92                    ));
93                }
94                Ok(Self::Edit {
95                    name: args.to_owned(),
96                })
97            }
98            "delete" => {
99                if args.is_empty() {
100                    return Err(SubAgentError::InvalidCommand(
101                        "usage: /agents delete <name>".into(),
102                    ));
103                }
104                Ok(Self::Delete {
105                    name: args.to_owned(),
106                })
107            }
108            other => Err(SubAgentError::InvalidCommand(format!(
109                "unknown subcommand '{other}'; try: list, show, create, edit, delete"
110            ))),
111        }
112    }
113}
114
115/// Typed representation of a parsed `/agent` CLI command or `@agent` mention.
116///
117/// # Examples
118///
119/// ```rust
120/// use zeph_subagent::AgentCommand;
121///
122/// let cmd = AgentCommand::parse("/agent spawn helper fix the bug", &[]).unwrap();
123/// assert_eq!(cmd, AgentCommand::Spawn {
124///     name: "helper".to_owned(),
125///     prompt: "fix the bug".to_owned(),
126/// });
127///
128/// // @mention syntax routes to known agents.
129/// let known = vec!["reviewer".to_owned()];
130/// let cmd = AgentCommand::parse("@reviewer check the PR", &known).unwrap();
131/// assert_eq!(cmd, AgentCommand::Mention {
132///     agent: "reviewer".to_owned(),
133///     prompt: "check the PR".to_owned(),
134/// });
135/// ```
136#[non_exhaustive]
137#[derive(Debug, PartialEq)]
138pub enum AgentCommand {
139    /// List all running sub-agent tasks.
140    List,
141    /// Spawn a foreground sub-agent and block until it completes.
142    Spawn { name: String, prompt: String },
143    /// Spawn a background sub-agent that runs independently.
144    Background { name: String, prompt: String },
145    /// Show a brief status summary of all running agents.
146    Status,
147    /// Cancel a running agent by task ID.
148    Cancel { id: String },
149    /// Approve a pending vault secret request for a running agent.
150    Approve { id: String },
151    /// Deny a pending vault secret request for a running agent.
152    Deny { id: String },
153    /// Foreground spawn triggered by `@agent_name <prompt>` mention syntax.
154    Mention { agent: String, prompt: String },
155    /// Resume a previously completed sub-agent session by ID prefix.
156    Resume { id: String, prompt: String },
157}
158
159impl AgentCommand {
160    /// Parse from raw input text.
161    ///
162    /// The input must start with `/agent`. Everything after that prefix is
163    /// interpreted as `<subcommand> [args]`.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`SubAgentError::InvalidCommand`] if:
168    /// - `input` does not start with `/agent`
169    /// - the subcommand is missing (empty after prefix)
170    /// - required arguments are missing
171    /// - the subcommand is not recognised
172    ///
173    /// Also handles `@agent_name prompt` mention syntax when `known_agents`
174    /// contains a match. If `@` prefix is present but the agent is unknown,
175    /// returns `Err` so the caller can fall back to file-reference handling.
176    pub fn parse(input: &str, known_agents: &[String]) -> Result<Self, SubAgentError> {
177        if input.starts_with('@') {
178            return Self::parse_mention(input, known_agents);
179        }
180
181        let rest = input
182            .strip_prefix("/agent")
183            .ok_or_else(|| {
184                SubAgentError::InvalidCommand("input must start with /agent or @".into())
185            })?
186            .trim();
187
188        if rest.is_empty() {
189            return Err(SubAgentError::InvalidCommand(
190                "usage: /agent <list|spawn|bg|resume|status|cancel|approve|deny> [args]".into(),
191            ));
192        }
193
194        let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
195        let cmd = cmd.trim();
196        let args = args.trim();
197
198        match cmd {
199            "list" => Ok(Self::List),
200            "status" => Ok(Self::Status),
201            "spawn" | "bg" => {
202                let (name, prompt) = args.split_once(' ').ok_or_else(|| {
203                    SubAgentError::InvalidCommand(format!("usage: /agent {cmd} <name> <prompt>"))
204                })?;
205                let name = name.trim().to_owned();
206                let prompt = prompt.trim().to_owned();
207                if name.is_empty() {
208                    return Err(SubAgentError::InvalidCommand(
209                        "sub-agent name must not be empty".into(),
210                    ));
211                }
212                if prompt.is_empty() {
213                    return Err(SubAgentError::InvalidCommand(
214                        "prompt must not be empty".into(),
215                    ));
216                }
217                if cmd == "bg" {
218                    Ok(Self::Background { name, prompt })
219                } else {
220                    Ok(Self::Spawn { name, prompt })
221                }
222            }
223            "cancel" => {
224                if args.is_empty() {
225                    return Err(SubAgentError::InvalidCommand(
226                        "usage: /agent cancel <id>".into(),
227                    ));
228                }
229                Ok(Self::Cancel {
230                    id: args.to_owned(),
231                })
232            }
233            "approve" => {
234                if args.is_empty() {
235                    return Err(SubAgentError::InvalidCommand(
236                        "usage: /agent approve <id>".into(),
237                    ));
238                }
239                Ok(Self::Approve {
240                    id: args.to_owned(),
241                })
242            }
243            "deny" => {
244                if args.is_empty() {
245                    return Err(SubAgentError::InvalidCommand(
246                        "usage: /agent deny <id>".into(),
247                    ));
248                }
249                Ok(Self::Deny {
250                    id: args.to_owned(),
251                })
252            }
253            "resume" => {
254                let (id, prompt) = args.split_once(' ').ok_or_else(|| {
255                    SubAgentError::InvalidCommand("usage: /agent resume <id> <prompt>".into())
256                })?;
257                let id = id.trim().to_owned();
258                let prompt = prompt.trim().to_owned();
259                if id.is_empty() {
260                    return Err(SubAgentError::InvalidCommand(
261                        "agent id must not be empty".into(),
262                    ));
263                }
264                // Require at least 4 characters to prevent accidental mass-match or session
265                // enumeration via very short prefixes.
266                if id.len() < 4 {
267                    return Err(SubAgentError::InvalidCommand(
268                        "agent id prefix must be at least 4 characters".into(),
269                    ));
270                }
271                if prompt.is_empty() {
272                    return Err(SubAgentError::InvalidCommand(
273                        "prompt must not be empty".into(),
274                    ));
275                }
276                Ok(Self::Resume { id, prompt })
277            }
278            other => Err(SubAgentError::InvalidCommand(format!(
279                "unknown subcommand '{other}'; try: list, spawn, bg, resume, status, cancel, approve, deny"
280            ))),
281        }
282    }
283
284    /// Parse an `@agent_name <prompt>` mention from raw input.
285    ///
286    /// Returns `Ok(Mention { agent, prompt })` if `input` starts with `@` and the
287    /// token after `@` matches one of `known_agents`. Returns
288    /// [`SubAgentError::InvalidCommand`] if:
289    /// - `input` does not start with `@`
290    /// - the agent name token is empty (bare `@`)
291    /// - the named agent is not in `known_agents` — caller should fall back to
292    ///   other `@` handling such as file references
293    ///
294    /// # Errors
295    ///
296    /// Returns [`SubAgentError::InvalidCommand`] on any parse failure.
297    ///
298    /// # Examples
299    ///
300    /// ```rust
301    /// use zeph_subagent::AgentCommand;
302    ///
303    /// let known = vec!["helper".to_owned()];
304    /// let cmd = AgentCommand::parse_mention("@helper fix this", &known).unwrap();
305    /// assert_eq!(cmd, AgentCommand::Mention {
306    ///     agent: "helper".to_owned(),
307    ///     prompt: "fix this".to_owned(),
308    /// });
309    ///
310    /// // Unknown agents are rejected so callers can fall back to file-reference handling.
311    /// assert!(AgentCommand::parse_mention("@unknown do work", &known).is_err());
312    /// ```
313    pub fn parse_mention(input: &str, known_agents: &[String]) -> Result<Self, SubAgentError> {
314        let rest = input
315            .strip_prefix('@')
316            .ok_or_else(|| SubAgentError::InvalidCommand("input must start with @".into()))?;
317
318        if rest.is_empty() || rest.starts_with(' ') {
319            return Err(SubAgentError::InvalidCommand(
320                "bare '@' is not a valid agent mention".into(),
321            ));
322        }
323
324        let (agent_token, prompt) = rest.split_once(' ').unwrap_or((rest, ""));
325        let agent = agent_token.trim().to_owned();
326
327        if !known_agents.iter().any(|n| n == &agent) {
328            return Err(SubAgentError::InvalidCommand(format!(
329                "@{agent} is not a known sub-agent"
330            )));
331        }
332
333        Ok(Self::Mention {
334            agent,
335            prompt: prompt.trim().to_owned(),
336        })
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use std::assert_matches;
344
345    #[test]
346    fn parse_list() {
347        assert_eq!(
348            AgentCommand::parse("/agent list", &[]).unwrap(),
349            AgentCommand::List
350        );
351    }
352
353    #[test]
354    fn parse_status() {
355        assert_eq!(
356            AgentCommand::parse("/agent status", &[]).unwrap(),
357            AgentCommand::Status
358        );
359    }
360
361    #[test]
362    fn parse_spawn() {
363        let cmd = AgentCommand::parse("/agent spawn helper do something useful", &[]).unwrap();
364        assert_eq!(
365            cmd,
366            AgentCommand::Spawn {
367                name: "helper".into(),
368                prompt: "do something useful".into(),
369            }
370        );
371    }
372
373    #[test]
374    fn parse_bg() {
375        let cmd = AgentCommand::parse("/agent bg reviewer check the code", &[]).unwrap();
376        assert_eq!(
377            cmd,
378            AgentCommand::Background {
379                name: "reviewer".into(),
380                prompt: "check the code".into(),
381            }
382        );
383    }
384
385    #[test]
386    fn parse_cancel() {
387        let cmd = AgentCommand::parse("/agent cancel abc123", &[]).unwrap();
388        assert_eq!(
389            cmd,
390            AgentCommand::Cancel {
391                id: "abc123".into()
392            }
393        );
394    }
395
396    #[test]
397    fn parse_approve() {
398        let cmd = AgentCommand::parse("/agent approve task-1", &[]).unwrap();
399        assert_eq!(
400            cmd,
401            AgentCommand::Approve {
402                id: "task-1".into()
403            }
404        );
405    }
406
407    #[test]
408    fn parse_deny() {
409        let cmd = AgentCommand::parse("/agent deny task-2", &[]).unwrap();
410        assert_eq!(
411            cmd,
412            AgentCommand::Deny {
413                id: "task-2".into()
414            }
415        );
416    }
417
418    #[test]
419    fn parse_wrong_prefix_returns_error() {
420        let err = AgentCommand::parse("/foo list", &[]).unwrap_err();
421        assert_matches!(err, SubAgentError::InvalidCommand(_));
422    }
423
424    #[test]
425    fn parse_empty_after_prefix_returns_usage() {
426        let err = AgentCommand::parse("/agent", &[]).unwrap_err();
427        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
428    }
429
430    #[test]
431    fn parse_whitespace_only_after_prefix_returns_usage() {
432        let err = AgentCommand::parse("/agent   ", &[]).unwrap_err();
433        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
434    }
435
436    #[test]
437    fn parse_unknown_subcommand_returns_error() {
438        let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
439        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate"));
440    }
441
442    #[test]
443    fn parse_spawn_missing_prompt_returns_error() {
444        let err = AgentCommand::parse("/agent spawn helper", &[]).unwrap_err();
445        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
446    }
447
448    #[test]
449    fn parse_spawn_missing_name_and_prompt_returns_error() {
450        let err = AgentCommand::parse("/agent spawn", &[]).unwrap_err();
451        assert_matches!(err, SubAgentError::InvalidCommand(_));
452    }
453
454    #[test]
455    fn parse_cancel_missing_id_returns_error() {
456        let err = AgentCommand::parse("/agent cancel", &[]).unwrap_err();
457        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
458    }
459
460    #[test]
461    fn parse_approve_missing_id_returns_error() {
462        let err = AgentCommand::parse("/agent approve", &[]).unwrap_err();
463        assert_matches!(err, SubAgentError::InvalidCommand(_));
464    }
465
466    #[test]
467    fn parse_deny_missing_id_returns_error() {
468        let err = AgentCommand::parse("/agent deny", &[]).unwrap_err();
469        assert_matches!(err, SubAgentError::InvalidCommand(_));
470    }
471
472    #[test]
473    fn parse_extra_whitespace_trimmed() {
474        // Extra spaces around subcommand and args should be handled gracefully.
475        let cmd = AgentCommand::parse("/agent  cancel  deadbeef", &[]).unwrap();
476        assert_eq!(
477            cmd,
478            AgentCommand::Cancel {
479                id: "deadbeef".into()
480            }
481        );
482    }
483
484    #[test]
485    fn parse_spawn_prompt_with_spaces_preserved() {
486        let cmd = AgentCommand::parse(
487            "/agent spawn bot review the PR and suggest improvements",
488            &[],
489        )
490        .unwrap();
491        assert_eq!(
492            cmd,
493            AgentCommand::Spawn {
494                name: "bot".into(),
495                prompt: "review the PR and suggest improvements".into(),
496            }
497        );
498    }
499
500    // ── parse_mention() tests ─────────────────────────────────────────────────
501
502    fn known() -> Vec<String> {
503        vec!["reviewer".into(), "helper".into()]
504    }
505
506    #[test]
507    fn mention_known_agent_with_prompt() {
508        let cmd = AgentCommand::parse_mention("@reviewer review this PR", &known()).unwrap();
509        assert_eq!(
510            cmd,
511            AgentCommand::Mention {
512                agent: "reviewer".into(),
513                prompt: "review this PR".into(),
514            }
515        );
516    }
517
518    #[test]
519    fn mention_known_agent_without_prompt() {
520        let cmd = AgentCommand::parse_mention("@helper", &known()).unwrap();
521        assert_eq!(
522            cmd,
523            AgentCommand::Mention {
524                agent: "helper".into(),
525                prompt: String::new(),
526            }
527        );
528    }
529
530    #[test]
531    fn mention_unknown_agent_returns_error() {
532        let err = AgentCommand::parse_mention("@unknown-thing do work", &known()).unwrap_err();
533        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("unknown-thing"));
534    }
535
536    #[test]
537    fn mention_bare_at_returns_error() {
538        let err = AgentCommand::parse_mention("@", &known()).unwrap_err();
539        assert_matches!(err, SubAgentError::InvalidCommand(_));
540    }
541
542    #[test]
543    fn mention_at_with_space_returns_error() {
544        let err = AgentCommand::parse_mention("@ something", &known()).unwrap_err();
545        assert_matches!(err, SubAgentError::InvalidCommand(_));
546    }
547
548    #[test]
549    fn mention_wrong_prefix_returns_error() {
550        let err = AgentCommand::parse_mention("reviewer do work", &known()).unwrap_err();
551        assert_matches!(err, SubAgentError::InvalidCommand(_));
552    }
553
554    #[test]
555    fn mention_empty_known_agents_always_fails() {
556        let err = AgentCommand::parse_mention("@reviewer do work", &[]).unwrap_err();
557        assert_matches!(err, SubAgentError::InvalidCommand(_));
558    }
559
560    // ── parse() unified entry point with @ ──────────────────────────────────
561
562    #[test]
563    fn parse_dispatches_at_mention_to_parse_mention() {
564        let cmd = AgentCommand::parse("@reviewer review this PR", &known()).unwrap();
565        assert_eq!(
566            cmd,
567            AgentCommand::Mention {
568                agent: "reviewer".into(),
569                prompt: "review this PR".into(),
570            }
571        );
572    }
573
574    #[test]
575    fn parse_at_unknown_agent_returns_error() {
576        let err = AgentCommand::parse("@unknown test", &known()).unwrap_err();
577        assert_matches!(err, SubAgentError::InvalidCommand(_));
578    }
579
580    #[test]
581    fn parse_at_with_empty_known_returns_error() {
582        let err = AgentCommand::parse("@reviewer test", &[]).unwrap_err();
583        assert_matches!(err, SubAgentError::InvalidCommand(_));
584    }
585
586    // ── parse resume ─────────────────────────────────────────────────────────
587
588    #[test]
589    fn parse_resume() {
590        let cmd = AgentCommand::parse("/agent resume deadbeef continue the analysis", &[]).unwrap();
591        assert_eq!(
592            cmd,
593            AgentCommand::Resume {
594                id: "deadbeef".into(),
595                prompt: "continue the analysis".into(),
596            }
597        );
598    }
599
600    #[test]
601    fn parse_resume_missing_prompt_returns_error() {
602        let err = AgentCommand::parse("/agent resume deadbeef", &[]).unwrap_err();
603        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
604    }
605
606    #[test]
607    fn parse_resume_missing_id_and_prompt_returns_error() {
608        let err = AgentCommand::parse("/agent resume", &[]).unwrap_err();
609        assert_matches!(err, SubAgentError::InvalidCommand(_));
610    }
611
612    #[test]
613    fn parse_resume_unknown_subcommand_hint() {
614        let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
615        if let SubAgentError::InvalidCommand(msg) = err {
616            assert!(
617                msg.contains("resume"),
618                "hint should mention 'resume': {msg}"
619            );
620        } else {
621            panic!("expected InvalidCommand");
622        }
623    }
624
625    #[test]
626    fn parse_resume_prompt_with_spaces_preserved() {
627        let cmd = AgentCommand::parse("/agent resume abc123 do more work and fix the issue", &[])
628            .unwrap();
629        assert_eq!(
630            cmd,
631            AgentCommand::Resume {
632                id: "abc123".into(),
633                prompt: "do more work and fix the issue".into(),
634            }
635        );
636    }
637
638    #[test]
639    fn parse_resume_id_too_short_returns_error() {
640        // id "abc" has only 3 chars — below the 4-char minimum.
641        let err = AgentCommand::parse("/agent resume abc continue", &[]).unwrap_err();
642        assert!(
643            matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("4 characters")),
644            "expected min-length error, got: {err:?}"
645        );
646    }
647
648    #[test]
649    fn parse_resume_id_exactly_four_chars_is_accepted() {
650        let cmd = AgentCommand::parse("/agent resume abcd continue the work", &[]).unwrap();
651        assert_eq!(
652            cmd,
653            AgentCommand::Resume {
654                id: "abcd".into(),
655                prompt: "continue the work".into(),
656            }
657        );
658    }
659
660    #[test]
661    fn parse_resume_whitespace_only_prompt_returns_error() {
662        // After split_once, prompt is "   " which trims to "".
663        let err = AgentCommand::parse("/agent resume deadbeef    ", &[]).unwrap_err();
664        // Either split_once returns None (no space after id) or prompt trims to empty.
665        assert_matches!(err, SubAgentError::InvalidCommand(_));
666    }
667
668    // ── AgentsCommand (definition CRUD) ────────────────────────────────────
669
670    #[test]
671    fn agents_parse_list() {
672        assert_eq!(
673            AgentsCommand::parse("/agents list").unwrap(),
674            AgentsCommand::List
675        );
676    }
677
678    #[test]
679    fn agents_parse_show() {
680        let cmd = AgentsCommand::parse("/agents show code-reviewer").unwrap();
681        assert_eq!(
682            cmd,
683            AgentsCommand::Show {
684                name: "code-reviewer".into()
685            }
686        );
687    }
688
689    #[test]
690    fn agents_parse_create() {
691        let cmd = AgentsCommand::parse("/agents create my-agent").unwrap();
692        assert_eq!(
693            cmd,
694            AgentsCommand::Create {
695                name: "my-agent".into()
696            }
697        );
698    }
699
700    #[test]
701    fn agents_parse_edit() {
702        let cmd = AgentsCommand::parse("/agents edit reviewer").unwrap();
703        assert_eq!(
704            cmd,
705            AgentsCommand::Edit {
706                name: "reviewer".into()
707            }
708        );
709    }
710
711    #[test]
712    fn agents_parse_delete() {
713        let cmd = AgentsCommand::parse("/agents delete reviewer").unwrap();
714        assert_eq!(
715            cmd,
716            AgentsCommand::Delete {
717                name: "reviewer".into()
718            }
719        );
720    }
721
722    #[test]
723    fn agents_parse_missing_subcommand_returns_usage() {
724        let err = AgentsCommand::parse("/agents").unwrap_err();
725        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
726    }
727
728    #[test]
729    fn agents_parse_show_missing_name_returns_usage() {
730        let err = AgentsCommand::parse("/agents show").unwrap_err();
731        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage"));
732    }
733
734    #[test]
735    fn agents_parse_unknown_subcommand_returns_error() {
736        let err = AgentsCommand::parse("/agents frobnicate").unwrap_err();
737        assert_matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate"));
738    }
739
740    #[test]
741    fn agents_parse_wrong_prefix_returns_error() {
742        let err = AgentsCommand::parse("/agent list").unwrap_err();
743        assert_matches!(err, SubAgentError::InvalidCommand(_));
744    }
745}