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
344    #[test]
345    fn parse_list() {
346        assert_eq!(
347            AgentCommand::parse("/agent list", &[]).unwrap(),
348            AgentCommand::List
349        );
350    }
351
352    #[test]
353    fn parse_status() {
354        assert_eq!(
355            AgentCommand::parse("/agent status", &[]).unwrap(),
356            AgentCommand::Status
357        );
358    }
359
360    #[test]
361    fn parse_spawn() {
362        let cmd = AgentCommand::parse("/agent spawn helper do something useful", &[]).unwrap();
363        assert_eq!(
364            cmd,
365            AgentCommand::Spawn {
366                name: "helper".into(),
367                prompt: "do something useful".into(),
368            }
369        );
370    }
371
372    #[test]
373    fn parse_bg() {
374        let cmd = AgentCommand::parse("/agent bg reviewer check the code", &[]).unwrap();
375        assert_eq!(
376            cmd,
377            AgentCommand::Background {
378                name: "reviewer".into(),
379                prompt: "check the code".into(),
380            }
381        );
382    }
383
384    #[test]
385    fn parse_cancel() {
386        let cmd = AgentCommand::parse("/agent cancel abc123", &[]).unwrap();
387        assert_eq!(
388            cmd,
389            AgentCommand::Cancel {
390                id: "abc123".into()
391            }
392        );
393    }
394
395    #[test]
396    fn parse_approve() {
397        let cmd = AgentCommand::parse("/agent approve task-1", &[]).unwrap();
398        assert_eq!(
399            cmd,
400            AgentCommand::Approve {
401                id: "task-1".into()
402            }
403        );
404    }
405
406    #[test]
407    fn parse_deny() {
408        let cmd = AgentCommand::parse("/agent deny task-2", &[]).unwrap();
409        assert_eq!(
410            cmd,
411            AgentCommand::Deny {
412                id: "task-2".into()
413            }
414        );
415    }
416
417    #[test]
418    fn parse_wrong_prefix_returns_error() {
419        let err = AgentCommand::parse("/foo list", &[]).unwrap_err();
420        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
421    }
422
423    #[test]
424    fn parse_empty_after_prefix_returns_usage() {
425        let err = AgentCommand::parse("/agent", &[]).unwrap_err();
426        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
427    }
428
429    #[test]
430    fn parse_whitespace_only_after_prefix_returns_usage() {
431        let err = AgentCommand::parse("/agent   ", &[]).unwrap_err();
432        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
433    }
434
435    #[test]
436    fn parse_unknown_subcommand_returns_error() {
437        let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
438        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate")));
439    }
440
441    #[test]
442    fn parse_spawn_missing_prompt_returns_error() {
443        let err = AgentCommand::parse("/agent spawn helper", &[]).unwrap_err();
444        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
445    }
446
447    #[test]
448    fn parse_spawn_missing_name_and_prompt_returns_error() {
449        let err = AgentCommand::parse("/agent spawn", &[]).unwrap_err();
450        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
451    }
452
453    #[test]
454    fn parse_cancel_missing_id_returns_error() {
455        let err = AgentCommand::parse("/agent cancel", &[]).unwrap_err();
456        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
457    }
458
459    #[test]
460    fn parse_approve_missing_id_returns_error() {
461        let err = AgentCommand::parse("/agent approve", &[]).unwrap_err();
462        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
463    }
464
465    #[test]
466    fn parse_deny_missing_id_returns_error() {
467        let err = AgentCommand::parse("/agent deny", &[]).unwrap_err();
468        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
469    }
470
471    #[test]
472    fn parse_extra_whitespace_trimmed() {
473        // Extra spaces around subcommand and args should be handled gracefully.
474        let cmd = AgentCommand::parse("/agent  cancel  deadbeef", &[]).unwrap();
475        assert_eq!(
476            cmd,
477            AgentCommand::Cancel {
478                id: "deadbeef".into()
479            }
480        );
481    }
482
483    #[test]
484    fn parse_spawn_prompt_with_spaces_preserved() {
485        let cmd = AgentCommand::parse(
486            "/agent spawn bot review the PR and suggest improvements",
487            &[],
488        )
489        .unwrap();
490        assert_eq!(
491            cmd,
492            AgentCommand::Spawn {
493                name: "bot".into(),
494                prompt: "review the PR and suggest improvements".into(),
495            }
496        );
497    }
498
499    // ── parse_mention() tests ─────────────────────────────────────────────────
500
501    fn known() -> Vec<String> {
502        vec!["reviewer".into(), "helper".into()]
503    }
504
505    #[test]
506    fn mention_known_agent_with_prompt() {
507        let cmd = AgentCommand::parse_mention("@reviewer review this PR", &known()).unwrap();
508        assert_eq!(
509            cmd,
510            AgentCommand::Mention {
511                agent: "reviewer".into(),
512                prompt: "review this PR".into(),
513            }
514        );
515    }
516
517    #[test]
518    fn mention_known_agent_without_prompt() {
519        let cmd = AgentCommand::parse_mention("@helper", &known()).unwrap();
520        assert_eq!(
521            cmd,
522            AgentCommand::Mention {
523                agent: "helper".into(),
524                prompt: String::new(),
525            }
526        );
527    }
528
529    #[test]
530    fn mention_unknown_agent_returns_error() {
531        let err = AgentCommand::parse_mention("@unknown-thing do work", &known()).unwrap_err();
532        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("unknown-thing")));
533    }
534
535    #[test]
536    fn mention_bare_at_returns_error() {
537        let err = AgentCommand::parse_mention("@", &known()).unwrap_err();
538        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
539    }
540
541    #[test]
542    fn mention_at_with_space_returns_error() {
543        let err = AgentCommand::parse_mention("@ something", &known()).unwrap_err();
544        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
545    }
546
547    #[test]
548    fn mention_wrong_prefix_returns_error() {
549        let err = AgentCommand::parse_mention("reviewer do work", &known()).unwrap_err();
550        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
551    }
552
553    #[test]
554    fn mention_empty_known_agents_always_fails() {
555        let err = AgentCommand::parse_mention("@reviewer do work", &[]).unwrap_err();
556        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
557    }
558
559    // ── parse() unified entry point with @ ──────────────────────────────────
560
561    #[test]
562    fn parse_dispatches_at_mention_to_parse_mention() {
563        let cmd = AgentCommand::parse("@reviewer review this PR", &known()).unwrap();
564        assert_eq!(
565            cmd,
566            AgentCommand::Mention {
567                agent: "reviewer".into(),
568                prompt: "review this PR".into(),
569            }
570        );
571    }
572
573    #[test]
574    fn parse_at_unknown_agent_returns_error() {
575        let err = AgentCommand::parse("@unknown test", &known()).unwrap_err();
576        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
577    }
578
579    #[test]
580    fn parse_at_with_empty_known_returns_error() {
581        let err = AgentCommand::parse("@reviewer test", &[]).unwrap_err();
582        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
583    }
584
585    // ── parse resume ─────────────────────────────────────────────────────────
586
587    #[test]
588    fn parse_resume() {
589        let cmd = AgentCommand::parse("/agent resume deadbeef continue the analysis", &[]).unwrap();
590        assert_eq!(
591            cmd,
592            AgentCommand::Resume {
593                id: "deadbeef".into(),
594                prompt: "continue the analysis".into(),
595            }
596        );
597    }
598
599    #[test]
600    fn parse_resume_missing_prompt_returns_error() {
601        let err = AgentCommand::parse("/agent resume deadbeef", &[]).unwrap_err();
602        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
603    }
604
605    #[test]
606    fn parse_resume_missing_id_and_prompt_returns_error() {
607        let err = AgentCommand::parse("/agent resume", &[]).unwrap_err();
608        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
609    }
610
611    #[test]
612    fn parse_resume_unknown_subcommand_hint() {
613        let err = AgentCommand::parse("/agent frobnicate", &[]).unwrap_err();
614        if let SubAgentError::InvalidCommand(msg) = err {
615            assert!(
616                msg.contains("resume"),
617                "hint should mention 'resume': {msg}"
618            );
619        } else {
620            panic!("expected InvalidCommand");
621        }
622    }
623
624    #[test]
625    fn parse_resume_prompt_with_spaces_preserved() {
626        let cmd = AgentCommand::parse("/agent resume abc123 do more work and fix the issue", &[])
627            .unwrap();
628        assert_eq!(
629            cmd,
630            AgentCommand::Resume {
631                id: "abc123".into(),
632                prompt: "do more work and fix the issue".into(),
633            }
634        );
635    }
636
637    #[test]
638    fn parse_resume_id_too_short_returns_error() {
639        // id "abc" has only 3 chars — below the 4-char minimum.
640        let err = AgentCommand::parse("/agent resume abc continue", &[]).unwrap_err();
641        assert!(
642            matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("4 characters")),
643            "expected min-length error, got: {err:?}"
644        );
645    }
646
647    #[test]
648    fn parse_resume_id_exactly_four_chars_is_accepted() {
649        let cmd = AgentCommand::parse("/agent resume abcd continue the work", &[]).unwrap();
650        assert_eq!(
651            cmd,
652            AgentCommand::Resume {
653                id: "abcd".into(),
654                prompt: "continue the work".into(),
655            }
656        );
657    }
658
659    #[test]
660    fn parse_resume_whitespace_only_prompt_returns_error() {
661        // After split_once, prompt is "   " which trims to "".
662        let err = AgentCommand::parse("/agent resume deadbeef    ", &[]).unwrap_err();
663        // Either split_once returns None (no space after id) or prompt trims to empty.
664        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
665    }
666
667    // ── AgentsCommand (definition CRUD) ────────────────────────────────────
668
669    #[test]
670    fn agents_parse_list() {
671        assert_eq!(
672            AgentsCommand::parse("/agents list").unwrap(),
673            AgentsCommand::List
674        );
675    }
676
677    #[test]
678    fn agents_parse_show() {
679        let cmd = AgentsCommand::parse("/agents show code-reviewer").unwrap();
680        assert_eq!(
681            cmd,
682            AgentsCommand::Show {
683                name: "code-reviewer".into()
684            }
685        );
686    }
687
688    #[test]
689    fn agents_parse_create() {
690        let cmd = AgentsCommand::parse("/agents create my-agent").unwrap();
691        assert_eq!(
692            cmd,
693            AgentsCommand::Create {
694                name: "my-agent".into()
695            }
696        );
697    }
698
699    #[test]
700    fn agents_parse_edit() {
701        let cmd = AgentsCommand::parse("/agents edit reviewer").unwrap();
702        assert_eq!(
703            cmd,
704            AgentsCommand::Edit {
705                name: "reviewer".into()
706            }
707        );
708    }
709
710    #[test]
711    fn agents_parse_delete() {
712        let cmd = AgentsCommand::parse("/agents delete reviewer").unwrap();
713        assert_eq!(
714            cmd,
715            AgentsCommand::Delete {
716                name: "reviewer".into()
717            }
718        );
719    }
720
721    #[test]
722    fn agents_parse_missing_subcommand_returns_usage() {
723        let err = AgentsCommand::parse("/agents").unwrap_err();
724        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
725    }
726
727    #[test]
728    fn agents_parse_show_missing_name_returns_usage() {
729        let err = AgentsCommand::parse("/agents show").unwrap_err();
730        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("usage")));
731    }
732
733    #[test]
734    fn agents_parse_unknown_subcommand_returns_error() {
735        let err = AgentsCommand::parse("/agents frobnicate").unwrap_err();
736        assert!(matches!(err, SubAgentError::InvalidCommand(ref m) if m.contains("frobnicate")));
737    }
738
739    #[test]
740    fn agents_parse_wrong_prefix_returns_error() {
741        let err = AgentsCommand::parse("/agent list").unwrap_err();
742        assert!(matches!(err, SubAgentError::InvalidCommand(_)));
743    }
744}