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 `AgentAccess::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 handle<'a>(
36        &'a self,
37        ctx: &'a mut CommandContext<'_>,
38        _args: &'a str,
39    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
40        Box::pin(async move {
41            let result = ctx.agent.compact_context().await?;
42            Ok(CommandOutput::Message(result))
43        })
44    }
45}
46
47/// New conversation handler for `/new`.
48///
49/// Delegates to `AgentAccess::reset_conversation` which is now Send-compatible:
50/// `reset_conversation` clones the `Arc<SemanticMemory>` before `.await` so no
51/// `&mut self` borrow is held across the await boundary.
52pub struct NewConversationCommand;
53
54impl CommandHandler<CommandContext<'_>> for NewConversationCommand {
55    fn name(&self) -> &'static str {
56        "/new"
57    }
58
59    fn description(&self) -> &'static str {
60        "Start a new conversation (reset context, preserve memory and MCP)"
61    }
62
63    fn args_hint(&self) -> &'static str {
64        "[--no-digest] [--keep-plan]"
65    }
66
67    fn category(&self) -> SlashCategory {
68        SlashCategory::Session
69    }
70
71    fn handle<'a>(
72        &'a self,
73        ctx: &'a mut CommandContext<'_>,
74        args: &'a str,
75    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
76        Box::pin(async move {
77            let (keep_plan, no_digest) = parse_new_flags(args);
78            let result = ctx.agent.reset_conversation(keep_plan, no_digest).await?;
79            Ok(CommandOutput::Message(result))
80        })
81    }
82}
83
84/// Session recap handler for `/recap`.
85///
86/// Delegates to `AgentAccess::session_recap`. Uses the agent registry (not the
87/// session/debug registry) because recap requires memory state, an LLM provider, and
88/// the cached digest.
89pub struct RecapCommand;
90
91impl CommandHandler<CommandContext<'_>> for RecapCommand {
92    fn name(&self) -> &'static str {
93        "/recap"
94    }
95
96    fn description(&self) -> &'static str {
97        "Show a recap of the current or previous session"
98    }
99
100    fn args_hint(&self) -> &'static str {
101        ""
102    }
103
104    fn category(&self) -> SlashCategory {
105        SlashCategory::Session
106    }
107
108    fn handle<'a>(
109        &'a self,
110        ctx: &'a mut CommandContext<'_>,
111        _args: &'a str,
112    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
113        Box::pin(async move {
114            let text = ctx.agent.session_recap().await?;
115            Ok(CommandOutput::Message(text))
116        })
117    }
118}
119
120/// Parse `--keep-plan` and `--no-digest` flags from the `/new` command args string.
121fn parse_new_flags(args: &str) -> (bool, bool) {
122    let keep_plan = args.split_whitespace().any(|a| a == "--keep-plan");
123    let no_digest = args.split_whitespace().any(|a| a == "--no-digest");
124    (keep_plan, no_digest)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::parse_new_flags;
130
131    #[test]
132    fn no_flags_both_false() {
133        assert_eq!(parse_new_flags(""), (false, false));
134        assert_eq!(parse_new_flags("   "), (false, false));
135    }
136
137    #[test]
138    fn keep_plan_flag_detected() {
139        assert_eq!(parse_new_flags("--keep-plan"), (true, false));
140        assert_eq!(parse_new_flags("--keep-plan --no-digest"), (true, true));
141    }
142
143    #[test]
144    fn no_digest_flag_detected() {
145        assert_eq!(parse_new_flags("--no-digest"), (false, true));
146    }
147
148    #[test]
149    fn both_flags_order_independent() {
150        assert_eq!(parse_new_flags("--no-digest --keep-plan"), (true, true));
151        assert_eq!(parse_new_flags("--keep-plan --no-digest"), (true, true));
152    }
153
154    #[test]
155    fn partial_flag_name_not_matched() {
156        assert_eq!(parse_new_flags("--keep"), (false, false));
157        assert_eq!(parse_new_flags("--no"), (false, false));
158        assert_eq!(parse_new_flags("keep-plan"), (false, false));
159    }
160}