Skip to main content

zeph_commands/handlers/
goal.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `/goal` slash command handler.
5//!
6//! Subcommands:
7//! - `create <text> [--budget N]` — create a new goal, pausing any existing active one
8//! - `pause` — pause the active goal
9//! - `resume` — resume the last paused goal
10//! - `complete` — mark the active goal as completed
11//! - `clear` — dismiss the active or paused goal
12//! - `status` — show the active goal and recent history
13//! - `list` — list all goals (active, paused, completed, cleared)
14
15use std::future::Future;
16use std::pin::Pin;
17
18use crate::context::CommandContext;
19use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
20
21/// Manage long-horizon goals that span multiple conversation turns.
22///
23/// At most one goal can be `active` at a time. Creating a new goal auto-pauses
24/// the previous one. Status, list, and pause/resume commands work even when
25/// `[goals] enabled = false` (read-only access is always available).
26pub struct GoalCommand;
27
28impl CommandHandler<CommandContext<'_>> for GoalCommand {
29    fn name(&self) -> &'static str {
30        "/goal"
31    }
32
33    fn description(&self) -> &'static str {
34        "Manage long-horizon goals that persist across conversation turns"
35    }
36
37    fn args_hint(&self) -> &'static str {
38        "create <text> [--budget N] | pause | resume | complete | clear | status | list"
39    }
40
41    fn category(&self) -> SlashCategory {
42        SlashCategory::Session
43    }
44
45    fn requires_auth(&self) -> bool {
46        true
47    }
48
49    fn handle<'a>(
50        &'a self,
51        ctx: &'a mut CommandContext<'_>,
52        args: &'a str,
53    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
54        use tracing::Instrument as _;
55        let span = tracing::info_span!("commands.goal.handle");
56        Box::pin(
57            async move {
58                let result = ctx.agent.handle_goal(args).await?;
59                Ok(CommandOutput::Message(result))
60            }
61            .instrument(span),
62        )
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::CommandRegistry;
70    use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
71    use crate::sink::NullSink;
72
73    #[test]
74    fn goal_name_and_description() {
75        assert_eq!(GoalCommand.name(), "/goal");
76        assert!(!GoalCommand.description().is_empty());
77    }
78
79    #[tokio::test]
80    async fn goal_propagates_error_from_agent() {
81        // NullAgent::handle_goal returns Err — the handler must propagate it.
82        let mut sink = NullSink;
83        let mut debug = MockDebug;
84        let mut messages = MockMessages;
85        let session = MockSession;
86        let mut agent = crate::NullAgent;
87        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
88        let result = GoalCommand.handle(&mut ctx, "status").await;
89        assert!(result.is_err());
90    }
91
92    #[tokio::test]
93    async fn goal_with_empty_args_propagates_error() {
94        let mut sink = NullSink;
95        let mut debug = MockDebug;
96        let mut messages = MockMessages;
97        let session = MockSession;
98        let mut agent = crate::NullAgent;
99        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
100        let result = GoalCommand.handle(&mut ctx, "").await;
101        assert!(result.is_err());
102    }
103
104    #[tokio::test]
105    async fn goal_dispatch_allowed_when_trusted() {
106        // NullAgent::handle_goal errors, but the error must come from the handler,
107        // not from the trust gate rejecting the dispatch.
108        let mut sink = NullSink;
109        let mut debug = MockDebug;
110        let mut messages = MockMessages;
111        let session = MockSession;
112        let mut agent = crate::NullAgent;
113        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
114
115        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
116        reg.register(GoalCommand);
117
118        let result = reg.dispatch(&mut ctx, "/goal status", true).await;
119        let err = result.unwrap().unwrap_err();
120        assert!(!err.0.contains("trusted"));
121    }
122
123    #[tokio::test]
124    async fn goal_dispatch_rejected_when_untrusted() {
125        let mut sink = NullSink;
126        let mut debug = MockDebug;
127        let mut messages = MockMessages;
128        let session = MockSession;
129        let mut agent = crate::NullAgent;
130        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
131
132        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
133        reg.register(GoalCommand);
134
135        let result = reg.dispatch(&mut ctx, "/goal status", false).await;
136        let err = result.unwrap().unwrap_err();
137        assert!(err.0.contains("trusted"));
138    }
139}