Skip to main content

zeph_commands/handlers/
session.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Session management command handlers: `/exit`, `/quit`, `/clear`, `/reset`, `/clear-queue`.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::CommandHandler;
10use crate::context::CommandContext;
11use crate::{CommandError, CommandOutput, SlashCategory};
12
13/// Exit the agent loop.
14///
15/// `/exit` and `/quit` are treated as aliases; both map to this handler via the registry.
16/// When the channel does not support exit (e.g., Telegram), the command is rejected with
17/// a user-visible message.
18pub struct ExitCommand;
19
20impl CommandHandler<CommandContext<'_>> for ExitCommand {
21    fn name(&self) -> &'static str {
22        "/exit"
23    }
24
25    fn description(&self) -> &'static str {
26        "Exit the agent (also: /quit)"
27    }
28
29    fn category(&self) -> SlashCategory {
30        SlashCategory::Session
31    }
32
33    fn handle<'a>(
34        &'a self,
35        ctx: &'a mut CommandContext<'_>,
36        _args: &'a str,
37    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
38        Box::pin(async move {
39            if ctx.session.supports_exit() {
40                Ok(CommandOutput::Exit)
41            } else {
42                ctx.sink
43                    .send("/exit is not supported in this channel.")
44                    .await?;
45                Ok(CommandOutput::Continue)
46            }
47        })
48    }
49}
50
51/// Alias for `/exit`.
52pub struct QuitCommand;
53
54impl CommandHandler<CommandContext<'_>> for QuitCommand {
55    fn name(&self) -> &'static str {
56        "/quit"
57    }
58
59    fn description(&self) -> &'static str {
60        "Exit the agent (alias for /exit)"
61    }
62
63    fn category(&self) -> SlashCategory {
64        SlashCategory::Session
65    }
66
67    fn handle<'a>(
68        &'a self,
69        ctx: &'a mut CommandContext<'_>,
70        _args: &'a str,
71    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
72        Box::pin(async move {
73            if ctx.session.supports_exit() {
74                Ok(CommandOutput::Exit)
75            } else {
76                ctx.sink
77                    .send("/exit is not supported in this channel.")
78                    .await?;
79                Ok(CommandOutput::Continue)
80            }
81        })
82    }
83}
84
85/// Clear conversation history and tool caches without sending a confirmation message.
86///
87/// Clears the message history (keeping only the system prompt), tool caches,
88/// pending images, and URL tracking.
89pub struct ClearCommand;
90
91impl CommandHandler<CommandContext<'_>> for ClearCommand {
92    fn name(&self) -> &'static str {
93        "/clear"
94    }
95
96    fn description(&self) -> &'static str {
97        "Clear conversation history"
98    }
99
100    fn category(&self) -> SlashCategory {
101        SlashCategory::Session
102    }
103
104    fn handle<'a>(
105        &'a self,
106        ctx: &'a mut CommandContext<'_>,
107        _args: &'a str,
108    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
109        Box::pin(async move {
110            ctx.messages.clear_history();
111            Ok(CommandOutput::Silent)
112        })
113    }
114}
115
116/// Reset conversation history (alias for `/clear`, replies with confirmation).
117pub struct ResetCommand;
118
119impl CommandHandler<CommandContext<'_>> for ResetCommand {
120    fn name(&self) -> &'static str {
121        "/reset"
122    }
123
124    fn description(&self) -> &'static str {
125        "Reset conversation history (alias for /clear, replies with confirmation)"
126    }
127
128    fn category(&self) -> SlashCategory {
129        SlashCategory::Session
130    }
131
132    fn handle<'a>(
133        &'a self,
134        ctx: &'a mut CommandContext<'_>,
135        _args: &'a str,
136    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
137        Box::pin(async move {
138            ctx.messages.clear_history();
139            Ok(CommandOutput::Message(
140                "Conversation history reset.".to_owned(),
141            ))
142        })
143    }
144}
145
146/// Discard all messages currently queued for processing.
147pub struct ClearQueueCommand;
148
149impl CommandHandler<CommandContext<'_>> for ClearQueueCommand {
150    fn name(&self) -> &'static str {
151        "/clear-queue"
152    }
153
154    fn description(&self) -> &'static str {
155        "Discard queued messages"
156    }
157
158    fn category(&self) -> SlashCategory {
159        SlashCategory::Session
160    }
161
162    fn handle<'a>(
163        &'a self,
164        ctx: &'a mut CommandContext<'_>,
165        _args: &'a str,
166    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
167        Box::pin(async move {
168            let n = ctx.messages.drain_queue();
169            // Notify the channel of the updated count; ignore errors (best-effort).
170            let _ = ctx.sink.send_queue_count(0).await;
171            Ok(CommandOutput::Message(format!(
172                "Cleared {n} queued messages."
173            )))
174        })
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::CommandRegistry;
182    use crate::context::CommandContext;
183    use crate::sink::ChannelSink;
184    use crate::traits::debug::DebugAccess;
185    use crate::traits::messages::MessageAccess;
186    use crate::traits::session::SessionAccess;
187    use std::future::Future;
188    use std::pin::Pin;
189
190    // --- Mock implementations ---
191
192    struct MockSink {
193        sent: Vec<String>,
194    }
195
196    impl ChannelSink for MockSink {
197        fn send<'a>(
198            &'a mut self,
199            msg: &'a str,
200        ) -> Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send + 'a>> {
201            self.sent.push(msg.to_owned());
202            Box::pin(async { Ok(()) })
203        }
204
205        fn flush_chunks<'a>(
206            &'a mut self,
207        ) -> Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send + 'a>> {
208            Box::pin(async { Ok(()) })
209        }
210
211        fn send_queue_count<'a>(
212            &'a mut self,
213            _count: usize,
214        ) -> Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send + 'a>> {
215            Box::pin(async { Ok(()) })
216        }
217
218        fn supports_exit(&self) -> bool {
219            false
220        }
221    }
222
223    struct MockDebug;
224
225    impl DebugAccess for MockDebug {
226        fn log_status(&self) -> String {
227            String::new()
228        }
229
230        fn read_log_tail<'a>(
231            &'a self,
232            _n: usize,
233        ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
234            Box::pin(async { None })
235        }
236
237        fn scrub(&self, text: &str) -> String {
238            text.to_owned()
239        }
240
241        fn dump_status(&self) -> Option<String> {
242            None
243        }
244
245        fn dump_format_name(&self) -> String {
246            "raw".to_owned()
247        }
248
249        fn enable_dump(&mut self, _dir: &str) -> Result<String, CommandError> {
250            Ok("/tmp".to_owned())
251        }
252
253        fn set_dump_format(&mut self, _name: &str) -> Result<(), CommandError> {
254            Ok(())
255        }
256    }
257
258    struct MockMessages {
259        pub cleared: bool,
260        pub queue: usize,
261    }
262
263    impl MessageAccess for MockMessages {
264        fn clear_history(&mut self) {
265            self.cleared = true;
266        }
267
268        fn queue_len(&self) -> usize {
269            self.queue
270        }
271
272        fn drain_queue(&mut self) -> usize {
273            let n = self.queue;
274            self.queue = 0;
275            n
276        }
277
278        fn notify_queue_count<'a>(
279            &'a mut self,
280            _count: usize,
281        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
282            Box::pin(async {})
283        }
284    }
285
286    struct MockSession {
287        supports_exit: bool,
288    }
289
290    impl SessionAccess for MockSession {
291        fn supports_exit(&self) -> bool {
292            self.supports_exit
293        }
294    }
295
296    fn make_ctx<'a>(
297        sink: &'a mut MockSink,
298        debug: &'a mut MockDebug,
299        messages: &'a mut MockMessages,
300        session: &'a MockSession,
301        agent: &'a mut crate::NullAgent,
302    ) -> CommandContext<'a> {
303        CommandContext {
304            sink,
305            debug,
306            messages,
307            session: session as &dyn SessionAccess,
308            agent,
309        }
310    }
311
312    // --- Tests ---
313
314    #[tokio::test]
315    async fn exit_returns_exit_when_supported() {
316        let mut sink = MockSink { sent: vec![] };
317        let mut debug = MockDebug;
318        let mut messages = MockMessages {
319            cleared: false,
320            queue: 0,
321        };
322        let session = MockSession {
323            supports_exit: true,
324        };
325        let mut agent = crate::NullAgent;
326        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
327        let out = ExitCommand.handle(&mut ctx, "").await.unwrap();
328        assert!(matches!(out, CommandOutput::Exit));
329    }
330
331    #[tokio::test]
332    async fn exit_sends_message_when_not_supported() {
333        let mut sink = MockSink { sent: vec![] };
334        let mut debug = MockDebug;
335        let mut messages = MockMessages {
336            cleared: false,
337            queue: 0,
338        };
339        let session = MockSession {
340            supports_exit: false,
341        };
342        let mut agent = crate::NullAgent;
343        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
344        let out = ExitCommand.handle(&mut ctx, "").await.unwrap();
345        assert!(matches!(out, CommandOutput::Continue));
346        assert!(!sink.sent.is_empty());
347    }
348
349    #[tokio::test]
350    async fn clear_clears_history() {
351        let mut sink = MockSink { sent: vec![] };
352        let mut debug = MockDebug;
353        let mut messages = MockMessages {
354            cleared: false,
355            queue: 0,
356        };
357        let session = MockSession {
358            supports_exit: false,
359        };
360        let out = {
361            let mut agent = crate::NullAgent;
362            let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
363            ClearCommand.handle(&mut ctx, "").await.unwrap()
364        };
365        assert!(matches!(out, CommandOutput::Silent));
366        assert!(messages.cleared);
367    }
368
369    #[tokio::test]
370    async fn reset_clears_and_confirms() {
371        let mut sink = MockSink { sent: vec![] };
372        let mut debug = MockDebug;
373        let mut messages = MockMessages {
374            cleared: false,
375            queue: 0,
376        };
377        let session = MockSession {
378            supports_exit: false,
379        };
380        let out = {
381            let mut agent = crate::NullAgent;
382            let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
383            ResetCommand.handle(&mut ctx, "").await.unwrap()
384        };
385        let CommandOutput::Message(msg) = out else {
386            panic!("expected Message")
387        };
388        assert!(msg.contains("reset"));
389        assert!(messages.cleared);
390    }
391
392    #[tokio::test]
393    async fn clear_queue_drains_and_reports() {
394        let mut sink = MockSink { sent: vec![] };
395        let mut debug = MockDebug;
396        let mut messages = MockMessages {
397            cleared: false,
398            queue: 3,
399        };
400        let session = MockSession {
401            supports_exit: false,
402        };
403        let out = {
404            let mut agent = crate::NullAgent;
405            let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
406            ClearQueueCommand.handle(&mut ctx, "").await.unwrap()
407        };
408        let CommandOutput::Message(msg) = out else {
409            panic!("expected Message")
410        };
411        assert!(msg.contains('3'));
412        assert_eq!(messages.queue, 0);
413    }
414
415    #[test]
416    fn registry_finds_all_session_commands() {
417        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
418        reg.register(ExitCommand);
419        reg.register(QuitCommand);
420        reg.register(ClearCommand);
421        reg.register(ResetCommand);
422        reg.register(ClearQueueCommand);
423
424        assert!(reg.find_handler("/exit").is_some());
425        assert!(reg.find_handler("/quit").is_some());
426        assert!(reg.find_handler("/clear").is_some());
427        assert!(reg.find_handler("/reset").is_some());
428        assert!(reg.find_handler("/clear-queue").is_some());
429    }
430}