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
13async fn handle_exit(ctx: &mut CommandContext<'_>) -> Result<CommandOutput, CommandError> {
14    if ctx.session.supports_exit() {
15        Ok(CommandOutput::Exit)
16    } else {
17        ctx.sink
18            .send("/exit is not supported in this channel.")
19            .await?;
20        Ok(CommandOutput::Continue)
21    }
22}
23
24/// Exit the agent loop.
25///
26/// `/exit` and `/quit` are treated as aliases; both map to this handler via the registry.
27/// When the channel does not support exit (e.g., Telegram), the command is rejected with
28/// a user-visible message.
29pub struct ExitCommand;
30
31impl CommandHandler<CommandContext<'_>> for ExitCommand {
32    fn name(&self) -> &'static str {
33        "/exit"
34    }
35
36    fn description(&self) -> &'static str {
37        "Exit the agent (also: /quit)"
38    }
39
40    fn category(&self) -> SlashCategory {
41        SlashCategory::Session
42    }
43
44    fn requires_auth(&self) -> bool {
45        false
46    }
47
48    fn handle<'a>(
49        &'a self,
50        ctx: &'a mut CommandContext<'_>,
51        _args: &'a str,
52    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
53        use tracing::Instrument as _;
54        let span = tracing::info_span!("commands.exit.handle");
55        Box::pin(async move { handle_exit(ctx).await }.instrument(span))
56    }
57}
58
59/// Alias for `/exit`.
60pub struct QuitCommand;
61
62impl CommandHandler<CommandContext<'_>> for QuitCommand {
63    fn name(&self) -> &'static str {
64        "/quit"
65    }
66
67    fn description(&self) -> &'static str {
68        "Exit the agent (alias for /exit)"
69    }
70
71    fn category(&self) -> SlashCategory {
72        SlashCategory::Session
73    }
74
75    fn requires_auth(&self) -> bool {
76        false
77    }
78
79    fn handle<'a>(
80        &'a self,
81        ctx: &'a mut CommandContext<'_>,
82        _args: &'a str,
83    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
84        use tracing::Instrument as _;
85        let span = tracing::info_span!("commands.quit.handle");
86        Box::pin(async move { handle_exit(ctx).await }.instrument(span))
87    }
88}
89
90/// Clear conversation history and tool caches without sending a confirmation message.
91///
92/// Clears the message history (keeping only the system prompt), tool caches,
93/// pending images, and URL tracking.
94pub struct ClearCommand;
95
96impl CommandHandler<CommandContext<'_>> for ClearCommand {
97    fn name(&self) -> &'static str {
98        "/clear"
99    }
100
101    fn description(&self) -> &'static str {
102        "Clear conversation history"
103    }
104
105    fn category(&self) -> SlashCategory {
106        SlashCategory::Session
107    }
108
109    fn requires_auth(&self) -> bool {
110        true
111    }
112
113    fn handle<'a>(
114        &'a self,
115        ctx: &'a mut CommandContext<'_>,
116        _args: &'a str,
117    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
118        use tracing::Instrument as _;
119        let span = tracing::info_span!("commands.clear.handle");
120        Box::pin(
121            async move {
122                ctx.messages.clear_history();
123                Ok(CommandOutput::Silent)
124            }
125            .instrument(span),
126        )
127    }
128}
129
130/// Reset conversation history (alias for `/clear`, replies with confirmation).
131pub struct ResetCommand;
132
133impl CommandHandler<CommandContext<'_>> for ResetCommand {
134    fn name(&self) -> &'static str {
135        "/reset"
136    }
137
138    fn description(&self) -> &'static str {
139        "Reset conversation history (alias for /clear, replies with confirmation)"
140    }
141
142    fn category(&self) -> SlashCategory {
143        SlashCategory::Session
144    }
145
146    fn requires_auth(&self) -> bool {
147        true
148    }
149
150    fn handle<'a>(
151        &'a self,
152        ctx: &'a mut CommandContext<'_>,
153        _args: &'a str,
154    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
155        use tracing::Instrument as _;
156        let span = tracing::info_span!("commands.reset.handle");
157        Box::pin(
158            async move {
159                ctx.messages.clear_history();
160                Ok(CommandOutput::Message(
161                    "Conversation history reset.".to_owned(),
162                ))
163            }
164            .instrument(span),
165        )
166    }
167}
168
169/// Discard all messages currently queued for processing.
170pub struct ClearQueueCommand;
171
172impl CommandHandler<CommandContext<'_>> for ClearQueueCommand {
173    fn name(&self) -> &'static str {
174        "/clear-queue"
175    }
176
177    fn description(&self) -> &'static str {
178        "Discard queued messages"
179    }
180
181    fn category(&self) -> SlashCategory {
182        SlashCategory::Session
183    }
184
185    fn requires_auth(&self) -> bool {
186        true
187    }
188
189    fn handle<'a>(
190        &'a self,
191        ctx: &'a mut CommandContext<'_>,
192        _args: &'a str,
193    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
194        use tracing::Instrument as _;
195        let span = tracing::info_span!("commands.clear_queue.handle");
196        Box::pin(
197            async move {
198                let n = ctx.messages.drain_queue();
199                if let Err(e) = ctx.sink.send_queue_count(0).await {
200                    tracing::debug!(
201                        "clear_queue: send_queue_count notification failed (best-effort): {e}"
202                    );
203                }
204                Ok(CommandOutput::Message(format!(
205                    "Cleared {n} queued messages."
206                )))
207            }
208            .instrument(span),
209        )
210    }
211}
212
213/// Show conversation history (spec-068 §13.6).
214///
215/// `/history` (no argument): the last `[session.resume] expand_default_lines` messages,
216/// sliced before formatting (INV-SP-6). `/history N`: the last `N` messages. `/history all`:
217/// pages through the full history in `expand_default_lines`-sized chunks — never
218/// materializes the full history before formatting — with an explicit "may take a moment"
219/// notice before the first page. `/history next`: continues from the pagination cursor left
220/// by a prior `/history all` or `/history next`.
221///
222/// Output is sent via [`crate::sink::ChannelSink::send_transcript`], not the `handle`
223/// return value, so channels with a structured display buffer (TUI) can backfill per-entry
224/// instead of flattening to one string.
225pub struct HistoryCommand;
226
227enum HistoryArgs {
228    Bounded(usize),
229    All,
230    Next,
231}
232
233fn parse_history_args(args: &str, default_lines: usize) -> HistoryArgs {
234    match args.trim() {
235        "" => HistoryArgs::Bounded(default_lines),
236        "all" => HistoryArgs::All,
237        "next" => HistoryArgs::Next,
238        n => n
239            .parse::<usize>()
240            .map_or(HistoryArgs::Bounded(default_lines), HistoryArgs::Bounded),
241    }
242}
243
244impl CommandHandler<CommandContext<'_>> for HistoryCommand {
245    fn name(&self) -> &'static str {
246        "/history"
247    }
248
249    fn description(&self) -> &'static str {
250        "Show conversation history (N most recent messages, or 'all' to page through)"
251    }
252
253    fn args_hint(&self) -> &'static str {
254        "[N|all|next]"
255    }
256
257    fn category(&self) -> SlashCategory {
258        SlashCategory::Session
259    }
260
261    fn requires_auth(&self) -> bool {
262        false
263    }
264
265    fn handle<'a>(
266        &'a self,
267        ctx: &'a mut CommandContext<'_>,
268        args: &'a str,
269    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
270        use tracing::Instrument as _;
271        let span = tracing::info_span!("commands.history.handle");
272        Box::pin(
273            async move {
274                let default_lines = ctx.session.history_expand_default_lines().max(1);
275                let total = ctx.messages.transcript_len();
276                if total == 0 {
277                    ctx.sink.send("No conversation history yet.").await?;
278                    return Ok(CommandOutput::Silent);
279                }
280
281                match parse_history_args(args, default_lines) {
282                    HistoryArgs::Bounded(n) => {
283                        let n = n.min(total);
284                        let start = total.saturating_sub(n);
285                        let entries = ctx.messages.transcript_page(start, n);
286                        ctx.messages.set_history_cursor(0);
287                        ctx.sink.send_transcript(&entries).await?;
288                    }
289                    HistoryArgs::All => {
290                        ctx.sink
291                            .send(&format!(
292                                "Showing full history ({total} messages) — this may take a moment."
293                            ))
294                            .await?;
295                        let count = default_lines.min(total);
296                        let entries = ctx.messages.transcript_page(0, count);
297                        ctx.messages.set_history_cursor(count);
298                        ctx.sink.send_transcript(&entries).await?;
299                        if count < total {
300                            let total_pages = total.div_ceil(default_lines);
301                            ctx.sink
302                                .send(&format!(
303                                    "Page 1/{total_pages} — use /history next to continue."
304                                ))
305                                .await?;
306                        }
307                    }
308                    HistoryArgs::Next => {
309                        let cursor = ctx.messages.history_cursor();
310                        if cursor == 0 || cursor >= total {
311                            ctx.sink
312                                .send(
313                                    "No more history to page through. Use /history all to start over.",
314                                )
315                                .await?;
316                            return Ok(CommandOutput::Silent);
317                        }
318                        let count = default_lines.min(total - cursor);
319                        let entries = ctx.messages.transcript_page(cursor, count);
320                        let new_cursor = cursor + count;
321                        ctx.messages.set_history_cursor(new_cursor);
322                        ctx.sink.send_transcript(&entries).await?;
323                        if new_cursor < total {
324                            // `page` is already 1-based (cursor=20, default_lines=20 -> page 2)
325                            // — do not add another `+ 1` (M2: previously printed "Page 3/3" for
326                            // what was actually page 2 of 3).
327                            let page = cursor / default_lines + 1;
328                            let total_pages = total.div_ceil(default_lines);
329                            ctx.sink
330                                .send(&format!(
331                                    "Page {page}/{total_pages} — use /history next to continue."
332                                ))
333                                .await?;
334                        }
335                    }
336                }
337                Ok(CommandOutput::Silent)
338            }
339            .instrument(span),
340        )
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::CommandRegistry;
348    use crate::context::CommandContext;
349    use crate::handlers::test_helpers::MockDebug;
350    use crate::sink::ChannelSink;
351    use crate::traits::messages::MessageAccess;
352    use crate::traits::session::SessionAccess;
353    use std::assert_matches;
354    use std::future::Future;
355    use std::pin::Pin;
356
357    // --- Mock implementations ---
358
359    struct MockSink {
360        sent: Vec<String>,
361    }
362
363    impl ChannelSink for MockSink {
364        fn send<'a>(
365            &'a mut self,
366            msg: &'a str,
367        ) -> Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send + 'a>> {
368            self.sent.push(msg.to_owned());
369            Box::pin(async { Ok(()) })
370        }
371
372        fn flush_chunks<'a>(
373            &'a mut self,
374        ) -> Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send + 'a>> {
375            Box::pin(async { Ok(()) })
376        }
377
378        fn send_queue_count<'a>(
379            &'a mut self,
380            _count: usize,
381        ) -> Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send + 'a>> {
382            Box::pin(async { Ok(()) })
383        }
384
385        fn supports_exit(&self) -> bool {
386            false
387        }
388    }
389
390    struct MockMessages {
391        pub cleared: bool,
392        pub queue: usize,
393        pub transcript: Vec<crate::transcript::TranscriptEntry>,
394        pub cursor: usize,
395    }
396
397    impl MessageAccess for MockMessages {
398        fn clear_history(&mut self) {
399            self.cleared = true;
400        }
401
402        fn queue_len(&self) -> usize {
403            self.queue
404        }
405
406        fn drain_queue(&mut self) -> usize {
407            let n = self.queue;
408            self.queue = 0;
409            n
410        }
411
412        fn notify_queue_count<'a>(
413            &'a mut self,
414            _count: usize,
415        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
416            Box::pin(async {})
417        }
418
419        fn transcript_len(&self) -> usize {
420            self.transcript.len()
421        }
422
423        fn transcript_page(
424            &self,
425            start: usize,
426            count: usize,
427        ) -> Vec<crate::transcript::TranscriptEntry> {
428            self.transcript
429                .iter()
430                .skip(start)
431                .take(count)
432                .cloned()
433                .collect()
434        }
435
436        fn history_cursor(&self) -> usize {
437            self.cursor
438        }
439
440        fn set_history_cursor(&mut self, pos: usize) {
441            self.cursor = pos;
442        }
443    }
444
445    struct MockSession {
446        supports_exit: bool,
447        expand_default_lines: usize,
448    }
449
450    impl SessionAccess for MockSession {
451        fn supports_exit(&self) -> bool {
452            self.supports_exit
453        }
454
455        fn history_expand_default_lines(&self) -> usize {
456            self.expand_default_lines
457        }
458    }
459
460    fn make_ctx<'a>(
461        sink: &'a mut MockSink,
462        debug: &'a mut MockDebug,
463        messages: &'a mut MockMessages,
464        session: &'a MockSession,
465        agent: &'a mut crate::NullAgent,
466    ) -> CommandContext<'a> {
467        CommandContext {
468            sink,
469            debug,
470            messages,
471            session: session as &dyn SessionAccess,
472            agent,
473        }
474    }
475
476    // --- Tests ---
477
478    #[tokio::test]
479    async fn exit_returns_exit_when_supported() {
480        let mut sink = MockSink { sent: vec![] };
481        let mut debug = MockDebug;
482        let mut messages = MockMessages {
483            cleared: false,
484            queue: 0,
485            transcript: Vec::new(),
486            cursor: 0,
487        };
488        let session = MockSession {
489            supports_exit: true,
490            expand_default_lines: 20,
491        };
492        let mut agent = crate::NullAgent;
493        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
494        let out = ExitCommand.handle(&mut ctx, "").await.unwrap();
495        assert_matches!(out, CommandOutput::Exit);
496    }
497
498    #[tokio::test]
499    async fn exit_sends_message_when_not_supported() {
500        let mut sink = MockSink { sent: vec![] };
501        let mut debug = MockDebug;
502        let mut messages = MockMessages {
503            cleared: false,
504            queue: 0,
505            transcript: Vec::new(),
506            cursor: 0,
507        };
508        let session = MockSession {
509            supports_exit: false,
510            expand_default_lines: 20,
511        };
512        let mut agent = crate::NullAgent;
513        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
514        let out = ExitCommand.handle(&mut ctx, "").await.unwrap();
515        assert_matches!(out, CommandOutput::Continue);
516        assert!(!sink.sent.is_empty());
517    }
518
519    #[tokio::test]
520    async fn clear_clears_history() {
521        let mut sink = MockSink { sent: vec![] };
522        let mut debug = MockDebug;
523        let mut messages = MockMessages {
524            cleared: false,
525            queue: 0,
526            transcript: Vec::new(),
527            cursor: 0,
528        };
529        let session = MockSession {
530            supports_exit: false,
531            expand_default_lines: 20,
532        };
533        let out = {
534            let mut agent = crate::NullAgent;
535            let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
536            ClearCommand.handle(&mut ctx, "").await.unwrap()
537        };
538        assert_matches!(out, CommandOutput::Silent);
539        assert!(messages.cleared);
540    }
541
542    #[tokio::test]
543    async fn reset_clears_and_confirms() {
544        let mut sink = MockSink { sent: vec![] };
545        let mut debug = MockDebug;
546        let mut messages = MockMessages {
547            cleared: false,
548            queue: 0,
549            transcript: Vec::new(),
550            cursor: 0,
551        };
552        let session = MockSession {
553            supports_exit: false,
554            expand_default_lines: 20,
555        };
556        let out = {
557            let mut agent = crate::NullAgent;
558            let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
559            ResetCommand.handle(&mut ctx, "").await.unwrap()
560        };
561        let CommandOutput::Message(msg) = out else {
562            panic!("expected Message")
563        };
564        assert!(msg.contains("reset"));
565        assert!(messages.cleared);
566    }
567
568    #[tokio::test]
569    async fn clear_queue_drains_and_reports() {
570        let mut sink = MockSink { sent: vec![] };
571        let mut debug = MockDebug;
572        let mut messages = MockMessages {
573            cleared: false,
574            queue: 3,
575            transcript: Vec::new(),
576            cursor: 0,
577        };
578        let session = MockSession {
579            supports_exit: false,
580            expand_default_lines: 20,
581        };
582        let out = {
583            let mut agent = crate::NullAgent;
584            let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
585            ClearQueueCommand.handle(&mut ctx, "").await.unwrap()
586        };
587        let CommandOutput::Message(msg) = out else {
588            panic!("expected Message")
589        };
590        assert!(msg.contains('3'));
591        assert_eq!(messages.queue, 0);
592    }
593
594    #[test]
595    fn exit_requires_auth_false() {
596        assert!(!ExitCommand.requires_auth());
597    }
598
599    #[test]
600    fn quit_requires_auth_false() {
601        assert!(!QuitCommand.requires_auth());
602    }
603
604    #[test]
605    fn registry_finds_all_session_commands() {
606        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
607        reg.register(ExitCommand);
608        reg.register(QuitCommand);
609        reg.register(ClearCommand);
610        reg.register(ResetCommand);
611        reg.register(ClearQueueCommand);
612
613        assert!(reg.find_handler("/exit").is_some());
614        assert!(reg.find_handler("/quit").is_some());
615        assert!(reg.find_handler("/clear").is_some());
616        assert!(reg.find_handler("/reset").is_some());
617        assert!(reg.find_handler("/clear-queue").is_some());
618    }
619
620    #[tokio::test]
621    async fn clear_dispatch_allowed_when_trusted() {
622        let mut sink = MockSink { sent: vec![] };
623        let mut debug = MockDebug;
624        let mut messages = MockMessages {
625            cleared: false,
626            queue: 0,
627            transcript: Vec::new(),
628            cursor: 0,
629        };
630        let session = MockSession {
631            supports_exit: false,
632            expand_default_lines: 20,
633        };
634        let mut agent = crate::NullAgent;
635        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
636
637        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
638        reg.register(ClearCommand);
639
640        let result = reg.dispatch(&mut ctx, "/clear", true).await;
641        assert!(result.unwrap().is_ok());
642    }
643
644    #[tokio::test]
645    async fn clear_dispatch_rejected_when_untrusted() {
646        let mut sink = MockSink { sent: vec![] };
647        let mut debug = MockDebug;
648        let mut messages = MockMessages {
649            cleared: false,
650            queue: 0,
651            transcript: Vec::new(),
652            cursor: 0,
653        };
654        let session = MockSession {
655            supports_exit: false,
656            expand_default_lines: 20,
657        };
658        let mut agent = crate::NullAgent;
659        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
660
661        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
662        reg.register(ClearCommand);
663
664        let result = reg.dispatch(&mut ctx, "/clear", false).await;
665        let err = result.unwrap().unwrap_err();
666        assert!(err.0.contains("trusted"));
667    }
668
669    #[tokio::test]
670    async fn reset_dispatch_allowed_when_trusted() {
671        let mut sink = MockSink { sent: vec![] };
672        let mut debug = MockDebug;
673        let mut messages = MockMessages {
674            cleared: false,
675            queue: 0,
676            transcript: Vec::new(),
677            cursor: 0,
678        };
679        let session = MockSession {
680            supports_exit: false,
681            expand_default_lines: 20,
682        };
683        let mut agent = crate::NullAgent;
684        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
685
686        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
687        reg.register(ResetCommand);
688
689        let result = reg.dispatch(&mut ctx, "/reset", true).await;
690        assert!(result.unwrap().is_ok());
691    }
692
693    #[tokio::test]
694    async fn reset_dispatch_rejected_when_untrusted() {
695        let mut sink = MockSink { sent: vec![] };
696        let mut debug = MockDebug;
697        let mut messages = MockMessages {
698            cleared: false,
699            queue: 0,
700            transcript: Vec::new(),
701            cursor: 0,
702        };
703        let session = MockSession {
704            supports_exit: false,
705            expand_default_lines: 20,
706        };
707        let mut agent = crate::NullAgent;
708        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
709
710        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
711        reg.register(ResetCommand);
712
713        let result = reg.dispatch(&mut ctx, "/reset", false).await;
714        let err = result.unwrap().unwrap_err();
715        assert!(err.0.contains("trusted"));
716    }
717
718    #[tokio::test]
719    async fn clear_queue_dispatch_allowed_when_trusted() {
720        let mut sink = MockSink { sent: vec![] };
721        let mut debug = MockDebug;
722        let mut messages = MockMessages {
723            cleared: false,
724            queue: 0,
725            transcript: Vec::new(),
726            cursor: 0,
727        };
728        let session = MockSession {
729            supports_exit: false,
730            expand_default_lines: 20,
731        };
732        let mut agent = crate::NullAgent;
733        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
734
735        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
736        reg.register(ClearQueueCommand);
737
738        let result = reg.dispatch(&mut ctx, "/clear-queue", true).await;
739        assert!(result.unwrap().is_ok());
740    }
741
742    #[tokio::test]
743    async fn clear_queue_dispatch_rejected_when_untrusted() {
744        let mut sink = MockSink { sent: vec![] };
745        let mut debug = MockDebug;
746        let mut messages = MockMessages {
747            cleared: false,
748            queue: 0,
749            transcript: Vec::new(),
750            cursor: 0,
751        };
752        let session = MockSession {
753            supports_exit: false,
754            expand_default_lines: 20,
755        };
756        let mut agent = crate::NullAgent;
757        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
758
759        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
760        reg.register(ClearQueueCommand);
761
762        let result = reg.dispatch(&mut ctx, "/clear-queue", false).await;
763        let err = result.unwrap().unwrap_err();
764        assert!(err.0.contains("trusted"));
765    }
766
767    // --- /history ---
768
769    fn make_transcript(n: usize) -> Vec<crate::transcript::TranscriptEntry> {
770        (0..n)
771            .map(|i| crate::transcript::TranscriptEntry {
772                role: crate::transcript::TranscriptRole::User,
773                content: format!("message {i}"),
774                tool_name: None,
775            })
776            .collect()
777    }
778
779    #[tokio::test]
780    async fn history_no_history_yet() {
781        let mut sink = MockSink { sent: vec![] };
782        let mut debug = MockDebug;
783        let mut messages = MockMessages {
784            cleared: false,
785            queue: 0,
786            transcript: Vec::new(),
787            cursor: 0,
788        };
789        let session = MockSession {
790            supports_exit: false,
791            expand_default_lines: 20,
792        };
793        let mut agent = crate::NullAgent;
794        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
795
796        let out = HistoryCommand.handle(&mut ctx, "").await.unwrap();
797        assert_matches!(out, CommandOutput::Silent);
798        assert!(sink.sent[0].contains("No conversation history"));
799    }
800
801    #[tokio::test]
802    async fn history_default_bounds_to_expand_default_lines() {
803        let mut sink = MockSink { sent: vec![] };
804        let mut debug = MockDebug;
805        let mut messages = MockMessages {
806            cleared: false,
807            queue: 0,
808            transcript: make_transcript(500),
809            cursor: 0,
810        };
811        let session = MockSession {
812            supports_exit: false,
813            expand_default_lines: 20,
814        };
815        let mut agent = crate::NullAgent;
816        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
817
818        HistoryCommand.handle(&mut ctx, "").await.unwrap();
819        // Only the last 20 of 500 messages must ever reach the sink — never the full set
820        // materialized then trimmed (INV-SP-6).
821        assert_eq!(sink.sent.len(), 1);
822        assert!(sink.sent[0].contains("message 480"));
823        assert!(sink.sent[0].contains("message 499"));
824        assert!(!sink.sent[0].contains("message 479"));
825    }
826
827    #[tokio::test]
828    async fn history_numeric_argument_bounds_explicitly() {
829        let mut sink = MockSink { sent: vec![] };
830        let mut debug = MockDebug;
831        let mut messages = MockMessages {
832            cleared: false,
833            queue: 0,
834            transcript: make_transcript(10),
835            cursor: 0,
836        };
837        let session = MockSession {
838            supports_exit: false,
839            expand_default_lines: 20,
840        };
841        let mut agent = crate::NullAgent;
842        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
843
844        HistoryCommand.handle(&mut ctx, "3").await.unwrap();
845        assert_eq!(sink.sent.len(), 1);
846        assert!(sink.sent[0].contains("message 7"));
847        assert!(sink.sent[0].contains("message 9"));
848        assert!(!sink.sent[0].contains("message 6"));
849    }
850
851    #[tokio::test]
852    async fn history_all_shows_notice_then_paginates() {
853        let mut sink = MockSink { sent: vec![] };
854        let mut debug = MockDebug;
855        let mut messages = MockMessages {
856            cleared: false,
857            queue: 0,
858            transcript: make_transcript(50),
859            cursor: 0,
860        };
861        let session = MockSession {
862            supports_exit: false,
863            expand_default_lines: 20,
864        };
865        let mut agent = crate::NullAgent;
866        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
867
868        HistoryCommand.handle(&mut ctx, "all").await.unwrap();
869        assert!(sink.sent[0].contains("may take a moment"));
870        assert!(sink.sent[1].contains("message 0"));
871        assert!(!sink.sent[1].contains("message 20"));
872        assert!(sink.sent[2].contains("Page 1/3"));
873        assert_eq!(messages.history_cursor(), 20);
874    }
875
876    #[tokio::test]
877    async fn history_next_continues_from_cursor() {
878        let mut sink = MockSink { sent: vec![] };
879        let mut debug = MockDebug;
880        let mut messages = MockMessages {
881            cleared: false,
882            queue: 0,
883            transcript: make_transcript(50),
884            cursor: 20,
885        };
886        let session = MockSession {
887            supports_exit: false,
888            expand_default_lines: 20,
889        };
890        let mut agent = crate::NullAgent;
891        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
892
893        HistoryCommand.handle(&mut ctx, "next").await.unwrap();
894        assert!(sink.sent[0].contains("message 20"));
895        assert!(sink.sent[0].contains("message 39"));
896        assert!(!sink.sent[0].contains("message 40"));
897        assert_eq!(messages.history_cursor(), 40);
898        // M2 regression: cursor 20 -> 40 of 50 total is page 2 of 3, not "Page 3/3" (the
899        // pre-fix off-by-one, which also contradicted its own "use /history next to continue"
900        // text by claiming the last page still had more to show).
901        assert_eq!(
902            sink.sent[1], "Page 2/3 — use /history next to continue.",
903            "page label must be 1-based without a stray extra +1"
904        );
905    }
906
907    #[tokio::test]
908    async fn history_next_without_prior_all_reports_nothing_to_page() {
909        let mut sink = MockSink { sent: vec![] };
910        let mut debug = MockDebug;
911        let mut messages = MockMessages {
912            cleared: false,
913            queue: 0,
914            transcript: make_transcript(50),
915            cursor: 0,
916        };
917        let session = MockSession {
918            supports_exit: false,
919            expand_default_lines: 20,
920        };
921        let mut agent = crate::NullAgent;
922        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
923
924        HistoryCommand.handle(&mut ctx, "next").await.unwrap();
925        assert!(sink.sent[0].contains("No more history"));
926    }
927
928    #[test]
929    fn history_requires_auth_false() {
930        assert!(!HistoryCommand.requires_auth());
931    }
932}