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}