Skip to main content

claude_session_parser/
activity.rs

1//! Deterministic classifier for Claude Code turns.
2//!
3//! Classifies a single assistant turn into one of 13 activity categories based
4//! on the tools it invoked, any bash commands it ran, the preceding user
5//! message text, and the amount of text it produced. No LLM calls, no regex —
6//! just case-insensitive substring scanning. Mirrors the taxonomy from
7//! CodeBurn (github.com/AgentSeal/codeburn) with minor naming adjustments.
8
9use 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
29// Tool name sets. These match the canonical Claude Code tool names plus a few
30// legacy aliases. Comparison is case-sensitive — Claude Code emits canonical
31// names, so this keeps the classifier O(n) and branch-friendly.
32const 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
45// User-message intent keywords. All compared against the lowercased snippet.
46const 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
67// Bash-command patterns. Matched as lowercased substrings of the raw command.
68// Order matters when multiple match: first one wins via the dispatch order
69// below (Testing → GitOps → Build → Install).
70const 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
98/// Truncate and lowercase a user message for keyword scanning.
99///
100/// 500 chars is generous — intent keywords almost always appear in the first
101/// sentence or two. Truncating bounds the work per turn and the memory cost of
102/// the rolling `last_user_text` variable in the parser.
103pub 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
109/// Classify a single assistant turn.
110///
111/// Arguments:
112/// - `tools`: tool names invoked in this turn (from `tool_use` content blocks)
113/// - `bash_cmds`: bash/shell command strings invoked (from `tool_use.input.command`)
114/// - `user_msg`: the preceding user message text, already lowercased+truncated
115///   via `normalize_user_text`. `None` if there is no paired user turn (rare —
116///   happens for synthetic / SDK-initiated conversations).
117/// - `text_chars`: number of text characters the assistant produced in this
118///   turn (sum of `text` content blocks). Used to distinguish silent tool-only
119///   turns from pure-text conversations.
120pub 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    // Delegation wins outright — Agent spawns imply sub-agent work regardless
138    // of what else the turn touched.
139    if has_agent {
140        return Activity::Delegation;
141    }
142
143    // Planning: ExitPlanMode or todo-list tools without concrete edits.
144    if has_plan || (has_task && !has_edit) {
145        return Activity::Planning;
146    }
147
148    // Edit-driven turns: refine with user intent. Order matters — "fix the bug
149    // in this refactor" reads as Debugging (the active intent) over Refactor.
150    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    // Bash-only turns: classify by command shape. Testing first (tests can
164    // invoke git/build scripts as side effects), then git, then build/install.
165    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    // Read / search / MCP with no edits: exploration.
182    if has_read || has_search {
183        return Activity::Exploration;
184    }
185
186    // Skill tool only and nothing else → general scaffolding.
187    if has_skill {
188        return Activity::General;
189    }
190
191    // No tools at all: infer from user intent, fall back to Conversation if
192    // the assistant produced any text.
193    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        // chars() not bytes — a 500-char cap must not split a multibyte char.
421        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}