use camino::{Utf8Path, Utf8PathBuf}; use chrono::Datelike; use dirigent_anth::{ correlation::correlate_tools, dedup::dedup_messages, noise::{classify_noise, NoiseKind}, parse_session, tree::ConversationTree, types::{ContentBlock, RawMessage}, util::parse_timestamp, }; #[test] fn parse_minimal_session() { let path = Utf8Path::new("tests/fixtures/minimal_session.jsonl"); let messages = parse_session(path).unwrap(); assert_eq!(messages.len(), 6, "Expected 6 messages, got {}", messages.len()); let type_names: Vec<&str> = messages .iter() .map(|m| match m { RawMessage::User(_) => "user", RawMessage::Assistant(_) => "assistant", RawMessage::Progress(_) => "progress", RawMessage::System(_) => "system", RawMessage::QueueOperation(_) => "queue-operation", RawMessage::FileHistorySnapshot(_) => "file-history-snapshot", RawMessage::LastPrompt(_) => "last-prompt", }) .collect(); assert_eq!( type_names.iter().filter(|&&t| t == "queue-operation").count(), 2 ); assert_eq!(type_names.iter().filter(|&&t| t == "user").count(), 2); assert_eq!( type_names.iter().filter(|&&t| t == "assistant").count(), 2 ); } #[test] fn parse_line_returns_none_for_invalid_json() { assert!(dirigent_anth::parse_line("not valid json", 1).is_none()); assert!(dirigent_anth::parse_line("{}", 1).is_none()); } #[test] fn dedup_streaming_session() { let path = Utf8Path::new("tests/fixtures/streaming_dedup.jsonl"); let messages = parse_session(path).unwrap(); // Raw should have 6 lines (including 3 versions of same assistant message) assert_eq!(messages.len(), 6, "Raw messages: expected 6, got {}", messages.len()); let deduped = dedup_messages(messages); // After dedup: U1, A1(final), U2, A2 = 4 assert_eq!(deduped.len(), 4, "Deduped messages: expected 4, got {}", deduped.len()); // The kept assistant message must be the final version let first_assistant = deduped.iter().find(|m| matches!(m, RawMessage::Assistant(_))).unwrap(); if let RawMessage::Assistant(a) = first_assistant { assert!(a.message.stop_reason.is_some(), "Deduped assistant should have stop_reason set"); assert_eq!(a.message.stop_reason.as_deref(), Some("tool_use")); assert_eq!(a.message.content.len(), 2, "Final version should have 2 content blocks"); } else { unreachable!(); } } #[test] fn dedup_preserves_non_streamed_messages() { let path = Utf8Path::new("tests/fixtures/minimal_session.jsonl"); let messages = parse_session(path).unwrap(); let count_before = messages.len(); let deduped = dedup_messages(messages); // No streaming in minimal_session, so count should be same assert_eq!(deduped.len(), count_before); } #[test] fn correlate_parallel_tools() { let path = Utf8Path::new("tests/fixtures/tool_correlation.jsonl"); let messages = dirigent_anth::parse_session_deduped(path).unwrap(); let exchanges = correlate_tools(&messages); // 3 tool calls: 2 parallel (Bash+Read) + 1 sequential (Write) assert_eq!(exchanges.len(), 3); // All should have results assert!(exchanges.iter().all(|e| e.result.is_some())); // Verify correct pairing by ID for ex in &exchanges { assert_eq!(ex.call.id, ex.result.as_ref().unwrap().tool_use_id); } } #[test] fn correlate_no_tools_returns_empty() { // Test with just a plain user message — no tool calls or results let messages = vec![ serde_json::from_str::( r#"{"type":"user","uuid":"x","timestamp":"2026-01-01T00:00:00Z","sessionId":"s","message":{"role":"user","content":"hello"}}"#, ) .unwrap(), ]; let exchanges = correlate_tools(&messages); assert!(exchanges.is_empty()); } #[test] fn build_branching_tree() { let path = Utf8Path::new("tests/fixtures/branching_tree.jsonl"); let messages = dirigent_anth::parse_session(path).unwrap(); let tree = ConversationTree::build(&messages); assert_eq!(tree.roots.len(), 1); assert!(!tree.is_linear()); assert_eq!(tree.branch_points().len(), 1); // A1 has 2 children let main = tree.main_thread(); assert_eq!(main.len(), 4); // R → A1 → U2 → A3 (first branch) } #[test] fn linear_conversation_is_linear() { let path = Utf8Path::new("tests/fixtures/minimal_session.jsonl"); let messages = dirigent_anth::parse_session(path).unwrap(); let tree = ConversationTree::build(&messages); assert!(tree.is_linear()); } #[test] fn classify_noise_from_fixture() { let path = Utf8Path::new("tests/fixtures/noise_patterns.jsonl"); let messages = dirigent_anth::parse_session(path).unwrap(); assert_eq!(messages.len(), 9, "Expected 9 messages in noise fixture"); let classifications: Vec> = messages.iter() .map(classify_noise) .collect(); assert_eq!(classifications[0], Some(NoiseKind::QueueOp)); assert_eq!(classifications[1], Some(NoiseKind::Meta)); assert_eq!(classifications[2], Some(NoiseKind::Warmup)); assert_eq!(classifications[3], Some(NoiseKind::Interrupted)); assert_eq!(classifications[4], Some(NoiseKind::Continuation)); assert_eq!(classifications[5], Some(NoiseKind::ApiError)); assert_eq!(classifications[6], Some(NoiseKind::SystemCaveat)); assert_eq!(classifications[7], None); // normal user assert_eq!(classifications[8], None); // normal assistant } #[test] fn load_subagent_from_fixture() { let artifacts_dir = Utf8Path::new("tests/fixtures/subagent/parent"); let subagents = dirigent_anth::load_subagents(artifacts_dir).unwrap(); assert_eq!(subagents.len(), 1); assert_eq!(subagents[0].agent_id, "abc123"); assert_eq!(subagents[0].meta.agent_type.as_deref(), Some("Explore")); assert_eq!(subagents[0].messages.len(), 2); } #[test] fn load_subagents_empty_dir() { // Non-existent artifacts dir should return empty vec let artifacts_dir = Utf8Path::new("tests/fixtures/nonexistent"); let subagents = dirigent_anth::load_subagents(artifacts_dir).unwrap(); assert!(subagents.is_empty()); } #[test] fn load_full_session_with_subagents() { use dirigent_anth::types::SessionRef; let session_ref = SessionRef { id: "parent".to_string(), jsonl_path: Utf8PathBuf::from("tests/fixtures/subagent/parent.jsonl"), artifacts_dir: Some(Utf8PathBuf::from("tests/fixtures/subagent/parent")), index_entry: None, }; let session = dirigent_anth::load_session(&session_ref).unwrap(); assert!(!session.messages.is_empty()); assert!(!session.subagents.is_empty()); assert!(!session.tree.roots.is_empty()); assert!(!session.tool_exchanges.is_empty()); } #[test] fn load_session_without_artifacts() { use dirigent_anth::types::SessionRef; let session_ref = SessionRef { id: "minimal".to_string(), jsonl_path: Utf8PathBuf::from("tests/fixtures/minimal_session.jsonl"), artifacts_dir: None, index_entry: None, }; let session = dirigent_anth::load_session(&session_ref).unwrap(); assert_eq!(session.messages.len(), 6); // 2 queue-ops + 2 users + 2 assistants assert!(session.subagents.is_empty()); assert!(session.tree.is_linear()); } #[test] fn content_as_string_or_blocks() { // String content let s: dirigent_anth::types::Content = serde_json::from_str(r#""hello""#).unwrap(); assert!(matches!(s, dirigent_anth::types::Content::Text(_))); // Block content let b: dirigent_anth::types::Content = serde_json::from_str(r#"[{"type":"text","text":"hi"}]"#).unwrap(); assert!(matches!(b, dirigent_anth::types::Content::Blocks(_))); // Empty blocks let empty: dirigent_anth::types::Content = serde_json::from_str(r#"[]"#).unwrap(); assert!(matches!(empty, dirigent_anth::types::Content::Blocks(ref v) if v.is_empty())); } #[test] fn missing_optional_fields_dont_crash() { // Minimal assistant message with many fields missing let json = r#"{ "type": "assistant", "message": { "content": [{"type": "text", "text": "hi"}] } }"#; let msg: RawMessage = serde_json::from_str(json).unwrap(); assert!(matches!(msg, RawMessage::Assistant(_))); } #[test] fn tool_result_content_string_and_blocks() { // tool_result with string content let json = r#"{"type":"tool_result","tool_use_id":"t1","content":"output text","is_error":false}"#; let block: ContentBlock = serde_json::from_str(json).unwrap(); if let ContentBlock::ToolResult { content, is_error, .. } = block { assert!(!is_error); assert!(content.is_some()); } else { panic!("Expected ToolResult"); } // tool_result with no content let json2 = r#"{"type":"tool_result","tool_use_id":"t2"}"#; let block2: ContentBlock = serde_json::from_str(json2).unwrap(); if let ContentBlock::ToolResult { content, is_error, .. } = block2 { assert!(!is_error); assert!(content.is_none()); } else { panic!("Expected ToolResult"); } } #[test] fn extra_unknown_fields_are_ignored() { // Messages with extra fields not in our structs should parse fine let json = r#"{ "type": "user", "uuid": "x", "timestamp": "2026-01-01T00:00:00Z", "sessionId": "s", "unknownField": "should be ignored", "anotherExtra": 42, "message": {"role": "user", "content": "hello"} }"#; let msg: RawMessage = serde_json::from_str(json).unwrap(); assert!(matches!(msg, RawMessage::User(_))); } #[test] fn timestamp_parsing_all_formats() { // ISO 8601 let iso = parse_timestamp(&serde_json::json!("2026-03-22T17:00:13.192Z")).unwrap(); assert_eq!(iso.year(), 2026); // Unix millis let ms = parse_timestamp(&serde_json::json!(1769461914249_i64)).unwrap(); assert!(ms.year() >= 2025); // Unix seconds let secs = parse_timestamp(&serde_json::json!(1769461914_i64)).unwrap(); assert!(secs.year() >= 2025); }