Files
dirigent/crates/dirigent_archivist/tests/integration_tests.rs
T
2026-05-08 01:59:04 +02:00

2415 lines
88 KiB
Rust

//! 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<MessageRecord> = (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<MessageRecord> = (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<MessageRecord> = (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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>>
{
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<Uuid> = 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::<Vec<_>>()
);
tokio::fs::remove_dir_all(temp_dir).await.ok();
Ok(())
}
}