1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum Activity {
14 Coding,
15 Debugging,
16 Feature,
17 Refactor,
18 Testing,
19 Exploration,
20 Planning,
21 Delegation,
22 GitOps,
23 Build,
24 Brainstorm,
25 Conversation,
26 General,
27}
28
29const EDIT_TOOLS: &[&str] = &[
33 "Edit", "Write", "FileEditTool", "FileWriteTool", "NotebookEdit",
34];
35const READ_TOOLS: &[&str] = &[
36 "Read", "Grep", "Glob", "FileReadTool", "GrepTool", "GlobTool",
37];
38const BASH_TOOLS: &[&str] = &["Bash", "BashTool", "PowerShellTool"];
39const TASK_TOOLS: &[&str] = &[
40 "TaskCreate", "TaskUpdate", "TaskGet", "TaskList", "TaskOutput",
41 "TaskStop", "TodoWrite",
42];
43const SEARCH_TOOLS: &[&str] = &["WebSearch", "WebFetch", "ToolSearch"];
44
45const DEBUG_KEYWORDS: &[&str] = &[
47 "fix", "bug", "error", "broken", "failing", "crash", "issue",
48 "debug", "traceback", "exception", "regression",
49];
50const FEATURE_KEYWORDS: &[&str] = &[
51 "add", "create", "implement", "new feature", "build ", "introduce",
52 "set up", "scaffold",
53];
54const REFACTOR_KEYWORDS: &[&str] = &[
55 "refactor", "clean up", "rename", "reorganize", "simplify",
56 "extract", "restructure", "dry up",
57];
58const BRAINSTORM_KEYWORDS: &[&str] = &[
59 "brainstorm", "what if", "think about", "approach", "design",
60 "ideation", "explore ideas",
61];
62const RESEARCH_KEYWORDS: &[&str] = &[
63 "research", "investigate", "look into", "find out", "analyze",
64 "review", "compare",
65];
66
67const TEST_PATTERNS: &[&str] = &[
71 "pytest", "vitest", "jest", "mocha", " spec", "coverage",
72 "npm test", "npm run test", "yarn test", "pnpm test", "cargo test",
73 "go test", "rspec",
74];
75const GIT_PATTERNS: &[&str] = &[
76 "git push", "git pull", "git commit", "git merge", "git rebase",
77 "git checkout", "git branch", "git fetch", "git stash",
78 "git reset", "git restore", "git tag", "git cherry-pick",
79];
80const BUILD_PATTERNS: &[&str] = &[
81 "npm run build", "yarn build", "pnpm build", "webpack",
82 "vite build", "rollup", "esbuild", "cargo build", "docker build",
83 "docker compose", "pm2 ", "deploy",
84];
85const INSTALL_PATTERNS: &[&str] = &[
86 "npm install", "npm i ", "yarn add", "pnpm install", "pnpm add",
87 "pip install", "cargo add", "bundle install", "brew install",
88];
89
90fn any_in(tools: &[&str], needles: &[&str]) -> bool {
91 tools.iter().any(|t| needles.contains(t))
92}
93
94fn contains_any(haystack: &str, needles: &[&str]) -> bool {
95 needles.iter().any(|n| haystack.contains(n))
96}
97
98pub fn normalize_user_text(raw: &str) -> String {
104 let mut s: String = raw.chars().take(500).collect();
105 s.make_ascii_lowercase();
106 s
107}
108
109pub fn classify(
121 tools: &[&str],
122 bash_cmds: &[&str],
123 user_msg: Option<&str>,
124 text_chars: u32,
125) -> Activity {
126 let user = user_msg.unwrap_or("");
127
128 let has_edit = any_in(tools, EDIT_TOOLS);
129 let has_bash = any_in(tools, BASH_TOOLS);
130 let has_read = any_in(tools, READ_TOOLS);
131 let has_search = any_in(tools, SEARCH_TOOLS);
132 let has_task = any_in(tools, TASK_TOOLS);
133 let has_agent = tools.contains(&"Agent");
134 let has_plan = tools.contains(&"ExitPlanMode") || tools.contains(&"EnterPlanMode");
135 let has_skill = tools.contains(&"Skill");
136
137 if has_agent {
140 return Activity::Delegation;
141 }
142
143 if has_plan || (has_task && !has_edit) {
145 return Activity::Planning;
146 }
147
148 if has_edit {
151 if contains_any(user, DEBUG_KEYWORDS) {
152 return Activity::Debugging;
153 }
154 if contains_any(user, REFACTOR_KEYWORDS) {
155 return Activity::Refactor;
156 }
157 if contains_any(user, FEATURE_KEYWORDS) {
158 return Activity::Feature;
159 }
160 return Activity::Coding;
161 }
162
163 if has_bash {
166 let cmds: Vec<String> = bash_cmds.iter().map(|c| c.to_ascii_lowercase()).collect();
167 let any_cmd_matches = |pats: &[&str]| cmds.iter().any(|c| contains_any(c, pats));
168
169 if any_cmd_matches(TEST_PATTERNS) {
170 return Activity::Testing;
171 }
172 if any_cmd_matches(GIT_PATTERNS) {
173 return Activity::GitOps;
174 }
175 if any_cmd_matches(BUILD_PATTERNS) || any_cmd_matches(INSTALL_PATTERNS) {
176 return Activity::Build;
177 }
178 return Activity::Exploration;
179 }
180
181 if has_read || has_search {
183 return Activity::Exploration;
184 }
185
186 if has_skill {
188 return Activity::General;
189 }
190
191 if tools.is_empty() {
194 if contains_any(user, BRAINSTORM_KEYWORDS) {
195 return Activity::Brainstorm;
196 }
197 if contains_any(user, RESEARCH_KEYWORDS) {
198 return Activity::Exploration;
199 }
200 if contains_any(user, DEBUG_KEYWORDS) {
201 return Activity::Debugging;
202 }
203 if contains_any(user, FEATURE_KEYWORDS) {
204 return Activity::Feature;
205 }
206 if text_chars > 0 {
207 return Activity::Conversation;
208 }
209 return Activity::General;
210 }
211
212 Activity::General
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn delegation_wins_over_everything() {
221 let tools = ["Agent", "Edit", "Bash"];
222 let tools: Vec<&str> = tools.to_vec();
223 assert_eq!(
224 classify(&tools, &[], Some("fix the bug"), 100),
225 Activity::Delegation
226 );
227 }
228
229 #[test]
230 fn plan_mode_classifies_as_planning() {
231 let tools = ["ExitPlanMode"];
232 assert_eq!(
233 classify(&tools, &[], Some("add a feature"), 50),
234 Activity::Planning
235 );
236 }
237
238 #[test]
239 fn task_tools_without_edits_are_planning() {
240 let tools = ["TaskCreate", "TaskUpdate"];
241 assert_eq!(
242 classify(&tools, &[], None, 0),
243 Activity::Planning
244 );
245 }
246
247 #[test]
248 fn task_tools_plus_edits_classify_by_edit_rules() {
249 let tools = ["TaskCreate", "Edit"];
250 assert_eq!(
251 classify(&tools, &[], Some("implement the login page"), 20),
252 Activity::Feature
253 );
254 }
255
256 #[test]
257 fn edit_plus_debug_keyword_is_debugging() {
258 let tools = ["Edit", "Read"];
259 assert_eq!(
260 classify(&tools, &[], Some("fix the crash in login"), 10),
261 Activity::Debugging
262 );
263 }
264
265 #[test]
266 fn edit_plus_refactor_keyword() {
267 let tools = ["Edit"];
268 assert_eq!(
269 classify(&tools, &[], Some("refactor the usage store"), 5),
270 Activity::Refactor
271 );
272 }
273
274 #[test]
275 fn edit_plus_feature_keyword() {
276 let tools = ["Write"];
277 assert_eq!(
278 classify(&tools, &[], Some("implement a new dashboard"), 5),
279 Activity::Feature
280 );
281 }
282
283 #[test]
284 fn edit_without_keyword_defaults_to_coding() {
285 let tools = ["Edit"];
286 assert_eq!(
287 classify(&tools, &[], Some("update the styling"), 5),
288 Activity::Coding
289 );
290 }
291
292 #[test]
293 fn debug_beats_refactor_when_both_keywords_present() {
294 let tools = ["Edit"];
295 assert_eq!(
296 classify(&tools, &[], Some("fix the bug in this refactor"), 5),
297 Activity::Debugging
298 );
299 }
300
301 #[test]
302 fn bash_with_test_cmd_is_testing() {
303 let tools = ["Bash"];
304 assert_eq!(
305 classify(&tools, &["pytest tests/"], None, 10),
306 Activity::Testing
307 );
308 }
309
310 #[test]
311 fn bash_with_git_cmd_is_gitops() {
312 let tools = ["Bash"];
313 assert_eq!(
314 classify(&tools, &["git push origin main"], None, 5),
315 Activity::GitOps
316 );
317 }
318
319 #[test]
320 fn bash_with_build_cmd_is_build() {
321 let tools = ["Bash"];
322 assert_eq!(
323 classify(&tools, &["npm run build"], None, 5),
324 Activity::Build
325 );
326 }
327
328 #[test]
329 fn bash_with_install_cmd_is_build() {
330 let tools = ["Bash"];
331 assert_eq!(
332 classify(&tools, &["npm install react"], None, 5),
333 Activity::Build
334 );
335 }
336
337 #[test]
338 fn bash_without_recognizable_cmd_is_exploration() {
339 let tools = ["Bash"];
340 assert_eq!(
341 classify(&tools, &["ls -la"], None, 5),
342 Activity::Exploration
343 );
344 }
345
346 #[test]
347 fn read_only_is_exploration() {
348 let tools = ["Read", "Grep"];
349 assert_eq!(
350 classify(&tools, &[], None, 100),
351 Activity::Exploration
352 );
353 }
354
355 #[test]
356 fn search_only_is_exploration() {
357 let tools = ["WebSearch"];
358 assert_eq!(
359 classify(&tools, &[], None, 50),
360 Activity::Exploration
361 );
362 }
363
364 #[test]
365 fn skill_only_is_general() {
366 let tools = ["Skill"];
367 assert_eq!(
368 classify(&tools, &[], None, 10),
369 Activity::General
370 );
371 }
372
373 #[test]
374 fn empty_tools_with_brainstorm_keyword() {
375 assert_eq!(
376 classify(&[], &[], Some("what if we tried a different approach"), 200),
377 Activity::Brainstorm
378 );
379 }
380
381 #[test]
382 fn empty_tools_with_research_keyword_is_exploration() {
383 assert_eq!(
384 classify(&[], &[], Some("research how anthropic handles caching"), 200),
385 Activity::Exploration
386 );
387 }
388
389 #[test]
390 fn empty_tools_with_debug_keyword_is_debugging() {
391 assert_eq!(
392 classify(&[], &[], Some("why is my test failing"), 100),
393 Activity::Debugging
394 );
395 }
396
397 #[test]
398 fn empty_tools_with_text_is_conversation() {
399 assert_eq!(
400 classify(&[], &[], Some("thanks, that works great"), 30),
401 Activity::Conversation
402 );
403 }
404
405 #[test]
406 fn empty_tools_no_text_no_user_is_general() {
407 assert_eq!(classify(&[], &[], None, 0), Activity::General);
408 }
409
410 #[test]
411 fn normalize_user_text_lowercases_and_truncates() {
412 let long: String = "A".repeat(1000);
413 let n = normalize_user_text(&long);
414 assert_eq!(n.len(), 500);
415 assert!(n.chars().all(|c| c == 'a'));
416 }
417
418 #[test]
419 fn normalize_preserves_multibyte_boundaries() {
420 let s = "é".repeat(600);
422 let n = normalize_user_text(&s);
423 assert_eq!(n.chars().count(), 500);
424 }
425
426 #[test]
427 fn activity_serializes_kebab_case() {
428 let json = serde_json::to_string(&Activity::GitOps).unwrap();
429 assert_eq!(json, "\"git-ops\"");
430 let json = serde_json::to_string(&Activity::Conversation).unwrap();
431 assert_eq!(json, "\"conversation\"");
432 }
433}