1use 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#[derive(Debug, Clone)]
21pub struct FleetEntry {
22 pub goal_id: String,
24 pub goal_text_short: String,
26 pub state: AutonomousState,
28 pub turns_executed: u32,
30 pub max_turns: u32,
32 pub elapsed: Duration,
34}
35
36#[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
117pub 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]
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}