Skip to main content

evolve_adapters/
claude_code.rs

1//! Claude Code adapter.
2
3use crate::signals::{ParsedSignal, SessionLog, SignalKind};
4use crate::traits::{Adapter, AdapterDetection, AdapterError};
5use async_trait::async_trait;
6use evolve_core::agent_config::AgentConfig;
7use evolve_core::ids::AdapterId;
8use serde_json::{Map, Value};
9use std::path::{Path, PathBuf};
10use tokio::fs;
11
12const MANAGED_START: &str = "<!-- evolve:start -->";
13const MANAGED_END: &str = "<!-- evolve:end -->";
14const HOOK_MARKER: &str = "evolve record-claude-code";
15const SESSION_START_MARKER: &str = "evolve session-start";
16
17/// Claude Code integration.
18#[derive(Debug, Clone, Default)]
19pub struct ClaudeCodeAdapter;
20
21impl ClaudeCodeAdapter {
22    /// Construct.
23    pub fn new() -> Self {
24        Self
25    }
26
27    fn settings_path(root: &Path) -> PathBuf {
28        root.join(".claude").join("settings.json")
29    }
30
31    fn claude_md_path(root: &Path) -> PathBuf {
32        root.join("CLAUDE.md")
33    }
34
35    /// Public for tests: build the `Stop` hook object inserted into settings.json.
36    pub fn stop_hook_entry() -> Value {
37        serde_json::json!({
38            "type": "command",
39            "command": HOOK_MARKER,
40        })
41    }
42
43    /// Public for tests: build the `SessionStart` hook object that flips the
44    /// deployed variant according to the running experiment's traffic share.
45    pub fn session_start_hook_entry() -> Value {
46        serde_json::json!({
47            "type": "command",
48            "command": SESSION_START_MARKER,
49        })
50    }
51
52    /// Render a config into the markdown snippet that goes inside
53    /// the managed section of CLAUDE.md.
54    pub fn render_managed_section(config: &AgentConfig) -> String {
55        let mut out = String::new();
56        out.push_str("# Evolve-managed configuration\n\n");
57        out.push_str("## System prompt prefix\n\n");
58        out.push_str(&config.system_prompt_prefix);
59        out.push_str("\n\n");
60        if !config.behavioral_rules.is_empty() {
61            out.push_str("## Behavioral rules\n\n");
62            for rule in &config.behavioral_rules {
63                out.push_str(&format!("- {rule}\n"));
64            }
65            out.push('\n');
66        }
67        out.push_str(&format!(
68            "## Response style\n\n{:?}\n\n",
69            config.response_style
70        ));
71        out.push_str(&format!("## Model preference\n\n{:?}\n", config.model_pref));
72        out
73    }
74}
75
76#[async_trait]
77impl Adapter for ClaudeCodeAdapter {
78    fn id(&self) -> AdapterId {
79        AdapterId::new("claude-code")
80    }
81
82    fn detect(&self, root: &Path) -> AdapterDetection {
83        if root.join(".claude").is_dir()
84            || root.join("CLAUDE.md").is_file()
85            || root.join(".claude").join("settings.json").is_file()
86        {
87            AdapterDetection::Detected
88        } else {
89            AdapterDetection::NotDetected
90        }
91    }
92
93    async fn install(&self, root: &Path, _config: &AgentConfig) -> Result<(), AdapterError> {
94        let settings_path = Self::settings_path(root);
95        if let Some(parent) = settings_path.parent() {
96            fs::create_dir_all(parent).await?;
97        }
98
99        let mut settings: Value = if settings_path.is_file() {
100            let raw = fs::read_to_string(&settings_path).await?;
101            if raw.trim().is_empty() {
102                Value::Object(Map::new())
103            } else {
104                serde_json::from_str(&raw)?
105            }
106        } else {
107            Value::Object(Map::new())
108        };
109
110        // Idempotency: scan hooks.Stop[] for an entry whose command contains
111        // our marker. If present, skip.
112        let hooks = settings
113            .as_object_mut()
114            .expect("settings is an object")
115            .entry("hooks".to_string())
116            .or_insert_with(|| Value::Object(Map::new()));
117        let hooks_obj = hooks
118            .as_object_mut()
119            .ok_or_else(|| AdapterError::Parse("hooks is not an object".into()))?;
120        let stop = hooks_obj
121            .entry("Stop".to_string())
122            .or_insert_with(|| Value::Array(Vec::new()));
123        let stop_arr = stop
124            .as_array_mut()
125            .ok_or_else(|| AdapterError::Parse("hooks.Stop is not an array".into()))?;
126
127        let already = stop_arr.iter().any(|entry| {
128            entry
129                .get("command")
130                .and_then(|c| c.as_str())
131                .map(|s| s.contains(HOOK_MARKER))
132                .unwrap_or(false)
133        });
134        if !already {
135            stop_arr.push(Self::stop_hook_entry());
136        }
137
138        // Also install a SessionStart hook so the deployed variant can be
139        // re-rolled per session (proper A/B). Idempotent.
140        let start = hooks_obj
141            .entry("SessionStart".to_string())
142            .or_insert_with(|| Value::Array(Vec::new()));
143        let start_arr = start
144            .as_array_mut()
145            .ok_or_else(|| AdapterError::Parse("hooks.SessionStart is not an array".into()))?;
146        let start_already = start_arr.iter().any(|entry| {
147            entry
148                .get("command")
149                .and_then(|c| c.as_str())
150                .map(|s| s.contains(SESSION_START_MARKER))
151                .unwrap_or(false)
152        });
153        if !start_already {
154            start_arr.push(Self::session_start_hook_entry());
155        }
156
157        let rendered = serde_json::to_string_pretty(&settings)?;
158        fs::write(&settings_path, rendered).await?;
159        Ok(())
160    }
161
162    async fn apply_config(&self, root: &Path, config: &AgentConfig) -> Result<(), AdapterError> {
163        let path = Self::claude_md_path(root);
164        let existing = if path.is_file() {
165            fs::read_to_string(&path).await?
166        } else {
167            String::new()
168        };
169        let new_section = Self::render_managed_section(config);
170        let updated = replace_managed_section(&existing, &new_section);
171        fs::write(&path, updated).await?;
172        Ok(())
173    }
174
175    async fn parse_session(&self, log: SessionLog) -> Result<Vec<ParsedSignal>, AdapterError> {
176        let path = match log {
177            SessionLog::Transcript(p) => p,
178            _ => return Err(AdapterError::Parse("expected Transcript log".into())),
179        };
180        let raw = fs::read_to_string(&path).await?;
181        Ok(parse_transcript_lines(&raw))
182    }
183
184    async fn forget(&self, root: &Path) -> Result<(), AdapterError> {
185        // Remove hook from settings.json (keep the file if other hooks exist).
186        let settings_path = Self::settings_path(root);
187        if settings_path.is_file() {
188            let raw = fs::read_to_string(&settings_path).await?;
189            if !raw.trim().is_empty() {
190                let mut settings: Value = serde_json::from_str(&raw)?;
191                for hook_name in ["Stop", "SessionStart"] {
192                    if let Some(arr) = settings
193                        .get_mut("hooks")
194                        .and_then(|h| h.get_mut(hook_name))
195                        .and_then(|s| s.as_array_mut())
196                    {
197                        arr.retain(|entry| {
198                            entry
199                                .get("command")
200                                .and_then(|c| c.as_str())
201                                .map(|s| {
202                                    !s.contains(HOOK_MARKER) && !s.contains(SESSION_START_MARKER)
203                                })
204                                .unwrap_or(true)
205                        });
206                    }
207                }
208                fs::write(&settings_path, serde_json::to_string_pretty(&settings)?).await?;
209            }
210        }
211
212        // Strip managed section from CLAUDE.md.
213        let md_path = Self::claude_md_path(root);
214        if md_path.is_file() {
215            let raw = fs::read_to_string(&md_path).await?;
216            let stripped = strip_managed_section(&raw);
217            fs::write(&md_path, stripped).await?;
218        }
219        Ok(())
220    }
221}
222
223/// Helper used by both real-schema and flat-schema user-message parsing to
224/// emit the regex-driven feedback signals.
225fn push_user_text_signals(
226    text: &str,
227    negative: &regex::Regex,
228    positive: &regex::Regex,
229    signals: &mut Vec<ParsedSignal>,
230) {
231    if text.trim() == "/clear" {
232        signals.push(ParsedSignal {
233            kind: SignalKind::Implicit,
234            source: "user_clear".into(),
235            value: 0.0,
236            payload_json: None,
237        });
238        return;
239    }
240    if negative.is_match(text) {
241        signals.push(ParsedSignal {
242            kind: SignalKind::Implicit,
243            source: "user_feedback_negative".into(),
244            value: 0.3,
245            payload_json: None,
246        });
247    }
248    if positive.is_match(text) {
249        signals.push(ParsedSignal {
250            kind: SignalKind::Implicit,
251            source: "user_feedback_positive".into(),
252            value: 0.9,
253            payload_json: None,
254        });
255    }
256}
257
258/// Replace (or insert) the managed section inside `existing`, returning the new content.
259fn replace_managed_section(existing: &str, new_body: &str) -> String {
260    let block = format!("{MANAGED_START}\n{}\n{MANAGED_END}", new_body.trim_end());
261    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
262        if end > start {
263            let end_full = end + MANAGED_END.len();
264            let mut out = String::new();
265            out.push_str(&existing[..start]);
266            out.push_str(&block);
267            out.push_str(&existing[end_full..]);
268            return out;
269        }
270    }
271    // No existing markers — append.
272    let mut out = String::from(existing);
273    if !out.is_empty() && !out.ends_with('\n') {
274        out.push('\n');
275    }
276    if !out.is_empty() {
277        out.push('\n');
278    }
279    out.push_str(&block);
280    out.push('\n');
281    out
282}
283
284/// Remove the managed section entirely, leaving the rest of the file.
285fn strip_managed_section(existing: &str) -> String {
286    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
287        if end > start {
288            let end_full = end + MANAGED_END.len();
289            let mut out = String::new();
290            out.push_str(&existing[..start]);
291            out.push_str(existing[end_full..].trim_start_matches('\n'));
292            return out;
293        }
294    }
295    existing.to_string()
296}
297
298/// Parse a Claude Code transcript (JSONL) into signals.
299///
300/// Parse Claude Code transcript JSONL into signals.
301///
302/// Handles two schemas:
303/// 1. **Real Anthropic Claude Code transcript:** events with
304///    `{"type":"user"|"assistant","message":{"role":"...","content": ...}}`
305///    where content is either a string or a list of content blocks
306///    (`text`, `tool_use`, `tool_result`).
307/// 2. **Flat schema** (used by some test fixtures and older transcripts):
308///    `{"type":"user","text":"..."}`, `{"type":"tool_use","tool":"bash","exit_code":N}`.
309///
310/// Both produce the same signal vocabulary:
311/// - `user_clear` (0.0) when `/clear` typed by user
312/// - `user_feedback_positive` (0.9) / `user_feedback_negative` (0.3) by regex
313/// - `tests_passed` (1.0) / `tests_failed` (0.0) by Bash exit code OR is_error
314///   on a `tool_result` paired with a test-like command
315/// - `subagent_ok` (1.0) / `subagent_fail` (0.0) for Task tool invocations
316fn parse_transcript_lines(raw: &str) -> Vec<ParsedSignal> {
317    use regex::Regex;
318
319    let negative = Regex::new(r"(?i)\b(redo|wrong|no,? that|try again|undo)\b").unwrap();
320    let positive = Regex::new(r"(?i)\b(thanks|perfect|looks good|lgtm|nice)\b").unwrap();
321    let test_cmd =
322        Regex::new(r"(?i)\b(cargo test|pytest|npm test|jest|go test|cargo check|cargo clippy)\b")
323            .unwrap();
324
325    // Track outstanding Bash tool_use ids so we can correlate their tool_result
326    // (Anthropic transcript schema separates the two).
327    let mut bash_test_ids: std::collections::HashMap<String, ()> = Default::default();
328    let mut signals = Vec::new();
329
330    for line in raw.lines() {
331        let line = line.trim();
332        if line.is_empty() {
333            continue;
334        }
335        let Ok(event): Result<Value, _> = serde_json::from_str(line) else {
336            continue;
337        };
338        let kind = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
339        match kind {
340            "user" => {
341                // Real schema: event.message.content is either a string or a list
342                // of content blocks. Content blocks of type=tool_result indicate
343                // a tool finished.
344                let content = event.pointer("/message/content");
345                if let Some(c) = content {
346                    if let Some(text) = c.as_str() {
347                        push_user_text_signals(text, &negative, &positive, &mut signals);
348                    } else if let Some(arr) = c.as_array() {
349                        for block in arr {
350                            let btype = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
351                            if btype == "text" {
352                                let t = block.get("text").and_then(|v| v.as_str()).unwrap_or("");
353                                push_user_text_signals(t, &negative, &positive, &mut signals);
354                            } else if btype == "tool_result" {
355                                let tool_use_id = block
356                                    .get("tool_use_id")
357                                    .and_then(|v| v.as_str())
358                                    .unwrap_or("");
359                                if !bash_test_ids.contains_key(tool_use_id) {
360                                    continue;
361                                }
362                                let is_error = block
363                                    .get("is_error")
364                                    .and_then(|v| v.as_bool())
365                                    .unwrap_or(false);
366                                signals.push(ParsedSignal {
367                                    kind: SignalKind::Implicit,
368                                    source: if !is_error {
369                                        "tests_passed".into()
370                                    } else {
371                                        "tests_failed".into()
372                                    },
373                                    value: if !is_error { 1.0 } else { 0.0 },
374                                    payload_json: None,
375                                });
376                                bash_test_ids.remove(tool_use_id);
377                            }
378                        }
379                    }
380                } else {
381                    // Flat-schema fallback.
382                    let text = event.get("text").and_then(|v| v.as_str()).unwrap_or("");
383                    push_user_text_signals(text, &negative, &positive, &mut signals);
384                }
385            }
386            "assistant" => {
387                // Real schema: event.message.content is a list of blocks; we look
388                // for tool_use blocks calling Bash with a test-like command, plus
389                // Task tool calls (subagents).
390                if let Some(arr) = event.pointer("/message/content").and_then(|c| c.as_array()) {
391                    for block in arr {
392                        let btype = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
393                        if btype != "tool_use" {
394                            continue;
395                        }
396                        let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
397                        let id = block
398                            .get("id")
399                            .and_then(|v| v.as_str())
400                            .unwrap_or("")
401                            .to_string();
402                        if name.eq_ignore_ascii_case("bash") {
403                            let cmd = block
404                                .pointer("/input/command")
405                                .and_then(|v| v.as_str())
406                                .unwrap_or("");
407                            if test_cmd.is_match(cmd) {
408                                bash_test_ids.insert(id, ());
409                            }
410                        } else if name == "Task" {
411                            let agent = block
412                                .pointer("/input/subagent_type")
413                                .and_then(|v| v.as_str())
414                                .unwrap_or("unknown");
415                            // Subagent outcomes arrive in the next user/tool_result;
416                            // mark as observed for now via a neutral 0.5 if we never
417                            // see the result. The real-schema test_passed/failed
418                            // covers the bash case; for Task we emit a baseline OK
419                            // so users can see it fired.
420                            signals.push(ParsedSignal {
421                                kind: SignalKind::Implicit,
422                                source: "subagent_invoked".into(),
423                                value: 0.5,
424                                payload_json: Some(format!("{{\"subagent\":\"{agent}\"}}")),
425                            });
426                        }
427                    }
428                }
429            }
430            "tool_use" => {
431                // Flat-schema fallback.
432                let tool = event.get("tool").and_then(|v| v.as_str()).unwrap_or("");
433                if tool != "bash" {
434                    continue;
435                }
436                let cmd = event.get("command").and_then(|v| v.as_str()).unwrap_or("");
437                if !test_cmd.is_match(cmd) {
438                    continue;
439                }
440                let exit = event
441                    .get("exit_code")
442                    .and_then(|v| v.as_i64())
443                    .unwrap_or(-1);
444                signals.push(ParsedSignal {
445                    kind: SignalKind::Implicit,
446                    source: if exit == 0 {
447                        "tests_passed".into()
448                    } else {
449                        "tests_failed".into()
450                    },
451                    value: if exit == 0 { 1.0 } else { 0.0 },
452                    payload_json: None,
453                });
454            }
455            "subagent" => {
456                // Flat-schema fallback for explicit subagent events.
457                let status = event.get("status").and_then(|v| v.as_str()).unwrap_or("");
458                let agent = event
459                    .get("subagent_type")
460                    .and_then(|v| v.as_str())
461                    .unwrap_or("unknown");
462                let (src, val) = match status {
463                    "completed" | "success" => ("subagent_ok", 1.0),
464                    "errored" | "failed" | "timeout" => ("subagent_fail", 0.0),
465                    _ => continue,
466                };
467                signals.push(ParsedSignal {
468                    kind: SignalKind::Implicit,
469                    source: src.to_string(),
470                    value: val,
471                    payload_json: Some(format!("{{\"subagent\":\"{agent}\"}}")),
472                });
473            }
474            _ => {}
475        }
476    }
477    signals
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use tempfile::TempDir;
484
485    fn sample_config() -> AgentConfig {
486        AgentConfig::default_for("claude-code")
487    }
488
489    #[tokio::test]
490    async fn detect_recognizes_claude_md() {
491        let tmp = TempDir::new().unwrap();
492        std::fs::write(tmp.path().join("CLAUDE.md"), "# test").unwrap();
493        let adapter = ClaudeCodeAdapter::new();
494        assert_eq!(adapter.detect(tmp.path()), AdapterDetection::Detected);
495    }
496
497    #[tokio::test]
498    async fn detect_returns_not_detected_for_empty_dir() {
499        let tmp = TempDir::new().unwrap();
500        let adapter = ClaudeCodeAdapter::new();
501        assert_eq!(adapter.detect(tmp.path()), AdapterDetection::NotDetected);
502    }
503
504    #[tokio::test]
505    async fn install_adds_stop_hook_to_fresh_settings() {
506        let tmp = TempDir::new().unwrap();
507        let adapter = ClaudeCodeAdapter::new();
508        adapter.install(tmp.path(), &sample_config()).await.unwrap();
509        let raw =
510            std::fs::read_to_string(tmp.path().join(".claude").join("settings.json")).unwrap();
511        assert!(raw.contains(HOOK_MARKER));
512    }
513
514    #[tokio::test]
515    async fn install_is_idempotent() {
516        let tmp = TempDir::new().unwrap();
517        let adapter = ClaudeCodeAdapter::new();
518        adapter.install(tmp.path(), &sample_config()).await.unwrap();
519        let first =
520            std::fs::read_to_string(tmp.path().join(".claude").join("settings.json")).unwrap();
521        adapter.install(tmp.path(), &sample_config()).await.unwrap();
522        let second =
523            std::fs::read_to_string(tmp.path().join(".claude").join("settings.json")).unwrap();
524        assert_eq!(
525            first, second,
526            "second install must not change settings.json"
527        );
528    }
529
530    #[tokio::test]
531    async fn install_preserves_unrelated_settings() {
532        let tmp = TempDir::new().unwrap();
533        let dir = tmp.path().join(".claude");
534        std::fs::create_dir_all(&dir).unwrap();
535        let existing = r#"{"theme":"dark","permissions":{"allow":["Bash"]}}"#;
536        std::fs::write(dir.join("settings.json"), existing).unwrap();
537        let adapter = ClaudeCodeAdapter::new();
538        adapter.install(tmp.path(), &sample_config()).await.unwrap();
539        let raw = std::fs::read_to_string(dir.join("settings.json")).unwrap();
540        assert!(raw.contains("\"theme\""));
541        assert!(raw.contains("\"permissions\""));
542        assert!(raw.contains(HOOK_MARKER));
543    }
544
545    #[tokio::test]
546    async fn apply_config_writes_managed_section_between_markers() {
547        let tmp = TempDir::new().unwrap();
548        let adapter = ClaudeCodeAdapter::new();
549        adapter
550            .apply_config(tmp.path(), &sample_config())
551            .await
552            .unwrap();
553        let raw = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
554        assert!(raw.contains(MANAGED_START));
555        assert!(raw.contains(MANAGED_END));
556        assert!(raw.contains("System prompt prefix"));
557    }
558
559    #[tokio::test]
560    async fn apply_config_preserves_user_content_outside_markers() {
561        let tmp = TempDir::new().unwrap();
562        let user_content = "# My own CLAUDE.md\n\nImportant project notes.\n";
563        std::fs::write(tmp.path().join("CLAUDE.md"), user_content).unwrap();
564        let adapter = ClaudeCodeAdapter::new();
565        adapter
566            .apply_config(tmp.path(), &sample_config())
567            .await
568            .unwrap();
569        let raw = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
570        assert!(raw.contains("Important project notes."));
571        assert!(raw.contains(MANAGED_START));
572    }
573
574    #[tokio::test]
575    async fn apply_config_replaces_existing_managed_section() {
576        let tmp = TempDir::new().unwrap();
577        let initial =
578            format!("# Keep\n\n{MANAGED_START}\nold content\n{MANAGED_END}\n\n# Also keep\n",);
579        std::fs::write(tmp.path().join("CLAUDE.md"), &initial).unwrap();
580        let adapter = ClaudeCodeAdapter::new();
581        adapter
582            .apply_config(tmp.path(), &sample_config())
583            .await
584            .unwrap();
585        let raw = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
586        assert!(!raw.contains("old content"));
587        assert!(raw.contains("# Keep"));
588        assert!(raw.contains("# Also keep"));
589    }
590
591    #[tokio::test]
592    async fn forget_removes_hook_but_keeps_other_hooks() {
593        let tmp = TempDir::new().unwrap();
594        let adapter = ClaudeCodeAdapter::new();
595        // Seed with a foreign hook + evolve hook.
596        adapter.install(tmp.path(), &sample_config()).await.unwrap();
597        let path = tmp.path().join(".claude").join("settings.json");
598        let raw = std::fs::read_to_string(&path).unwrap();
599        let mut settings: Value = serde_json::from_str(&raw).unwrap();
600        settings["hooks"]["Stop"]
601            .as_array_mut()
602            .unwrap()
603            .push(serde_json::json!({"type":"command","command":"other-thing"}));
604        std::fs::write(&path, serde_json::to_string_pretty(&settings).unwrap()).unwrap();
605
606        adapter.forget(tmp.path()).await.unwrap();
607        let after = std::fs::read_to_string(&path).unwrap();
608        assert!(!after.contains(HOOK_MARKER));
609        assert!(after.contains("other-thing"));
610    }
611
612    #[tokio::test]
613    async fn forget_strips_managed_section_preserves_user_text() {
614        let tmp = TempDir::new().unwrap();
615        let path = tmp.path().join("CLAUDE.md");
616        let content = format!("# User\n\n{MANAGED_START}\nmanaged\n{MANAGED_END}\n\n# Tail\n",);
617        std::fs::write(&path, &content).unwrap();
618        ClaudeCodeAdapter::new().forget(tmp.path()).await.unwrap();
619        let after = std::fs::read_to_string(&path).unwrap();
620        assert!(after.contains("# User"));
621        assert!(after.contains("# Tail"));
622        assert!(!after.contains("managed"));
623    }
624
625    // ----- transcript parsing -----
626
627    fn jsonl(events: &[&str]) -> String {
628        events.join("\n")
629    }
630
631    #[tokio::test]
632    async fn parse_session_detects_user_clear() {
633        let tmp = TempDir::new().unwrap();
634        let path = tmp.path().join("t.jsonl");
635        std::fs::write(&path, jsonl(&[r#"{"type":"user","text":"/clear"}"#])).unwrap();
636        let signals = ClaudeCodeAdapter::new()
637            .parse_session(SessionLog::Transcript(path))
638            .await
639            .unwrap();
640        assert_eq!(signals.len(), 1);
641        assert_eq!(signals[0].source, "user_clear");
642        assert_eq!(signals[0].value, 0.0);
643    }
644
645    #[tokio::test]
646    async fn parse_session_detects_test_pass_and_fail() {
647        let tmp = TempDir::new().unwrap();
648        let path = tmp.path().join("t.jsonl");
649        std::fs::write(
650            &path,
651            jsonl(&[
652                r#"{"type":"tool_use","tool":"bash","command":"cargo test","exit_code":0}"#,
653                r#"{"type":"tool_use","tool":"bash","command":"cargo test","exit_code":1}"#,
654            ]),
655        )
656        .unwrap();
657        let signals = ClaudeCodeAdapter::new()
658            .parse_session(SessionLog::Transcript(path))
659            .await
660            .unwrap();
661        assert_eq!(signals.len(), 2);
662        assert_eq!(signals[0].source, "tests_passed");
663        assert_eq!(signals[0].value, 1.0);
664        assert_eq!(signals[1].source, "tests_failed");
665        assert_eq!(signals[1].value, 0.0);
666    }
667
668    #[tokio::test]
669    async fn parse_session_detects_positive_and_negative_feedback() {
670        let tmp = TempDir::new().unwrap();
671        let path = tmp.path().join("t.jsonl");
672        std::fs::write(
673            &path,
674            jsonl(&[
675                r#"{"type":"user","text":"perfect, thanks!"}"#,
676                r#"{"type":"user","text":"no, that's wrong, redo"}"#,
677            ]),
678        )
679        .unwrap();
680        let signals = ClaudeCodeAdapter::new()
681            .parse_session(SessionLog::Transcript(path))
682            .await
683            .unwrap();
684        let sources: Vec<&str> = signals.iter().map(|s| s.source.as_str()).collect();
685        assert!(sources.contains(&"user_feedback_positive"));
686        assert!(sources.contains(&"user_feedback_negative"));
687    }
688
689    #[tokio::test]
690    async fn parse_session_ignores_unrelated_bash_commands() {
691        let tmp = TempDir::new().unwrap();
692        let path = tmp.path().join("t.jsonl");
693        std::fs::write(
694            &path,
695            jsonl(&[r#"{"type":"tool_use","tool":"bash","command":"ls -la","exit_code":0}"#]),
696        )
697        .unwrap();
698        let signals = ClaudeCodeAdapter::new()
699            .parse_session(SessionLog::Transcript(path))
700            .await
701            .unwrap();
702        assert!(signals.is_empty());
703    }
704
705    #[tokio::test]
706    async fn parse_session_detects_subagent_completion() {
707        let tmp = TempDir::new().unwrap();
708        let path = tmp.path().join("t.jsonl");
709        std::fs::write(
710            &path,
711            jsonl(&[
712                r#"{"type":"subagent","status":"completed","subagent_type":"code-reviewer"}"#,
713                r#"{"type":"subagent","status":"errored","subagent_type":"debugger"}"#,
714            ]),
715        )
716        .unwrap();
717        let signals = ClaudeCodeAdapter::new()
718            .parse_session(SessionLog::Transcript(path))
719            .await
720            .unwrap();
721        assert_eq!(signals.len(), 2);
722        assert_eq!(signals[0].source, "subagent_ok");
723        assert_eq!(signals[0].value, 1.0);
724        assert!(
725            signals[0]
726                .payload_json
727                .as_deref()
728                .unwrap()
729                .contains("code-reviewer")
730        );
731        assert_eq!(signals[1].source, "subagent_fail");
732        assert_eq!(signals[1].value, 0.0);
733    }
734
735    #[tokio::test]
736    async fn parse_session_handles_real_anthropic_schema() {
737        let tmp = TempDir::new().unwrap();
738        let path = tmp.path().join("real.jsonl");
739        std::fs::write(
740            &path,
741            jsonl(&[
742                // user with text-block content
743                r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"thanks, looks good"}]}}"#,
744                // assistant invoking Bash with cargo test
745                r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"cargo test"}}]}}"#,
746                // user message containing the tool_result for that bash call
747                r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"ok","is_error":false}]}}"#,
748            ]),
749        )
750        .unwrap();
751        let signals = ClaudeCodeAdapter::new()
752            .parse_session(SessionLog::Transcript(path))
753            .await
754            .unwrap();
755        let sources: Vec<&str> = signals.iter().map(|s| s.source.as_str()).collect();
756        assert!(sources.contains(&"user_feedback_positive"));
757        assert!(sources.contains(&"tests_passed"));
758    }
759
760    #[tokio::test]
761    async fn parse_session_real_schema_failed_test_emits_failed() {
762        let tmp = TempDir::new().unwrap();
763        let path = tmp.path().join("realfail.jsonl");
764        std::fs::write(
765            &path,
766            jsonl(&[
767                r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_99","name":"Bash","input":{"command":"pytest"}}]}}"#,
768                r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_99","content":"FAILED","is_error":true}]}}"#,
769            ]),
770        )
771        .unwrap();
772        let signals = ClaudeCodeAdapter::new()
773            .parse_session(SessionLog::Transcript(path))
774            .await
775            .unwrap();
776        let sources: Vec<&str> = signals.iter().map(|s| s.source.as_str()).collect();
777        assert!(sources.contains(&"tests_failed"));
778    }
779
780    #[tokio::test]
781    async fn parse_session_real_schema_task_subagent_invocation() {
782        let tmp = TempDir::new().unwrap();
783        let path = tmp.path().join("subagent.jsonl");
784        std::fs::write(
785            &path,
786            jsonl(&[
787                r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"task_01","name":"Task","input":{"subagent_type":"code-reviewer","prompt":"review"}}]}}"#,
788            ]),
789        )
790        .unwrap();
791        let signals = ClaudeCodeAdapter::new()
792            .parse_session(SessionLog::Transcript(path))
793            .await
794            .unwrap();
795        assert_eq!(signals.len(), 1);
796        assert_eq!(signals[0].source, "subagent_invoked");
797        assert!(
798            signals[0]
799                .payload_json
800                .as_deref()
801                .unwrap()
802                .contains("code-reviewer")
803        );
804    }
805
806    #[tokio::test]
807    async fn parse_session_tolerates_invalid_json_lines() {
808        let tmp = TempDir::new().unwrap();
809        let path = tmp.path().join("t.jsonl");
810        std::fs::write(
811            &path,
812            "not json\n{\"type\":\"user\",\"text\":\"/clear\"}\nalso not json",
813        )
814        .unwrap();
815        let signals = ClaudeCodeAdapter::new()
816            .parse_session(SessionLog::Transcript(path))
817            .await
818            .unwrap();
819        assert_eq!(signals.len(), 1);
820        assert_eq!(signals[0].source, "user_clear");
821    }
822}