1use 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#[derive(Debug, Clone, Default)]
19pub struct ClaudeCodeAdapter;
20
21impl ClaudeCodeAdapter {
22 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 pub fn stop_hook_entry() -> Value {
37 serde_json::json!({
38 "type": "command",
39 "command": HOOK_MARKER,
40 })
41 }
42
43 pub fn session_start_hook_entry() -> Value {
46 serde_json::json!({
47 "type": "command",
48 "command": SESSION_START_MARKER,
49 })
50 }
51
52 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 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 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 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 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
223fn push_user_text_signals(
226 text: &str,
227 negative: ®ex::Regex,
228 positive: ®ex::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
258fn 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 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
284fn 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
298fn 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 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 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 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 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 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 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 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 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 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 r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"thanks, looks good"}]}}"#,
744 r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"cargo test"}}]}}"#,
746 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}