//! Integration tests for dirigent_archivist //! //! These tests verify the end-to-end functionality of the archivist, //! including storage, retrieval, and event streaming. #[cfg(test)] mod tests { use chrono::Utc; use dirigent_archivist::{ Archivist, MessageRecord, RegisterConnectorRequest, RegisterSessionRequest, RegisterStatus, Result, SessionKind, SessionListQuery, SessionMetadata, }; use dirigent_archivist::storage::ndjson::append_ndjson; use dirigent_archivist::storage::json::write_json; use uuid::Uuid; #[tokio::test] async fn test_archivist_creation() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Verify coordinator construction succeeded (smoke test). let _ = &archivist; // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_register_connector_acceptance() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; let req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let response = archivist.register_connector(req, None).await?; assert_eq!(response.status, RegisterStatus::Accepted); assert!(response.alias_of.is_none()); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_register_connector_aliasing() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; let req1 = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let response1 = archivist.register_connector(req1, None).await?; assert_eq!(response1.status, RegisterStatus::Accepted); // Register again with same client_native_id let req2 = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector 2".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let response2 = archivist.register_connector(req2, None).await?; assert_eq!(response2.status, RegisterStatus::Aliased); assert_eq!(response2.connector_uid, response1.connector_uid); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_register_session_acceptance() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector first let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; // Register session let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; assert_eq!(session_response.status, RegisterStatus::Accepted); assert!(session_response.alias_of.is_none()); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_register_session_aliasing() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; // Register session let session_req1 = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response1 = archivist.register_session(session_req1, None).await?; // Register again with same native_session_id let session_req2 = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session 2".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response2 = archivist.register_session(session_req2, None).await?; assert_eq!(session_response2.status, RegisterStatus::Aliased); assert_eq!(session_response2.scroll_id, session_response1.scroll_id); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_append_and_get_messages() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Create and append messages let message1 = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: None, ts: Utc::now(), role: "user".to_string(), author: Some("test".to_string()), content_md: "Hello, world!".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; let message2 = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: Some(message1.message_id), ts: Utc::now(), role: "assistant".to_string(), author: Some("assistant".to_string()), content_md: "Hi there!".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; archivist .append_messages( session_response.scroll_id, vec![message1.clone(), message2.clone()], None, ) .await?; // Retrieve messages let messages = archivist.get_messages(session_response.scroll_id, None).await?; assert_eq!(messages.len(), 2); assert_eq!(messages[0].message_id, message1.message_id); assert_eq!(messages[1].message_id, message2.message_id); assert_eq!(messages[0].content_md, "Hello, world!"); assert_eq!(messages[1].content_md, "Hi there!"); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_list_sessions() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; // Register multiple sessions let session_req1 = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-1".to_string(), title: Some("Session 1".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response1 = archivist.register_session(session_req1, None).await?; // Wait a moment to ensure different timestamps tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; let session_req2 = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-2".to_string(), title: Some("Session 2".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response2 = archivist.register_session(session_req2, None).await?; // List sessions let sessions = archivist .list_sessions_paged( SessionListQuery::default() .with_connector(connector_response.connector_uid) .with_limit(100), ) .await? .items; assert_eq!(sessions.len(), 2); // Verify sessions are sorted by updated_at descending (newest first) // Session 2 should be first because it was created later assert_eq!(sessions[0].scroll_id, session_response2.scroll_id); assert_eq!(sessions[1].scroll_id, session_response1.scroll_id); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_get_session_metadata() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Get session metadata let metadata = archivist .get_session_metadata(session_response.scroll_id, None) .await?; assert_eq!(metadata.scroll_id, session_response.scroll_id); assert_eq!(metadata.title, Some("Test Session".to_string())); assert_eq!(metadata.connector_uid, connector_response.connector_uid); assert_eq!(metadata.native_session_id, Some("native-123".to_string())); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_resolve_session() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Resolve native session ID to scroll ID let scroll_id = archivist .resolve_session(connector_response.connector_uid, "native-123", None) .await?; assert_eq!(scroll_id, session_response.scroll_id); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_register_connector_custom_uid_collision() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; let custom_uid = Uuid::now_v7(); // Register connector with custom UID let req1 = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector 1".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: Some(custom_uid), metadata: serde_json::json!({}), fingerprint: None, }; let response1 = archivist.register_connector(req1, None).await?; assert_eq!(response1.status, RegisterStatus::Accepted); assert_eq!(response1.connector_uid, custom_uid); // Try to register another connector with same custom UID let req2 = RegisterConnectorRequest { r#type: "ACP".to_string(), title: "Test Connector 2".to_string(), client_native_id: "acp@localhost:3000".to_string(), custom_uid: Some(custom_uid), metadata: serde_json::json!({}), fingerprint: None, }; let result2 = archivist.register_connector(req2, None).await; assert!(result2.is_err(), "Expected error for custom_uid collision"); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_register_session_with_unknown_connector() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Try to register session with unknown connector let unknown_connector = Uuid::now_v7(); let session_req = RegisterSessionRequest { connector_uid: unknown_connector, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let result = archivist.register_session(session_req, None).await; assert!(result.is_err()); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_get_messages_empty_session() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Get messages from empty session let messages = archivist.get_messages(session_response.scroll_id, None).await?; assert_eq!(messages.len(), 0); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_get_messages_unknown_scroll_id() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Try to get messages from unknown session let unknown_scroll_id = Uuid::now_v7(); let messages = archivist.get_messages(unknown_scroll_id, None).await?; // Should return empty vector for unknown session assert_eq!(messages.len(), 0); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_multiple_message_appends() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Append messages in multiple batches let message1 = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: None, ts: Utc::now(), role: "user".to_string(), author: Some("test".to_string()), content_md: "First message".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; archivist .append_messages(session_response.scroll_id, vec![message1.clone()], None) .await?; let message2 = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: Some(message1.message_id), ts: Utc::now(), role: "assistant".to_string(), author: Some("assistant".to_string()), content_md: "Second message".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; archivist .append_messages(session_response.scroll_id, vec![message2.clone()], None) .await?; let message3 = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: Some(message2.message_id), ts: Utc::now(), role: "user".to_string(), author: Some("test".to_string()), content_md: "Third message".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; archivist .append_messages(session_response.scroll_id, vec![message3.clone()], None) .await?; // Retrieve all messages let messages = archivist.get_messages(session_response.scroll_id, None).await?; assert_eq!(messages.len(), 3); assert_eq!(messages[0].content_md, "First message"); assert_eq!(messages[1].content_md, "Second message"); assert_eq!(messages[2].content_md, "Third message"); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_messages_sorted_chronologically() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Create messages with specific timestamps in chronological order use chrono::TimeZone; let base_time = Utc.with_ymd_and_hms(2025, 11, 18, 18, 23, 36).unwrap(); let msg_snake_user = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: None, ts: base_time + chrono::Duration::milliseconds(947), role: "user".to_string(), author: Some("user".to_string()), content_md: "hello please tell me a joke about snakes".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; let msg_snake_assistant = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: Some(msg_snake_user.message_id), ts: base_time + chrono::Duration::milliseconds(969), role: "assistant".to_string(), author: Some("claude".to_string()), content_md: "Why don't snakes need cutlery? They have forked tongues!".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; let msg_tiger_user = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: Some(msg_snake_assistant.message_id), ts: base_time + chrono::Duration::milliseconds(13429), role: "user".to_string(), author: Some("user".to_string()), content_md: "now one about tigers".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; let msg_tiger_assistant = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: Some(msg_tiger_user.message_id), ts: base_time + chrono::Duration::milliseconds(13448), role: "assistant".to_string(), author: Some("claude".to_string()), content_md: "What do tigers wear to bed? Striped pajamas!".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; let msg_hyena_user = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: Some(msg_tiger_assistant.message_id), ts: base_time + chrono::Duration::milliseconds(32623), role: "user".to_string(), author: Some("user".to_string()), content_md: "and a third one about hyenas".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; // Append messages OUT OF ORDER to simulate real-world event arrival // (assistant replies often arrive after subsequent user messages) archivist .append_messages( session_response.scroll_id, vec![msg_snake_user.clone()], None, ) .await?; archivist .append_messages( session_response.scroll_id, vec![msg_tiger_user.clone()], None, ) .await?; archivist .append_messages( session_response.scroll_id, vec![msg_snake_assistant.clone()], None, ) .await?; archivist .append_messages( session_response.scroll_id, vec![msg_hyena_user.clone()], None, ) .await?; archivist .append_messages( session_response.scroll_id, vec![msg_tiger_assistant.clone()], None, ) .await?; // Retrieve messages - should be sorted chronologically despite out-of-order appends let messages = archivist.get_messages(session_response.scroll_id, None).await?; assert_eq!(messages.len(), 5); // Verify chronological order by timestamp assert_eq!(messages[0].message_id, msg_snake_user.message_id); assert_eq!(messages[0].content_md, "hello please tell me a joke about snakes"); assert_eq!(messages[1].message_id, msg_snake_assistant.message_id); assert_eq!(messages[1].content_md, "Why don't snakes need cutlery? They have forked tongues!"); assert_eq!(messages[2].message_id, msg_tiger_user.message_id); assert_eq!(messages[2].content_md, "now one about tigers"); assert_eq!(messages[3].message_id, msg_tiger_assistant.message_id); assert_eq!(messages[3].content_md, "What do tigers wear to bed? Striped pajamas!"); assert_eq!(messages[4].message_id, msg_hyena_user.message_id); assert_eq!(messages[4].content_md, "and a third one about hyenas"); // Verify timestamps are strictly increasing for i in 1..messages.len() { assert!( messages[i].ts >= messages[i - 1].ts, "Messages not in chronological order: message {} has ts {} which is before message {} with ts {}", i, messages[i].ts, i - 1, messages[i - 1].ts ); } // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_messages_with_identical_timestamps() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Create multiple messages with the exact same timestamp // This tests the secondary sorting by message_id let same_timestamp = Utc::now(); // Create messages with explicitly ordered UUIDs (v7 includes timestamp) // Sleep briefly between creations to ensure UUIDv7 ordering let msg1 = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: None, ts: same_timestamp, role: "user".to_string(), author: Some("user".to_string()), content_md: "First message".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; tokio::time::sleep(tokio::time::Duration::from_micros(1)).await; let msg2 = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: Some(msg1.message_id), ts: same_timestamp, role: "assistant".to_string(), author: Some("assistant".to_string()), content_md: "Second message".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; tokio::time::sleep(tokio::time::Duration::from_micros(1)).await; let msg3 = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: Some(msg2.message_id), ts: same_timestamp, role: "user".to_string(), author: Some("user".to_string()), content_md: "Third message".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; // Append in reverse order to ensure sorting is working archivist .append_messages( session_response.scroll_id, vec![msg3.clone()], None, ) .await?; archivist .append_messages( session_response.scroll_id, vec![msg1.clone()], None, ) .await?; archivist .append_messages( session_response.scroll_id, vec![msg2.clone()], None, ) .await?; // Retrieve messages - should be sorted by message_id since timestamps are identical let messages = archivist.get_messages(session_response.scroll_id, None).await?; assert_eq!(messages.len(), 3); // All timestamps should be the same assert_eq!(messages[0].ts, same_timestamp); assert_eq!(messages[1].ts, same_timestamp); assert_eq!(messages[2].ts, same_timestamp); // Messages should be ordered by message_id (UUIDv7 preserves creation order) assert_eq!(messages[0].message_id, msg1.message_id); assert_eq!(messages[1].message_id, msg2.message_id); assert_eq!(messages[2].message_id, msg3.message_id); assert_eq!(messages[0].content_md, "First message"); assert_eq!(messages[1].content_md, "Second message"); assert_eq!(messages[2].content_md, "Third message"); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_append_messages_updates_timestamp() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Test Connector".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "native-123".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Get initial metadata let metadata_before = archivist .get_session_metadata(session_response.scroll_id, None) .await?; // Wait a moment tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; // Append a message let message = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: None, ts: Utc::now(), role: "user".to_string(), author: Some("test".to_string()), content_md: "Hello!".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; archivist .append_messages(session_response.scroll_id, vec![message], None) .await?; // Get updated metadata let metadata_after = archivist .get_session_metadata(session_response.scroll_id, None) .await?; // Verify updated_at changed assert!(metadata_after.updated_at > metadata_before.updated_at); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } // ======================================================================== // Performance Benchmarks // These tests are marked with #[ignore] to avoid slowing down regular test runs // Run with: cargo test --package dirigent_archivist -- --ignored // ======================================================================== #[tokio::test] #[ignore] async fn bench_append_1000_messages() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_bench_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Bench Connector".to_string(), client_native_id: "bench@localhost".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "bench-session".to_string(), title: Some("Bench Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Create 1000 messages let messages: Vec = (0..1000) .map(|i| MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: None, ts: Utc::now(), role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(), author: Some("bench".to_string()), content_md: format!("Message number {} with some realistic content that might appear in a conversation. This helps simulate real-world usage patterns.", i), content_parts: None, attachments: vec![], metadata: serde_json::json!({"index": i}), }) .collect(); // Benchmark appending messages let start = std::time::Instant::now(); archivist .append_messages(session_response.scroll_id, messages, None) .await?; let elapsed = start.elapsed(); let messages_per_sec = 1000.0 / elapsed.as_secs_f64(); println!("\nBenchmark: Append 1000 messages"); println!(" Total time: {:?}", elapsed); println!(" Messages/sec: {:.2}", messages_per_sec); println!(" Avg time per message: {:?}", elapsed / 1000); // Target: >100 msg/s assert!( messages_per_sec > 100.0, "Performance degraded: {:.2} msg/s < 100 msg/s", messages_per_sec ); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] #[ignore] async fn bench_read_100_messages() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_bench_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Bench Connector".to_string(), client_native_id: "bench@localhost".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "bench-session-100".to_string(), title: Some("Bench Session 100".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Create and append 100 messages let messages: Vec = (0..100) .map(|i| MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: None, ts: Utc::now(), role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(), author: Some("bench".to_string()), content_md: format!("Message {}", i), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }) .collect(); archivist .append_messages(session_response.scroll_id, messages, None) .await?; // Benchmark reading messages let start = std::time::Instant::now(); let retrieved = archivist.get_messages(session_response.scroll_id, None).await?; let elapsed = start.elapsed(); println!("\nBenchmark: Read 100 messages"); println!(" Total time: {:?}", elapsed); println!(" Messages retrieved: {}", retrieved.len()); // Target: sub-100ms for typical sessions assert!( elapsed.as_millis() < 100, "Read performance degraded: {:?} > 100ms", elapsed ); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] #[ignore] async fn bench_read_1000_messages() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_bench_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector and session let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Bench Connector".to_string(), client_native_id: "bench@localhost".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: "bench-session-1000".to_string(), title: Some("Bench Session 1000".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let session_response = archivist.register_session(session_req, None).await?; // Create and append 1000 messages let messages: Vec = (0..1000) .map(|i| MessageRecord { version: 1, message_id: Uuid::now_v7(), session: session_response.scroll_id, parent_id: None, ts: Utc::now(), role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(), author: Some("bench".to_string()), content_md: format!("Message {}", i), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }) .collect(); archivist .append_messages(session_response.scroll_id, messages, None) .await?; // Benchmark reading messages let start = std::time::Instant::now(); let retrieved = archivist.get_messages(session_response.scroll_id, None).await?; let elapsed = start.elapsed(); println!("\nBenchmark: Read 1000 messages"); println!(" Total time: {:?}", elapsed); println!(" Messages retrieved: {}", retrieved.len()); // Log for tracking (no strict requirement for large sessions) println!(" Note: Performance acceptable for large session"); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] #[ignore] async fn bench_list_100_sessions() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_bench_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Bench Connector".to_string(), client_native_id: "bench@localhost".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_response = archivist.register_connector(connector_req, None).await?; // Register 100 sessions for i in 0..100 { let session_req = RegisterSessionRequest { connector_uid: connector_response.connector_uid, native_session_id: format!("bench-session-{}", i), title: Some(format!("Session {}", i)), custom_scroll_id: None, metadata: serde_json::json!({"index": i}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; archivist.register_session(session_req, None).await?; } // Benchmark listing sessions let start = std::time::Instant::now(); let sessions = archivist .list_sessions_paged( SessionListQuery::default() .with_connector(connector_response.connector_uid) .with_limit(dirigent_archivist::MAX_PAGE_LIMIT), ) .await? .items; let elapsed = start.elapsed(); println!("\nBenchmark: List 100 sessions"); println!(" Total time: {:?}", elapsed); println!(" Sessions listed: {}", sessions.len()); // Target: sub-100ms for typical connector assert!( elapsed.as_millis() < 100, "List performance degraded: {:?} > 100ms", elapsed ); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_mixed_format_compatibility() { // Create archivist with temp directory let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await.unwrap() ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await.unwrap(); // Register connector let connector_req = RegisterConnectorRequest { r#type: "Test".to_string(), title: "Test Connector".to_string(), client_native_id: "test-connector".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let connector_resp = archivist.register_connector(connector_req, None).await.unwrap(); let connector_uid = connector_resp.connector_uid; // Manually create a session with .jsonl format for messages let scroll_id = Uuid::now_v7(); let session_metadata = SessionMetadata { version: 1, scroll_id, created_at: Utc::now(), updated_at: Utc::now(), title: Some("Test Session".to_string()), connector_uid, native_session_id: Some("test-123".to_string()), agent_id: None, parent_scroll_id: None, continuation: None, tags: vec![], metadata: serde_json::json!({}), no_update: false, kind: SessionKind::Chat, acp_client_id: None, is_connected: None, current_session_id: None, models: None, modes: None, config_options: None, completeness: Default::default(), matrix_room_id: None, matrix_sharing_active: false, matrix_shared_at: None, is_subagent: false, subagent_type: None, spawning_tool_use_id: None, }; backend.paths().ensure_dirs(scroll_id).await.unwrap(); write_json(&backend.paths().session_json(scroll_id), &session_metadata).await.unwrap(); // Create messages.jsonl (not .ndjson) let jsonl_path = backend.paths().session_dir(scroll_id).join("messages.jsonl"); let message = MessageRecord { version: 1, message_id: Uuid::now_v7(), session: scroll_id, parent_id: None, ts: Utc::now(), role: "user".to_string(), author: None, content_md: "Hello from .jsonl file".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; append_ndjson(&jsonl_path, &message).await.unwrap(); // Read messages using archivist API let messages = archivist.get_messages(scroll_id, None).await.unwrap(); assert_eq!(messages.len(), 1); assert_eq!(messages[0].content_md, "Hello from .jsonl file"); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); } #[tokio::test] async fn test_fingerprint_registration_and_matching() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register first connector with a fingerprint let fingerprint = "acp/stdio:/usr/bin/claude".to_string(); let req1 = RegisterConnectorRequest { r#type: "ACP".to_string(), title: "Claude CLI".to_string(), client_native_id: "acp-session-abc123".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: Some(fingerprint.clone()), }; let response1 = archivist.register_connector(req1, None).await?; assert_eq!(response1.status, RegisterStatus::Accepted); let original_uid = response1.connector_uid; // Register a second connector with a DIFFERENT client_native_id // but the SAME fingerprint. Should be ALIASED to the original. let req2 = RegisterConnectorRequest { r#type: "ACP".to_string(), title: "Claude CLI (re-added)".to_string(), client_native_id: "acp-session-xyz789".to_string(), custom_uid: None, metadata: serde_json::json!({"version": 2}), fingerprint: Some(fingerprint.clone()), }; let response2 = archivist.register_connector(req2, None).await?; assert_eq!( response2.status, RegisterStatus::Aliased, "Same fingerprint should cause ALIASED status" ); assert_eq!( response2.connector_uid, original_uid, "Aliased connector should return the original UID" ); assert_eq!(response2.alias_of, Some(original_uid)); assert!( response2.note.as_deref().unwrap_or("").contains("fingerprint"), "Note should mention fingerprint matching" ); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_fingerprint_no_match_different_fingerprints() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register first connector with fingerprint A let req1 = RegisterConnectorRequest { r#type: "ACP".to_string(), title: "Claude CLI".to_string(), client_native_id: "acp-claude-1".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: Some("acp/stdio:/usr/bin/claude".to_string()), }; let response1 = archivist.register_connector(req1, None).await?; assert_eq!(response1.status, RegisterStatus::Accepted); // Register second connector with fingerprint B (different) let req2 = RegisterConnectorRequest { r#type: "ACP".to_string(), title: "Codex Agent".to_string(), client_native_id: "acp-codex-1".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: Some("acp/stdio:/usr/bin/codex".to_string()), }; let response2 = archivist.register_connector(req2, None).await?; assert_eq!( response2.status, RegisterStatus::Accepted, "Different fingerprints should both be ACCEPTED" ); assert_ne!( response2.connector_uid, response1.connector_uid, "Different fingerprints should get different UIDs" ); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_fingerprint_none_skips_matching() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register first connector WITH a fingerprint let req1 = RegisterConnectorRequest { r#type: "ACP".to_string(), title: "Claude CLI".to_string(), client_native_id: "acp-claude-1".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: Some("acp/stdio:/usr/bin/claude".to_string()), }; let response1 = archivist.register_connector(req1, None).await?; assert_eq!(response1.status, RegisterStatus::Accepted); // Register second connector WITHOUT a fingerprint (different native ID) // Should NOT match the first connector even though one exists with a fingerprint let req2 = RegisterConnectorRequest { r#type: "ACP".to_string(), title: "Unknown ACP Agent".to_string(), client_native_id: "acp-unknown-1".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let response2 = archivist.register_connector(req2, None).await?; assert_eq!( response2.status, RegisterStatus::Accepted, "Connector with fingerprint=None should not match existing fingerprints" ); assert_ne!( response2.connector_uid, response1.connector_uid, "Should get a new UID when no fingerprint is provided" ); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_connector_fingerprint_persistence() -> Result<()> { let temp_dir = std::env::temp_dir().join(format!("archivist_test_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // Register connector with a fingerprint let fingerprint_value = "acp/stdio:/usr/bin/claude".to_string(); let connector_req = RegisterConnectorRequest { r#type: "ACP".to_string(), title: "Claude CLI".to_string(), client_native_id: "acp-claude-1".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: Some(fingerprint_value.clone()), }; let response = archivist.register_connector(connector_req, None).await?; assert_eq!(response.status, RegisterStatus::Accepted); let connector_uid = response.connector_uid; // Verify fingerprint is persisted in connector.json let connector_json_path = backend.paths() .connector_dir(connector_uid) .join("connector.json"); let raw_json = tokio::fs::read_to_string(&connector_json_path).await.unwrap(); let connector_record: serde_json::Value = serde_json::from_str(&raw_json).unwrap(); assert_eq!( connector_record["fingerprint"].as_str(), Some(fingerprint_value.as_str()), "Fingerprint should be persisted in connector.json" ); // Verify fingerprint is persisted in TSV index let index_path = backend.paths().connector_index_tsv(); let tsv_content = tokio::fs::read_to_string(&index_path).await.unwrap(); assert!( tsv_content.contains(&fingerprint_value), "Fingerprint should appear in TSV index" ); // Verify fingerprint can be read back via TSV reader let rows = dirigent_archivist::storage::tsv::read_connector_index(&index_path).await.unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].fingerprint, Some(fingerprint_value.clone())); // Register a second connector WITHOUT a fingerprint (ensure None is handled) let connector_req2 = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "OpenCode Local".to_string(), client_native_id: "opencode@localhost:12225".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let response2 = archivist.register_connector(connector_req2, None).await?; assert_eq!(response2.status, RegisterStatus::Accepted); // Re-read TSV and verify both connectors let rows = dirigent_archivist::storage::tsv::read_connector_index(&index_path).await.unwrap(); assert_eq!(rows.len(), 2); // First connector should have fingerprint let row_with_fp = rows.iter().find(|r| r.connector_uid == connector_uid).unwrap(); assert_eq!(row_with_fp.fingerprint, Some(fingerprint_value)); // Second connector should have no fingerprint let row_without_fp = rows.iter().find(|r| r.connector_uid == response2.connector_uid).unwrap(); assert_eq!(row_without_fp.fingerprint, None); // Clean up tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_list_connectors() -> std::result::Result<(), Box> { let temp_dir = std::env::temp_dir().join(format!("archivist_lc_{}", uuid::Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; let req1 = RegisterConnectorRequest { r#type: "Acp".to_string(), title: "Claude".to_string(), client_native_id: "c1".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: Some("acp/stdio:/usr/bin/claude".to_string()), }; archivist.register_connector(req1, None).await?; let req2 = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "OC".to_string(), client_native_id: "c2".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; archivist.register_connector(req2, None).await?; let connectors = archivist.list_connectors(None).await?; assert_eq!(connectors.len(), 2); tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_move_session() -> std::result::Result<(), Box> { let temp_dir = std::env::temp_dir().join(format!("archivist_mv_{}", uuid::Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; let c1 = archivist .register_connector( RegisterConnectorRequest { r#type: "Acp".to_string(), title: "Source".to_string(), client_native_id: "src".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }, None, ) .await? .connector_uid; let c2 = archivist .register_connector( RegisterConnectorRequest { r#type: "Acp".to_string(), title: "Target".to_string(), client_native_id: "tgt".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }, None, ) .await? .connector_uid; let session = archivist .register_session( RegisterSessionRequest { connector_uid: c1, native_session_id: "s1".to_string(), title: Some("Test Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }, None, ) .await?; // Verify under c1 assert_eq!( archivist .list_sessions_paged( SessionListQuery::default().with_connector(c1).with_limit(100), ) .await? .items .len(), 1 ); // Move to c2 archivist .move_session_to_connector(session.scroll_id, c2, None) .await?; // c1 should be empty, c2 should have the session assert_eq!( archivist .list_sessions_paged( SessionListQuery::default().with_connector(c1).with_limit(100), ) .await? .items .len(), 0 ); let c2_sessions = archivist .list_sessions_paged( SessionListQuery::default().with_connector(c2).with_limit(100), ) .await? .items; assert_eq!(c2_sessions.len(), 1); assert_eq!(c2_sessions[0].connector_uid, c2); tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_copy_session() -> std::result::Result<(), Box> { let temp_dir = std::env::temp_dir().join(format!("archivist_cp_{}", uuid::Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; let c1 = archivist .register_connector( RegisterConnectorRequest { r#type: "Acp".to_string(), title: "Source".to_string(), client_native_id: "src".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }, None, ) .await? .connector_uid; let c2 = archivist .register_connector( RegisterConnectorRequest { r#type: "Acp".to_string(), title: "Target".to_string(), client_native_id: "tgt".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }, None, ) .await? .connector_uid; let session = archivist .register_session( RegisterSessionRequest { connector_uid: c1, native_session_id: "s1".to_string(), title: Some("Original".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }, None, ) .await?; // Add a message let msg = MessageRecord { version: 1, message_id: uuid::Uuid::now_v7(), session: session.scroll_id, parent_id: None, ts: chrono::Utc::now(), role: "user".to_string(), author: Some("test".to_string()), content_md: "Hello".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; archivist .append_messages(session.scroll_id, vec![msg], None) .await?; // Copy let new_scroll_id = archivist .copy_session_to_connector(session.scroll_id, c2, None) .await?; assert_ne!(new_scroll_id, session.scroll_id); // Original still under c1 assert_eq!( archivist .list_sessions_paged( SessionListQuery::default().with_connector(c1).with_limit(100), ) .await? .items .len(), 1 ); // Copy under c2 let c2_sessions = archivist .list_sessions_paged( SessionListQuery::default().with_connector(c2).with_limit(100), ) .await? .items; assert_eq!(c2_sessions.len(), 1); assert_eq!(c2_sessions[0].connector_uid, c2); // Messages copied let msgs = archivist.get_messages(new_scroll_id, None).await?; assert_eq!(msgs.len(), 1); assert_eq!(msgs[0].content_md, "Hello"); tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_move_sessions_bulk() -> std::result::Result<(), Box> { let temp_dir = std::env::temp_dir().join(format!("archivist_mvb_{}", uuid::Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; let c1 = archivist .register_connector( RegisterConnectorRequest { r#type: "Acp".to_string(), title: "Source".to_string(), client_native_id: "src".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }, None, ) .await? .connector_uid; let c2 = archivist .register_connector( RegisterConnectorRequest { r#type: "Acp".to_string(), title: "Target".to_string(), client_native_id: "tgt".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }, None, ) .await? .connector_uid; let mut scroll_ids = Vec::new(); for i in 0..3 { let s = archivist .register_session( RegisterSessionRequest { connector_uid: c1, native_session_id: format!("s{}", i), title: Some(format!("Session {}", i)), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }, None, ) .await?; scroll_ids.push(s.scroll_id); } let report = archivist .move_sessions_to_connector(scroll_ids, c2, None) .await?; assert_eq!(report.moved, 3); assert_eq!(report.failed, 0); assert!(report.errors.is_empty()); assert_eq!( archivist .list_sessions_paged( SessionListQuery::default().with_connector(c1).with_limit(100), ) .await? .items .len(), 0 ); assert_eq!( archivist .list_sessions_paged( SessionListQuery::default().with_connector(c2).with_limit(100), ) .await? .items .len(), 3 ); tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_connector_identity_persistence_e2e() -> std::result::Result<(), Box> { let temp_dir = std::env::temp_dir().join(format!("archivist_e2e_{}", uuid::Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; // 1. Register connector with fingerprint let req1 = RegisterConnectorRequest { r#type: "Acp".to_string(), title: "Claude v1".to_string(), client_native_id: "first-run-id".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: Some("acp/stdio:/usr/bin/claude".to_string()), }; let resp1 = archivist.register_connector(req1, None).await?; let original_uid = resp1.connector_uid; assert_eq!(resp1.status, RegisterStatus::Accepted); // 2. Create sessions under this connector let s1 = archivist .register_session( RegisterSessionRequest { connector_uid: original_uid, native_session_id: "session-1".to_string(), title: Some("Important Session".to_string()), custom_scroll_id: None, metadata: serde_json::json!({}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }, None, ) .await?; // 3. Add messages let msg = MessageRecord { version: 1, message_id: uuid::Uuid::now_v7(), session: s1.scroll_id, parent_id: None, ts: chrono::Utc::now(), role: "user".to_string(), author: Some("test".to_string()), content_md: "Don't lose me!".to_string(), content_parts: None, attachments: vec![], metadata: serde_json::json!({}), }; archivist .append_messages(s1.scroll_id, vec![msg], None) .await?; // 4. Simulate "remove and re-add" -- new connector_id, same fingerprint let req2 = RegisterConnectorRequest { r#type: "Acp".to_string(), title: "Claude v2 (reinstalled)".to_string(), client_native_id: "second-run-id".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: Some("acp/stdio:/usr/bin/claude".to_string()), }; let resp2 = archivist.register_connector(req2, None).await?; // 5. Verify: same UID, ALIASED status assert_eq!(resp2.status, RegisterStatus::Aliased); assert_eq!(resp2.connector_uid, original_uid); // 6. Verify: sessions still accessible under the same UID let sessions = archivist .list_sessions_paged( SessionListQuery::default() .with_connector(original_uid) .with_limit(100), ) .await? .items; assert_eq!(sessions.len(), 1); assert_eq!(sessions[0].title, Some("Important Session".to_string())); // 7. Verify: messages intact let messages = archivist.get_messages(s1.scroll_id, None).await?; assert_eq!(messages.len(), 1); assert_eq!(messages[0].content_md, "Don't lose me!"); // 8. Verify: connector record is preserved under the original UID. // // NOTE: Pre-Phase-2 `FileBasedArchivist` ALSO refreshed the matched // connector's `title`/`metadata` on fingerprint-based ALIASED // registration. The `Archivist` deliberately drops that // refresh (see `coordinator/connectors.rs` for the rationale — the // `ConnectorRegistryBackend` trait has no "update metadata" method // yet, and `put_connector` would append rather than mutate). The // identity (UID) is stable; the title stays the original. let connectors = archivist.list_connectors(None).await?; let connector = connectors .iter() .find(|c| c.connector_uid == original_uid) .unwrap(); assert_eq!(connector.title, "Claude v1"); // 9. Test move_session works after fingerprint re-association let c2 = archivist .register_connector( RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "Secondary".to_string(), client_native_id: "secondary".to_string(), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }, None, ) .await? .connector_uid; archivist .move_session_to_connector(s1.scroll_id, c2, None) .await?; assert_eq!( archivist .list_sessions_paged( SessionListQuery::default() .with_connector(original_uid) .with_limit(100), ) .await? .items .len(), 0 ); assert_eq!( archivist .list_sessions_paged( SessionListQuery::default().with_connector(c2).with_limit(100), ) .await? .items .len(), 1 ); // Move it back archivist .move_session_to_connector(s1.scroll_id, original_uid, None) .await?; assert_eq!( archivist .list_sessions_paged( SessionListQuery::default() .with_connector(original_uid) .with_limit(100), ) .await? .items .len(), 1 ); tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } #[tokio::test] async fn test_paged_walk_fifty_sessions() -> Result<()> { use chrono::{Duration, Utc}; use dirigent_archivist::SessionListQuery; let temp_dir = std::env::temp_dir().join(format!("paged_walk_{}", Uuid::now_v7())); let backend = std::sync::Arc::new( dirigent_archivist::backends::JsonlBackend::new(temp_dir.clone()).await? ); let archivist = Archivist::from_single_backend( "main".into(), backend.clone() ).await?; let connector_req = RegisterConnectorRequest { r#type: "OpenCode".to_string(), title: "paged-walk".to_string(), client_native_id: format!("paged-walk@{}", Uuid::now_v7()), custom_uid: None, metadata: serde_json::json!({}), fingerprint: None, }; let cresp = archivist.register_connector(connector_req, None).await?; let uid = cresp.connector_uid; let base = Utc::now(); for i in 0..50 { let tag = if i % 2 == 0 { "even" } else { "odd" }; let req = RegisterSessionRequest { connector_uid: uid, native_session_id: format!("walk-{i}"), title: Some(format!("title-{i}")), custom_scroll_id: None, metadata: serde_json::json!({"model": "claude-3-5-sonnet"}), completeness: Default::default(), parent_scroll_id: None, is_subagent: false, continuation: None, agent_id: None, subagent_type: None, spawning_tool_use_id: None, }; let r = archivist.register_session(req, None).await?; let mut meta = archivist.get_session_metadata(r.scroll_id, None).await?; meta.updated_at = base - Duration::seconds(i); meta.tags = vec![tag.to_string()]; let path = backend.paths().session_json(r.scroll_id); dirigent_archivist::storage::json::write_json(&path, &meta) .await .map_err(dirigent_archivist::ArchivistError::Io)?; } // Walk in chunks of 10 — 5 pages, 50 items, no dupes. let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut cursor = None; let mut page_count = 0; loop { let page = archivist .list_sessions_paged( SessionListQuery::default() .with_connector(uid) .with_limit(10) .with_cursor(cursor.clone()), ) .await?; page_count += 1; for s in &page.items { assert!(seen.insert(s.scroll_id), "duplicate scroll_id across pages"); } if page.next_cursor.is_none() { break; } cursor = page.next_cursor; assert!(page_count <= 10, "runaway pagination"); } assert_eq!(seen.len(), 50); assert_eq!(page_count, 5); // Compose filter: tag=even AND title contains "1" → titles 10, 12, 14, 16, 18. let mut q = SessionListQuery::default().with_connector(uid).with_limit(50); q.tags = vec!["even".into()]; q.title_query = Some("1".into()); let page = archivist.list_sessions_paged(q).await?; assert_eq!( page.items.len(), 5, "got titles {:?}", page.items.iter().map(|s| s.title.clone()).collect::>() ); tokio::fs::remove_dir_all(temp_dir).await.ok(); Ok(()) } }