Skip to main content

zeph_commands/handlers/
agents_fleet.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `/agents` fleet view handler: lists autonomous goal sessions and sub-agent definitions.
5
6use std::fmt::Write as _;
7use std::future::Future;
8use std::pin::Pin;
9use std::time::Duration;
10
11use zeph_config::autonomous::AutonomousState;
12
13use crate::context::CommandContext;
14use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
15
16/// Snapshot of a single autonomous goal session for the fleet view.
17///
18/// Constructed by querying `AutonomousRegistry` (`zeph-core`) and forwarded to
19/// [`format_fleet_section`] for display.
20#[derive(Debug, Clone)]
21pub struct FleetEntry {
22    /// UUID string of the goal.
23    pub goal_id: String,
24    /// First 80 characters of goal text (may be followed by `…`).
25    pub goal_text_short: String,
26    /// Current autonomous state.
27    pub state: AutonomousState,
28    /// Number of turns executed so far in this session.
29    pub turns_executed: u32,
30    /// Maximum turns allowed for this session.
31    pub max_turns: u32,
32    /// Wall-clock time elapsed since the session started.
33    pub elapsed: Duration,
34}
35
36/// Format the "Autonomous Goals" header section from a list of [`FleetEntry`] values.
37///
38/// Returns an empty string when `entries` is empty so callers can skip the section
39/// entirely if there are no active sessions.
40///
41/// # Examples
42///
43/// ```rust
44/// use std::time::Duration;
45/// use zeph_commands::handlers::agents_fleet::{FleetEntry, format_fleet_section};
46/// use zeph_config::autonomous::AutonomousState;
47///
48/// let entries = vec![FleetEntry {
49///     goal_id: "a1b2c3d4".to_owned(),
50///     goal_text_short: "Deploy the new API".to_owned(),
51///     state: AutonomousState::Running,
52///     turns_executed: 12,
53///     max_turns: 20,
54///     elapsed: Duration::from_secs(272),
55/// }];
56///
57/// let out = format_fleet_section(&entries);
58/// assert!(out.contains("Autonomous Goals:"));
59/// assert!(out.contains("a1b2c"));
60/// assert!(out.contains("running"));
61/// assert!(out.contains("12/20"));
62/// ```
63#[must_use]
64pub fn format_fleet_section(entries: &[FleetEntry]) -> String {
65    if entries.is_empty() {
66        return String::new();
67    }
68
69    let mut out = String::from("Autonomous Goals:\n");
70    let _ = writeln!(
71        out,
72        "  {:<8}  {:<30}  {:<10}  {:<8}  Elapsed",
73        "ID", "Goal (truncated)", "State", "Turns"
74    );
75
76    for e in entries {
77        let short_id = &e.goal_id[..8.min(e.goal_id.len())];
78        let elapsed = format_elapsed(e.elapsed);
79        let _ = writeln!(
80            out,
81            "  {:<8}  {:<30}  {:<10}  {:<8}  {}",
82            short_id,
83            truncate_display(&e.goal_text_short, 30),
84            e.state.to_string(),
85            format!("{}/{}", e.turns_executed, e.max_turns),
86            elapsed,
87        );
88    }
89
90    out
91}
92
93fn format_elapsed(d: Duration) -> String {
94    let total = d.as_secs();
95    let h = total / 3600;
96    let m = (total % 3600) / 60;
97    let s = total % 60;
98    if h > 0 {
99        format!("{h}h {m}m {s}s")
100    } else if m > 0 {
101        format!("{m}m {s}s")
102    } else {
103        format!("{s}s")
104    }
105}
106
107fn truncate_display(s: &str, max_chars: usize) -> String {
108    let char_count = s.chars().count();
109    if char_count <= max_chars {
110        s.to_owned()
111    } else {
112        let end = s.floor_char_boundary(max_chars.saturating_sub(1));
113        format!("{}…", &s[..end])
114    }
115}
116
117/// Show all active autonomous goal sessions and sub-agent definitions.
118///
119/// Autonomous goal sessions appear first (via [`format_fleet_section`]), followed
120/// by the standard sub-agent definition list produced by `/agents list`.
121/// The sub-agent section is omitted when no definitions are found.
122pub struct AgentsFleetCommand;
123
124impl CommandHandler<CommandContext<'_>> for AgentsFleetCommand {
125    fn name(&self) -> &'static str {
126        "/agents"
127    }
128
129    fn description(&self) -> &'static str {
130        "List active autonomous goal sessions and sub-agent definitions"
131    }
132
133    fn args_hint(&self) -> &'static str {
134        "[list|show|create|edit|delete <name>]"
135    }
136
137    fn category(&self) -> SlashCategory {
138        SlashCategory::Integration
139    }
140
141    fn requires_auth(&self) -> bool {
142        true
143    }
144
145    fn handle<'a>(
146        &'a self,
147        ctx: &'a mut CommandContext<'_>,
148        args: &'a str,
149    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
150        use tracing::Instrument as _;
151        let span = tracing::info_span!("commands.agents.handle");
152        Box::pin(
153            async move {
154                let result = ctx.agent.handle_agents(args).await?;
155                Ok(CommandOutput::Message(result))
156            }
157            .instrument(span),
158        )
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn entry(
167        id: &str,
168        text: &str,
169        state: AutonomousState,
170        turns: u32,
171        max: u32,
172        secs: u64,
173    ) -> FleetEntry {
174        FleetEntry {
175            goal_id: id.to_owned(),
176            goal_text_short: text.to_owned(),
177            state,
178            turns_executed: turns,
179            max_turns: max,
180            elapsed: Duration::from_secs(secs),
181        }
182    }
183
184    #[test]
185    fn empty_entries_returns_empty_string() {
186        assert_eq!(format_fleet_section(&[]), "");
187    }
188
189    #[test]
190    fn single_running_entry_contains_expected_fields() {
191        let entries = vec![entry(
192            "a1b2c3d4e5",
193            "Deploy the new API",
194            AutonomousState::Running,
195            12,
196            20,
197            272,
198        )];
199        let out = format_fleet_section(&entries);
200        assert!(out.contains("Autonomous Goals:"), "header missing");
201        assert!(out.contains("a1b2c3d4"), "short ID missing");
202        assert!(out.contains("running"), "state missing");
203        assert!(out.contains("12/20"), "turns counter missing");
204        assert!(out.contains("4m 32s"), "elapsed time missing");
205    }
206
207    #[test]
208    fn two_entries_both_appear() {
209        let entries = vec![
210            entry(
211                "aaaa1111",
212                "First goal",
213                AutonomousState::Running,
214                3,
215                20,
216                60,
217            ),
218            entry(
219                "bbbb2222",
220                "Second goal",
221                AutonomousState::Verifying,
222                5,
223                10,
224                75,
225            ),
226        ];
227        let out = format_fleet_section(&entries);
228        assert!(out.contains("aaaa1111"), "first id missing");
229        assert!(out.contains("bbbb2222"), "second id missing");
230        assert!(out.contains("verifying"), "verifying state missing");
231    }
232
233    #[test]
234    fn elapsed_hours_formatted_correctly() {
235        let entries = vec![entry(
236            "cccc3333",
237            "Long running goal",
238            AutonomousState::Running,
239            1,
240            5,
241            3665,
242        )];
243        let out = format_fleet_section(&entries);
244        assert!(out.contains("1h"), "hours missing");
245        assert!(out.contains("1m"), "minutes missing");
246    }
247
248    #[test]
249    fn elapsed_seconds_only() {
250        let entries = vec![entry(
251            "dddd4444",
252            "Short goal",
253            AutonomousState::Achieved,
254            1,
255            5,
256            45,
257        )];
258        let out = format_fleet_section(&entries);
259        assert!(out.contains("45s"), "seconds missing");
260    }
261
262    #[test]
263    fn long_goal_text_truncated() {
264        let long = "a".repeat(80);
265        let entries = vec![entry("eeee5555", &long, AutonomousState::Running, 0, 5, 0)];
266        let out = format_fleet_section(&entries);
267        assert!(out.contains('…'), "truncation indicator missing");
268    }
269
270    #[test]
271    fn agents_fleet_command_name() {
272        assert_eq!(AgentsFleetCommand.name(), "/agents");
273        assert!(!AgentsFleetCommand.description().is_empty());
274    }
275
276    #[tokio::test]
277    async fn agents_fleet_dispatch_allowed_when_trusted() {
278        use crate::CommandRegistry;
279        use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
280        use crate::sink::NullSink;
281
282        let mut sink = NullSink;
283        let mut debug = MockDebug;
284        let mut messages = MockMessages;
285        let session = MockSession;
286        let mut agent = crate::NullAgent;
287        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
288
289        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
290        reg.register(AgentsFleetCommand);
291
292        let result = reg.dispatch(&mut ctx, "/agents list", true).await;
293        assert!(result.unwrap().is_ok());
294    }
295
296    #[tokio::test]
297    async fn agents_fleet_dispatch_rejected_when_untrusted() {
298        use crate::CommandRegistry;
299        use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
300        use crate::sink::NullSink;
301
302        let mut sink = NullSink;
303        let mut debug = MockDebug;
304        let mut messages = MockMessages;
305        let session = MockSession;
306        let mut agent = crate::NullAgent;
307        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
308
309        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
310        reg.register(AgentsFleetCommand);
311
312        let result = reg.dispatch(&mut ctx, "/agents list", false).await;
313        let err = result.unwrap().unwrap_err();
314        assert!(err.0.contains("trusted"));
315    }
316
317    // Test format_elapsed edge cases
318    #[test]
319    fn format_elapsed_zero() {
320        assert_eq!(format_elapsed(Duration::ZERO), "0s");
321    }
322
323    #[test]
324    fn format_elapsed_one_minute() {
325        assert_eq!(format_elapsed(Duration::from_mins(1)), "1m 0s");
326    }
327
328    #[test]
329    fn format_elapsed_one_hour() {
330        assert_eq!(format_elapsed(Duration::from_hours(1)), "1h 0m 0s");
331    }
332}