zeph_commands/handlers/
compaction.rs1use std::future::Future;
7use std::pin::Pin;
8
9use crate::context::CommandContext;
10use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
11
12pub struct CompactCommand;
17
18impl CommandHandler<CommandContext<'_>> for CompactCommand {
19 fn name(&self) -> &'static str {
20 "/compact"
21 }
22
23 fn description(&self) -> &'static str {
24 "Compact the context window by summarizing older messages"
25 }
26
27 fn args_hint(&self) -> &'static str {
28 ""
29 }
30
31 fn category(&self) -> SlashCategory {
32 SlashCategory::Session
33 }
34
35 fn requires_auth(&self) -> bool {
36 true
37 }
38
39 fn handle<'a>(
40 &'a self,
41 ctx: &'a mut CommandContext<'_>,
42 _args: &'a str,
43 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
44 use tracing::Instrument as _;
45 let span = tracing::info_span!("commands.compact.handle");
46 Box::pin(
47 async move {
48 let result = ctx.agent.compact_context().await?;
49 Ok(CommandOutput::Message(result))
50 }
51 .instrument(span),
52 )
53 }
54}
55
56pub struct NewConversationCommand;
62
63impl CommandHandler<CommandContext<'_>> for NewConversationCommand {
64 fn name(&self) -> &'static str {
65 "/new"
66 }
67
68 fn description(&self) -> &'static str {
69 "Start a new conversation (reset context, preserve memory and MCP)"
70 }
71
72 fn args_hint(&self) -> &'static str {
73 "[--no-digest] [--keep-plan]"
74 }
75
76 fn category(&self) -> SlashCategory {
77 SlashCategory::Session
78 }
79
80 fn requires_auth(&self) -> bool {
81 true
82 }
83
84 fn handle<'a>(
85 &'a self,
86 ctx: &'a mut CommandContext<'_>,
87 args: &'a str,
88 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
89 use tracing::Instrument as _;
90 let span = tracing::info_span!("commands.new_conversation.handle");
91 Box::pin(
92 async move {
93 let (keep_plan, no_digest) = parse_new_flags(args);
94 let result = ctx.agent.reset_conversation(keep_plan, no_digest).await?;
95 Ok(CommandOutput::Message(result))
96 }
97 .instrument(span),
98 )
99 }
100}
101
102pub struct RecapCommand;
108
109impl CommandHandler<CommandContext<'_>> for RecapCommand {
110 fn name(&self) -> &'static str {
111 "/recap"
112 }
113
114 fn description(&self) -> &'static str {
115 "Show a recap of the current or previous session"
116 }
117
118 fn args_hint(&self) -> &'static str {
119 ""
120 }
121
122 fn category(&self) -> SlashCategory {
123 SlashCategory::Session
124 }
125
126 fn requires_auth(&self) -> bool {
127 false
128 }
129
130 fn handle<'a>(
131 &'a self,
132 ctx: &'a mut CommandContext<'_>,
133 _args: &'a str,
134 ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
135 use tracing::Instrument as _;
136 let span = tracing::info_span!("commands.recap.handle");
137 Box::pin(
138 async move {
139 let text = ctx.agent.session_recap().await?;
140 Ok(CommandOutput::Message(text))
141 }
142 .instrument(span),
143 )
144 }
145}
146
147fn parse_new_flags(args: &str) -> (bool, bool) {
149 let keep_plan = args.split_whitespace().any(|a| a == "--keep-plan");
150 let no_digest = args.split_whitespace().any(|a| a == "--no-digest");
151 (keep_plan, no_digest)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use crate::CommandRegistry;
158 use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
159 use crate::sink::NullSink;
160
161 #[test]
162 fn no_flags_both_false() {
163 assert_eq!(parse_new_flags(""), (false, false));
164 assert_eq!(parse_new_flags(" "), (false, false));
165 }
166
167 #[test]
168 fn keep_plan_flag_detected() {
169 assert_eq!(parse_new_flags("--keep-plan"), (true, false));
170 assert_eq!(parse_new_flags("--keep-plan --no-digest"), (true, true));
171 }
172
173 #[test]
174 fn no_digest_flag_detected() {
175 assert_eq!(parse_new_flags("--no-digest"), (false, true));
176 }
177
178 #[test]
179 fn both_flags_order_independent() {
180 assert_eq!(parse_new_flags("--no-digest --keep-plan"), (true, true));
181 assert_eq!(parse_new_flags("--keep-plan --no-digest"), (true, true));
182 }
183
184 #[test]
185 fn partial_flag_name_not_matched() {
186 assert_eq!(parse_new_flags("--keep"), (false, false));
187 assert_eq!(parse_new_flags("--no"), (false, false));
188 assert_eq!(parse_new_flags("keep-plan"), (false, false));
189 }
190
191 #[test]
192 fn recap_requires_auth_false() {
193 assert!(!RecapCommand.requires_auth());
194 }
195
196 #[tokio::test]
197 async fn compact_dispatch_allowed_when_trusted() {
198 let mut sink = NullSink;
199 let mut debug = MockDebug;
200 let mut messages = MockMessages;
201 let session = MockSession;
202 let mut agent = crate::NullAgent;
203 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
204
205 let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
206 reg.register(CompactCommand);
207
208 let result = reg.dispatch(&mut ctx, "/compact", true).await;
209 assert!(result.unwrap().is_ok());
210 }
211
212 #[tokio::test]
213 async fn compact_dispatch_rejected_when_untrusted() {
214 let mut sink = NullSink;
215 let mut debug = MockDebug;
216 let mut messages = MockMessages;
217 let session = MockSession;
218 let mut agent = crate::NullAgent;
219 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
220
221 let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
222 reg.register(CompactCommand);
223
224 let result = reg.dispatch(&mut ctx, "/compact", false).await;
225 let err = result.unwrap().unwrap_err();
226 assert!(err.0.contains("trusted"));
227 }
228
229 #[tokio::test]
230 async fn new_conversation_dispatch_allowed_when_trusted() {
231 let mut sink = NullSink;
232 let mut debug = MockDebug;
233 let mut messages = MockMessages;
234 let session = MockSession;
235 let mut agent = crate::NullAgent;
236 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
237
238 let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
239 reg.register(NewConversationCommand);
240
241 let result = reg.dispatch(&mut ctx, "/new", true).await;
242 assert!(result.unwrap().is_ok());
243 }
244
245 #[tokio::test]
246 async fn new_conversation_dispatch_rejected_when_untrusted() {
247 let mut sink = NullSink;
248 let mut debug = MockDebug;
249 let mut messages = MockMessages;
250 let session = MockSession;
251 let mut agent = crate::NullAgent;
252 let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
253
254 let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
255 reg.register(NewConversationCommand);
256
257 let result = reg.dispatch(&mut ctx, "/new", false).await;
258 let err = result.unwrap().unwrap_err();
259 assert!(err.0.contains("trusted"));
260 }
261}