Skip to main content

zeph_commands/handlers/
compaction.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Conversation management handlers: `/new` and `/compact`.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::context::CommandContext;
10use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
11
12/// Compact context handler for `/compact`.
13///
14/// Delegates to `SessionControlAccess::compact_context`. The implementation extracts all
15/// non-`Send` borrows before `.await` points so the future satisfies `Send + 'a`.
16pub 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
56/// New conversation handler for `/new`.
57///
58/// Delegates to `SessionControlAccess::reset_conversation` which is now Send-compatible:
59/// `reset_conversation` clones the `Arc<SemanticMemory>` before `.await` so no
60/// `&mut self` borrow is held across the await boundary.
61pub 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
102/// Session recap handler for `/recap`.
103///
104/// Delegates to `SessionControlAccess::session_recap`. Uses the agent registry (not the
105/// session/debug registry) because recap requires memory state, an LLM provider, and
106/// the cached digest.
107pub 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
147/// Parse `--keep-plan` and `--no-digest` flags from the `/new` command args string.
148fn 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}