1use 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
24pub 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
59pub 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
90pub 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
130pub 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
169pub 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
213pub 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 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 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 #[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 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 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 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}