ed8bc3e5fd
Rename the Claude Code session parser crate from dirigent_ant to dirigent_anth. Binary targets renamed: ant → anth_bear, ant_usage → anth_usage. Module claude_usage renamed to anth_usage throughout. Also normalizes CRLF → LF line endings across touched files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
295 lines
10 KiB
Rust
295 lines
10 KiB
Rust
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::<RawMessage>(
|
|
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<Option<NoiseKind>> = 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);
|
|
}
|