Skip to main content

agent_abstraction/
command.rs

1//! Slash commands: the CLI's own verbs, addressed as values rather than text.
2//!
3//! Claude Code answers a set of commands that are not prompts. `/compact`
4//! summarises the conversation and continues from the summary; `/clear` throws
5//! it away. They travel the same channel as a prompt, which is exactly why a
6//! host should not have to build one by hand: `"/compact"` typed into a string
7//! literal is indistinguishable from a user who meant to say the word, and a
8//! typo produces a turn where the model earnestly discusses the command it was
9//! sent rather than the command running.
10//!
11//! # A command is its own turn
12//!
13//! Verified against claude 2.1.212. Sending `/compact` produces a complete,
14//! self-contained turn:
15//!
16//! ```text
17//! system/status           status=compacting
18//! system/status           compact_result=success
19//! system/init             the session re-initialises
20//! system/compact_boundary where the summary begins
21//! result                  is_error=false, num_turns=0, result=""
22//! ```
23//!
24//! So a command belongs in a run of its own that resumes the session, not
25//! injected into a turn already in flight with [`crate::Run::send`]. Injected,
26//! its `result` record arrives *after* the turn's own and overwrites the
27//! outcome: the answer's text becomes the compaction's empty string and the
28//! turn's usage becomes the compaction's zeroes. The empty `result` is not a
29//! failure, and neither is `num_turns: 0` — a compaction generates no answer.
30//! [`crate::Event::Compaction`] is what reports whether it worked.
31
32use serde::{Deserialize, Serialize};
33
34/// A slash command a run can carry instead of a prompt.
35///
36/// `#[non_exhaustive]`: the CLI's vocabulary grows, and this names only the
37/// commands whose behaviour has been verified. Anything else in the catalogue
38/// reaches the agent through [`Command::Other`].
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41#[non_exhaustive]
42pub enum Command {
43    /// Summarise the conversation so far and continue from the summary.
44    ///
45    /// The answer to a context window filling up. The optional instructions are
46    /// passed to the summariser, for steering what survives: `Some("keep the
47    /// API surface and the failing test")`.
48    ///
49    /// Refused with `Not enough messages to compact.` on a conversation too
50    /// short to be worth summarising. That arrives as a completed run carrying
51    /// [`crate::Event::Compaction`] with the reason, not as an error: the
52    /// command ran and answered.
53    Compact {
54        /// What the summary should preserve. `None` leaves it to the agent.
55        instructions: Option<String>,
56    },
57    /// Discard the conversation and start fresh, keeping the session.
58    Clear,
59    /// Any other command this install offers, named without its leading slash.
60    ///
61    /// The catalogue is per-install — skills, plugins and user commands all
62    /// land in it — so it cannot be enumerated here honestly.
63    /// [`crate::Event::Commands`] reports what the running agent actually has.
64    Other(String),
65}
66
67impl Command {
68    /// The text the agent reads, leading slash included.
69    #[must_use]
70    pub fn wire(&self) -> String {
71        match self {
72            Command::Compact {
73                instructions: Some(how),
74            } => format!("/compact {how}"),
75            Command::Compact { instructions: None } => "/compact".to_string(),
76            Command::Clear => "/clear".to_string(),
77            // Trimmed of a slash the caller may have included, rather than
78            // sending `//skill`, which the CLI reads as prose.
79            Command::Other(name) => format!("/{}", name.trim_start_matches('/')),
80        }
81    }
82}
83
84/// What the running agent reports it can do, from its own catalogue.
85///
86/// Emitted as [`crate::Event::Commands`] when a run starts. Read from the
87/// agent rather than compiled in, because the set is per-install: skills,
88/// plugins and user-defined commands all appear, and a hardcoded list would
89/// describe the developer's machine instead of the user's.
90#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
91#[non_exhaustive]
92pub struct Commands {
93    /// Every command, without leading slashes. Includes the skills below.
94    pub all: Vec<String>,
95    /// The subset that are skills rather than built-in utilities.
96    ///
97    /// Claude Code reports these separately, and the split is the one a user
98    /// sees: a skill is a capability someone installed, a utility is part of
99    /// the tool. [`Commands::utilities`] is the other half.
100    pub skills: Vec<String>,
101}
102
103impl Commands {
104    /// The built-in half: everything that is not a skill.
105    #[must_use]
106    pub fn utilities(&self) -> Vec<&str> {
107        self.all
108            .iter()
109            .filter(|name| !self.skills.iter().any(|skill| skill == *name))
110            .map(String::as_str)
111            .collect()
112    }
113
114    /// Whether the agent offers a command, by name or with its slash.
115    #[must_use]
116    pub fn has(&self, name: &str) -> bool {
117        let wanted = name.trim_start_matches('/');
118        self.all.iter().any(|known| known == wanted)
119    }
120}
121
122/// How far a `/compact` got.
123///
124/// Both arms arrive on a run that completed: a refused compaction is an answer,
125/// not an error, so it is reported rather than raised.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "kebab-case")]
128#[non_exhaustive]
129pub enum Compaction {
130    /// The agent has begun summarising. A UI can say so; nothing else follows
131    /// until it finishes.
132    Started,
133    /// The summary is in place, or was refused with a reason.
134    Finished {
135        /// Whether the conversation was actually compacted.
136        ok: bool,
137        /// Why not, when the agent said. `Not enough messages to compact.` is
138        /// the common one.
139        error: Option<String>,
140    },
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn compact_carries_its_instructions_and_nothing_more() {
149        assert_eq!(Command::Compact { instructions: None }.wire(), "/compact");
150        assert_eq!(
151            Command::Compact {
152                instructions: Some("keep the failing test".into())
153            }
154            .wire(),
155            "/compact keep the failing test"
156        );
157    }
158
159    /// A caller who writes the slash should not send two.
160    #[test]
161    fn a_named_command_gets_exactly_one_slash() {
162        assert_eq!(Command::Other("context".into()).wire(), "/context");
163        assert_eq!(Command::Other("/context".into()).wire(), "/context");
164    }
165
166    /// The split the user sees, from the two lists the agent reports.
167    #[test]
168    fn utilities_are_the_commands_that_are_not_skills() {
169        let commands = Commands {
170            all: vec![
171                "code-review".into(),
172                "compact".into(),
173                "context".into(),
174                "verify".into(),
175            ],
176            skills: vec!["code-review".into(), "verify".into()],
177        };
178        assert_eq!(commands.utilities(), vec!["compact", "context"]);
179        assert!(commands.has("compact"));
180        assert!(commands.has("/compact"));
181        assert!(!commands.has("nonesuch"));
182    }
183}