Skip to main content

agentzero_channels/
commands.rs

1use crate::ChannelMessage;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4
5/// Result of attempting to parse a message as a runtime command.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum CommandResult {
8    /// The message is a recognized command; the response should be sent back
9    /// to the user instead of forwarding to the LLM.
10    Response(String),
11    /// The message is not a command; pass it through to the LLM pipeline.
12    PassThrough,
13}
14
15/// Runtime context for command execution.
16///
17/// Carries mutable state (e.g. approval list) and the config path so that
18/// commands like `/approve` can persist changes to disk.
19#[derive(Clone)]
20pub struct CommandContext {
21    pub auto_approve: Arc<Mutex<Vec<String>>>,
22    pub config_path: Option<PathBuf>,
23}
24
25/// Parsed in-chat command.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ChatCommand {
28    /// `/models` or `/models <provider>` — list available models.
29    Models(Option<String>),
30    /// `/model` — show current model; `/model <id>` — switch model.
31    Model(Option<String>),
32    /// `/new` — clear conversation history for this sender.
33    New,
34    /// `/approve <tool>` — auto-approve a tool for this session.
35    Approve(String),
36    /// `/unapprove <tool>` — remove auto-approval.
37    Unapprove(String),
38    /// `/approvals` — list current approvals.
39    Approvals,
40    /// `/approve-request <tool>` — request approval for a tool.
41    ApproveRequest(String),
42    /// `/approve-confirm <id>` — confirm a pending approval request.
43    ApproveConfirm(String),
44    /// `/approve-pending` — list pending approval requests.
45    ApprovePending,
46    /// `/help` — show available commands.
47    Help,
48}
49
50/// Try to parse a message as a runtime command.
51/// Returns `None` if the message is not a command.
52pub fn parse_command(text: &str) -> Option<ChatCommand> {
53    let trimmed = text.trim();
54    if !trimmed.starts_with('/') {
55        return None;
56    }
57
58    let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
59    let cmd = parts[0].to_lowercase();
60    let arg = parts.get(1).map(|s| s.trim().to_string());
61
62    match cmd.as_str() {
63        "/models" => Some(ChatCommand::Models(arg)),
64        "/model" => Some(ChatCommand::Model(arg)),
65        "/new" => Some(ChatCommand::New),
66        "/approve" => arg.map(ChatCommand::Approve),
67        "/unapprove" => arg.map(ChatCommand::Unapprove),
68        "/approvals" => Some(ChatCommand::Approvals),
69        "/approve-request" => arg.map(ChatCommand::ApproveRequest),
70        "/approve-confirm" => arg.map(ChatCommand::ApproveConfirm),
71        "/approve-pending" => Some(ChatCommand::ApprovePending),
72        "/help" => Some(ChatCommand::Help),
73        _ => None,
74    }
75}
76
77/// Handle a parsed command and produce a response string.
78/// This provides default responses; the runtime can override with richer behavior.
79pub fn handle_command(cmd: &ChatCommand, _msg: &ChannelMessage) -> CommandResult {
80    handle_command_with_context(cmd, _msg, None)
81}
82
83/// Handle a parsed command with optional runtime context for stateful operations.
84pub fn handle_command_with_context(
85    cmd: &ChatCommand,
86    _msg: &ChannelMessage,
87    ctx: Option<&CommandContext>,
88) -> CommandResult {
89    match cmd {
90        ChatCommand::Models(provider) => {
91            let response = if let Some(p) = provider {
92                format!("Listing models for provider `{p}`. (Requires runtime integration)")
93            } else {
94                "Available providers: openrouter, openai, anthropic, ollama. Use `/models <provider>` to list models.".to_string()
95            };
96            CommandResult::Response(response)
97        }
98        ChatCommand::Model(id) => {
99            let response = if let Some(model_id) = id {
100                format!("Switching model to `{model_id}` for this session.")
101            } else {
102                "Current model: (default from config). Use `/model <id>` to switch.".to_string()
103            };
104            CommandResult::Response(response)
105        }
106        ChatCommand::New => CommandResult::Response("Conversation history cleared.".to_string()),
107        ChatCommand::Approve(tool) => {
108            if let Some(ctx) = ctx {
109                approve_tool(tool, ctx)
110            } else {
111                CommandResult::Response(format!("Auto-approved tool `{tool}` for this session."))
112            }
113        }
114        ChatCommand::Unapprove(tool) => {
115            if let Some(ctx) = ctx {
116                unapprove_tool(tool, ctx)
117            } else {
118                CommandResult::Response(format!("Removed auto-approval for tool `{tool}`."))
119            }
120        }
121        ChatCommand::Approvals => {
122            if let Some(ctx) = ctx {
123                let list = ctx
124                    .auto_approve
125                    .lock()
126                    .expect("auto_approve mutex poisoned");
127                if list.is_empty() {
128                    CommandResult::Response("Current approvals: (none)".to_string())
129                } else {
130                    CommandResult::Response(format!("Current approvals: {}", list.join(", ")))
131                }
132            } else {
133                CommandResult::Response("Current approvals: (none configured)".to_string())
134            }
135        }
136        ChatCommand::ApproveRequest(tool) => {
137            CommandResult::Response(format!("Approval requested for tool `{tool}`."))
138        }
139        ChatCommand::ApproveConfirm(id) => {
140            CommandResult::Response(format!("Approval confirmed for request `{id}`."))
141        }
142        ChatCommand::ApprovePending => {
143            CommandResult::Response("Pending approvals: (none)".to_string())
144        }
145        ChatCommand::Help => CommandResult::Response(
146            "Available commands:\n\
147                /models [provider] - List available models\n\
148                /model [id] - Show or switch model\n\
149                /new - Clear conversation history\n\
150                /approve <tool> - Auto-approve a tool\n\
151                /unapprove <tool> - Remove auto-approval\n\
152                /approvals - List current approvals\n\
153                /approve-request <tool> - Request tool approval\n\
154                /approve-confirm <id> - Confirm pending approval\n\
155                /approve-pending - List pending approvals\n\
156                /help - Show this help"
157                .to_string(),
158        ),
159    }
160}
161
162fn approve_tool(tool: &str, ctx: &CommandContext) -> CommandResult {
163    let mut list = ctx
164        .auto_approve
165        .lock()
166        .expect("auto_approve mutex poisoned");
167    if list.contains(&tool.to_string()) {
168        return CommandResult::Response(format!("Tool `{tool}` is already approved."));
169    }
170    list.push(tool.to_string());
171    let persist_msg = persist_approvals(&list, ctx.config_path.as_deref());
172    CommandResult::Response(format!("Auto-approved tool `{tool}`.{persist_msg}"))
173}
174
175fn unapprove_tool(tool: &str, ctx: &CommandContext) -> CommandResult {
176    let mut list = ctx
177        .auto_approve
178        .lock()
179        .expect("auto_approve mutex poisoned");
180    let before = list.len();
181    list.retain(|t| t != tool);
182    if list.len() == before {
183        return CommandResult::Response(format!("Tool `{tool}` was not in the approval list."));
184    }
185    let persist_msg = persist_approvals(&list, ctx.config_path.as_deref());
186    CommandResult::Response(format!(
187        "Removed auto-approval for tool `{tool}`.{persist_msg}"
188    ))
189}
190
191fn persist_approvals(tools: &[String], config_path: Option<&Path>) -> String {
192    let Some(path) = config_path else {
193        return String::new();
194    };
195    match agentzero_config::update_auto_approve(path, tools) {
196        Ok(()) => " Saved to config.".to_string(),
197        Err(e) => format!(" (warning: failed to persist: {e})"),
198    }
199}
200
201/// Check if a message is a runtime command and handle it.
202/// Returns `CommandResult::Response` with the reply if it's a command,
203/// or `CommandResult::PassThrough` if the message should go to the LLM.
204pub fn intercept_command(msg: &ChannelMessage) -> CommandResult {
205    match parse_command(&msg.content) {
206        Some(cmd) => handle_command(&cmd, msg),
207        None => CommandResult::PassThrough,
208    }
209}
210
211/// Check if a message is a runtime command and handle it with context.
212pub fn intercept_command_with_context(msg: &ChannelMessage, ctx: &CommandContext) -> CommandResult {
213    match parse_command(&msg.content) {
214        Some(cmd) => handle_command_with_context(&cmd, msg, Some(ctx)),
215        None => CommandResult::PassThrough,
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn test_msg(content: &str) -> ChannelMessage {
224        ChannelMessage {
225            id: "1".into(),
226            sender: "alice".into(),
227            reply_target: "alice".into(),
228            content: content.into(),
229            channel: "test".into(),
230            timestamp: 0,
231            thread_ts: None,
232            privacy_boundary: String::new(),
233        }
234    }
235
236    #[test]
237    fn parse_models_no_arg() {
238        assert_eq!(parse_command("/models"), Some(ChatCommand::Models(None)));
239    }
240
241    #[test]
242    fn parse_models_with_provider() {
243        assert_eq!(
244            parse_command("/models openai"),
245            Some(ChatCommand::Models(Some("openai".into())))
246        );
247    }
248
249    #[test]
250    fn parse_model_no_arg() {
251        assert_eq!(parse_command("/model"), Some(ChatCommand::Model(None)));
252    }
253
254    #[test]
255    fn parse_model_with_id() {
256        assert_eq!(
257            parse_command("/model gpt-4o"),
258            Some(ChatCommand::Model(Some("gpt-4o".into())))
259        );
260    }
261
262    #[test]
263    fn parse_new() {
264        assert_eq!(parse_command("/new"), Some(ChatCommand::New));
265    }
266
267    #[test]
268    fn parse_approve_with_tool() {
269        assert_eq!(
270            parse_command("/approve shell"),
271            Some(ChatCommand::Approve("shell".into()))
272        );
273    }
274
275    #[test]
276    fn parse_approve_without_tool_returns_none() {
277        assert_eq!(parse_command("/approve"), None);
278    }
279
280    #[test]
281    fn parse_unapprove() {
282        assert_eq!(
283            parse_command("/unapprove shell"),
284            Some(ChatCommand::Unapprove("shell".into()))
285        );
286    }
287
288    #[test]
289    fn parse_approvals() {
290        assert_eq!(parse_command("/approvals"), Some(ChatCommand::Approvals));
291    }
292
293    #[test]
294    fn parse_approve_pending() {
295        assert_eq!(
296            parse_command("/approve-pending"),
297            Some(ChatCommand::ApprovePending)
298        );
299    }
300
301    #[test]
302    fn parse_approve_confirm() {
303        assert_eq!(
304            parse_command("/approve-confirm req-123"),
305            Some(ChatCommand::ApproveConfirm("req-123".into()))
306        );
307    }
308
309    #[test]
310    fn parse_help() {
311        assert_eq!(parse_command("/help"), Some(ChatCommand::Help));
312    }
313
314    #[test]
315    fn parse_unknown_command_returns_none() {
316        assert_eq!(parse_command("/foobar"), None);
317    }
318
319    #[test]
320    fn parse_non_command_returns_none() {
321        assert_eq!(parse_command("hello world"), None);
322        assert_eq!(parse_command(""), None);
323    }
324
325    #[test]
326    fn intercept_command_handles_known_command() {
327        let msg = test_msg("/help");
328        match intercept_command(&msg) {
329            CommandResult::Response(text) => {
330                assert!(text.contains("Available commands"));
331            }
332            CommandResult::PassThrough => panic!("expected Response"),
333        }
334    }
335
336    #[test]
337    fn intercept_command_passes_through_non_command() {
338        let msg = test_msg("hello");
339        assert_eq!(intercept_command(&msg), CommandResult::PassThrough);
340    }
341
342    #[test]
343    fn case_insensitive_commands() {
344        assert_eq!(parse_command("/MODELS"), Some(ChatCommand::Models(None)));
345        assert_eq!(parse_command("/Help"), Some(ChatCommand::Help));
346        assert_eq!(parse_command("/NEW"), Some(ChatCommand::New));
347    }
348
349    #[test]
350    fn handle_model_switch_response() {
351        let cmd = ChatCommand::Model(Some("claude-3-opus".into()));
352        let msg = test_msg("/model claude-3-opus");
353        match handle_command(&cmd, &msg) {
354            CommandResult::Response(text) => {
355                assert!(text.contains("claude-3-opus"));
356            }
357            CommandResult::PassThrough => panic!("expected Response"),
358        }
359    }
360
361    #[test]
362    fn handle_new_clears_history() {
363        let msg = test_msg("/new");
364        match handle_command(&ChatCommand::New, &msg) {
365            CommandResult::Response(text) => {
366                assert!(text.contains("cleared"));
367            }
368            CommandResult::PassThrough => panic!("expected Response"),
369        }
370    }
371
372    fn test_ctx() -> CommandContext {
373        CommandContext {
374            auto_approve: Arc::new(Mutex::new(Vec::new())),
375            config_path: None,
376        }
377    }
378
379    #[test]
380    fn approve_adds_tool_to_list() {
381        let ctx = test_ctx();
382        let msg = test_msg("/approve shell");
383        match intercept_command_with_context(&msg, &ctx) {
384            CommandResult::Response(text) => {
385                assert!(text.contains("Auto-approved tool `shell`"));
386            }
387            CommandResult::PassThrough => panic!("expected Response"),
388        }
389        let list = ctx.auto_approve.lock().unwrap();
390        assert_eq!(*list, vec!["shell".to_string()]);
391    }
392
393    #[test]
394    fn approve_duplicate_rejected() {
395        let ctx = test_ctx();
396        ctx.auto_approve.lock().unwrap().push("shell".to_string());
397        let msg = test_msg("/approve shell");
398        match intercept_command_with_context(&msg, &ctx) {
399            CommandResult::Response(text) => {
400                assert!(text.contains("already approved"));
401            }
402            CommandResult::PassThrough => panic!("expected Response"),
403        }
404    }
405
406    #[test]
407    fn unapprove_removes_tool() {
408        let ctx = test_ctx();
409        ctx.auto_approve.lock().unwrap().push("shell".to_string());
410        let msg = test_msg("/unapprove shell");
411        match intercept_command_with_context(&msg, &ctx) {
412            CommandResult::Response(text) => {
413                assert!(text.contains("Removed auto-approval"));
414            }
415            CommandResult::PassThrough => panic!("expected Response"),
416        }
417        let list = ctx.auto_approve.lock().unwrap();
418        assert!(list.is_empty());
419    }
420
421    #[test]
422    fn unapprove_missing_tool_reports_not_found() {
423        let ctx = test_ctx();
424        let msg = test_msg("/unapprove shell");
425        match intercept_command_with_context(&msg, &ctx) {
426            CommandResult::Response(text) => {
427                assert!(text.contains("was not in the approval list"));
428            }
429            CommandResult::PassThrough => panic!("expected Response"),
430        }
431    }
432
433    #[test]
434    fn approvals_lists_current() {
435        let ctx = test_ctx();
436        {
437            let mut list = ctx.auto_approve.lock().unwrap();
438            list.push("shell".to_string());
439            list.push("browser".to_string());
440        }
441        let msg = test_msg("/approvals");
442        match intercept_command_with_context(&msg, &ctx) {
443            CommandResult::Response(text) => {
444                assert!(text.contains("shell"));
445                assert!(text.contains("browser"));
446            }
447            CommandResult::PassThrough => panic!("expected Response"),
448        }
449    }
450
451    #[test]
452    fn approvals_empty_shows_none() {
453        let ctx = test_ctx();
454        let msg = test_msg("/approvals");
455        match intercept_command_with_context(&msg, &ctx) {
456            CommandResult::Response(text) => {
457                assert!(text.contains("(none)"));
458            }
459            CommandResult::PassThrough => panic!("expected Response"),
460        }
461    }
462
463    #[test]
464    fn approve_persists_to_disk() {
465        let dir = std::env::temp_dir().join("agentzero-test-approve");
466        let _ = std::fs::create_dir_all(&dir);
467        let path = dir.join("config.toml");
468        std::fs::write(&path, "").unwrap();
469
470        let ctx = CommandContext {
471            auto_approve: Arc::new(Mutex::new(Vec::new())),
472            config_path: Some(path.clone()),
473        };
474
475        let msg = test_msg("/approve shell");
476        let result = intercept_command_with_context(&msg, &ctx);
477        match result {
478            CommandResult::Response(text) => {
479                assert!(text.contains("Saved to config"));
480            }
481            CommandResult::PassThrough => panic!("expected Response"),
482        }
483
484        let content = std::fs::read_to_string(&path).unwrap();
485        assert!(content.contains("auto_approve"));
486        assert!(content.contains("shell"));
487
488        let _ = std::fs::remove_dir_all(&dir);
489    }
490}