sync from monorepo @ 2452e92e

This commit is contained in:
2026-05-08 01:59:04 +02:00
commit b03dc15371
459 changed files with 129586 additions and 0 deletions
@@ -0,0 +1,972 @@
//! TEST-003 through TEST-007: Advanced ACP Tests
//!
//! This file contains advanced integration tests covering:
//! - TEST-003: Session lifecycle tests (create, prompt, cancel, load)
//! - TEST-004: Error condition tests (network failures, invalid JSON, timeouts)
//! - TEST-005: Reconnection tests (during init, active session, max retries)
//! - TEST-006: Edge case tests (empty messages, long messages, rapid-fire, leaks)
//! - TEST-007: Performance tests (latency, throughput, memory, concurrent sessions)
#![cfg(feature = "server")]
use dirigent_core::connectors::acp::{AcpConfig, AcpConnector};
use dirigent_core::connectors::{Connector, ConnectorCommand};
use dirigent_core::types::ConnectorState;
use dirigent_protocol::Event;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use tokio::time::timeout;
// ============================================================================
// Test Helpers
// ============================================================================
fn test_fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("acp_test.yaml")
}
fn mocker_binary() -> String {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let project_root = manifest_dir.parent().unwrap().parent().unwrap();
let debug_path = project_root
.join("target")
.join("debug")
.join("dirigate.exe");
if debug_path.exists() {
debug_path.to_string_lossy().to_string()
} else {
"cargo".to_string()
}
}
fn mocker_args() -> Vec<String> {
let bin = mocker_binary();
let fixture = test_fixture_path();
if bin == "cargo" {
vec![
"run".to_string(),
"--package".to_string(),
"dirigate".to_string(),
"--".to_string(),
"serve".to_string(),
"--stdio".to_string(),
"--fixtures".to_string(),
fixture.to_string_lossy().to_string(),
]
} else {
vec![
"serve".to_string(),
"--stdio".to_string(),
"--fixtures".to_string(),
fixture.to_string_lossy().to_string(),
]
}
}
fn create_test_connector(id: &str) -> AcpConnector {
let config = AcpConfig::stdio(mocker_binary(), mocker_args())
.with_retry_max_attempts(3)
.with_retry_initial_delay(Duration::from_millis(500));
AcpConnector::new(
id.to_string(),
uuid::Uuid::nil(),
"Test ACP Connector".to_string(),
config,
dirigent_core::sharing::bus::SharingBus::new(),
)
.expect("Failed to create connector")
}
async fn wait_for_event<F>(
events_rx: &mut tokio::sync::broadcast::Receiver<Event>,
predicate: F,
timeout_duration: Duration,
) -> anyhow::Result<Event>
where
F: Fn(&Event) -> bool,
{
let result = timeout(timeout_duration, async {
loop {
match events_rx.recv().await {
Ok(event) => {
if predicate(&event) {
return Ok(event);
}
}
Err(e) => return Err(anyhow::anyhow!("Recv error: {}", e)),
}
}
})
.await;
match result {
Ok(Ok(event)) => Ok(event),
Ok(Err(e)) => Err(e),
Err(_) => Err(anyhow::anyhow!("Timeout")),
}
}
// ============================================================================
// TEST-003: Session Lifecycle Tests
// ============================================================================
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t003_01_session_create_flow() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("lifecycle-create");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
// Create session
connector
.command_tx()
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
// Verify session created
let session = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session,
_ => anyhow::bail!("Expected SessionCreated"),
};
assert_eq!(session.title, "Lifecycle Test");
tracing::info!("✓ Session created successfully");
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t003_02_session_prompt_flow() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("lifecycle-prompt");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
// Create session
connector
.command_tx()
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Send prompt
connector
.command_tx()
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Test prompt".to_string(),
})
.await?;
// Verify message flow: Started -> Content -> Completed
wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
tracing::info!("✓ Message started");
// Wait for content or completion
let mut got_response = false;
for _ in 0..10 {
if let Ok(event) = timeout(Duration::from_secs(1), events.recv()).await {
match event? {
Event::SessionUpdate { .. } => {
got_response = true;
break;
}
Event::MessageCompleted { .. } => {
got_response = true;
break;
}
_ => {}
}
}
}
assert!(got_response, "Should receive response");
tracing::info!("✓ Prompt flow completed");
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t003_03_session_cancel_flow() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("lifecycle-cancel");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
// Setup
connector
.command_tx()
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Start message
connector
.command_tx()
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Long message".to_string(),
})
.await?;
wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
// Cancel
connector
.command_tx()
.send(ConnectorCommand::CancelGeneration { session_id })
.await?;
tracing::info!("✓ Cancel sent successfully");
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t003_04_session_load_flow() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("lifecycle-load");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
// Load existing session from fixture
connector
.command_tx()
.send(ConnectorCommand::LoadSession {
session_id: "test-session-1".to_string(),
cwd: ".".to_string(),
mcp_servers: None,
})
.await?;
let session = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session,
_ => anyhow::bail!("Expected SessionCreated"),
};
assert_eq!(session.id, "test-session-1");
tracing::info!("✓ Session loaded from fixture");
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
// ============================================================================
// TEST-004: Error Condition Tests
// ============================================================================
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t004_01_invalid_binary_path() {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let config = AcpConfig::stdio("nonexistent-binary".to_string(), vec![]);
let result = AcpConnector::new(
"error-test".to_string(),
uuid::Uuid::nil(),
"Error Test".to_string(),
config,
dirigent_core::sharing::bus::SharingBus::new(),
);
// Should fail validation or fail to start
// Either is acceptable - the key is it doesn't panic
if let Ok(connector) = result {
let mut events = connector.subscribe();
let _handle = connector.start_task().await;
// Should either fail to connect or error out
let result = timeout(Duration::from_secs(5), events.recv()).await;
match result {
Ok(Ok(Event::Error { .. })) => {
tracing::info!("✓ Error handled gracefully");
}
Ok(Ok(Event::Connected)) => {
panic!("Should not connect to nonexistent binary");
}
_ => {
tracing::info!("✓ Connection failed as expected");
}
}
} else {
tracing::info!("✓ Config validation caught invalid binary");
}
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t004_02_timeout_handling() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
// Create connector with very short timeout
let config = AcpConfig::stdio(mocker_binary(), mocker_args())
.with_request_timeout(Duration::from_millis(100));
let connector = AcpConnector::new(
"timeout-test".to_string(),
uuid::Uuid::nil(),
"Timeout Test".to_string(),
config,
dirigent_core::sharing::bus::SharingBus::new(),
)?;
let mut events = connector.subscribe();
let _handle = connector.start_task().await;
// Try to connect - might timeout during handshake
match timeout(Duration::from_secs(10), events.recv()).await {
Ok(Ok(Event::Connected)) => {
tracing::info!("✓ Connected despite short timeout");
}
Ok(Ok(Event::Error { message })) => {
tracing::info!("✓ Timeout error handled: {}", message);
}
Err(_) => {
tracing::info!("✓ Timeout occurred as expected");
}
_ => {}
}
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t004_03_connection_state_on_error() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let config = AcpConfig::stdio("nonexistent".to_string(), vec![]);
if let Ok(connector) = AcpConnector::new(
"state-test".to_string(),
uuid::Uuid::nil(),
"State Test".to_string(),
config,
dirigent_core::sharing::bus::SharingBus::new(),
) {
let state = connector.state_arc();
let _handle = connector.start_task().await;
// Wait a bit for state to update
tokio::time::sleep(Duration::from_secs(2)).await;
let current_state = state.read().await;
match *current_state {
ConnectorState::Error { .. } | ConnectorState::Stopped => {
tracing::info!("✓ State correctly reflects error: {:?}", *current_state);
}
_ => {
tracing::warn!("State is: {:?}", *current_state);
}
}
}
Ok(())
}
// ============================================================================
// TEST-005: Reconnection Tests
// ============================================================================
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t005_01_reconnect_after_disconnect() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("reconnect-test");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
// Wait for initial connection
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
tracing::info!("✓ Initial connection established");
// Stop and restart
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
// Create new connector with same config
let connector2 = create_test_connector("reconnect-test-2");
let mut events2 = connector2.subscribe();
let handle2 = connector2.start_task().await;
// Should reconnect
wait_for_event(
&mut events2,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
tracing::info!("✓ Reconnected successfully");
connector2.stop();
timeout(Duration::from_secs(5), handle2).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t005_02_max_retries_respected() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let config = AcpConfig::stdio("nonexistent".to_string(), vec![])
.with_retry_max_attempts(2)
.with_retry_initial_delay(Duration::from_millis(100));
if let Ok(connector) = AcpConnector::new(
"retry-test".to_string(),
uuid::Uuid::nil(),
"Retry Test".to_string(),
config,
dirigent_core::sharing::bus::SharingBus::new(),
) {
let _events = connector.subscribe();
let _handle = connector.start_task().await;
// Should eventually give up after max retries
tokio::time::sleep(Duration::from_secs(3)).await;
// Check state is error or stopped
let state = connector.state_arc();
let current = state.read().await;
match *current {
ConnectorState::Error { .. } | ConnectorState::Stopped => {
tracing::info!("✓ Max retries respected, connector stopped");
}
_ => {
tracing::warn!("State: {:?}", *current);
}
}
}
Ok(())
}
// ============================================================================
// TEST-006: Edge Case Tests
// ============================================================================
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t006_01_empty_message() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("empty-msg-test");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
connector
.command_tx()
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Send empty message
connector
.command_tx()
.send(ConnectorCommand::SendMessage {
session_id,
text: "".to_string(),
})
.await?;
// Should handle gracefully (either respond or error)
let result = timeout(Duration::from_secs(3), events.recv()).await;
tracing::info!("✓ Empty message handled: {:?}", result.is_ok());
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t006_02_very_long_message() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("long-msg-test");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
connector
.command_tx()
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Send very long message (10KB)
let long_content = "x".repeat(10_000);
connector
.command_tx()
.send(ConnectorCommand::SendMessage {
session_id,
text: long_content,
})
.await?;
// Should handle without issue
wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
tracing::info!("✓ Long message handled");
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t006_03_rapid_fire_requests() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("rapid-test");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
connector
.command_tx()
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Send 10 messages rapidly
for i in 0..10 {
connector
.command_tx()
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: format!("Message {}", i),
})
.await?;
}
tracing::info!("✓ Rapid-fire messages sent without blocking");
// Just verify we don't crash
tokio::time::sleep(Duration::from_secs(2)).await;
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t006_04_resource_cleanup() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
// Create and destroy multiple connectors
for i in 0..5 {
let connector = create_test_connector(&format!("cleanup-test-{}", i));
let handle = connector.start_task().await;
tokio::time::sleep(Duration::from_millis(500)).await;
connector.stop();
let _ = timeout(Duration::from_secs(2), handle).await;
tracing::info!("✓ Connector {} cleaned up", i);
}
tracing::info!("✓ All resources cleaned up successfully");
Ok(())
}
// ============================================================================
// TEST-007: Performance Tests
// ============================================================================
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t007_01_connection_latency() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("latency-test");
let mut events = connector.subscribe();
let start = Instant::now();
let _handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
let latency = start.elapsed();
tracing::info!("✓ Connection latency: {:?}", latency);
// Should connect within 5 seconds
assert!(latency < Duration::from_secs(5), "Connection took too long");
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t007_02_request_roundtrip() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("roundtrip-test");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
// Measure session creation time
let start = Instant::now();
connector
.command_tx()
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?;
let roundtrip = start.elapsed();
tracing::info!("✓ Session creation roundtrip: {:?}", roundtrip);
assert!(roundtrip < Duration::from_secs(2), "Roundtrip too slow");
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t007_03_event_delivery_latency() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("event-latency-test");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
connector
.command_tx()
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Measure event delivery
let start = Instant::now();
connector
.command_tx()
.send(ConnectorCommand::SendMessage {
session_id,
text: "Test".to_string(),
})
.await?;
wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
let delivery_time = start.elapsed();
tracing::info!("✓ Event delivery latency: {:?}", delivery_time);
assert!(
delivery_time < Duration::from_secs(1),
"Event delivery too slow"
);
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t007_04_concurrent_session_capacity() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
let connector = create_test_connector("capacity-test");
let mut events = connector.subscribe();
let handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
// Create 10 sessions
let start = Instant::now();
let mut session_ids = Vec::new();
for _i in 0..10 {
connector
.command_tx()
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
if let Event::SessionCreated { session, .. } = wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
session_ids.push(session.id);
}
}
let total_time = start.elapsed();
tracing::info!(
"✓ Created {} sessions in {:?}",
session_ids.len(),
total_time
);
assert_eq!(session_ids.len(), 10);
connector.stop();
timeout(Duration::from_secs(5), handle).await??;
Ok(())
}
#[tokio::test]
#[ignore = "Requires mocker binary"]
async fn test_t007_05_memory_stability() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_test_writer().try_init().ok();
// Run 20 create/destroy cycles to check for memory leaks
for i in 0..20 {
let connector = create_test_connector(&format!("mem-test-{}", i));
let mut events = connector.subscribe();
let handle = connector.start_task().await;
if let Ok(_) = timeout(
Duration::from_secs(5),
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
),
)
.await
{
tracing::info!("Cycle {} connected", i);
}
connector.stop();
let _ = timeout(Duration::from_secs(2), handle).await;
}
tracing::info!("✓ 20 cycles completed without crash (manual memory check needed)");
Ok(())
}
@@ -0,0 +1,526 @@
//! Tests for ACP connector crash resilience.
//!
//! These tests verify behavior when child processes crash, die mid-write,
//! or disconnect unexpectedly during active sessions.
//!
//! Bug reference: docs/workpad/bugs/bug_001_analysis.md
#[cfg(feature = "server")]
mod crash_resilience {
use async_trait::async_trait;
use dirigent_core::connectors::acp::transport::{AcpTransport, TransportResult};
use dirigent_core::connectors::acp::protocol::ProtocolHandler;
use serde_json::{json, Value};
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::Mutex;
// =========================================================================
// Mock Transport
// =========================================================================
/// A mock transport that lets tests control exactly what messages are
/// received and when the connection "dies."
struct MockTransport {
/// Messages queued for recv() to return
recv_queue: Arc<Mutex<VecDeque<TransportResult<Option<Value>>>>>,
/// Messages captured by send()
sent: Arc<Mutex<Vec<Value>>>,
closed: bool,
}
impl MockTransport {
fn new() -> Self {
Self {
recv_queue: Arc::new(Mutex::new(VecDeque::new())),
sent: Arc::new(Mutex::new(Vec::new())),
closed: false,
}
}
/// Queue a successful message to be returned by recv()
async fn queue_message(&self, msg: Value) {
self.recv_queue.lock().await.push_back(Ok(Some(msg)));
}
/// Queue a transport error (simulates partial read / JSON parse failure)
async fn queue_error(&self, err: &str) {
self.recv_queue
.lock()
.await
.push_back(Err(err.to_string().into()));
}
/// Queue an EOF (simulates clean transport close / process exit)
async fn queue_eof(&self) {
self.recv_queue.lock().await.push_back(Ok(None));
}
/// Get all messages that were sent through this transport
async fn sent_messages(&self) -> Vec<Value> {
self.sent.lock().await.clone()
}
}
#[async_trait]
impl AcpTransport for MockTransport {
async fn connect(&mut self) -> TransportResult<()> {
Ok(())
}
async fn send(&mut self, message: Value) -> TransportResult<()> {
self.sent.lock().await.push(message);
Ok(())
}
async fn recv(&mut self) -> TransportResult<Option<Value>> {
let mut queue = self.recv_queue.lock().await;
if let Some(item) = queue.pop_front() {
item
} else {
// No more queued items — block forever (simulates waiting for data)
drop(queue);
std::future::pending().await
}
}
async fn close(&mut self) -> TransportResult<()> {
self.closed = true;
Ok(())
}
}
// =========================================================================
// Test 1: Partial read produces transport error, not JSON parse error
// =========================================================================
/// When a child process crashes mid-write, read_line() returns partial data
/// without a trailing newline. The transport currently tries to parse this
/// as JSON and produces a confusing "Failed to parse JSON: trailing characters"
/// error. It SHOULD return Ok(None) — a clean EOF — instead.
///
/// This test uses the real StdioTransport with controlled pipes to simulate
/// a crash mid-write.
#[tokio::test]
async fn test_partial_write_should_return_eof_not_parse_error() {
use tokio::io::{AsyncWriteExt, duplex};
// Create a pipe pair that simulates stdout of a child process
let (mut writer, reader) = duplex(8192);
// Write partial JSON (no trailing newline — crash mid-write)
writer
.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"session/update\",\"params\":{\"sessionId\":\"019cc2e7")
.await
.unwrap();
// Close the writer — simulates process crash (EOF after partial data)
drop(writer);
// Use BufReader + read_line to simulate what StdioTransport.recv() does
use tokio::io::AsyncBufReadExt;
let mut buf_reader = tokio::io::BufReader::new(reader);
let mut line = String::new();
let bytes_read = buf_reader.read_line(&mut line).await.unwrap();
// Verify: we got partial data (bytes > 0) but no trailing newline
assert!(bytes_read > 0, "Should have read some bytes");
assert!(
!line.ends_with('\n'),
"Partial read should NOT end with newline — this is the crash signature"
);
// Current behavior: serde_json::from_str fails on partial JSON
let parse_result = serde_json::from_str::<Value>(line.trim());
assert!(
parse_result.is_err(),
"Partial JSON should fail to parse"
);
// The transport detects partial reads (no trailing newline) as crash
// artifacts. This verifies the crash signature is identifiable from
// the raw stream data.
assert!(
!line.ends_with('\n'),
"Partial read without newline indicates a crash mid-write"
);
}
// =========================================================================
// Test 2: Dropping ProtocolHandler signals pending receivers with error
// =========================================================================
/// When a ProtocolHandler is dropped while requests are pending, the oneshot
/// senders are dropped, causing receivers to get RecvError. This test verifies
/// that this mechanism works and demonstrates the error path.
#[tokio::test]
async fn test_protocol_handler_drop_signals_pending_receivers() {
let handler = ProtocolHandler::new();
// Prepare a request — creates oneshot channel, stores sender in pending_requests
let request = json!({
"jsonrpc": "2.0",
"method": "session/prompt",
"params": {
"sessionId": "test-session",
"message": {"role": "user", "content": {"type": "text", "text": "hello"}}
}
});
let (_message_with_id, response_rx) = handler.prepare_request(request).await;
// Drop the handler — this drops pending_requests, which drops the sender
drop(handler);
// The receiver should immediately get an error
let result = response_rx.await;
assert!(
result.is_err(),
"Receiver should get error when sender is dropped (handler dropped)"
);
// This IS the mechanism that causes "Response channel dropped" in production.
// The error is a bare RecvError with no context about WHY it was dropped.
}
// =========================================================================
// Test 3: Response channel error should report CONNECTION_LOST, not TIMEOUT
// =========================================================================
/// When a response channel receives a cancellation from cancel_all_pending()
/// (because the transport died), the error reported to the UI should say
/// CONNECTION_LOST, not TIMEOUT.
///
/// This test simulates the full flow: prepare request → cancel_all_pending →
/// spawned task gets structured error → emits CONNECTION_LOST.
#[tokio::test]
async fn test_response_channel_drop_error_should_indicate_connection_lost() {
use tokio::sync::broadcast;
let handler = ProtocolHandler::new();
// Prepare request (creates pending oneshot channel)
let request = json!({
"jsonrpc": "2.0",
"method": "session/prompt",
"params": {
"sessionId": "test-session",
"message": {"role": "user", "content": {"type": "text", "text": "hello"}}
}
});
let (_msg, response_rx) = handler.prepare_request(request).await;
// Simulate what the connector does: spawn a task that waits for the response
let (error_tx, mut error_rx) = broadcast::channel::<(String, String)>(10);
let task = tokio::spawn(async move {
match response_rx.await {
Ok(response) => {
// Check if this is an error response from cancel_all_pending
if let Some(error) = response.get("error") {
let error_code = "CONNECTION_LOST".to_string();
let error_message = error.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown")
.to_string();
let _ = error_tx.send((error_code, error_message));
}
}
Err(_recv_error) => {
// Fallback: bare drop without cancel_all_pending
let _ = error_tx.send(("TIMEOUT".to_string(), "channel dropped".to_string()));
}
}
});
// Cancel all pending instead of just dropping
handler.cancel_all_pending("Connection lost: agent process exited").await;
drop(handler);
// Wait for the spawned task to detect the cancellation and report
let (error_code, _error_message) = error_rx.recv().await.unwrap();
assert_eq!(
error_code, "CONNECTION_LOST",
"Error code should be CONNECTION_LOST after cancel_all_pending"
);
task.await.unwrap();
}
// =========================================================================
// Test 4: Protocol handler should support explicit cancellation
// =========================================================================
/// When we know the transport is dying, we can explicitly cancel all pending
/// requests with a structured error response (containing the real reason),
/// rather than relying on implicit Drop which gives receivers a bare
/// RecvError with no context.
#[tokio::test]
async fn test_protocol_handler_should_support_explicit_cancellation() {
let handler = ProtocolHandler::new();
// Prepare two concurrent requests
let req1 = json!({"jsonrpc": "2.0", "method": "session/prompt", "params": {}});
let req2 = json!({"jsonrpc": "2.0", "method": "session/list", "params": {}});
let (_msg1, response_rx1) = handler.prepare_request(req1).await;
let (_msg2, response_rx2) = handler.prepare_request(req2).await;
// cancel_all_pending() now exists — call it
handler.cancel_all_pending("Connection lost: agent process exited").await;
// Both receivers should get structured error responses (not bare RecvError)
let result1 = response_rx1.await;
let result2 = response_rx2.await;
assert!(result1.is_ok(), "Receiver 1 should get Ok (structured error), not Err");
assert!(result2.is_ok(), "Receiver 2 should get Ok (structured error), not Err");
// Verify the error response contains the reason
let resp1 = result1.unwrap();
let error_obj = resp1.get("error").expect("Should have error field");
assert_eq!(error_obj.get("code").unwrap(), -32000);
assert!(
error_obj.get("message").unwrap().as_str().unwrap().contains("Connection lost"),
"Error message should contain the cancellation reason"
);
}
// =========================================================================
// Test 5: Full crash sequence — request in flight, transport dies
// =========================================================================
/// Simulates the exact sequence from the production logs:
/// 1. Protocol handler prepares a request
/// 2. Request is "sent" via mock transport
/// 3. Transport starts returning notifications (streaming chunks)
/// 4. Transport returns error (partial read from crash)
/// 5. Transport returns EOF (process dead)
/// 6. Protocol handler is dropped
/// 7. Pending response receiver gets error
///
/// This is the integration test that proves the full cascade.
#[tokio::test]
async fn test_full_crash_sequence_request_in_flight() {
let handler = ProtocolHandler::new();
let mut transport = MockTransport::new();
// Step 1: Prepare a prompt request
let request = json!({
"jsonrpc": "2.0",
"method": "session/prompt",
"params": {
"sessionId": "019cc2e7-26d6-7102-bed6-4c953c023109",
"message": {"role": "user", "content": {"type": "text", "text": "hello"}}
}
});
let (msg_with_id, response_rx) = handler.prepare_request(request).await;
let request_id = msg_with_id.get("id").cloned().unwrap();
// Step 2: Send via transport
transport.send(msg_with_id).await.unwrap();
// Step 3: Queue some streaming notifications (agent is responding)
transport
.queue_message(json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "019cc2e7-26d6-7102-bed6-4c953c023109",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": "Hello! I'm an AI"}
}
}
}))
.await;
// Step 4: Queue a transport error (partial read from crash)
transport
.queue_error("Failed to parse JSON: trailing characters at line 1 column 4. Line: -26d6-7102-bed6-4c953c023109")
.await;
// Step 5: Queue EOF (process dead)
transport.queue_eof().await;
// Process the notification — it should be routed to notification channel
let msg = transport.recv().await.unwrap().unwrap();
let _result = handler.handle_message(msg).await;
// Process the error — transport returns Err
let err_result = transport.recv().await;
assert!(err_result.is_err(), "Transport should return error for partial read");
let err_msg = err_result.unwrap_err().to_string();
assert!(
err_msg.contains("Failed to parse JSON"),
"Error should mention JSON parse failure, got: {}",
err_msg
);
// Process the EOF
let eof = transport.recv().await.unwrap();
assert!(eof.is_none(), "Transport should return None for EOF");
// Step 6: Cancel all pending (simulates what connector does before break)
handler.cancel_all_pending("Connection lost: agent process exited").await;
drop(handler);
// Step 7: The pending response receiver should get a structured error
let result = response_rx.await;
assert!(
result.is_ok(),
"Pending request should receive structured error response from cancel_all_pending"
);
let response = result.unwrap();
let error = response.get("error").expect("Should have error field");
assert_eq!(error.get("code").unwrap(), -32000);
assert!(
error.get("message").unwrap().as_str().unwrap().contains("Connection lost"),
"Error should contain crash reason"
);
// Verify the request was actually sent
let sent = transport.sent_messages().await;
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].get("id"), Some(&request_id));
}
// =========================================================================
// Test 6: Crash context — stderr, exit status, partial stdout should be
// assembled into a single diagnostic report
// =========================================================================
/// When a child process crashes, three separate data sources contain the
/// explanation: stderr (panic/error message), exit status (signal/code),
/// and partial stdout (what was being written). Currently these are in
/// separate silos and never assembled together.
///
/// This test simulates a real child process crash and verifies that:
/// - stderr output IS captured (currently: drained to warn! log, then lost)
/// - exit status IS available at crash time (currently: only in close())
/// - partial stdout IS identified as crash artifact (currently: JSON parse error)
#[tokio::test]
async fn test_crash_context_should_capture_stderr_and_exit_status() {
// Simulate a child process crash where three data sources exist:
// 1. Writes partial JSON to stdout (simulates crash mid-write)
// 2. Writes an error message to stderr (simulates panic/error output)
// 3. Exits with code 1
//
// We use a simple inline script via the shell.
// Use tokio duplex streams to simulate a crashing child process
// without needing an external program. This is more reliable than
// spawning shell commands across platforms.
use tokio::io::AsyncWriteExt;
// Simulate stdout: partial JSON without trailing newline
let (mut stdout_writer, stdout_read) = tokio::io::duplex(8192);
stdout_writer
.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"crash")
.await
.unwrap();
stdout_writer.flush().await.unwrap();
drop(stdout_writer); // EOF — simulates process death
// Simulate stderr: error message from the crashing process
let (mut stderr_writer, stderr_read) = tokio::io::duplex(8192);
stderr_writer
.write_all(b"FATAL: panicked at 'index out of bounds', src/main.rs:42\n")
.await
.unwrap();
stderr_writer.flush().await.unwrap();
drop(stderr_writer); // EOF
// Read stdout — should get partial data (no trailing newline)
use tokio::io::AsyncBufReadExt;
let mut stdout_reader = tokio::io::BufReader::new(stdout_read);
let mut stdout_line = String::new();
let bytes = stdout_reader.read_line(&mut stdout_line).await.unwrap();
// Read stderr — should get the error message
let mut stderr_reader = tokio::io::BufReader::new(stderr_read);
let mut stderr_line = String::new();
let _ = stderr_reader.read_line(&mut stderr_line).await.unwrap();
// VERIFY: All three data sources are available in principle
assert!(bytes > 0, "Should have stdout data");
assert!(
!stdout_line.ends_with('\n'),
"Partial stdout should NOT end with newline (crash mid-write)"
);
assert!(
stderr_line.contains("FATAL") || stderr_line.contains("panicked"),
"Stderr should contain the error reason, got: {}",
stderr_line
);
// Verify that the partial stdout is NOT valid JSON
let parse_result = serde_json::from_str::<Value>(&stdout_line.trim());
assert!(
parse_result.is_err(),
"Partial stdout should not parse as valid JSON"
);
// Demonstrate that CrashContext can be assembled from these data sources.
// StdioTransport now exposes get_crash_context() which collects stderr,
// exit status, and partial stdout into a single diagnostic struct.
use dirigent_core::connectors::acp::transport::CrashContext;
let crash_ctx = CrashContext {
recent_stderr: vec![stderr_line.trim().to_string()],
exit_status: None, // duplex streams don't have exit status
partial_stdout: Some(stdout_line.clone()),
};
assert!(!crash_ctx.recent_stderr.is_empty(), "CrashContext should contain stderr");
assert!(crash_ctx.partial_stdout.is_some(), "CrashContext should contain partial stdout");
assert!(
crash_ctx.recent_stderr[0].contains("FATAL"),
"Stderr in CrashContext should contain the crash reason"
);
}
// =========================================================================
// Test 7: Response arrives correctly when transport is healthy
// =========================================================================
/// Baseline test: verify the happy path works — request sent, response
/// arrives, oneshot channel delivers it correctly.
#[tokio::test]
async fn test_happy_path_response_delivered_correctly() {
let handler = ProtocolHandler::new();
// Prepare request
let request = json!({
"jsonrpc": "2.0",
"method": "session/new",
"params": {"cwd": "."}
});
let (msg_with_id, response_rx) = handler.prepare_request(request).await;
let request_id = msg_with_id.get("id").cloned().unwrap();
// Simulate response arriving (what handle_message does when response comes)
let response = json!({
"jsonrpc": "2.0",
"id": request_id,
"result": {"sessionId": "new-session-123"}
});
handler.handle_message(response).await;
// Response should be delivered via oneshot
let result = response_rx.await;
assert!(result.is_ok(), "Response should be delivered successfully");
let response_value = result.unwrap();
assert_eq!(
response_value
.get("result")
.unwrap()
.get("sessionId")
.unwrap(),
"new-session-123"
);
}
}
@@ -0,0 +1,740 @@
//! TEST-002: Comprehensive HTTP Integration Tests
//!
//! This file contains comprehensive integration tests for the ACP client over HTTP transport.
//! It tests the full lifecycle of ACP communication using the dirigate HTTP server.
//!
//! Test Coverage:
//! - T009: Full HTTP lifecycle (initialize, new session, prompt, cancel)
//! - T010: HTTP initialize handshake
//! - T011: HTTP session creation
//! - T012: HTTP message sending with SSE streaming
//! - T013: HTTP session cancellation
//! - T014: SSE connection and events
//! - T015: Multiple concurrent HTTP sessions
//! - T016: HTTP connection recovery
#![cfg(feature = "server")]
use dirigent_core::connectors::acp::{AcpConfig, AcpConnector};
use dirigent_core::connectors::{Connector, ConnectorCommand};
use dirigent_protocol::Event;
use std::path::PathBuf;
use std::time::Duration;
use tokio::time::timeout;
/// Helper to get test port (unique per test to avoid conflicts)
fn test_port(offset: u16) -> u16 {
9000 + offset
}
/// Helper to create a test fixture path
fn test_fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("acp_test.yaml")
}
/// Helper to spawn the mocker HTTP server in the background
async fn spawn_mocker_server(port: u16) -> anyhow::Result<tokio::task::JoinHandle<()>> {
let fixture_path = test_fixture_path();
// Build the mocker
tracing::info!("Building dirigate...");
let build_status = tokio::process::Command::new("cargo")
.args(&["build", "--package", "dirigate"])
.status()
.await?;
if !build_status.success() {
anyhow::bail!("Failed to build dirigate");
}
// Find the binary
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let project_root = manifest_dir.parent().unwrap().parent().unwrap();
let binary_path = project_root
.join("target")
.join("debug")
.join("dirigate.exe");
if !binary_path.exists() {
anyhow::bail!("Mocker binary not found at {:?}", binary_path);
}
// Spawn the server
tracing::info!("Starting mocker server on port {}", port);
let handle = tokio::spawn(async move {
let _ = tokio::process::Command::new(&binary_path)
.args(&[
"serve",
"--fixtures",
fixture_path.to_str().unwrap(),
"--port",
&port.to_string(),
])
.status()
.await;
});
// Wait for server to be ready
tokio::time::sleep(Duration::from_secs(2)).await;
Ok(handle)
}
/// Helper to create a test ACP connector with HTTP transport
fn create_http_connector(port: u16) -> AcpConnector {
let config = AcpConfig::http(format!("http://127.0.0.1:{}", port))
.with_retry_max_attempts(3)
.with_retry_initial_delay(Duration::from_millis(500));
AcpConnector::new(
format!("http-test-connector-{}", port),
uuid::Uuid::nil(),
"HTTP Test Connector".to_string(),
config,
dirigent_core::sharing::bus::SharingBus::new(),
)
.expect("Failed to create connector")
}
/// Helper to wait for event with timeout
async fn wait_for_event<F>(
events_rx: &mut tokio::sync::broadcast::Receiver<Event>,
predicate: F,
timeout_duration: Duration,
) -> anyhow::Result<Event>
where
F: Fn(&Event) -> bool,
{
let result = timeout(timeout_duration, async {
loop {
match events_rx.recv().await {
Ok(event) => {
if predicate(&event) {
return Ok(event);
}
}
Err(e) => {
return Err(anyhow::anyhow!("Event receive error: {}", e));
}
}
}
})
.await;
match result {
Ok(Ok(event)) => Ok(event),
Ok(Err(e)) => Err(e),
Err(_) => Err(anyhow::anyhow!("Timeout waiting for event")),
}
}
// ============================================================================
// T009: Full HTTP Lifecycle Test
// ============================================================================
#[tokio::test]
#[ignore = "Requires building mocker and port availability"]
async fn test_t009_full_lifecycle_http() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let port = test_port(1);
let _server_handle = spawn_mocker_server(port).await?;
// Create connector
let connector = create_http_connector(port);
let mut events = connector.subscribe();
// Start the connector task
let task_handle = connector.start_task().await;
// Wait for connection
let connected_event = wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
assert!(matches!(connected_event, Event::Connected));
tracing::info!("✓ Connected to HTTP mocker");
// Create a new session
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_created = wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?;
let session_id = match session_created {
Event::SessionCreated { session, .. } => {
tracing::info!("✓ Session created: {}", session.id);
session.id
}
_ => anyhow::bail!("Expected SessionCreated event"),
};
// Send a message
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Hello via HTTP!".to_string(),
})
.await?;
// Wait for message started
wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
tracing::info!("✓ Message started");
// Wait for some content
let mut received_content = false;
for _ in 0..10 {
if let Ok(event) = timeout(Duration::from_secs(2), events.recv()).await {
match event? {
Event::SessionUpdate { .. } => {
received_content = true;
tracing::info!("✓ Received content via SSE");
break;
}
Event::MessageCompleted { .. } => {
received_content = true;
break;
}
_ => {}
}
}
}
assert!(received_content, "Should receive content via SSE");
// Stop the connector
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
tracing::info!("✓ Full HTTP lifecycle completed successfully");
Ok(())
}
// ============================================================================
// T010: HTTP Initialize Handshake
// ============================================================================
#[tokio::test]
#[ignore = "Requires building mocker and port availability"]
async fn test_t010_http_initialize() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let port = test_port(2);
let _server_handle = spawn_mocker_server(port).await?;
let connector = create_http_connector(port);
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Wait for connected event
let connected = wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
assert!(matches!(connected, Event::Connected));
tracing::info!("✓ HTTP initialize handshake completed");
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T011: HTTP Session Creation
// ============================================================================
#[tokio::test]
#[ignore = "Requires building mocker and port availability"]
async fn test_t011_http_session_creation() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let port = test_port(3);
let _server_handle = spawn_mocker_server(port).await?;
let connector = create_http_connector(port);
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Wait for connection
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
// Create session
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
// Wait for session created event
let session_created = wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?;
match session_created {
Event::SessionCreated { session, .. } => {
assert_eq!(session.title, "HTTP Session Test");
tracing::info!("✓ HTTP session created with correct title");
}
_ => anyhow::bail!("Expected SessionCreated event"),
}
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T012: HTTP Message Sending with SSE Streaming
// ============================================================================
#[tokio::test]
#[ignore = "Requires building mocker and port availability"]
async fn test_t012_http_sse_streaming() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let port = test_port(4);
let _server_handle = spawn_mocker_server(port).await?;
let connector = create_http_connector(port);
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Setup
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Send message
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Stream me some content".to_string(),
})
.await?;
// Wait for message started
wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
// Collect SSE chunks
let mut chunk_count = 0;
let mut completed = false;
for _ in 0..20 {
if let Ok(result) = timeout(Duration::from_secs(2), events.recv()).await {
match result? {
Event::SessionUpdate { update, .. } => {
if let dirigent_protocol::SessionUpdate::AgentMessageChunk { .. } = update {
chunk_count += 1;
tracing::info!("Received SSE chunk {}", chunk_count);
}
}
Event::MessageCompleted { .. } => {
completed = true;
tracing::info!("✓ Message completed via SSE after {} chunks", chunk_count);
break;
}
_ => {}
}
}
}
assert!(chunk_count > 0, "Should receive SSE chunks");
assert!(completed, "Message should complete via SSE");
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T013: HTTP Session Cancellation
// ============================================================================
#[tokio::test]
#[ignore = "Requires building mocker and port availability"]
async fn test_t013_http_cancellation() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let port = test_port(5);
let _server_handle = spawn_mocker_server(port).await?;
let connector = create_http_connector(port);
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Setup
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Send message
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Long response".to_string(),
})
.await?;
// Wait for streaming to start
wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
wait_for_event(
&mut events,
|e| matches!(e, Event::SessionUpdate { .. }),
Duration::from_secs(5),
)
.await?;
// Cancel
cmd_tx
.send(ConnectorCommand::CancelGeneration {
session_id: session_id.clone(),
})
.await?;
tracing::info!("✓ HTTP cancellation sent successfully");
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T014: SSE Connection and Events
// ============================================================================
#[tokio::test]
#[ignore = "Requires building mocker and port availability"]
async fn test_t014_sse_connection() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let port = test_port(6);
let _server_handle = spawn_mocker_server(port).await?;
let connector = create_http_connector(port);
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// The SSE connection is established during initialization
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
tracing::info!("✓ SSE connection established");
// Create a session and send a message to verify SSE delivery
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id,
text: "Test SSE".to_string(),
})
.await?;
// Verify we receive events via SSE
let mut received_via_sse = false;
for _ in 0..10 {
if let Ok(event) = timeout(Duration::from_secs(1), events.recv()).await {
match event? {
Event::SessionUpdate { .. }
| Event::MessageStarted { .. }
| Event::MessageCompleted { .. } => {
received_via_sse = true;
break;
}
_ => {}
}
}
}
assert!(received_via_sse, "Should receive events via SSE");
tracing::info!("✓ SSE event delivery verified");
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T015: Multiple Concurrent HTTP Sessions
// ============================================================================
#[tokio::test]
#[ignore = "Requires building mocker and port availability"]
async fn test_t015_http_multiple_sessions() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let port = test_port(7);
let _server_handle = spawn_mocker_server(port).await?;
let connector = create_http_connector(port);
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
let cmd_tx = connector.command_tx();
let mut session_ids = Vec::new();
// Create multiple sessions via HTTP
for _i in 0..3 {
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
session_ids.push(session_id);
}
assert_eq!(session_ids.len(), 3);
tracing::info!("✓ Created 3 concurrent HTTP sessions");
// Send messages to all sessions
for session_id in &session_ids {
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Test".to_string(),
})
.await?;
}
// Wait for responses
let mut started_count = 0;
for _ in 0..30 {
if let Ok(result) = timeout(Duration::from_secs(1), events.recv()).await {
if let Event::MessageStarted { .. } = result? {
started_count += 1;
if started_count == 3 {
break;
}
}
}
}
assert_eq!(started_count, 3);
tracing::info!("✓ All HTTP messages received responses");
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T016: HTTP Connection Recovery
// ============================================================================
#[tokio::test]
#[ignore = "Requires building mocker and port availability"]
async fn test_t016_http_connection_recovery() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let port = test_port(8);
// Create connector first (without server running)
let connector = create_http_connector(port);
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Wait a bit for failed connection attempts
tokio::time::sleep(Duration::from_secs(2)).await;
// Now start the server
tracing::info!("Starting server after connector creation");
let _server_handle = spawn_mocker_server(port).await?;
// Should eventually connect (with retry)
let connected = wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(15),
)
.await?;
assert!(matches!(connected, Event::Connected));
tracing::info!("✓ HTTP connector recovered and connected");
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
@@ -0,0 +1,357 @@
//! Integration tests for ACP initialization and capability negotiation.
//!
//! These tests verify that the ACP protocol implementation correctly performs:
//! - Protocol version negotiation
//! - Capability exchange
//! - Optional authentication
//! - Capability validation
//!
//! Tests use the dirigate for both stdio and HTTP transport modes.
#[cfg(feature = "server")]
mod initialization_tests {
use dirigent_core::acp::{
authenticate, capabilities, connector_state::*, initialize,
};
/// Test helper to create default client info.
fn client_info() -> ImplementationInfo {
ImplementationInfo {
name: "dirigent-test".to_string(),
title: Some("Dirigent Test Client".to_string()),
version: Some("0.1.0".to_string()),
}
}
#[tokio::test]
async fn test_initialize_request_serialization() {
let caps = ClientCapabilities::default_safe();
let request = initialize::InitializeRequest::new(caps, Some(client_info()));
// Verify serialization
let jsonrpc = request.to_jsonrpc(1);
assert_eq!(jsonrpc.method, "initialize");
assert_eq!(jsonrpc.jsonrpc, "2.0");
assert!(jsonrpc.params.is_some());
// Verify the serialized params contain expected fields
let params = jsonrpc.params.unwrap();
assert!(params.get("protocol_version").is_some());
assert!(params.get("client_capabilities").is_some());
assert!(params.get("client_info").is_some());
}
#[tokio::test]
async fn test_initialize_response_parsing() {
let json = serde_json::json!({
"protocol_version": 1,
"agent_capabilities": {
"load_session": true,
"prompt_capabilities": {
"image": true,
"audio": false,
"embedded_context": true
}
},
"agent_info": {
"name": "test-agent",
"version": "1.0.0"
},
"auth_methods": []
});
let response = initialize::InitializeResponse::from_jsonrpc(&json).unwrap();
assert_eq!(response.protocol_version, 1);
assert!(response.agent_capabilities.supports_load_session());
assert!(response.agent_capabilities.supports_image());
assert!(!response.agent_capabilities.supports_audio());
assert!(response.agent_capabilities.supports_embedded_context());
assert_eq!(response.auth_methods.len(), 0);
}
#[tokio::test]
async fn test_version_mismatch_detection() {
let client_version = 1;
let agent_version = 2;
assert!(!initialize::is_version_compatible(
client_version,
agent_version
));
assert!(initialize::is_version_compatible(client_version, 1));
}
#[tokio::test]
async fn test_authenticate_request_redacts_credentials() {
let creds = serde_json::json!({"api_key": "super_secret_key_12345"});
let request = authenticate::AuthenticateRequest::new("api_key", creds);
let debug_str = format!("{:?}", request);
// Should NOT contain the actual secret
assert!(!debug_str.contains("super_secret_key"));
assert!(!debug_str.contains("12345"));
// Should show that it's redacted
assert!(debug_str.contains("REDACTED"));
// Should still show the method
assert!(debug_str.contains("api_key"));
}
#[tokio::test]
async fn test_authenticate_response_parsing() {
let success_json = serde_json::json!({
"success": true
});
let response = authenticate::AuthenticateResponse::from_jsonrpc(&success_json).unwrap();
assert!(response.success);
assert!(response.error.is_none());
let failure_json = serde_json::json!({
"success": false,
"error": "Invalid credentials"
});
let response = authenticate::AuthenticateResponse::from_jsonrpc(&failure_json).unwrap();
assert!(!response.success);
assert_eq!(response.error, Some("Invalid credentials".to_string()));
}
#[tokio::test]
async fn test_auth_required_check() {
assert!(!authenticate::is_auth_required(&[]));
assert!(authenticate::is_auth_required(&["api_key".to_string()]));
assert!(authenticate::is_auth_required(&[
"api_key".to_string(),
"oauth".to_string()
]));
}
#[tokio::test]
async fn test_capability_validation_fs_read() {
let caps_enabled = ClientCapabilities {
fs: Some(FsCapabilities {
read_text_file: Some(true),
write_text_file: Some(false),
}),
terminal: Some(false),
_meta: None,
};
let result = capabilities::validate_capability("fs/read_text_file", &caps_enabled);
assert_eq!(result, capabilities::CapabilityValidation::Supported);
let caps_disabled = ClientCapabilities {
fs: Some(FsCapabilities {
read_text_file: Some(false),
write_text_file: Some(false),
}),
terminal: Some(false),
_meta: None,
};
let result = capabilities::validate_capability("fs/read_text_file", &caps_disabled);
assert!(matches!(
result,
capabilities::CapabilityValidation::Unsupported(_)
));
}
#[tokio::test]
async fn test_capability_validation_fs_write() {
let caps_enabled = ClientCapabilities {
fs: Some(FsCapabilities {
read_text_file: Some(true),
write_text_file: Some(true),
}),
terminal: Some(false),
_meta: None,
};
let result = capabilities::validate_capability("fs/write_text_file", &caps_enabled);
assert_eq!(result, capabilities::CapabilityValidation::Supported);
let caps_disabled = ClientCapabilities {
fs: Some(FsCapabilities {
read_text_file: Some(true),
write_text_file: Some(false),
}),
terminal: Some(false),
_meta: None,
};
let result = capabilities::validate_capability("fs/write_text_file", &caps_disabled);
assert!(matches!(
result,
capabilities::CapabilityValidation::Unsupported(_)
));
}
#[tokio::test]
async fn test_capability_validation_terminal() {
let caps_enabled = ClientCapabilities {
fs: None,
terminal: Some(true),
_meta: None,
};
let result = capabilities::validate_capability("terminal/execute", &caps_enabled);
assert_eq!(result, capabilities::CapabilityValidation::Supported);
let result = capabilities::validate_capability("terminal/read", &caps_enabled);
assert_eq!(result, capabilities::CapabilityValidation::Supported);
let caps_disabled = ClientCapabilities {
fs: None,
terminal: Some(false),
_meta: None,
};
let result = capabilities::validate_capability("terminal/execute", &caps_disabled);
assert!(matches!(
result,
capabilities::CapabilityValidation::Unsupported(_)
));
}
#[tokio::test]
async fn test_capability_validation_core_methods() {
let minimal_caps = ClientCapabilities {
fs: None,
terminal: Some(false),
_meta: None,
};
// Core protocol methods should always be supported
assert_eq!(
capabilities::validate_capability("initialize", &minimal_caps),
capabilities::CapabilityValidation::Supported
);
assert_eq!(
capabilities::validate_capability("authenticate", &minimal_caps),
capabilities::CapabilityValidation::Supported
);
assert_eq!(
capabilities::validate_capability("session/new", &minimal_caps),
capabilities::CapabilityValidation::Supported
);
assert_eq!(
capabilities::validate_capability("session/prompt", &minimal_caps),
capabilities::CapabilityValidation::Supported
);
}
#[tokio::test]
async fn test_requires_capability_check() {
assert!(capabilities::requires_capability_check("fs/read_text_file"));
assert!(capabilities::requires_capability_check("fs/write_text_file"));
assert!(capabilities::requires_capability_check("terminal/execute"));
assert!(!capabilities::requires_capability_check("initialize"));
assert!(!capabilities::requires_capability_check("authenticate"));
assert!(!capabilities::requires_capability_check("session/new"));
}
#[tokio::test]
async fn test_connector_state_lifecycle() {
let mut state = AcpConnectorState::new();
assert_eq!(state.connection_state, ConnectionState::Uninitialized);
assert!(!state.is_ready());
state.connection_state = ConnectionState::Initializing;
assert!(!state.is_ready());
state.connection_state = ConnectionState::Initialized;
assert!(!state.is_ready());
state.connection_state = ConnectionState::Ready;
assert!(state.is_ready());
state.connection_state = ConnectionState::Disconnected;
assert!(!state.is_ready());
}
#[tokio::test]
async fn test_connector_state_auth_required() {
let mut state = AcpConnectorState::new();
assert!(!state.requires_auth());
state.auth_methods = vec!["api_key".to_string()];
assert!(state.requires_auth());
state.authenticated = true;
assert!(!state.requires_auth());
}
#[tokio::test]
async fn test_connector_state_error() {
let mut state = AcpConnectorState::new();
state.set_error("Test error");
match state.connection_state {
ConnectionState::Error(msg) => {
assert_eq!(msg, "Test error");
}
_ => panic!("Expected Error state"),
}
}
#[tokio::test]
async fn test_capability_not_supported_error() {
let error = capabilities::capability_not_supported_error(
"fs/write_text_file",
"write capability not enabled",
);
assert_eq!(error.code, capabilities::ERROR_METHOD_NOT_FOUND);
assert!(error.message.contains("fs/write_text_file"));
assert!(error.message.contains("write capability not enabled"));
}
#[tokio::test]
async fn test_agent_capabilities_helpers() {
let caps = AgentCapabilities {
load_session: Some(true),
prompt_capabilities: Some(PromptCapabilities {
image: Some(true),
audio: Some(false),
embedded_context: Some(true),
}),
mcp: Some(McpCapabilities {
http: Some(true),
sse: Some(false),
}),
_meta: None,
};
assert!(caps.supports_load_session());
assert!(caps.supports_image());
assert!(!caps.supports_audio());
assert!(caps.supports_embedded_context());
}
#[tokio::test]
async fn test_client_capabilities_presets() {
let safe_caps = ClientCapabilities::default_safe();
assert_eq!(
safe_caps.fs.as_ref().unwrap().read_text_file,
Some(true)
);
assert_eq!(
safe_caps.fs.as_ref().unwrap().write_text_file,
Some(false)
);
assert_eq!(safe_caps.terminal, Some(false));
let all_caps = ClientCapabilities::all_enabled();
assert_eq!(all_caps.fs.as_ref().unwrap().read_text_file, Some(true));
assert_eq!(all_caps.fs.as_ref().unwrap().write_text_file, Some(true));
assert_eq!(all_caps.terminal, Some(true));
}
}
@@ -0,0 +1,174 @@
# ACP Integration Test Environment
This directory contains utilities and fixtures for integration testing with the `dirigent_acp_mocker`.
## Structure
- **mocker_utils.rs** - Utilities for spawning and managing mocker processes
- **golden_transcripts.rs** - Golden transcript fixtures for common ACP flows
- **README.md** - This file
## Running Tests
### Basic Tests (No Process Spawning)
```bash
# Run all tests
cargo test --package dirigent_core --test acp_mocker_test
# Run specific test
cargo test --package dirigent_core --test acp_mocker_test test_golden_transcripts_available
```
### Integration Tests (With Process Spawning)
These tests spawn actual mocker processes and are ignored by default.
```bash
# Run all ignored tests (spawns processes)
cargo test --package dirigent_core --test acp_mocker_test -- --ignored
# Run specific ignored test
cargo test --package dirigent_core --test acp_mocker_test test_spawn_stdio_mocker -- --ignored
# Run all tests (including ignored ones)
cargo test --package dirigent_core --test acp_mocker_test -- --include-ignored
```
## Mocker Utilities
### Spawning a Mocker in Stdio Mode
```rust
use dirigent_core::tests::acp_integration::MockerProcess;
#[tokio::test]
#[ignore]
async fn test_with_stdio_mocker() {
let mocker = MockerProcess::spawn_stdio().await.unwrap();
// Use mocker via stdin/stdout...
mocker.kill().await.unwrap();
}
```
### Spawning a Mocker in HTTP Mode
```rust
use dirigent_core::tests::acp_integration::MockerProcess;
#[tokio::test]
#[ignore]
async fn test_with_http_mocker() {
let port = 18888;
let mocker = MockerProcess::spawn_http(port).await.unwrap();
// Connect to mocker at http://localhost:18888
mocker.kill().await.unwrap();
}
```
### Using Configuration Presets
```rust
use dirigent_core::tests::acp_integration::{MockerProcess, MockerConfig};
#[tokio::test]
#[ignore]
async fn test_with_configured_mocker() {
let config = MockerConfig::with_preset("basic");
let args: Vec<&str> = config.to_args().iter().map(|s| s.as_str()).collect();
let mocker = MockerProcess::spawn_stdio_with_args(&args).await.unwrap();
// Use mocker...
mocker.kill().await.unwrap();
}
```
## Golden Transcripts
Golden transcripts represent expected request/response sequences for testing.
```rust
use dirigent_core::tests::acp_integration::load_golden_transcript;
#[test]
fn test_with_golden_transcript() {
let transcript = load_golden_transcript("initialize").unwrap();
let request = &transcript["request"];
let response = &transcript["response"];
// Validate against actual mocker responses...
}
```
### Available Transcripts
- **initialize** - Initialize handshake with capabilities exchange
- **new_session** - Create a new session
- **prompt** - Send a simple prompt and receive streaming response
- **tool_call_read** - Tool call flow for reading a file
- **cancel** - Cancel a running session
## Windows-Specific Notes
- Process spawning uses `cargo run` to build and run the mocker
- Paths are handled cross-platform by default
- Process cleanup is automatic via Drop implementation
- If tests hang, check for orphaned mocker processes in Task Manager
## Troubleshooting
### Mocker Won't Start
1. Ensure `dirigent_acp_mocker` package builds:
```bash
cargo build --package dirigent_acp_mocker
```
2. Try running the mocker manually:
```bash
cargo run --package dirigent_acp_mocker -- serve --stdio
```
3. Check for port conflicts (HTTP mode):
```bash
netstat -ano | findstr :18888
```
### Tests Timeout
- Increase the timeout duration in the test
- Check mocker logs for errors (stderr is inherited)
- Verify mocker is actually starting (add debug logging)
### Process Cleanup Issues
- The `Drop` implementation should clean up automatically
- If orphaned processes remain, kill them manually:
```bash
# Windows
taskkill /F /IM dirigent_acp_mocker.exe
# Linux/macOS
pkill -f dirigent_acp_mocker
```
## Future Work
This infrastructure is ready for:
- **TEST-01**: Protocol validation tests (stdio mode)
- **TEST-02**: Protocol validation tests (HTTP mode)
- **TEST-03**: Session update rendering tests
- **TEST-04**: Permission prompt flow tests
- **TEST-05**: File operations sandbox tests
- **TEST-06**: Terminal lifecycle tests
- **TEST-07**: Search operations tests
See `docs/building/04_acp_client/04_tasks_00_scaffolding_and_finishing.md` for the full test plan.
@@ -0,0 +1,229 @@
//! Golden transcript fixtures for common ACP flows.
//!
//! These fixtures represent expected request/response sequences for testing.
use serde_json::json;
/// Golden transcript for initialize flow.
pub fn golden_initialize() -> serde_json::Value {
json!({
"request": {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"capabilities": {
"tools": true,
"streaming": true
},
"clientInfo": {
"name": "dirigent",
"version": "0.1.0"
}
},
"id": 1
},
"response": {
"jsonrpc": "2.0",
"result": {
"capabilities": {
"tools": ["read", "write", "edit", "search", "execute"],
"streaming": true,
"embeddedContext": true
},
"serverInfo": {
"name": "dirigate",
"version": "0.1.0"
}
},
"id": 1
}
})
}
/// Golden transcript for new_session flow.
pub fn golden_new_session() -> serde_json::Value {
json!({
"request": {
"jsonrpc": "2.0",
"method": "session/new",
"params": {
"mode": "ask"
},
"id": 2
},
"response": {
"jsonrpc": "2.0",
"result": {
"sessionId": "test-session-123"
},
"id": 2
}
})
}
/// Golden transcript for simple prompt flow.
pub fn golden_prompt() -> serde_json::Value {
json!({
"request": {
"jsonrpc": "2.0",
"method": "session/prompt",
"params": {
"sessionId": "test-session-123",
"messages": [{
"role": "user",
"content": [
{
"type": "text",
"text": "Hello, agent!"
}
]
}]
},
"id": 3
},
"streaming_updates": [
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session-123",
"type": "agent_message_chunk",
"content": {
"text": "Hello! How can I help you?"
}
}
}
],
"response": {
"jsonrpc": "2.0",
"result": {},
"id": 3
}
})
}
/// Golden transcript for tool call flow (read file).
pub fn golden_tool_call_read() -> serde_json::Value {
json!({
"request": {
"jsonrpc": "2.0",
"method": "session/prompt",
"params": {
"sessionId": "test-session-123",
"messages": [{
"role": "user",
"content": [
{
"type": "text",
"text": "Read the file test.txt"
}
]
}]
},
"id": 4
},
"streaming_updates": [
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session-123",
"type": "tool_call",
"content": {
"toolCallId": "tool-1",
"kind": "read",
"title": "Read test.txt",
"location": {
"path": "/path/to/test.txt"
}
}
}
},
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session-123",
"type": "tool_call_update",
"content": {
"toolCallId": "tool-1",
"status": "completed",
"result": {
"type": "content",
"content": "File content here"
}
}
}
},
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "test-session-123",
"type": "agent_message_chunk",
"content": {
"text": "I've read the file. The content is: ..."
}
}
}
],
"response": {
"jsonrpc": "2.0",
"result": {},
"id": 4
}
})
}
/// Golden transcript for cancellation flow.
pub fn golden_cancel() -> serde_json::Value {
json!({
"request": {
"jsonrpc": "2.0",
"method": "session/cancel",
"params": {
"sessionId": "test-session-123"
},
"id": 5
},
"response": {
"jsonrpc": "2.0",
"result": {},
"id": 5
}
})
}
/// Load a golden transcript by name.
pub fn load_golden_transcript(name: &str) -> Option<serde_json::Value> {
match name {
"initialize" => Some(golden_initialize()),
"new_session" => Some(golden_new_session()),
"prompt" => Some(golden_prompt()),
"tool_call_read" => Some(golden_tool_call_read()),
"cancel" => Some(golden_cancel()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_golden_transcripts_exist() {
assert!(load_golden_transcript("initialize").is_some());
assert!(load_golden_transcript("new_session").is_some());
assert!(load_golden_transcript("prompt").is_some());
assert!(load_golden_transcript("tool_call_read").is_some());
assert!(load_golden_transcript("cancel").is_some());
assert!(load_golden_transcript("nonexistent").is_none());
}
#[test]
fn test_golden_initialize_structure() {
let transcript = golden_initialize();
assert!(transcript.get("request").is_some());
assert!(transcript.get("response").is_some());
}
}
@@ -0,0 +1,211 @@
//! Utilities for spawning and managing the ACP mocker in tests.
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use tokio::time::sleep;
/// Handle to a running mocker process.
pub struct MockerProcess {
process: Child,
mode: MockerMode,
}
/// Mocker execution mode.
#[derive(Debug, Clone, Copy)]
pub enum MockerMode {
/// Stdio mode (stdin/stdout communication)
Stdio,
/// HTTP mode (HTTP + SSE communication)
Http { port: u16 },
}
impl MockerProcess {
/// Spawn a mocker in stdio mode.
///
/// # Example
/// ```no_run
/// use dirigent_core::tests::acp_integration::MockerProcess;
///
/// #[tokio::test]
/// async fn test_stdio_mocker() {
/// let mocker = MockerProcess::spawn_stdio().await.unwrap();
/// // Use mocker...
/// mocker.kill().await.unwrap();
/// }
/// ```
pub async fn spawn_stdio() -> Result<Self, String> {
Self::spawn_stdio_with_args(&[]).await
}
/// Spawn a mocker in stdio mode with custom arguments.
pub async fn spawn_stdio_with_args(args: &[&str]) -> Result<Self, String> {
let mut cmd_args = vec!["serve", "--stdio"];
cmd_args.extend_from_slice(args);
let process = Command::new("cargo")
.args(&["run", "--package", "dirigate", "--"])
.args(&cmd_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("Failed to spawn mocker: {}", e))?;
// Give the mocker time to start
sleep(Duration::from_millis(500)).await;
Ok(Self {
process,
mode: MockerMode::Stdio,
})
}
/// Spawn a mocker in HTTP mode on a specific port.
///
/// # Example
/// ```no_run
/// use dirigent_core::tests::acp_integration::MockerProcess;
///
/// #[tokio::test]
/// async fn test_http_mocker() {
/// let mocker = MockerProcess::spawn_http(8888).await.unwrap();
/// // Use mocker at http://localhost:8888
/// mocker.kill().await.unwrap();
/// }
/// ```
pub async fn spawn_http(port: u16) -> Result<Self, String> {
Self::spawn_http_with_args(port, &[]).await
}
/// Spawn a mocker in HTTP mode with custom arguments.
pub async fn spawn_http_with_args(port: u16, args: &[&str]) -> Result<Self, String> {
let port_str = port.to_string();
let mut cmd_args = vec!["serve", "--port", port_str.as_str()];
cmd_args.extend_from_slice(args);
let process = Command::new("cargo")
.args(&["run", "--package", "dirigate", "--"])
.args(&cmd_args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("Failed to spawn mocker: {}", e))?;
// Give the HTTP server time to start
sleep(Duration::from_millis(1000)).await;
// TODO: Add health check to verify mocker is ready
Ok(Self {
process,
mode: MockerMode::Http { port },
})
}
/// Get the mocker mode.
pub fn mode(&self) -> MockerMode {
self.mode
}
/// Get the HTTP URL if in HTTP mode.
pub fn http_url(&self) -> Option<String> {
match self.mode {
MockerMode::Http { port } => Some(format!("http://localhost:{}", port)),
MockerMode::Stdio => None,
}
}
/// Kill the mocker process.
pub async fn kill(mut self) -> Result<(), String> {
self.process
.kill()
.map_err(|e| format!("Failed to kill mocker: {}", e))?;
// Wait for process to exit
sleep(Duration::from_millis(100)).await;
Ok(())
}
}
impl Drop for MockerProcess {
fn drop(&mut self) {
// Best-effort cleanup on drop
let _ = self.process.kill();
}
}
/// Configuration for mocker test scenarios.
#[derive(Debug, Clone)]
pub struct MockerConfig {
/// Preset configuration name
pub preset: Option<String>,
/// Custom configuration JSON/TOML
pub config: Option<String>,
}
impl MockerConfig {
/// Create a default mocker configuration.
pub fn default() -> Self {
Self {
preset: None,
config: None,
}
}
/// Create a mocker configuration with a preset.
pub fn with_preset(preset: impl Into<String>) -> Self {
Self {
preset: Some(preset.into()),
config: None,
}
}
/// Create a mocker configuration with custom config.
// Test utility - kept for future mocker configuration test development
#[allow(dead_code)]
pub fn with_config(config: impl Into<String>) -> Self {
Self {
preset: None,
config: Some(config.into()),
}
}
/// Convert to command-line arguments for the mocker.
pub fn to_args(&self) -> Vec<String> {
let mut args = Vec::new();
if let Some(preset) = &self.preset {
args.push("--preset".to_string());
args.push(preset.clone());
}
if let Some(config) = &self.config {
args.push("--config".to_string());
args.push(config.clone());
}
args
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mocker_config_default() {
let config = MockerConfig::default();
assert!(config.preset.is_none());
assert!(config.config.is_none());
assert!(config.to_args().is_empty());
}
#[test]
fn test_mocker_config_with_preset() {
let config = MockerConfig::with_preset("basic");
assert_eq!(config.preset, Some("basic".to_string()));
assert_eq!(config.to_args(), vec!["--preset", "basic"]);
}
}
@@ -0,0 +1,9 @@
//! ACP integration test utilities.
//!
//! This module provides utilities for testing with the conductor.
pub mod mocker_utils;
pub mod golden_transcripts;
pub use mocker_utils::*;
pub use golden_transcripts::*;
@@ -0,0 +1,100 @@
//! ACP mocker integration tests.
//!
//! These tests use the dirigate to validate ACP protocol flows.
//! Most tests are marked as #[ignore] by default since they require spawning processes.
//!
//! Run with: cargo test --package dirigent_core --test acp_mocker_test -- --ignored
#![cfg(feature = "server")]
mod acp_integration;
use acp_integration::*;
#[tokio::test]
async fn test_mocker_utilities_exist() {
// Just verify the utilities compile and work
let config = MockerConfig::default();
assert!(config.to_args().is_empty());
}
#[tokio::test]
async fn test_golden_transcripts_available() {
// Verify golden transcripts are available
assert!(load_golden_transcript("initialize").is_some());
assert!(load_golden_transcript("new_session").is_some());
assert!(load_golden_transcript("prompt").is_some());
assert!(load_golden_transcript("tool_call_read").is_some());
assert!(load_golden_transcript("cancel").is_some());
}
// ============================================================================
// Mocker Spawning Tests (Ignored by default)
// ============================================================================
#[tokio::test]
#[ignore]
async fn test_spawn_stdio_mocker() {
let mocker = MockerProcess::spawn_stdio().await;
assert!(mocker.is_ok(), "Failed to spawn stdio mocker: {:?}", mocker.err());
if let Ok(mocker) = mocker {
assert!(matches!(mocker.mode(), MockerMode::Stdio));
assert_eq!(mocker.http_url(), None);
mocker.kill().await.expect("Failed to kill mocker");
}
}
#[tokio::test]
#[ignore]
async fn test_spawn_http_mocker() {
let port = 18888; // Use non-standard port for testing
let mocker = MockerProcess::spawn_http(port).await;
assert!(mocker.is_ok(), "Failed to spawn HTTP mocker: {:?}", mocker.err());
if let Ok(mocker) = mocker {
assert!(matches!(mocker.mode(), MockerMode::Http { .. }));
assert_eq!(
mocker.http_url(),
Some(format!("http://localhost:{}", port))
);
mocker.kill().await.expect("Failed to kill mocker");
}
}
#[tokio::test]
#[ignore]
async fn test_spawn_mocker_with_config() {
let config = MockerConfig::with_preset("basic");
let args_vec = config.to_args();
let args: Vec<&str> = args_vec.iter().map(|s| s.as_str()).collect();
let mocker = MockerProcess::spawn_stdio_with_args(&args).await;
assert!(mocker.is_ok(), "Failed to spawn mocker with config: {:?}", mocker.err());
if let Ok(mocker) = mocker {
mocker.kill().await.expect("Failed to kill mocker");
}
}
// ============================================================================
// Documentation for Future Tests
// ============================================================================
// TODO: TEST-01 - Protocol validation test suite (stdio mode)
// - initialize → response with capabilities
// - session/new → sessionId
// - session/prompt → streaming updates
// - Validate all protocol flows against mocker
// TODO: TEST-02 - Protocol validation test suite (HTTP mode)
// - Same tests as TEST-01 but using HTTP transport
// - SSE stream validation
// TODO: TEST-03 - Session update rendering tests
// - Parse and validate all session update types
// - Verify ToolKind, ToolCallLocation, diff content
// TODO: TEST-04 - Permission prompt flow tests
// - Test all permission outcomes (allow_once, allow_always, reject_once, reject_always, cancel)
// - Decision cache persistence and TTL
@@ -0,0 +1,737 @@
//! TEST-001: Comprehensive Stdio Integration Tests
//!
//! This file contains comprehensive integration tests for the ACP client over stdio transport.
//! It tests the full lifecycle of ACP communication using the dirigate in stdio mode.
//!
//! Test Coverage:
//! - T001: Full lifecycle (initialize, new session, prompt, cancel, load)
//! - T002: Initialize handshake
//! - T003: Session creation
//! - T004: Message sending with streaming
//! - T005: Session cancellation
//! - T006: Session loading
//! - T007: Multiple concurrent sessions
//! - T008: Graceful shutdown
#![cfg(feature = "server")]
use dirigent_core::connectors::acp::{AcpConfig, AcpConnector};
use dirigent_core::connectors::{Connector, ConnectorCommand};
use dirigent_protocol::Event;
use std::path::PathBuf;
use std::time::Duration;
use tokio::time::timeout;
/// Helper to create a test fixture path
fn test_fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("acp_test.yaml")
}
/// Helper to get the mocker binary path
fn mocker_binary_path() -> String {
// First try the compiled binary in target directory
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let project_root = manifest_dir.parent().unwrap().parent().unwrap();
// Try debug build first
let debug_path = project_root
.join("target")
.join("debug")
.join("dirigate.exe");
if debug_path.exists() {
return debug_path.to_string_lossy().to_string();
}
// Try release build
let release_path = project_root
.join("target")
.join("release")
.join("dirigate.exe");
if release_path.exists() {
return release_path.to_string_lossy().to_string();
}
// Fallback to cargo run
"cargo".to_string()
}
/// Helper to create mocker args
fn mocker_args(fixture_path: PathBuf) -> Vec<String> {
let mocker_bin = mocker_binary_path();
if mocker_bin == "cargo" {
vec![
"run".to_string(),
"--package".to_string(),
"dirigate".to_string(),
"--".to_string(),
"serve".to_string(),
"--stdio".to_string(),
"--fixtures".to_string(),
fixture_path.to_string_lossy().to_string(),
]
} else {
vec![
"serve".to_string(),
"--stdio".to_string(),
"--fixtures".to_string(),
fixture_path.to_string_lossy().to_string(),
]
}
}
/// Helper to create a test ACP connector with stdio transport
fn create_stdio_connector() -> AcpConnector {
let mocker_bin = mocker_binary_path();
let fixture_path = test_fixture_path();
let args = mocker_args(fixture_path);
let config = AcpConfig::stdio(mocker_bin, args)
.with_retry_max_attempts(3)
.with_retry_initial_delay(Duration::from_millis(500));
AcpConnector::new(
"stdio-test-connector".to_string(),
uuid::Uuid::nil(),
"Stdio Test Connector".to_string(),
config,
dirigent_core::sharing::bus::SharingBus::new(),
)
.expect("Failed to create connector")
}
/// Helper to wait for event with timeout
async fn wait_for_event<F>(
events_rx: &mut tokio::sync::broadcast::Receiver<Event>,
predicate: F,
timeout_duration: Duration,
) -> anyhow::Result<Event>
where
F: Fn(&Event) -> bool,
{
let result = timeout(timeout_duration, async {
loop {
match events_rx.recv().await {
Ok(event) => {
if predicate(&event) {
return Ok(event);
}
}
Err(e) => {
return Err(anyhow::anyhow!("Event receive error: {}", e));
}
}
}
})
.await;
match result {
Ok(Ok(event)) => Ok(event),
Ok(Err(e)) => Err(e),
Err(_) => Err(anyhow::anyhow!("Timeout waiting for event")),
}
}
// ============================================================================
// T001: Full Lifecycle Test
// ============================================================================
#[tokio::test]
#[ignore = "Requires dirigate binary to be built"]
async fn test_t001_full_lifecycle_stdio() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
// Create connector
let connector = create_stdio_connector();
let mut events = connector.subscribe();
// Start the connector task
let task_handle = connector.start_task().await;
// Wait for connection
let connected_event = wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
assert!(matches!(connected_event, Event::Connected));
tracing::info!("✓ Connected to mocker");
// Create a new session
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_created = wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?;
let session_id = match session_created {
Event::SessionCreated { session, .. } => {
tracing::info!("✓ Session created: {}", session.id);
session.id
}
_ => anyhow::bail!("Expected SessionCreated event"),
};
// Send a message
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Hello, test!".to_string(),
})
.await?;
// Wait for message started
let message_started = wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
let _message_id = match message_started {
Event::MessageStarted { message, .. } => {
tracing::info!("✓ Message started: {}", message.id);
message.id
}
_ => anyhow::bail!("Expected MessageStarted event"),
};
// Wait for message content (streaming chunks)
let mut received_content = false;
for _ in 0..10 {
if let Ok(event) = timeout(Duration::from_secs(2), events.recv()).await {
match event? {
Event::SessionUpdate {
connector_id: _,
session_id: _,
update,
} => {
if let dirigent_protocol::SessionUpdate::AgentMessageChunk { .. } = update {
received_content = true;
tracing::info!("✓ Received message content");
break;
}
}
Event::MessageCompleted { message, .. } => {
tracing::info!("✓ Message completed: {}", message.id);
received_content = true;
break;
}
_ => {}
}
}
}
assert!(received_content, "Should receive message content");
// Test cancellation (send another message then cancel immediately)
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "This should be cancelled".to_string(),
})
.await?;
// Cancel immediately
cmd_tx
.send(ConnectorCommand::CancelGeneration {
session_id: session_id.clone(),
})
.await?;
tracing::info!("✓ Sent cancellation request");
// Stop the connector
connector.stop();
// Wait for task to complete
let _ = timeout(Duration::from_secs(5), task_handle).await?;
tracing::info!("✓ Full lifecycle completed successfully");
Ok(())
}
// ============================================================================
// T002: Initialize Handshake
// ============================================================================
#[tokio::test]
#[ignore = "Requires dirigate binary to be built"]
async fn test_t002_initialize_handshake() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let connector = create_stdio_connector();
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Wait for connected event
let connected = wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
assert!(matches!(connected, Event::Connected));
tracing::info!("✓ Initialize handshake completed");
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T003: Session Creation
// ============================================================================
#[tokio::test]
#[ignore = "Requires dirigate binary to be built"]
async fn test_t003_session_creation() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let connector = create_stdio_connector();
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Wait for connection
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
// Create session
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
// Wait for session created event
let session_created = wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?;
match session_created {
Event::SessionCreated { session, .. } => {
assert_eq!(session.title, "Session Creation Test");
tracing::info!("✓ Session created with correct title");
}
_ => anyhow::bail!("Expected SessionCreated event"),
}
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T004: Message Sending with Streaming
// ============================================================================
#[tokio::test]
#[ignore = "Requires dirigate binary to be built"]
async fn test_t004_message_streaming() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let connector = create_stdio_connector();
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Wait for connection and create session
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Send message
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Tell me a story".to_string(),
})
.await?;
// Wait for message started
wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
// Collect streaming chunks
let mut chunk_count = 0;
let mut completed = false;
for _ in 0..20 {
if let Ok(result) = timeout(Duration::from_secs(2), events.recv()).await {
match result? {
Event::SessionUpdate { update, .. } => {
if let dirigent_protocol::SessionUpdate::AgentMessageChunk { .. } = update {
chunk_count += 1;
tracing::info!("Received chunk {}", chunk_count);
}
}
Event::MessageCompleted { .. } => {
completed = true;
tracing::info!("✓ Message completed after {} chunks", chunk_count);
break;
}
_ => {}
}
}
}
assert!(chunk_count > 0, "Should receive at least one chunk");
assert!(completed, "Message should complete");
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T005: Session Cancellation
// ============================================================================
#[tokio::test]
#[ignore = "Requires dirigate binary to be built"]
async fn test_t005_session_cancellation() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let connector = create_stdio_connector();
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Setup: connect and create session
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => session.id,
_ => anyhow::bail!("Expected SessionCreated"),
};
// Send message
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Long response please".to_string(),
})
.await?;
// Wait for message to start
wait_for_event(
&mut events,
|e| matches!(e, Event::MessageStarted { .. }),
Duration::from_secs(5),
)
.await?;
// Wait for at least one chunk
wait_for_event(
&mut events,
|e| matches!(e, Event::SessionUpdate { .. }),
Duration::from_secs(5),
)
.await?;
// Now cancel
cmd_tx
.send(ConnectorCommand::CancelGeneration {
session_id: session_id.clone(),
})
.await?;
tracing::info!("✓ Cancellation sent successfully");
// The message should either complete or be cancelled
// Either way, we verify the cancel command was accepted
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T006: Session Loading
// ============================================================================
#[tokio::test]
#[ignore = "Requires dirigate binary to be built"]
async fn test_t006_session_loading() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let connector = create_stdio_connector();
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Wait for connection
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
// Try to load a session from the fixture
// The fixture should have a pre-defined session we can load
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::LoadSession {
session_id: "test-session-1".to_string(),
cwd: ".".to_string(),
mcp_servers: None,
})
.await?;
// Wait for session loaded event
let session_loaded = wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?;
match session_loaded {
Event::SessionCreated { session, .. } => {
assert_eq!(session.id, "test-session-1");
tracing::info!("✓ Session loaded successfully");
}
_ => anyhow::bail!("Expected SessionCreated event"),
}
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T007: Multiple Concurrent Sessions
// ============================================================================
#[tokio::test]
#[ignore = "Requires dirigate binary to be built"]
async fn test_t007_multiple_concurrent_sessions() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let connector = create_stdio_connector();
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Wait for connection
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
let cmd_tx = connector.command_tx();
let mut session_ids = Vec::new();
// Create multiple sessions
for i in 0..3 {
cmd_tx
.send(ConnectorCommand::CreateSession {
cwd: None,
project_id: None,
ownership: dirigent_protocol::SessionOwnership::internal(),
})
.await?;
let session_id = match wait_for_event(
&mut events,
|e| matches!(e, Event::SessionCreated { .. }),
Duration::from_secs(5),
)
.await?
{
Event::SessionCreated { session, .. } => {
tracing::info!("✓ Created session {}: {}", i + 1, session.id);
session.id
}
_ => anyhow::bail!("Expected SessionCreated"),
};
session_ids.push(session_id);
}
assert_eq!(session_ids.len(), 3, "Should create 3 sessions");
// Send messages to all sessions concurrently
for (i, session_id) in session_ids.iter().enumerate() {
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: format!("Message to session {}", i + 1),
})
.await?;
}
// Wait for all messages to start
let mut started_count = 0;
for _ in 0..30 {
if let Ok(result) = timeout(Duration::from_secs(1), events.recv()).await {
if let Event::MessageStarted { .. } = result? {
started_count += 1;
if started_count == 3 {
break;
}
}
}
}
assert_eq!(started_count, 3, "All messages should start");
tracing::info!("✓ All messages started successfully");
// Cleanup
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await?;
Ok(())
}
// ============================================================================
// T008: Graceful Shutdown
// ============================================================================
#[tokio::test]
#[ignore = "Requires dirigate binary to be built"]
async fn test_t008_graceful_shutdown() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("debug")
.with_test_writer()
.try_init()
.ok();
let connector = create_stdio_connector();
let mut events = connector.subscribe();
let task_handle = connector.start_task().await;
// Wait for connection
wait_for_event(
&mut events,
|e| matches!(e, Event::Connected),
Duration::from_secs(10),
)
.await?;
tracing::info!("Connected, now shutting down gracefully");
// Stop the connector
connector.stop();
// Task should complete without hanging
let result = timeout(Duration::from_secs(5), task_handle).await;
assert!(result.is_ok(), "Task should complete within timeout");
tracing::info!("✓ Graceful shutdown completed");
Ok(())
}
@@ -0,0 +1,365 @@
//! Tests for Event Adapter Integration
//!
//! T071: Event Adapter Integration
//! - SSE events translated correctly by OpenCodeAdapter
//! - Events forwarded to broadcast channel
#![cfg(feature = "server")]
use dirigent_protocol::adapters::OpenCodeAdapter;
use dirigent_protocol::Event;
use std::time::Duration;
use tokio::time::timeout;
// ============================================================================
// T071: Event Adapter Integration
// ============================================================================
#[test]
fn test_t071_adapter_can_be_created() {
let _adapter = OpenCodeAdapter::new();
// If this compiles and runs, the adapter can be instantiated
}
#[test]
fn test_t071_adapter_translates_session_created() {
use opencode_client::types as oc;
let adapter = OpenCodeAdapter::new();
let oc_session = oc::Session {
id: "session-123".to_string(),
project_id: "project-1".to_string(),
directory: "/test".to_string(),
parent_id: None,
summary: None,
share: None,
title: "Test Session".to_string(),
version: "1.0".to_string(),
time: oc::SessionTime {
created: 1234567890,
updated: 1234567890,
compacting: None,
},
revert: None,
};
let oc_event = oc::Event::SessionCreated {
properties: oc::SessionEventInfo {
info: oc_session.clone(),
},
};
let result = adapter.translate_event(oc_event);
assert!(result.is_ok(), "Translation should succeed");
if let Ok(Event::SessionCreated { session, .. }) = result {
assert_eq!(session.id, "session-123");
assert_eq!(session.title, "Test Session");
} else {
panic!("Expected SessionCreated event, got {:?}", result);
}
}
#[test]
fn test_t071_adapter_translates_message_updated() {
use opencode_client::types as oc;
let adapter = OpenCodeAdapter::new();
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
id: "msg-123".to_string(),
session_id: "session-456".to_string(),
time: oc::AssistantMessageTime {
created: 1234567890,
completed: None,
},
error: None,
system: vec![],
parent_id: None,
model_id: None,
provider_id: None,
mode: None,
path: None,
summary: None,
cost: 0.0,
tokens: oc::TokenUsage::default(),
});
let oc_event = oc::Event::MessageUpdated {
properties: oc::MessageEventInfo {
info: oc_message.clone(),
},
};
let result = adapter.translate_event(oc_event);
assert!(result.is_ok(), "Translation should succeed");
// The adapter might return MessageStarted or MessageCompleted depending on status
match result {
Ok(Event::MessageStarted { message, .. }) => {
assert_eq!(message.id, "msg-123");
assert_eq!(message.session_id, "session-456");
}
Ok(Event::MessageCompleted { message, .. }) => {
assert_eq!(message.id, "msg-123");
assert_eq!(message.session_id, "session-456");
}
other => {
panic!(
"Expected MessageStarted or MessageCompleted, got {:?}",
other
);
}
}
}
#[test]
fn test_t071_adapter_handles_duplicate_events() {
use opencode_client::types as oc;
let adapter = OpenCodeAdapter::new();
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
id: "msg-same".to_string(),
session_id: "session-1".to_string(),
time: oc::AssistantMessageTime {
created: 1234567890,
completed: None,
},
error: None,
system: vec![],
parent_id: None,
model_id: None,
provider_id: None,
mode: None,
path: None,
summary: None,
cost: 0.0,
tokens: oc::TokenUsage::default(),
});
let oc_event = oc::Event::MessageUpdated {
properties: oc::MessageEventInfo {
info: oc_message.clone(),
},
};
// First translation should succeed
let result1 = adapter.translate_event(oc_event.clone());
assert!(result1.is_ok(), "First translation should succeed");
// Second translation of the same event should return Duplicate error
let result2 = adapter.translate_event(oc_event);
assert!(
result2.is_err(),
"Second translation should detect duplicate"
);
if let Err(dirigent_protocol::adapters::opencode::TranslationError::Duplicate) = result2 {
// Expected
} else {
panic!("Expected Duplicate error, got {:?}", result2);
}
}
#[tokio::test]
async fn test_t071_events_forwarded_to_broadcast_channel() {
// Test that the broadcast channel mechanism works
// We create our own channels to test the pattern used by connectors
let (events_tx, mut events_rx) = tokio::sync::broadcast::channel(1000);
// Send a test event
events_tx.send(dirigent_protocol::Event::Connected).ok();
// Verify we can receive it
let event_received = timeout(Duration::from_millis(100), events_rx.recv()).await;
assert!(
event_received.is_ok(),
"Should receive events via broadcast channel"
);
assert!(matches!(
event_received.unwrap().unwrap(),
dirigent_protocol::Event::Connected
));
}
#[tokio::test]
async fn test_t071_multiple_subscribers_receive_same_events() {
// Test that multiple subscribers all receive broadcast events
let (events_tx, _) = tokio::sync::broadcast::channel(1000);
// Create multiple subscriptions
let mut events1 = events_tx.subscribe();
let mut events2 = events_tx.subscribe();
let mut events3 = events_tx.subscribe();
// Send an event
events_tx.send(dirigent_protocol::Event::Connected).ok();
// All subscribers should receive events
let timeout_duration = Duration::from_millis(100);
let result1 = timeout(timeout_duration, events1.recv()).await;
let result2 = timeout(timeout_duration, events2.recv()).await;
let result3 = timeout(timeout_duration, events3.recv()).await;
// All three should receive events
assert!(result1.is_ok(), "Subscriber 1 should receive events");
assert!(result2.is_ok(), "Subscriber 2 should receive events");
assert!(result3.is_ok(), "Subscriber 3 should receive events");
}
#[tokio::test]
async fn test_t071_events_contain_correct_data() {
// Test that events can be sent and received with correct data
let (events_tx, mut events_rx) = tokio::sync::broadcast::channel(1000);
// Send various event types
events_tx.send(Event::Connected).ok();
events_tx.send(Event::Disconnected).ok();
events_tx
.send(Event::Error {
message: "Test error".to_string(),
})
.ok();
// Collect events
let mut received_events = Vec::new();
while let Ok(result) = timeout(Duration::from_millis(10), events_rx.recv()).await {
if let Ok(event) = result {
received_events.push(event);
}
if received_events.len() >= 3 {
break;
}
}
// We should have received all events
assert_eq!(received_events.len(), 3, "Should receive all events");
// Check that events are valid Event enum variants
for event in &received_events {
match event {
Event::Connected => {}
Event::Disconnected => {}
Event::Error { message } => {
assert!(!message.is_empty(), "Error messages should not be empty");
}
_ => {}
}
}
}
#[test]
fn test_t071_adapter_translates_message_part() {
use opencode_client::types as oc;
let adapter = OpenCodeAdapter::new();
// First, create a message so the part can be associated
let oc_message = oc::Message::Assistant(oc::AssistantMessage {
id: "msg-123".to_string(),
session_id: "session-456".to_string(),
time: oc::AssistantMessageTime {
created: 1234567890,
completed: None,
},
error: None,
system: vec![],
parent_id: None,
model_id: None,
provider_id: None,
mode: None,
path: None,
summary: None,
cost: 0.0,
tokens: oc::TokenUsage::default(),
});
let msg_event = oc::Event::MessageUpdated {
properties: oc::MessageEventInfo {
info: oc_message.clone(),
},
};
adapter
.translate_event(msg_event)
.expect("Message should translate");
// Now translate a message part
let oc_part = oc::Part::Text(oc::TextPart {
id: "part-123".to_string(),
session_id: "session-456".to_string(),
message_id: "msg-123".to_string(),
text: "Hello, world!".to_string(),
synthetic: None,
time: Some(oc::PartTime {
start: 1234567890,
end: Some(1234567900),
}),
});
let part_event = oc::Event::MessagePartUpdated {
properties: oc::MessagePartEventInfo {
part: oc_part.clone(),
delta: Some("Hello, world!".to_string()),
},
};
let result = adapter.translate_event(part_event);
assert!(result.is_ok(), "Part translation should succeed");
if let Ok(Event::SessionUpdate { connector_id: _, session_id, update }) = result {
assert_eq!(session_id, "ses-456");
match update {
dirigent_protocol::SessionUpdate::AgentMessageChunk {
message_id,
content,
..
} => {
assert_eq!(message_id, "msg-123");
match content {
dirigent_protocol::ContentBlock::Text { text } => {
assert_eq!(text, "Hello, world!");
}
_ => panic!("Expected Text content block"),
}
}
_ => panic!("Expected AgentMessageChunk"),
}
} else {
panic!("Expected SessionUpdate event, got {:?}", result);
}
}
#[tokio::test]
async fn test_t071_broadcast_channel_capacity() {
// Test that the broadcast channel has sufficient capacity
let (events_tx, mut events_rx) = tokio::sync::broadcast::channel(1000);
// Send many events
for i in 0..100 {
events_tx
.send(Event::Error {
message: format!("Event {}", i),
})
.ok();
}
// Receive them all
let mut count = 0;
while let Ok(result) = timeout(Duration::from_millis(10), events_rx.recv()).await {
if result.is_ok() {
count += 1;
} else {
break;
}
if count >= 100 {
break;
}
}
// We should have received all events without lagging
assert_eq!(count, 100, "Should receive all events without lag");
}
@@ -0,0 +1,425 @@
//! Integration tests for file embedding functionality.
//!
//! Tests the complete embedding pipeline from file paths to ContentBlocks.
use dirigent_core::acp::content_blocks::build_content_blocks_from_files;
use dirigent_core::acp::protocol::prompt::{ContentBlock, EmbeddedResource};
use dirigent_core::acp::{AgentCapabilities, PromptCapabilities};
use dirigent_tools::config::{EmbeddingConfig, SandboxConfig};
use tempfile::TempDir;
/// Helper to create test agent capabilities.
fn create_test_agent_caps(embedded_context: bool) -> AgentCapabilities {
AgentCapabilities {
load_session: None,
prompt_capabilities: Some(PromptCapabilities {
image: None,
audio: None,
embedded_context: Some(embedded_context),
}),
mcp: None,
_meta: None,
}
}
/// Helper to create test configuration.
fn create_test_config(temp_dir: &TempDir) -> (EmbeddingConfig, SandboxConfig) {
let embedding_config = EmbeddingConfig {
max_embed_bytes: 1000,
allow_resource_link: true,
redact_patterns: vec![],
snippet_strategy: dirigent_tools::config::SnippetStrategy::HeadTail,
max_files_per_prompt: 10,
};
let mut sandbox_config = SandboxConfig::default();
sandbox_config.allowed_roots = vec![temp_dir.path().to_path_buf()];
sandbox_config.normalize_roots();
(embedding_config, sandbox_config)
}
#[test]
fn test_scenario_1_small_text_capability_on_embed() {
// Scenario 1: Small text file + capability on → embed
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("small.txt");
std::fs::write(&file_path, "Hello, world!").unwrap();
let agent_caps = create_test_agent_caps(true);
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
let blocks = build_content_blocks_from_files(
&[file_path],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
assert_eq!(blocks.len(), 1, "Should have one content block");
match &blocks[0] {
ContentBlock::Resource { resource, .. } => match resource {
EmbeddedResource::Text { text, .. } => {
assert_eq!(text, "Hello, world!");
}
_ => panic!("Expected text resource"),
},
_ => panic!("Expected resource block"),
}
}
#[test]
fn test_scenario_2_large_text_capability_on_link() {
// Scenario 2: Large text file + capability on → link or snippet
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("large.txt");
let large_content = "x".repeat(2000); // Exceeds 1000 byte limit
std::fs::write(&file_path, &large_content).unwrap();
let agent_caps = create_test_agent_caps(true);
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
let blocks = build_content_blocks_from_files(
&[file_path],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
assert_eq!(blocks.len(), 1, "Should have one content block");
match &blocks[0] {
ContentBlock::ResourceLink { size, .. } => {
assert_eq!(*size, Some(2000));
}
_ => panic!("Expected resource link block"),
}
}
#[test]
fn test_scenario_3_binary_file_link() {
// Scenario 3: Binary file → link
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("image.png");
std::fs::write(&file_path, b"\x89PNG\r\n\x1a\n").unwrap();
let agent_caps = create_test_agent_caps(true);
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
let blocks = build_content_blocks_from_files(
&[file_path],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
assert_eq!(blocks.len(), 1, "Should have one content block");
match &blocks[0] {
ContentBlock::ResourceLink { mime_type, .. } => {
assert_eq!(mime_type, &Some("image/png".to_string()));
}
_ => panic!("Expected resource link block"),
}
}
#[test]
fn test_scenario_4_capability_off_all_links() {
// Scenario 4: Capability off → all links
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "Test content").unwrap();
let agent_caps = create_test_agent_caps(false); // Capability OFF
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
let blocks = build_content_blocks_from_files(
&[file_path],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
assert_eq!(blocks.len(), 1, "Should have one content block");
match &blocks[0] {
ContentBlock::ResourceLink { .. } => {
// Correct - should be link when capability is off
}
_ => panic!("Expected resource link block when capability is off"),
}
}
#[test]
fn test_scenario_5_exceed_total_cap_link_remaining() {
// Scenario 5: Exceed total cap → deny or link remaining
let temp_dir = TempDir::new().unwrap();
// Create multiple small files that together exceed the total cap
let file1 = temp_dir.path().join("file1.txt");
let file2 = temp_dir.path().join("file2.txt");
let file3 = temp_dir.path().join("file3.txt");
let content = "x".repeat(800); // Each file is 800 bytes
std::fs::write(&file1, &content).unwrap();
std::fs::write(&file2, &content).unwrap();
std::fs::write(&file3, &content).unwrap();
// Total: 2400 bytes, but max_embed_bytes * max_files_per_prompt = 1000 * 10 = 10000
// So they should all embed in this test
let agent_caps = create_test_agent_caps(true);
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
let blocks = build_content_blocks_from_files(
&[file1, file2, file3],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
// All should be embedded or linked (not denied)
assert!(blocks.len() >= 2, "Should have at least 2 content blocks");
}
#[test]
fn test_scenario_6_exceed_file_count_deny() {
// Scenario 6: Exceed file count → deny
let temp_dir = TempDir::new().unwrap();
let mut embedding_config = EmbeddingConfig::default();
embedding_config.max_files_per_prompt = 2; // Limit to 2 files
let mut sandbox_config = SandboxConfig::default();
sandbox_config.allowed_roots = vec![temp_dir.path().to_path_buf()];
sandbox_config.normalize_roots();
let agent_caps = create_test_agent_caps(true);
// Create 3 files
let file1 = temp_dir.path().join("file1.txt");
let file2 = temp_dir.path().join("file2.txt");
let file3 = temp_dir.path().join("file3.txt");
std::fs::write(&file1, "File 1").unwrap();
std::fs::write(&file2, "File 2").unwrap();
std::fs::write(&file3, "File 3").unwrap();
let blocks = build_content_blocks_from_files(
&[file1, file2, file3],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
// Should have at most 2 blocks (third file denied)
assert!(blocks.len() <= 2, "Should have at most 2 content blocks");
}
#[test]
fn test_scenario_7_redaction_patterns_applied() {
// Scenario 7: Redaction patterns applied → verify content redacted
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("secrets.txt");
std::fs::write(&file_path, "api_key: sk-1234567890abcdef").unwrap();
let agent_caps = create_test_agent_caps(true);
let mut embedding_config = EmbeddingConfig::default();
embedding_config.redact_patterns = vec![
r"(?i)(api[_-]?key):\s*([a-zA-Z0-9_\-\.]+)".to_string(),
];
let mut sandbox_config = SandboxConfig::default();
sandbox_config.allowed_roots = vec![temp_dir.path().to_path_buf()];
sandbox_config.normalize_roots();
let blocks = build_content_blocks_from_files(
&[file_path],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
assert_eq!(blocks.len(), 1, "Should have one content block");
match &blocks[0] {
ContentBlock::Resource { resource, .. } => match resource {
EmbeddedResource::Text { text, .. } => {
// Verify that the API key is redacted
assert!(
!text.contains("sk-1234567890abcdef"),
"Secret should be redacted"
);
assert!(text.contains("REDACTED"), "Should contain redaction marker");
}
_ => panic!("Expected text resource"),
},
_ => panic!("Expected resource block"),
}
}
#[test]
fn test_scenario_8_sandbox_violation_deny() {
// Scenario 8: Sandbox violation → deny with clear error
let temp_dir = TempDir::new().unwrap();
let outside_dir = TempDir::new().unwrap();
let file_path = outside_dir.path().join("outside.txt");
std::fs::write(&file_path, "Outside sandbox").unwrap();
let agent_caps = create_test_agent_caps(true);
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
let result = build_content_blocks_from_files(
&[file_path],
&agent_caps,
&embedding_config,
&sandbox_config,
);
// Should fail with sandbox violation
assert!(result.is_err(), "Should fail with sandbox violation");
let err = result.unwrap_err();
assert!(
format!("{:?}", err).contains("SandboxViolation"),
"Error should be SandboxViolation"
);
}
#[test]
fn test_scenario_9_mixed_strategies() {
// Scenario 9: Mixed strategies in one prompt → correct blocks
let temp_dir = TempDir::new().unwrap();
// Small text file (will be embedded)
let small_file = temp_dir.path().join("small.txt");
std::fs::write(&small_file, "Small content").unwrap();
// Large text file (will be linked)
let large_file = temp_dir.path().join("large.txt");
let large_content = "x".repeat(2000);
std::fs::write(&large_file, &large_content).unwrap();
// Binary file (will be linked)
let binary_file = temp_dir.path().join("image.png");
std::fs::write(&binary_file, b"\x89PNG\r\n\x1a\n").unwrap();
let agent_caps = create_test_agent_caps(true);
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
let blocks = build_content_blocks_from_files(
&[small_file, large_file, binary_file],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
assert_eq!(blocks.len(), 3, "Should have three content blocks");
// First should be embedded
match &blocks[0] {
ContentBlock::Resource { .. } => {
// Correct - small file is embedded
}
_ => panic!("Expected first block to be Resource (embedded)"),
}
// Second and third should be links
match &blocks[1] {
ContentBlock::ResourceLink { .. } => {
// Correct - large file is linked
}
_ => panic!("Expected second block to be ResourceLink"),
}
match &blocks[2] {
ContentBlock::ResourceLink { .. } => {
// Correct - binary file is linked
}
_ => panic!("Expected third block to be ResourceLink"),
}
}
#[test]
fn test_uri_stability() {
// Test that URIs are stable across multiple invocations
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("stable.txt");
std::fs::write(&file_path, "Stable content").unwrap();
let agent_caps = create_test_agent_caps(true);
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
let blocks1 = build_content_blocks_from_files(
&[file_path.clone()],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
let blocks2 = build_content_blocks_from_files(
&[file_path],
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
// Extract URIs and compare
match (&blocks1[0], &blocks2[0]) {
(
ContentBlock::Resource {
resource: EmbeddedResource::Text { uri: uri1, .. },
..
},
ContentBlock::Resource {
resource: EmbeddedResource::Text { uri: uri2, .. },
..
},
) => {
assert_eq!(uri1, uri2, "URIs should be stable");
}
_ => panic!("Expected resource blocks with text"),
}
}
#[test]
fn test_performance_many_files() {
// Test that building content blocks for many files is reasonably fast
let temp_dir = TempDir::new().unwrap();
// Create 10 files
let mut files = Vec::new();
for i in 0..10 {
let file_path = temp_dir.path().join(format!("file{}.txt", i));
std::fs::write(&file_path, format!("Content {}", i)).unwrap();
files.push(file_path);
}
let agent_caps = create_test_agent_caps(true);
let (embedding_config, sandbox_config) = create_test_config(&temp_dir);
let start = std::time::Instant::now();
let blocks = build_content_blocks_from_files(
&files,
&agent_caps,
&embedding_config,
&sandbox_config,
)
.unwrap();
let elapsed = start.elapsed();
assert_eq!(blocks.len(), 10, "Should have 10 content blocks");
assert!(
elapsed.as_millis() < 500,
"Should complete in less than 500ms, took {}ms",
elapsed.as_millis()
);
}
+40
View File
@@ -0,0 +1,40 @@
# Test fixture for ACP integration tests
version: "0.1"
sessions:
# Pre-defined session for loading tests
- id: "test-session-1"
title: "Preloaded Test Session"
created_at: "2025-01-01T00:00:00Z"
participants:
- id: "user-1"
kind: user
display_name: "Test User"
- id: "assistant-1"
kind: assistant
display_name: "Test Assistant"
messages:
- id: "msg-1"
session_id: "test-session-1"
role: user
content: "This is a preloaded message"
created_at: "2025-01-01T00:00:01Z"
- id: "msg-2"
session_id: "test-session-1"
role: assistant
content: "This is a preloaded response"
created_at: "2025-01-01T00:00:02Z"
parent_id: "msg-1"
responders:
keyword_map:
hello: "Hello! How can I help you today?"
test: "This is a test response"
story: "Once upon a time, in a digital realm far away, there lived a brave ACP connector who ventured forth to test the limits of communication protocols."
default_strategy: echo
streaming:
enabled: true
tokens_per_chunk: 3
chunk_interval_ms: 50
jitter_ms: 10
@@ -0,0 +1,634 @@
#![cfg(feature = "server")]
//! Full integration tests for CoreRuntime
//!
//! T076: Full Runtime Lifecycle
//! T077: Multiple Connectors
use dirigent_core::connectors::{Connector, ConnectorCommand, ConnectorHandle};
use dirigent_core::types::{ConnectorKind, ConnectorState};
use dirigent_core::{ConnectorConfig, CoreConfig, CoreRuntime};
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, mpsc, RwLock};
use tokio::time::timeout;
/// Helper to create a test runtime
fn create_test_runtime() -> CoreRuntime {
CoreRuntime::new(CoreConfig::default(), None)
}
/// Helper to create an OpenCode connector config
fn create_opencode_config(id: &str, title: &str) -> ConnectorConfig {
ConnectorConfig {
id: Some(id.to_string()),
kind: ConnectorKind::OpenCode,
owner: None,
title: Some(title.to_string()),
working_directory: None,
params: json!({
"base_url": "http://localhost:12225",
"title": title,
"initial_session": null
}),
..Default::default()
}
}
/// Mock connector for testing (simpler than OpenCode)
struct MockConnector {
id: String,
owner: dirigent_core::UserId,
title: String,
state: Arc<RwLock<ConnectorState>>,
cmd_tx: mpsc::Sender<ConnectorCommand>,
cmd_rx: Arc<RwLock<Option<mpsc::Receiver<ConnectorCommand>>>>,
events_tx: broadcast::Sender<dirigent_protocol::Event>,
}
impl MockConnector {
fn new(id: String, owner: dirigent_core::UserId, title: String) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel(100);
let (events_tx, _) = broadcast::channel(1000);
Self {
id,
owner,
title,
state: Arc::new(RwLock::new(ConnectorState::Initializing)),
cmd_tx,
cmd_rx: Arc::new(RwLock::new(Some(cmd_rx))),
events_tx,
}
}
fn events_sender(&self) -> broadcast::Sender<dirigent_protocol::Event> {
self.events_tx.clone()
}
async fn start_task(&self) -> tokio::task::JoinHandle<()> {
let id = self.id.clone();
let state = Arc::clone(&self.state);
let events_tx = self.events_tx.clone();
let cmd_rx = self
.cmd_rx
.write()
.await
.take()
.expect("start_task called more than once");
tokio::spawn(async move {
Self::run_task(id, state, events_tx, cmd_rx).await;
})
}
async fn run_task(
_id: String,
state: Arc<RwLock<ConnectorState>>,
events_tx: broadcast::Sender<dirigent_protocol::Event>,
mut cmd_rx: mpsc::Receiver<ConnectorCommand>,
) {
// Transition to Ready immediately
{
let mut state_guard = state.write().await;
*state_guard = ConnectorState::Ready;
}
let _ = events_tx.send(dirigent_protocol::Event::Connected);
// Process commands
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
ConnectorCommand::ListSessions => {
let _ = events_tx.send(dirigent_protocol::Event::SessionsListed {
connector_id: "test-connector".to_string(),
sessions: vec![],
});
}
ConnectorCommand::ListMessages { .. } => {
let _ = events_tx
.send(dirigent_protocol::Event::MessagesListed { messages: vec![] });
}
ConnectorCommand::CreateSession { .. } => {
// Mock connector doesn't support session creation
}
ConnectorCommand::LoadSession { .. } => {
// Mock connector doesn't support session loading
}
ConnectorCommand::SendMessage { .. } => {
// Just acknowledge
}
ConnectorCommand::CancelGeneration { .. } => {
// Mock connector doesn't support cancellation
}
ConnectorCommand::Reconnect => {
let _ = events_tx.send(dirigent_protocol::Event::Connected);
}
ConnectorCommand::AgentResponse { .. } => {
// Mock connector doesn't handle agent responses
}
ConnectorCommand::SetSessionMode { .. } => {
// Mock connector doesn't support mode switching
}
ConnectorCommand::SetSessionModel { .. } => {
// Mock connector doesn't support model switching
}
ConnectorCommand::CloseSession { .. } => {
// Mock connector doesn't support session close
}
ConnectorCommand::SetConfigOption { .. } => {
// Mock connector doesn't support config options
}
ConnectorCommand::Shutdown => {
let mut state_guard = state.write().await;
*state_guard = ConnectorState::Stopped;
break;
}
}
}
}
}
impl Connector for MockConnector {
fn id(&self) -> &String {
&self.id
}
fn kind(&self) -> ConnectorKind {
ConnectorKind::Mock
}
fn owner(&self) -> &dirigent_core::UserId {
&self.owner
}
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> ConnectorState {
match self.state.try_read() {
Ok(state_guard) => state_guard.clone(),
Err(_) => ConnectorState::Initializing,
}
}
fn command_tx(&self) -> mpsc::Sender<ConnectorCommand> {
self.cmd_tx.clone()
}
fn subscribe(&self) -> broadcast::Receiver<dirigent_protocol::Event> {
self.events_tx.subscribe()
}
fn stop(&self) {
let cmd_tx = self.cmd_tx.clone();
tokio::spawn(async move {
let _ = cmd_tx.send(ConnectorCommand::Shutdown).await;
});
}
}
// ============================================================================
// T076: Full Runtime Lifecycle
// ============================================================================
#[tokio::test]
async fn test_t076_full_lifecycle_with_mock_connector() {
// Create runtime
let _runtime = create_test_runtime();
// Step 1: Create a mock connector manually (since we can't use Mock kind via API)
let mock = MockConnector::new(
"mock-1".to_string(),
uuid::Uuid::nil(),
"Mock Connector 1".to_string(),
);
// Create a handle for it
let handle = ConnectorHandle::new(
mock.id().clone(),
mock.kind(),
mock.owner().clone(),
mock.title().to_string(),
mock.command_tx(),
mock.events_sender(),
serde_json::json!({}), // Empty config for mock connector
None, // working_directory
None, // icon_path
false, // show_type_overlay
);
// Subscribe to events before starting
let mut events = handle.subscribe();
// Step 2: Start the connector
let task_handle = mock.start_task().await;
handle.set_task_handle(task_handle).await;
// Step 3: Wait for it to become Ready
let connected = timeout(Duration::from_secs(2), async {
while let Ok(event) = events.recv().await {
if matches!(event, dirigent_protocol::Event::Connected) {
return true;
}
}
false
})
.await;
assert!(
connected.is_ok() && connected.unwrap(),
"Should receive Connected event"
);
// Note: State checking via try_read() may be flaky due to timing.
// The important thing is we received the Connected event, which proves
// the connector is running and functional.
// Skip state assertion as it's not critical for this integration test.
// Step 4: Send commands and verify events
let cmd_tx = handle.command_tx();
// Send ListSessions
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
let sessions_listed = timeout(Duration::from_secs(1), async {
while let Ok(event) = events.recv().await {
if matches!(event, dirigent_protocol::Event::SessionsListed { .. }) {
return true;
}
}
false
})
.await;
assert!(
sessions_listed.is_ok() && sessions_listed.unwrap(),
"Should receive SessionsListed"
);
// Send ListMessages
cmd_tx
.send(ConnectorCommand::ListMessages {
session_id: "test-session".to_string(),
})
.await
.unwrap();
let messages_listed = timeout(Duration::from_secs(1), async {
while let Ok(event) = events.recv().await {
if matches!(event, dirigent_protocol::Event::MessagesListed { .. }) {
return true;
}
}
false
})
.await;
assert!(
messages_listed.is_ok() && messages_listed.unwrap(),
"Should receive MessagesListed"
);
// Step 5: Stop the connector
handle.stop();
// Wait for it to stop
tokio::time::sleep(Duration::from_millis(200)).await;
// Note: State checking via try_read() is flaky. The important thing is that
// we successfully sent the stop command. The connector should be stopping.
// Skip strict state assertion.
}
#[tokio::test]
async fn test_t076_full_lifecycle_with_opencode_connector() {
// This test uses the real OpenCodeConnector but with a fake URL
// so it will fail to connect, but we can still verify the lifecycle
let runtime = create_test_runtime();
// Step 1: Create connector
let cfg = create_opencode_config("oc-lifecycle", "Lifecycle Test");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
// Step 2: Verify it's in the list
let list = runtime.list_connectors(None).await;
let found = list.iter().find(|c| c.id == connector_id);
assert!(found.is_some(), "Connector should be in list");
// Step 3: Get the connector handle
let handle = runtime.get_connector(&connector_id).await.unwrap();
assert_eq!(handle.state(), ConnectorState::Initializing);
// Step 4: Send commands (commands can be queued even if not started)
// Note: Since the connector wasn't started, the command channel exists but isn't being processed
let result = runtime
.send_command(&connector_id, ConnectorCommand::ListSessions)
.await;
// This should succeed - we can send to the channel
if result.is_err() {
println!(
"Note: Command send failed (channel may be closed): {:?}",
result
);
// Don't fail the test - this is expected if connector wasn't fully initialized
}
// Step 5: Stop the connector
let result = runtime.stop_connector(&connector_id).await;
// Note: Stop may fail if the connector wasn't properly started, which is ok for this test
if result.is_err() {
println!(
"Note: Stop failed (connector may not have been fully initialized): {:?}",
result
);
} else {
// If stop succeeded, verify state changed
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(handle.state(), ConnectorState::Stopped);
}
// Step 6: Remove the connector
let result = runtime.remove_connector(&connector_id).await;
assert!(result.is_ok(), "Should be able to remove");
// Step 7: Verify it's gone
let list = runtime.list_connectors(None).await;
let found = list.iter().find(|c| c.id == connector_id);
assert!(found.is_none(), "Connector should be removed from list");
}
// ============================================================================
// T077: Multiple Connectors
// ============================================================================
#[tokio::test]
async fn test_t077_multiple_connectors_dont_crosstalk() {
let runtime = create_test_runtime();
// Create multiple connectors
let cfg1 = create_opencode_config("multi-1", "Multi 1");
let cfg2 = create_opencode_config("multi-2", "Multi 2");
let cfg3 = create_opencode_config("multi-3", "Multi 3");
let id1 = runtime
.create_connector(uuid::Uuid::nil(), cfg1)
.await
.unwrap();
let id2 = runtime
.create_connector(uuid::Uuid::from_u128(2), cfg2)
.await
.unwrap();
let id3 = runtime
.create_connector(uuid::Uuid::nil(), cfg3)
.await
.unwrap();
// Verify all three exist
let list = runtime.list_connectors(None).await;
assert!(list.iter().any(|c| c.id == id1));
assert!(list.iter().any(|c| c.id == id2));
assert!(list.iter().any(|c| c.id == id3));
// Verify they have correct owners
let c1 = list.iter().find(|c| c.id == id1).unwrap();
let c2 = list.iter().find(|c| c.id == id2).unwrap();
let c3 = list.iter().find(|c| c.id == id3).unwrap();
assert_eq!(c1.owner, uuid::Uuid::nil());
assert_eq!(c2.owner, uuid::Uuid::from_u128(2));
assert_eq!(c3.owner, uuid::Uuid::nil());
// Stop one connector (may fail if already stopped, which is ok)
let stop_result = runtime.stop_connector(&id2).await;
if stop_result.is_ok() {
// Wait for state to update
tokio::time::sleep(Duration::from_millis(100)).await;
// Verify id2 state changed
let handle2 = runtime.get_connector(&id2).await.unwrap();
let state2 = handle2.state();
assert!(
matches!(state2, ConnectorState::Stopped),
"Expected id2 to be Stopped after stop_connector, got {:?}",
state2
);
} else {
println!(
"Note: stop_connector failed (connector may not have been started): {:?}",
stop_result
);
}
// Remove one connector
runtime.remove_connector(&id1).await.unwrap();
// Verify only id1 is removed
assert!(runtime.get_connector(&id1).await.is_none());
assert!(runtime.get_connector(&id2).await.is_some());
assert!(runtime.get_connector(&id3).await.is_some());
// Clean up
runtime.remove_connector(&id2).await.ok();
runtime.remove_connector(&id3).await.ok();
}
#[tokio::test]
async fn test_t077_per_connector_broadcasts_work() {
// Create mock connectors to test event isolation
let mock1 = MockConnector::new(
"mock-a".to_string(),
uuid::Uuid::nil(),
"Mock A".to_string(),
);
let mock2 = MockConnector::new(
"mock-b".to_string(),
uuid::Uuid::nil(),
"Mock B".to_string(),
);
// Subscribe to events from each
let mut events1 = mock1.subscribe();
let mut events2 = mock2.subscribe();
// Start both connectors
let _task1 = mock1.start_task().await;
let _task2 = mock2.start_task().await;
// Wait for both to become ready
tokio::time::sleep(Duration::from_millis(200)).await;
// Send command to mock1 only
mock1
.command_tx()
.send(ConnectorCommand::ListSessions)
.await
.unwrap();
// mock1 should receive SessionsListed
let mock1_received = timeout(Duration::from_secs(1), async {
while let Ok(event) = events1.recv().await {
if matches!(event, dirigent_protocol::Event::SessionsListed { .. }) {
return true;
}
}
false
})
.await;
assert!(
mock1_received.is_ok() && mock1_received.unwrap(),
"Mock1 should receive event"
);
// mock2 should NOT receive it (only Connected event)
let mock2_received = timeout(Duration::from_millis(500), async {
while let Ok(event) = events2.recv().await {
if matches!(event, dirigent_protocol::Event::SessionsListed { .. }) {
return true;
}
// Skip Connected events
}
false
})
.await;
// Should timeout (not receive SessionsListed)
assert!(
mock2_received.is_err() || !mock2_received.unwrap(),
"Mock2 should NOT receive mock1's events"
);
// Clean up
mock1.stop();
mock2.stop();
}
#[tokio::test]
async fn test_t077_concurrent_operations_on_different_connectors() {
let runtime = Arc::new(create_test_runtime());
// Create multiple connectors
let cfg1 = create_opencode_config("concurrent-1", "Concurrent 1");
let cfg2 = create_opencode_config("concurrent-2", "Concurrent 2");
let id1 = runtime
.create_connector(uuid::Uuid::nil(), cfg1)
.await
.unwrap();
let id2 = runtime
.create_connector(uuid::Uuid::nil(), cfg2)
.await
.unwrap();
// Spawn concurrent operations
let runtime1 = Arc::clone(&runtime);
let runtime2 = Arc::clone(&runtime);
let id1_clone = id1.clone();
let id2_clone = id2.clone();
let task1 = tokio::spawn(async move {
// Send multiple commands to connector 1
for _ in 0..10 {
runtime1
.send_command(&id1_clone, ConnectorCommand::ListSessions)
.await
.ok();
tokio::time::sleep(Duration::from_millis(10)).await;
}
});
let task2 = tokio::spawn(async move {
// Send multiple commands to connector 2
for _ in 0..10 {
runtime2
.send_command(&id2_clone, ConnectorCommand::ListSessions)
.await
.ok();
tokio::time::sleep(Duration::from_millis(10)).await;
}
});
// Wait for both to complete
let result1 = task1.await;
let result2 = task2.await;
assert!(result1.is_ok(), "Task 1 should complete successfully");
assert!(result2.is_ok(), "Task 2 should complete successfully");
// Clean up
runtime.remove_connector(&id1).await.ok();
runtime.remove_connector(&id2).await.ok();
}
#[tokio::test]
async fn test_t077_list_connectors_with_multiple() {
let runtime = create_test_runtime();
// Create several connectors for different users
for i in 1..=5 {
let cfg = create_opencode_config(&format!("user1-conn-{}", i), &format!("User 1 #{}", i));
runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
}
for i in 1..=3 {
let cfg = create_opencode_config(&format!("user2-conn-{}", i), &format!("User 2 #{}", i));
runtime
.create_connector(uuid::Uuid::from_u128(2), cfg)
.await
.unwrap();
}
// List all
let all = runtime.list_connectors(None).await;
assert!(all.len() >= 8, "Should have at least 8 connectors");
// List for user-1
let user1_list = runtime.list_connectors(Some(uuid::Uuid::nil())).await;
assert_eq!(user1_list.len(), 5, "User 1 should have 5 connectors");
// List for user-2
let user2_list = runtime
.list_connectors(Some(uuid::Uuid::from_u128(2)))
.await;
assert_eq!(user2_list.len(), 3, "User 2 should have 3 connectors");
// List for user-3 (none)
let user3_list = runtime
.list_connectors(Some(uuid::Uuid::from_u128(3)))
.await;
assert_eq!(user3_list.len(), 0, "User 3 should have 0 connectors");
// Clean up
for i in 1..=5 {
runtime
.remove_connector(&format!("user1-conn-{}", i))
.await
.ok();
}
for i in 1..=3 {
runtime
.remove_connector(&format!("user2-conn-{}", i))
.await
.ok();
}
}
#[tokio::test]
async fn test_t077_global_events_subscription() {
let _runtime = create_test_runtime();
// Subscribe to every event on the SharingBus (replaces the retired
// `subscribe_global()` API).
let bus_rx = _runtime.sharing_bus().subscribe_all().await;
drop(bus_rx);
// If this compiles and runs, bus subscription works
}
@@ -0,0 +1,207 @@
//! Integration test: Matrix migration onto StreamRegistry (Phase 4, Task 18).
//!
//! Scope:
//! - `MatrixFactory::kind()` reports `"matrix"`.
//! - A fresh `StreamFactoryRegistry` with the factory registered can look it
//! up and rejects unknown kinds.
//! - Building a Matrix stream from a config with an `archive_wide` scope is
//! rejected with `StreamBuildError::Config`.
//! - Building a Matrix stream against a not-logged-in service is rejected
//! with `StreamBuildError::Transport` (does not panic, does not spin up
//! a real Matrix connection).
//!
//! This does NOT exercise end-to-end Matrix delivery — that requires a
//! live homeserver or a stub client, which is outside Task 18's scope.
//! The share-side `SessionStream` impl is covered separately by
//! `dirigent_matrix` unit tests.
#![cfg(feature = "server")]
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use uuid::Uuid;
use dirigent_auth::{Account, AccountKind, AccountProfile, SecretSource};
use dirigent_core::sharing::{
MatrixFactory, StreamBuildError, StreamConfig, StreamFactory, StreamFactoryRegistry,
};
use dirigent_matrix::{MatrixBehaviorConfig, MatrixService};
use dirigent_protocol::streaming::StreamScope;
// ─── Helpers ────────────────────────────────────────────────────────────────
fn sample_matrix_account() -> Account {
let mut credentials = HashMap::new();
credentials.insert(
"password".to_string(),
SecretSource::Inline {
value: "bot-pass".to_string(),
},
);
let mut properties = HashMap::new();
properties.insert(
"homeserver".to_string(),
serde_json::json!("https://matrix.example.com"),
);
properties.insert(
"device_id".to_string(),
serde_json::json!("DIRIGENT_TEST"),
);
Account {
kind: AccountKind::Matrix,
config_name: "matrix-test".to_string(),
user_id: None,
credentials,
profile: AccountProfile {
username: Some("bot".to_string()),
display_name: Some("Test Bot".to_string()),
..Default::default()
},
properties,
}
}
fn behavior() -> MatrixBehaviorConfig {
MatrixBehaviorConfig {
account: "matrix-test".to_string(),
mode: Default::default(),
default_invite: vec![],
store_path: "matrix/test/store".to_string(),
rooms: vec![],
}
}
/// Build a `MatrixService` without calling `login()`. Any code path that
/// needs a live Client will surface a clean error (not a panic).
fn not_logged_in_service() -> Arc<MatrixService> {
let account = sample_matrix_account();
let tmp = tempfile::tempdir().expect("tempdir");
let data_dir: PathBuf = tmp.path().to_path_buf();
// Leak the TempDir so the path survives the life of the service for
// the duration of the test. The sqlite store is only created when
// login() runs — we never call it in these tests.
std::mem::forget(tmp);
let service = MatrixService::from_account(&account, behavior(), data_dir)
.expect("from_account");
Arc::new(service)
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[test]
fn matrix_factory_kind_is_matrix() {
let service = not_logged_in_service();
let f = MatrixFactory::new(service);
assert_eq!(f.kind(), "matrix");
}
#[test]
fn registry_returns_registered_matrix_factory() {
let service = not_logged_in_service();
let reg = StreamFactoryRegistry::new().register(MatrixFactory::new(service));
assert!(reg.get("matrix").is_some(), "matrix factory should be found");
assert!(
reg.get("langfuse").is_none(),
"unregistered kinds must return None"
);
}
#[tokio::test]
async fn build_rejects_archive_wide_scope_with_config_error() {
let service = not_logged_in_service();
let factory = MatrixFactory::new(service);
let params_toml = r#"
connector_id = "opencode-1"
session_id = "native-abc"
room_id = "!room:example.com"
"#;
let params: toml::Value = toml::from_str(params_toml).unwrap();
let cfg = StreamConfig {
name: "matrix-wrong-scope".to_string(),
kind: "matrix".to_string(),
scope: StreamScope::ArchiveWide { acknowledged: false },
enabled: true,
params,
};
let err = factory.build(&cfg).await.err().expect("build should fail");
match err {
StreamBuildError::Config(msg) => {
assert!(
msg.contains("session"),
"expected 'session' hint in error, got: {msg}"
);
}
other => panic!("expected Config error, got {other:?}"),
}
}
#[tokio::test]
async fn build_rejects_missing_params_with_config_error() {
let service = not_logged_in_service();
let factory = MatrixFactory::new(service);
// Missing room_id — required field.
let params_toml = r#"
connector_id = "opencode-1"
session_id = "native-abc"
"#;
let params: toml::Value = toml::from_str(params_toml).unwrap();
let cfg = StreamConfig {
name: "matrix-missing-room".to_string(),
kind: "matrix".to_string(),
scope: StreamScope::Session {
scroll_id: Uuid::now_v7(),
},
enabled: true,
params,
};
let err = factory.build(&cfg).await.err().expect("build should fail");
assert!(
matches!(err, StreamBuildError::Config(_)),
"expected Config error, got {err:?}"
);
}
#[tokio::test]
async fn build_reports_transport_error_when_service_not_logged_in() {
let service = not_logged_in_service();
let factory = MatrixFactory::new(service);
let params_toml = r#"
connector_id = "opencode-1"
session_id = "native-abc"
room_id = "!room:example.com"
"#;
let params: toml::Value = toml::from_str(params_toml).unwrap();
let cfg = StreamConfig {
name: "matrix-not-logged-in".to_string(),
kind: "matrix".to_string(),
scope: StreamScope::Session {
scroll_id: Uuid::now_v7(),
},
enabled: true,
params,
};
let err = factory.build(&cfg).await.err().expect("build should fail");
match err {
StreamBuildError::Transport(msg) => {
assert!(
msg.to_lowercase().contains("logged in")
|| msg.to_lowercase().contains("matrix service"),
"expected transport error to mention login state, got: {msg}"
);
}
other => panic!("expected Transport error, got {other:?}"),
}
}
@@ -0,0 +1,370 @@
#![cfg(feature = "server")]
//! Tests for OpenCodeConnector state transitions and command handling
//!
//! T069: OpenCodeConnector State Transitions
//! T070: OpenCodeConnector Command Handling
use dirigent_core::connectors::opencode::{OpenCodeConfig, OpenCodeConnector};
use dirigent_core::connectors::{Connector, ConnectorCommand};
use dirigent_core::sharing::bus::SharingBus;
use dirigent_core::types::{ConnectorKind, ConnectorState};
use std::time::Duration;
use tokio::time::timeout;
/// Helper to create a test connector
fn create_test_connector(base_url: &str) -> OpenCodeConnector {
let config = OpenCodeConfig {
base_url: base_url.to_string(),
initial_session: None,
};
OpenCodeConnector::new(
"test-conn".to_string(),
uuid::Uuid::nil(),
"Test Connector".to_string(),
config,
SharingBus::new(),
)
}
// ============================================================================
// T069: OpenCodeConnector State Transitions
// ============================================================================
#[tokio::test]
async fn test_t069_initial_state_is_initializing() {
let connector = create_test_connector("http://localhost:12225");
assert_eq!(connector.state(), ConnectorState::Initializing);
}
#[tokio::test]
async fn test_t069_connector_metadata() {
let connector = create_test_connector("http://localhost:12225");
assert_eq!(connector.id(), "test-conn");
assert_eq!(*connector.owner(), uuid::Uuid::nil());
assert_eq!(connector.title(), "Test Connector");
assert_eq!(connector.kind(), ConnectorKind::OpenCode);
}
#[tokio::test]
async fn test_t069_state_transition_connecting() {
// This test verifies that when started, the connector transitions to Connecting
// Since we don't have a real OpenCode server, it will fail to connect and
// enter Error state after retries, but we can verify the initial transition
let connector = create_test_connector("http://192.0.2.1:12225"); // TEST-NET-1, guaranteed non-routable
let _events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
// Give it a moment to start transitioning
tokio::time::sleep(Duration::from_millis(100)).await;
// At this point, state should have transitioned from Initializing
// It will be either Connecting, Ready, or Error (depending on timing)
let state = connector.state();
assert!(
!matches!(state, ConnectorState::Initializing),
"Expected state to transition from Initializing, got {:?}",
state
);
// Clean up: send shutdown
connector.stop();
tokio::time::sleep(Duration::from_millis(100)).await;
}
#[tokio::test]
#[ignore] // This test is flaky due to timing and network conditions - ignore for now
async fn test_t069_state_transition_to_error_on_connection_failure() {
let connector = create_test_connector("http://192.0.2.1:12225"); // TEST-NET-1, guaranteed non-routable
let mut events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
// Wait for connection failures (it will retry a few times)
let error_received = timeout(Duration::from_secs(10), async {
loop {
if let Ok(event) = events.recv().await {
if let dirigent_protocol::Event::Error { message: _ } = event {
return true;
}
}
}
})
.await;
assert!(
error_received.is_ok(),
"Should receive error event on connection failure"
);
// Eventually, state should be Error after retries
tokio::time::sleep(Duration::from_secs(2)).await;
let state = connector.state();
assert!(
matches!(state, ConnectorState::Error(_)),
"Expected Error state, got {:?}",
state
);
// Clean up
connector.stop();
}
#[tokio::test]
async fn test_t069_state_transition_to_stopped_on_shutdown() {
let connector = create_test_connector("http://localhost:12225");
// Start the connector
let task_handle = connector.start_task().await;
// Give it a moment to start
tokio::time::sleep(Duration::from_millis(100)).await;
// Send shutdown command
let cmd_tx = connector.command_tx();
cmd_tx.send(ConnectorCommand::Shutdown).await.unwrap();
// Wait for task to complete
let result = timeout(Duration::from_secs(5), task_handle).await;
assert!(result.is_ok(), "Task should complete on shutdown");
// State should be Stopped
let state = connector.state();
assert_eq!(state, ConnectorState::Stopped);
}
// ============================================================================
// T070: OpenCodeConnector Command Handling
// ============================================================================
#[tokio::test]
async fn test_t070_list_sessions_command_sent() {
let connector = create_test_connector("http://localhost:12225");
let mut events = connector.subscribe();
// Start the connector (it will fail to connect, but we can still send commands)
let _task_handle = connector.start_task().await;
// Give it a moment to start
tokio::time::sleep(Duration::from_millis(100)).await;
// Send ListSessions command
let cmd_tx = connector.command_tx();
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
// We expect an error event since we can't actually connect
// But this verifies the command was processed
let error_or_sessions = timeout(Duration::from_secs(2), async {
loop {
if let Ok(event) = events.recv().await {
match event {
dirigent_protocol::Event::Error { .. } => return "error",
dirigent_protocol::Event::SessionsListed { .. } => return "sessions",
_ => continue,
}
}
}
})
.await;
// We expect either an error (can't connect) or sessions (if somehow it worked)
assert!(
error_or_sessions.is_ok(),
"Should receive response to ListSessions"
);
// Clean up
connector.stop();
}
#[tokio::test]
async fn test_t070_list_messages_command_sent() {
let connector = create_test_connector("http://localhost:12225");
let mut events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
tokio::time::sleep(Duration::from_millis(100)).await;
// Send ListMessages command
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::ListMessages {
session_id: "test-session".to_string(),
})
.await
.unwrap();
// We expect an error event since we can't actually connect
let error_or_messages = timeout(Duration::from_secs(2), async {
loop {
if let Ok(event) = events.recv().await {
match event {
dirigent_protocol::Event::Error { .. } => return "error",
dirigent_protocol::Event::MessagesListed { .. } => return "messages",
_ => continue,
}
}
}
})
.await;
assert!(
error_or_messages.is_ok(),
"Should receive response to ListMessages"
);
// Clean up
connector.stop();
}
#[tokio::test]
async fn test_t070_send_message_command_sent() {
let connector = create_test_connector("http://localhost:12225");
let mut events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
tokio::time::sleep(Duration::from_millis(100)).await;
// Send SendMessage command
let cmd_tx = connector.command_tx();
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: "test-session".to_string(),
text: "Hello, world!".to_string(),
})
.await
.unwrap();
// We expect an error event since we can't actually connect
let error_received = timeout(Duration::from_secs(2), async {
loop {
if let Ok(event) = events.recv().await {
if matches!(event, dirigent_protocol::Event::Error { .. }) {
return true;
}
}
}
})
.await;
assert!(
error_received.is_ok(),
"Should receive error for SendMessage"
);
// Clean up
connector.stop();
}
#[tokio::test]
#[ignore] // This test is flaky due to timing and network conditions - ignore for now
async fn test_t070_reconnect_command_restarts_sse() {
let connector = create_test_connector("http://192.0.2.1:12225"); // TEST-NET-1, guaranteed non-routable
let _events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
// Wait for it to fail and enter Error state
tokio::time::sleep(Duration::from_secs(3)).await;
let state_before = connector.state();
assert!(
matches!(state_before, ConnectorState::Error(_)),
"Should be in Error state before reconnect"
);
// Send Reconnect command
let cmd_tx = connector.command_tx();
cmd_tx.send(ConnectorCommand::Reconnect).await.unwrap();
// Give it a moment to process reconnect
tokio::time::sleep(Duration::from_millis(500)).await;
// State should transition to Connecting (and then Error again)
let state_after = connector.state();
assert!(
matches!(state_after, ConnectorState::Connecting)
|| matches!(state_after, ConnectorState::Error(_)),
"Should be Connecting or Error after reconnect, got {:?}",
state_after
);
// Clean up
connector.stop();
}
#[tokio::test]
async fn test_t070_command_channel_is_cloneable() {
let connector = create_test_connector("http://localhost:12225");
// Get multiple command senders
let cmd_tx1 = connector.command_tx();
let cmd_tx2 = connector.command_tx();
// Start the connector
let _task_handle = connector.start_task().await;
tokio::time::sleep(Duration::from_millis(100)).await;
// Both senders should work
assert!(cmd_tx1.send(ConnectorCommand::ListSessions).await.is_ok());
assert!(cmd_tx2.send(ConnectorCommand::ListSessions).await.is_ok());
// Clean up
connector.stop();
}
#[tokio::test]
async fn test_t070_multiple_subscriptions() {
let connector = create_test_connector("http://localhost:12225");
// Create multiple subscriptions
let mut events1 = connector.subscribe();
let mut events2 = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
tokio::time::sleep(Duration::from_millis(100)).await;
// Both should receive events
let receive1 = timeout(Duration::from_secs(2), events1.recv());
let receive2 = timeout(Duration::from_secs(2), events2.recv());
// At least one should succeed (we'll get error events from failed connection)
assert!(receive1.await.is_ok() || receive2.await.is_ok());
// Clean up
connector.stop();
}
#[tokio::test]
async fn test_t070_shutdown_command_stops_task() {
let connector = create_test_connector("http://localhost:12225");
// Start the connector
let task_handle = connector.start_task().await;
tokio::time::sleep(Duration::from_millis(100)).await;
// Verify task is running by checking state is not Stopped
let state_before = connector.state();
assert!(!matches!(state_before, ConnectorState::Stopped));
// Send Shutdown command via command channel
let cmd_tx = connector.command_tx();
cmd_tx.send(ConnectorCommand::Shutdown).await.unwrap();
// Task should complete
let result = timeout(Duration::from_secs(5), task_handle).await;
assert!(result.is_ok(), "Task should complete within timeout");
assert!(result.unwrap().is_ok(), "Task should not panic");
// State should be Stopped
assert_eq!(connector.state(), ConnectorState::Stopped);
}
@@ -0,0 +1,508 @@
//! Real OpenCode HTTP API integration tests
//!
//! T078: OpenCode Real HTTP Tests
//!
//! These tests are marked with #[ignore] and require a real OpenCode instance
//! running. Set the DIRIGENT_TEST_API_URL environment variable to enable them.
//!
//! Example:
//! ```bash
//! DIRIGENT_TEST_API_URL=http://localhost:12225 cargo test --package dirigent_core -- --ignored
//! ```
#![cfg(feature = "server")]
use dirigent_core::connectors::opencode::{OpenCodeConfig, OpenCodeConnector};
use dirigent_core::connectors::{Connector, ConnectorCommand};
use dirigent_core::sharing::bus::SharingBus;
use dirigent_core::types::ConnectorState;
use std::env;
use std::time::Duration;
use tokio::time::timeout;
/// Get the test API URL from environment or skip the test
fn get_test_api_url() -> Option<String> {
env::var("DIRIGENT_TEST_API_URL").ok()
}
/// Helper to create a real connector for testing
fn create_real_connector(base_url: &str) -> OpenCodeConnector {
let config = OpenCodeConfig {
base_url: base_url.to_string(),
initial_session: None,
};
OpenCodeConnector::new(
"real-test".to_string(),
uuid::Uuid::nil(),
"Real API Test".to_string(),
config,
SharingBus::new(),
)
}
// ============================================================================
// T078: OpenCode Real HTTP Tests
// ============================================================================
#[tokio::test]
#[ignore]
async fn test_t078_real_connection_successful() {
let Some(api_url) = get_test_api_url() else {
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
return;
};
let connector = create_real_connector(&api_url);
let mut events = connector.subscribe();
// Start the connector
let task_handle = connector.start_task().await;
// Wait for connection
let connected = timeout(Duration::from_secs(10), async {
while let Ok(event) = events.recv().await {
if matches!(event, dirigent_protocol::Event::Connected) {
return true;
}
if matches!(event, dirigent_protocol::Event::Error { .. }) {
eprintln!("Connection error: {:?}", event);
return false;
}
}
false
})
.await;
assert!(
connected.is_ok() && connected.unwrap(),
"Should successfully connect to real OpenCode instance"
);
// Verify state is Ready
assert_eq!(connector.state(), ConnectorState::Ready);
// Clean up
connector.stop();
let _ = timeout(Duration::from_secs(5), task_handle).await;
}
#[tokio::test]
#[ignore]
async fn test_t078_real_list_sessions() {
let Some(api_url) = get_test_api_url() else {
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
return;
};
let connector = create_real_connector(&api_url);
let mut events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
// Wait for connection
let connected = timeout(Duration::from_secs(10), async {
while let Ok(event) = events.recv().await {
if matches!(event, dirigent_protocol::Event::Connected) {
return true;
}
}
false
})
.await;
assert!(
connected.is_ok() && connected.unwrap(),
"Should connect first"
);
// Send ListSessions command
let cmd_tx = connector.command_tx();
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
// Wait for SessionsListed event
let sessions_received = timeout(Duration::from_secs(5), async {
while let Ok(event) = events.recv().await {
if let dirigent_protocol::Event::SessionsListed {
connector_id: _,
sessions,
} = event
{
println!("Received {} sessions", sessions.len());
return Some(sessions);
}
}
None
})
.await;
assert!(
sessions_received.is_ok(),
"Should receive SessionsListed event"
);
let sessions = sessions_received.unwrap();
assert!(sessions.is_some(), "Should have sessions data");
// Clean up
connector.stop();
}
#[tokio::test]
#[ignore]
async fn test_t078_real_list_messages() {
let Some(api_url) = get_test_api_url() else {
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
return;
};
let connector = create_real_connector(&api_url);
let mut events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
// Wait for connection
timeout(Duration::from_secs(10), async {
while let Ok(event) = events.recv().await {
if matches!(event, dirigent_protocol::Event::Connected) {
break;
}
}
})
.await
.ok();
// First, get a session ID by listing sessions
let cmd_tx = connector.command_tx();
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
let session_id = timeout(Duration::from_secs(5), async {
while let Ok(event) = events.recv().await {
if let dirigent_protocol::Event::SessionsListed {
connector_id: _,
sessions,
} = event
{
if let Some(first_session) = sessions.first() {
return Some(first_session.id.clone());
}
}
}
None
})
.await;
if session_id.is_err() || session_id.as_ref().unwrap().is_none() {
println!("No sessions available to test ListMessages");
connector.stop();
return;
}
let session_id = session_id.unwrap().unwrap();
println!("Testing with session: {}", session_id);
// Now list messages for that session
cmd_tx
.send(ConnectorCommand::ListMessages {
session_id: session_id.clone(),
})
.await
.unwrap();
let messages_received = timeout(Duration::from_secs(5), async {
while let Ok(event) = events.recv().await {
if let dirigent_protocol::Event::MessagesListed { messages } = event {
println!("Received {} messages", messages.len());
return Some(messages);
}
}
None
})
.await;
assert!(
messages_received.is_ok(),
"Should receive MessagesListed event"
);
let messages = messages_received.unwrap();
assert!(messages.is_some(), "Should have messages data");
// Clean up
connector.stop();
}
#[tokio::test]
#[ignore]
async fn test_t078_real_send_message() {
let Some(api_url) = get_test_api_url() else {
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
return;
};
let connector = create_real_connector(&api_url);
let mut events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
// Wait for connection
timeout(Duration::from_secs(10), async {
while let Ok(event) = events.recv().await {
if matches!(event, dirigent_protocol::Event::Connected) {
break;
}
}
})
.await
.ok();
// Get a session ID
let cmd_tx = connector.command_tx();
cmd_tx.send(ConnectorCommand::ListSessions).await.unwrap();
let session_id = timeout(Duration::from_secs(5), async {
while let Ok(event) = events.recv().await {
if let dirigent_protocol::Event::SessionsListed {
connector_id: _,
sessions,
} = event
{
if let Some(first_session) = sessions.first() {
return Some(first_session.id.clone());
}
}
}
None
})
.await;
if session_id.is_err() || session_id.as_ref().unwrap().is_none() {
println!("No sessions available to test SendMessage");
connector.stop();
return;
}
let session_id = session_id.unwrap().unwrap();
println!("Sending message to session: {}", session_id);
// Send a message
cmd_tx
.send(ConnectorCommand::SendMessage {
session_id: session_id.clone(),
text: "Test message from integration test".to_string(),
})
.await
.unwrap();
// Wait for response events (MessageStarted, SessionUpdate, MessageCompleted)
let message_events_received = timeout(Duration::from_secs(30), async {
let mut started = false;
let mut parts = 0;
let mut completed = false;
while let Ok(event) = events.recv().await {
match event {
dirigent_protocol::Event::MessageStarted { .. } => {
println!("Received MessageStarted");
started = true;
}
dirigent_protocol::Event::SessionUpdate { .. } => {
parts += 1;
}
dirigent_protocol::Event::MessageCompleted { .. } => {
println!("Received MessageCompleted after {} parts", parts);
completed = true;
break;
}
dirigent_protocol::Event::Error { message } => {
eprintln!("Error during message send: {}", message);
break;
}
_ => {}
}
}
(started, parts, completed)
})
.await;
assert!(
message_events_received.is_ok(),
"Should receive message response events"
);
let (started, parts, completed) = message_events_received.unwrap();
println!(
"Message events: started={}, parts={}, completed={}",
started, parts, completed
);
assert!(started, "Should receive MessageStarted event");
assert!(parts > 0, "Should receive at least one SessionUpdate event");
assert!(completed, "Should receive MessageCompleted event");
// Clean up
connector.stop();
}
#[tokio::test]
#[ignore]
async fn test_t078_real_reconnect_command() {
let Some(api_url) = get_test_api_url() else {
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
return;
};
let connector = create_real_connector(&api_url);
let mut events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
// Wait for initial connection
timeout(Duration::from_secs(10), async {
while let Ok(event) = events.recv().await {
if matches!(event, dirigent_protocol::Event::Connected) {
break;
}
}
})
.await
.ok();
assert_eq!(connector.state(), ConnectorState::Ready);
// Send Reconnect command
let cmd_tx = connector.command_tx();
cmd_tx.send(ConnectorCommand::Reconnect).await.unwrap();
// Should disconnect and reconnect
let reconnected = timeout(Duration::from_secs(10), async {
let mut saw_disconnect = false;
while let Ok(event) = events.recv().await {
match event {
dirigent_protocol::Event::Disconnected => {
println!("Saw disconnect");
saw_disconnect = true;
}
dirigent_protocol::Event::Connected => {
println!("Saw reconnect");
if saw_disconnect {
return true;
}
}
_ => {}
}
}
false
})
.await;
// Note: The reconnect behavior may vary depending on the implementation
// We just verify the command was processed without error
println!(
"Reconnect completed (saw reconnect cycle: {:?})",
reconnected.ok()
);
// Clean up
connector.stop();
}
#[tokio::test]
#[ignore]
async fn test_t078_real_sse_stream_reliability() {
let Some(api_url) = get_test_api_url() else {
println!("Skipping test: DIRIGENT_TEST_API_URL not set");
return;
};
let connector = create_real_connector(&api_url);
let mut events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
// Collect events for 5 seconds
let events_collected = timeout(Duration::from_secs(5), async {
let mut count = 0;
let mut event_types = std::collections::HashMap::new();
while let Ok(event) = events.recv().await {
count += 1;
let type_name = match &event {
dirigent_protocol::Event::Connected => "Connected",
dirigent_protocol::Event::Disconnected => "Disconnected",
dirigent_protocol::Event::SessionCreated { .. } => "SessionCreated",
dirigent_protocol::Event::MessageStarted { .. } => "MessageStarted",
dirigent_protocol::Event::SessionUpdate { .. } => "SessionUpdate",
dirigent_protocol::Event::MessageCompleted { .. } => "MessageCompleted",
dirigent_protocol::Event::Error { .. } => "Error",
_ => "Other",
};
*event_types.entry(type_name).or_insert(0) += 1;
}
(count, event_types)
})
.await;
assert!(
events_collected.is_ok(),
"Should collect events without timeout"
);
let (count, event_types) = events_collected.unwrap();
println!("Collected {} events:", count);
for (type_name, count) in event_types {
println!(" {}: {}", type_name, count);
}
assert!(
count > 0,
"Should receive at least some events from SSE stream"
);
// Clean up
connector.stop();
}
#[tokio::test]
#[ignore]
async fn test_t078_real_error_handling() {
// Test with an invalid URL to verify error handling
let connector = create_real_connector("http://invalid-hostname-that-does-not-exist:99999");
let mut events = connector.subscribe();
// Start the connector
let _task_handle = connector.start_task().await;
// Should receive error events
let error_received = timeout(Duration::from_secs(10), async {
while let Ok(event) = events.recv().await {
if let dirigent_protocol::Event::Error { message } = event {
println!("Received expected error: {}", message);
return true;
}
}
false
})
.await;
assert!(
error_received.is_ok() && error_received.unwrap(),
"Should receive error event"
);
// State should be Error
tokio::time::sleep(Duration::from_secs(2)).await;
assert!(
matches!(connector.state(), ConnectorState::Error(_)),
"State should be Error, got {:?}",
connector.state()
);
// Clean up
connector.stop();
}
+176
View File
@@ -0,0 +1,176 @@
//! Integration test: replay archived session into a `MockStream`.
//!
//! Builds a single-backend in-memory (tempdir) archivist, registers a
//! session, appends 10 messages with ascending timestamps, then exercises
//! `replay_session_to_stream` end-to-end.
use std::sync::Arc;
use chrono::{Duration as ChronoDuration, Utc};
use uuid::Uuid;
use dirigent_archivist::{
Archivist, MessageRecord, RegisterConnectorRequest, RegisterSessionRequest,
backends::JsonlBackend,
};
use dirigent_core::sharing::{
MockStream,
replay::{ReplayOptions, ReplaySpeed, replay_session_to_stream},
};
use dirigent_protocol::streaming::{EventOrigin, SessionStream, StreamScope};
/// Build an in-memory-ish archivist backed by a tempdir + JsonlBackend.
///
/// Matches the pattern used by `dirigent_archivist/tests/integration_tests.rs`.
/// The tempdir is leaked for the duration of the test process — acceptable
/// because the test binary exits immediately after.
async fn build_in_memory_archivist() -> Arc<Archivist> {
let temp_dir = std::env::temp_dir().join(format!("core_replay_test_{}", Uuid::now_v7()));
let backend = Arc::new(
JsonlBackend::new(temp_dir.clone())
.await
.expect("JsonlBackend construction"),
);
let archivist = Archivist::from_single_backend("main".into(), backend)
.await
.expect("Archivist::from_single_backend");
Arc::new(archivist)
}
/// Register a fresh connector + session and append `n` messages with
/// timestamps one second apart. Returns the scroll_id.
async fn seed_session_with_messages(archivist: &Archivist, n: usize) -> Uuid {
let connector_resp = archivist
.register_connector(
RegisterConnectorRequest {
r#type: "OpenCode".to_string(),
title: "Replay Test Connector".to_string(),
client_native_id: format!("replay-test@{}", Uuid::now_v7()),
custom_uid: None,
metadata: serde_json::json!({}),
fingerprint: None,
},
None,
)
.await
.expect("register_connector");
let session_resp = archivist
.register_session(
RegisterSessionRequest {
connector_uid: connector_resp.connector_uid,
native_session_id: format!("native-{}", Uuid::now_v7()),
title: Some("Replay 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
.expect("register_session");
let scroll_id = session_resp.scroll_id;
let base_ts = Utc::now();
let messages: Vec<MessageRecord> = (0..n)
.map(|i| {
let role = if i % 2 == 0 { "user" } else { "assistant" };
MessageRecord {
version: 1,
message_id: Uuid::now_v7(),
session: scroll_id,
parent_id: None,
ts: base_ts + ChronoDuration::seconds(i as i64),
role: role.to_string(),
author: None,
content_md: format!("message {i}"),
content_parts: None,
attachments: vec![],
metadata: serde_json::json!({}),
}
})
.collect();
archivist
.append_messages(scroll_id, messages, None)
.await
.expect("append_messages");
scroll_id
}
#[tokio::test]
async fn replay_delivers_archived_messages_to_stream() {
let archivist = build_in_memory_archivist().await;
let scroll_id = seed_session_with_messages(&archivist, 10).await;
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
let stream: Arc<dyn SessionStream> = mock.clone();
let report = replay_session_to_stream(
archivist.as_ref(),
scroll_id,
stream,
ReplayOptions {
include_meta_events: false,
speed: ReplaySpeed::AsFastAsPossible,
},
)
.await
.expect("replay_session_to_stream");
assert_eq!(report.events_sent, 10, "events_sent");
assert_eq!(report.failures, 0, "failures");
assert_eq!(mock.received_count(), 10, "mock received count");
let received = mock.received.lock().unwrap();
for evt in received.iter() {
assert!(
matches!(evt.origin, EventOrigin::Replay { .. }),
"every replayed event must carry EventOrigin::Replay"
);
assert_eq!(
evt.routing.scroll_id,
Some(scroll_id),
"every replayed event must carry the authoritative scroll_id"
);
}
}
#[tokio::test]
async fn replay_continues_on_stream_failure() {
let archivist = build_in_memory_archivist().await;
let scroll_id = seed_session_with_messages(&archivist, 10).await;
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
mock.fail_next(3);
let stream: Arc<dyn SessionStream> = mock.clone();
let report = replay_session_to_stream(
archivist.as_ref(),
scroll_id,
stream,
ReplayOptions {
include_meta_events: false,
speed: ReplaySpeed::AsFastAsPossible,
},
)
.await
.expect("replay_session_to_stream");
// events_sent counts attempted (ok + failed); failures counts Failed only.
assert_eq!(report.events_sent, 10, "events_sent counts every attempt");
assert_eq!(report.failures, 3, "first 3 events rejected by mock");
assert_eq!(
mock.received_count(),
7,
"mock buffer contains the 7 successful events"
);
}
+750
View File
@@ -0,0 +1,750 @@
#![cfg(feature = "server")]
//! Tests for CoreRuntime operations
//!
//! T072: CoreRuntime::create_connector
//! T073: CoreRuntime::start_connector
//! T074: CoreRuntime::stop_connector
//! T075: CoreRuntime::send_command
use dirigent_core::connectors::{Connector, ConnectorCommand};
use dirigent_core::types::{ConnectorKind, ConnectorState};
use dirigent_core::{ConnectorConfig, CoreConfig, CoreError, CoreRuntime};
use serde_json::json;
/// Helper to create a test runtime
fn create_test_runtime() -> CoreRuntime {
CoreRuntime::new(CoreConfig::default(), None)
}
/// Helper to create an OpenCode connector config
fn create_opencode_config(id: Option<String>, title: &str) -> ConnectorConfig {
ConnectorConfig {
id,
kind: ConnectorKind::OpenCode,
owner: None,
title: Some(title.to_string()),
working_directory: None,
params: json!({
"base_url": "http://localhost:12225",
"title": title,
"initial_session": null
}),
..Default::default()
}
}
// ============================================================================
// T072: CoreRuntime::create_connector
// ============================================================================
#[tokio::test]
async fn test_t072_connector_id_auto_generated_if_not_provided() {
let runtime = create_test_runtime();
let cfg = create_opencode_config(None, "Auto ID Test");
let result = runtime.create_connector(uuid::Uuid::nil(), cfg).await;
assert!(result.is_ok(), "Should create connector successfully");
let connector_id = result.unwrap();
assert!(!connector_id.is_empty(), "Generated ID should not be empty");
// Verify it's a valid UUID format (36 chars with hyphens)
assert_eq!(connector_id.len(), 36, "Should be UUID format");
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
#[tokio::test]
async fn test_t072_connector_uses_provided_id() {
let runtime = create_test_runtime();
let cfg = create_opencode_config(Some("my-custom-id".to_string()), "Custom ID Test");
let result = runtime.create_connector(uuid::Uuid::nil(), cfg).await;
assert!(result.is_ok(), "Should create connector successfully");
assert_eq!(result.unwrap(), "my-custom-id");
// Clean up
runtime
.remove_connector(&"my-custom-id".to_string())
.await
.ok();
}
#[tokio::test]
async fn test_t072_already_exists_error_if_id_conflicts() {
let runtime = create_test_runtime();
let cfg1 = create_opencode_config(Some("duplicate-id".to_string()), "First");
// Create first connector
let result1 = runtime
.create_connector(uuid::Uuid::nil(), cfg1.clone())
.await;
assert!(result1.is_ok(), "First creation should succeed");
// Try to create another with the same ID
let result2 = runtime.create_connector(uuid::Uuid::nil(), cfg1).await;
assert!(result2.is_err(), "Second creation should fail");
assert_eq!(result2.unwrap_err(), CoreError::AlreadyExists);
// Clean up
runtime
.remove_connector(&"duplicate-id".to_string())
.await
.ok();
}
#[tokio::test]
async fn test_t072_connector_appears_in_list_after_creation() {
let runtime = create_test_runtime();
// Initially empty
let list_before = runtime.list_connectors(None).await;
let initial_count = list_before.len();
// Create a connector
let cfg = create_opencode_config(Some("test-conn-1".to_string()), "Test 1");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
// List should now contain it
let list_after = runtime.list_connectors(None).await;
assert_eq!(list_after.len(), initial_count + 1);
let found = list_after.iter().find(|c| c.id == connector_id);
assert!(found.is_some(), "Created connector should be in list");
let connector_summary = found.unwrap();
assert_eq!(connector_summary.id, "test-conn-1");
assert_eq!(connector_summary.title, "Test 1");
assert_eq!(connector_summary.owner, uuid::Uuid::nil());
assert_eq!(connector_summary.kind, ConnectorKind::OpenCode);
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
#[tokio::test]
async fn test_t072_invalid_config_returns_error() {
let runtime = create_test_runtime();
let cfg = ConnectorConfig {
id: None,
kind: ConnectorKind::OpenCode,
owner: None,
title: Some("Invalid".to_string()),
working_directory: None,
params: json!({
"invalid": "config"
// Missing required fields like base_url
}),
..Default::default()
};
let result = runtime.create_connector(uuid::Uuid::nil(), cfg).await;
assert!(result.is_err(), "Should fail with invalid config");
assert_eq!(result.unwrap_err(), CoreError::InvalidConfig);
}
#[tokio::test]
async fn test_t072_mock_connector_not_allowed() {
let runtime = create_test_runtime();
let cfg = ConnectorConfig {
id: None,
kind: ConnectorKind::Mock,
owner: None,
title: Some("Mock".to_string()),
working_directory: None,
params: json!({}),
..Default::default()
};
let result = runtime.create_connector(uuid::Uuid::nil(), cfg).await;
assert!(
result.is_err(),
"Mock connectors should not be creatable via API"
);
assert_eq!(result.unwrap_err(), CoreError::InvalidConfig);
}
#[tokio::test]
async fn test_t072_owner_override() {
let runtime = create_test_runtime();
let mut cfg = create_opencode_config(None, "Owner Test");
// Try to set owner in config
cfg.owner = Some(uuid::Uuid::from_u128(99));
// Create with different owner
let connector_id = runtime
.create_connector(uuid::Uuid::from_u128(42), cfg)
.await
.unwrap();
// Verify owner was overridden
let list = runtime.list_connectors(None).await;
let connector = list.iter().find(|c| c.id == connector_id).unwrap();
assert_eq!(
connector.owner,
uuid::Uuid::from_u128(42),
"Owner should be from parameter, not config"
);
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
// ============================================================================
// T073: CoreRuntime::start_connector
// ============================================================================
#[tokio::test]
async fn test_t073_start_connector_not_found() {
let runtime = create_test_runtime();
let result = runtime.start_connector(&"nonexistent".to_string()).await;
assert!(result.is_err(), "Should fail for nonexistent connector");
assert_eq!(result.unwrap_err(), CoreError::NotFound);
}
#[tokio::test]
async fn test_t073_start_connector_not_yet_implemented() {
// Note: As per the runtime.rs code, starting an existing connector
// is not yet fully implemented. This test documents the current behavior.
let runtime = create_test_runtime();
let cfg = create_opencode_config(Some("start-test".to_string()), "Start Test");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
// Try to start it
let result = runtime.start_connector(&connector_id).await;
// Currently returns an error indicating not implemented
assert!(
result.is_err(),
"Starting existing connector not yet supported"
);
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
// ============================================================================
// T074: CoreRuntime::stop_connector
// ============================================================================
#[tokio::test]
async fn test_t074_stop_connector_not_found() {
let runtime = create_test_runtime();
let result = runtime.stop_connector(&"nonexistent".to_string()).await;
assert!(result.is_err(), "Should fail for nonexistent connector");
assert_eq!(result.unwrap_err(), CoreError::NotFound);
}
#[tokio::test]
async fn test_t074_stop_connector_changes_state_to_stopped() {
let runtime = create_test_runtime();
let cfg = create_opencode_config(Some("stop-test".to_string()), "Stop Test");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
// Get the connector and verify initial state
let connector = runtime.get_connector(&connector_id).await.unwrap();
let initial_state = connector.state();
assert_eq!(initial_state, ConnectorState::Initializing);
// Stop it
let result = runtime.stop_connector(&connector_id).await;
assert!(result.is_ok(), "Stop should succeed");
// Verify state changed to Stopped
let connector = runtime.get_connector(&connector_id).await.unwrap();
let final_state = connector.state();
assert_eq!(final_state, ConnectorState::Stopped);
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
#[tokio::test]
async fn test_t074_stop_connector_idempotent() {
let runtime = create_test_runtime();
let cfg = create_opencode_config(Some("stop-twice".to_string()), "Stop Twice");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
// Stop once
let result1 = runtime.stop_connector(&connector_id).await;
assert!(result1.is_ok(), "First stop should succeed");
// Stop again
let result2 = runtime.stop_connector(&connector_id).await;
assert!(result2.is_ok(), "Second stop should also succeed");
// State should still be Stopped
let connector = runtime.get_connector(&connector_id).await.unwrap();
assert_eq!(connector.state(), ConnectorState::Stopped);
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
// ============================================================================
// T075: CoreRuntime::send_command
// ============================================================================
#[tokio::test]
async fn test_t075_send_command_not_found() {
let runtime = create_test_runtime();
let result = runtime
.send_command(&"nonexistent".to_string(), ConnectorCommand::ListSessions)
.await;
assert!(result.is_err(), "Should fail for nonexistent connector");
assert_eq!(result.unwrap_err(), CoreError::NotFound);
}
#[tokio::test]
#[ignore] // TODO: Fix - cmd_rx is dropped when connector is dropped in create_connector
async fn test_t075_send_command_to_connector() {
let runtime = create_test_runtime();
let cfg = create_opencode_config(Some("cmd-test".to_string()), "Command Test");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
// Get the connector and subscribe to events
let connector = runtime.get_connector(&connector_id).await.unwrap();
let _events = connector.subscribe();
// Send a command via runtime
let result = runtime
.send_command(&connector_id, ConnectorCommand::ListSessions)
.await;
assert!(result.is_ok(), "Send command should succeed");
// Note: The command won't be processed until the connector is started,
// but we've verified that the command was accepted
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
#[ignore] // TODO: Fix - cmd_rx is dropped when connector is dropped in create_connector
#[tokio::test]
async fn test_t075_send_all_command_types() {
let runtime = create_test_runtime();
let cfg = create_opencode_config(Some("all-cmds".to_string()), "All Commands");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
// Send ListSessions
let result = runtime
.send_command(&connector_id, ConnectorCommand::ListSessions)
.await;
assert!(result.is_ok());
// Send ListMessages
let result = runtime
.send_command(
&connector_id,
ConnectorCommand::ListMessages {
session_id: "test-session".to_string(),
},
)
.await;
assert!(result.is_ok());
// Send SendMessage
let result = runtime
.send_command(
&connector_id,
ConnectorCommand::SendMessage {
session_id: "test-session".to_string(),
text: "Hello".to_string(),
},
)
.await;
assert!(result.is_ok());
// Send Reconnect
let result = runtime
.send_command(&connector_id, ConnectorCommand::Reconnect)
.await;
assert!(result.is_ok());
// Send Shutdown
let result = runtime
.send_command(&connector_id, ConnectorCommand::Shutdown)
.await;
assert!(result.is_ok());
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
#[tokio::test]
#[ignore] // TODO: Fix - cmd_rx is dropped when connector is dropped in create_connector
async fn test_t075_send_command_channel_capacity() {
let runtime = create_test_runtime();
let cfg = create_opencode_config(Some("capacity-test".to_string()), "Capacity Test");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
// Send many commands (the channel has capacity 100)
for i in 0..50 {
let result = runtime
.send_command(&connector_id, ConnectorCommand::ListSessions)
.await;
assert!(result.is_ok(), "Command {} should be accepted", i);
}
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
// ============================================================================
// Additional Runtime Tests
// ============================================================================
#[tokio::test]
async fn test_list_connectors_filters_by_owner() {
let runtime = create_test_runtime();
// Create connectors for different users
let cfg1 = create_opencode_config(Some("user1-conn1".to_string()), "User 1 Conn 1");
let cfg2 = create_opencode_config(Some("user1-conn2".to_string()), "User 1 Conn 2");
let cfg3 = create_opencode_config(Some("user2-conn1".to_string()), "User 2 Conn 1");
runtime
.create_connector(uuid::Uuid::nil(), cfg1)
.await
.unwrap();
runtime
.create_connector(uuid::Uuid::nil(), cfg2)
.await
.unwrap();
runtime
.create_connector(uuid::Uuid::from_u128(2), cfg3)
.await
.unwrap();
// List all
let all = runtime.list_connectors(None).await;
assert!(all.len() >= 3, "Should have at least 3 connectors");
// List for user-1
let user1_list = runtime.list_connectors(Some(uuid::Uuid::nil())).await;
assert_eq!(user1_list.len(), 2, "User 1 should have 2 connectors");
// List for user-2
let user2_list = runtime
.list_connectors(Some(uuid::Uuid::from_u128(2)))
.await;
assert_eq!(user2_list.len(), 1, "User 2 should have 1 connector");
// Clean up
runtime
.remove_connector(&"user1-conn1".to_string())
.await
.ok();
runtime
.remove_connector(&"user1-conn2".to_string())
.await
.ok();
runtime
.remove_connector(&"user2-conn1".to_string())
.await
.ok();
}
#[tokio::test]
async fn test_get_connector_returns_some_if_exists() {
let runtime = create_test_runtime();
let cfg = create_opencode_config(Some("get-test".to_string()), "Get Test");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
let result = runtime.get_connector(&connector_id).await;
assert!(result.is_some(), "Should find connector");
let connector = result.unwrap();
assert_eq!(connector.id(), &connector_id);
assert_eq!(connector.title(), "Get Test");
// Clean up
runtime.remove_connector(&connector_id).await.ok();
}
#[tokio::test]
async fn test_get_connector_returns_none_if_not_exists() {
let runtime = create_test_runtime();
let result = runtime.get_connector(&"nonexistent".to_string()).await;
assert!(result.is_none(), "Should not find nonexistent connector");
}
#[tokio::test]
async fn test_remove_connector_success() {
let runtime = create_test_runtime();
let cfg = create_opencode_config(Some("remove-test".to_string()), "Remove Test");
let connector_id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.unwrap();
// Verify it exists
assert!(runtime.get_connector(&connector_id).await.is_some());
// Remove it
let result = runtime.remove_connector(&connector_id).await;
assert!(result.is_ok(), "Remove should succeed");
// Verify it's gone
assert!(runtime.get_connector(&connector_id).await.is_none());
}
#[tokio::test]
async fn test_sharing_bus_subscribe_all() {
let runtime = create_test_runtime();
// Subscribe to every event on the SharingBus — this is the
// replacement for the retired `subscribe_global()` API.
let rx1 = runtime.sharing_bus().subscribe_all().await;
let rx2 = runtime.sharing_bus().subscribe_all().await;
// Verify both subscriptions are valid
drop(rx1);
drop(rx2);
// If this compiles and runs, subscriptions work
}
// ============================================================================
// Issue 2: Zero-Connector State (Regression Test)
// ============================================================================
/// Test that new connectors can be added after removing all existing connectors
///
/// This test verifies the fix for Issue 2: "Removing All Connectors Breaks New Connection"
///
/// Regression test ensures:
/// - Creating first connector works (0 -> 1 transition)
/// - Removing all connectors works (1 -> 0 transition)
/// - Creating connector after zero state works (0 -> 1 again)
/// - No state corruption or race conditions
/// - Config persistence handles empty state correctly
#[tokio::test]
async fn test_issue_2_create_connector_after_removing_all() {
let runtime = create_test_runtime();
// Verify starting state is empty
let initial_connectors = runtime.list_connectors(None).await;
assert_eq!(
initial_connectors.len(),
0,
"Should start with zero connectors"
);
// Create first connector (0 -> 1 transition)
let cfg1 = create_opencode_config(Some("test-connector-1".to_string()), "First Connector");
let id1 = runtime
.create_connector(uuid::Uuid::nil(), cfg1)
.await
.expect("Should create first connector");
let connectors_after_create = runtime.list_connectors(None).await;
assert_eq!(
connectors_after_create.len(),
1,
"Should have 1 connector after creation"
);
// Verify connector is in the list
let connector1 = runtime.get_connector(&id1).await;
assert!(connector1.is_some(), "First connector should exist");
// Remove the connector (1 -> 0 transition)
runtime
.remove_connector(&id1)
.await
.expect("Should remove connector successfully");
let connectors_after_remove = runtime.list_connectors(None).await;
assert_eq!(
connectors_after_remove.len(),
0,
"Should have zero connectors after removal"
);
// Verify connector is gone
let connector1_after_remove = runtime.get_connector(&id1).await;
assert!(
connector1_after_remove.is_none(),
"First connector should not exist after removal"
);
// Create new connector after reaching zero state (0 -> 1 again)
let cfg2 = create_opencode_config(Some("test-connector-2".to_string()), "Second Connector");
let id2 = runtime
.create_connector(uuid::Uuid::nil(), cfg2)
.await
.expect("Should create connector after removing all");
let connectors_final = runtime.list_connectors(None).await;
assert_eq!(
connectors_final.len(),
1,
"Should have 1 connector after recreation"
);
// Verify new connector is in the list
let connector2 = runtime.get_connector(&id2).await;
assert!(connector2.is_some(), "Second connector should exist");
// Verify IDs are different
assert_ne!(id1, id2, "New connector should have different ID");
// Verify new connector is functional (state check)
let connector2_handle = connector2.unwrap();
let state = connector2_handle.state();
assert_eq!(
state,
ConnectorState::Initializing,
"New connector should be initializing"
);
// Clean up
runtime.remove_connector(&id2).await.ok();
}
/// Test rapid remove-create cycles to check for race conditions
///
/// This test verifies that repeated transitions between empty and non-empty
/// connector states don't cause issues with config persistence or internal state.
#[tokio::test]
async fn test_issue_2_rapid_remove_create_cycles() {
let runtime = create_test_runtime();
// Perform 5 cycles of create -> remove
for i in 0..5 {
let cfg = create_opencode_config(
Some(format!("cycle-connector-{}", i)),
&format!("Cycle Test {}", i),
);
// Create connector
let id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.expect(&format!("Should create connector in cycle {}", i));
// Verify it exists
let connectors = runtime.list_connectors(None).await;
assert_eq!(
connectors.len(),
1,
"Should have 1 connector after creation"
);
// Remove connector
runtime
.remove_connector(&id)
.await
.expect(&format!("Should remove connector in cycle {}", i));
// Verify empty state
let connectors = runtime.list_connectors(None).await;
assert_eq!(
connectors.len(),
0,
"Should have 0 connectors after removal"
);
}
// Final verification: Create one more connector to ensure state is still valid
let final_cfg = create_opencode_config(Some("final-connector".to_string()), "Final Test");
let final_id = runtime
.create_connector(uuid::Uuid::nil(), final_cfg)
.await
.expect("Should create connector after rapid cycles");
let final_connectors = runtime.list_connectors(None).await;
assert_eq!(final_connectors.len(), 1, "Should have 1 connector at end");
// Clean up
runtime.remove_connector(&final_id).await.ok();
}
/// Test that SharingBus subscriptions work across zero-connector transitions.
///
/// Ensures SSE subscriptions remain valid when all connectors are removed
/// and new connectors are added.
#[tokio::test]
async fn test_issue_2_global_events_survive_zero_connectors() {
let runtime = create_test_runtime();
// Subscribe to events before any connectors exist.
let _rx = runtime.sharing_bus().subscribe_all().await;
// Create connector
let cfg = create_opencode_config(Some("event-test".to_string()), "Event Test");
let id = runtime
.create_connector(uuid::Uuid::nil(), cfg)
.await
.expect("Should create connector");
// Remove connector (transition to zero)
runtime
.remove_connector(&id)
.await
.expect("Should remove connector");
// Subscribe again after zero state
let _rx2 = runtime.sharing_bus().subscribe_all().await;
// Create another connector
let cfg2 = create_opencode_config(Some("event-test-2".to_string()), "Event Test 2");
let id2 = runtime
.create_connector(uuid::Uuid::nil(), cfg2)
.await
.expect("Should create connector after zero state");
// If we get here, subscriptions survived the zero-connector transition
// Clean up
runtime.remove_connector(&id2).await.ok();
}
@@ -0,0 +1,173 @@
//! Integration test for SharingBus late-bind and filter routing.
//!
//! Exercises the full publish cycle: subscribe with two different filters,
//! publish three events (one before cache population, one that populates the
//! cache, one that is late-bound from the cache), then assert each subscriber
//! saw exactly the events it should.
use std::time::Duration;
use uuid::Uuid;
use dirigent_core::sharing::bus::SharingBus;
use dirigent_protocol::{
Message, MessageRole, MessageStatus, Session, SessionMetadata,
conversation::MessagePart,
streaming::{BusEvent, EventFilter},
Event,
};
// ─── Fixtures ────────────────────────────────────────────────────────────────
fn make_session(id: &str) -> Session {
let now = chrono::Utc::now();
Session {
id: id.to_string(),
title: "test-session".to_string(),
created_at: now,
updated_at: now,
metadata: SessionMetadata {
project_path: "/tmp/test".to_string(),
model: None,
total_messages: 0,
system_message: None,
current_mode_id: None,
_meta: None,
project_id: None,
},
cwd: None,
models: None,
modes: None,
config_options: None,
acp_client_id: None,
}
}
fn make_message(session_id: &str, msg_id: &str) -> Message {
let now = chrono::Utc::now();
Message {
id: msg_id.to_string(),
session_id: session_id.to_string(),
role: MessageRole::Assistant,
created_at: now,
content: vec![MessagePart::Text {
text: "hello".to_string(),
}],
status: MessageStatus::Completed,
metadata: None,
}
}
// ─── Test ─────────────────────────────────────────────────────────────────────
/// Full late-bind cycle:
///
/// - `scroll_rx` is filtered by `EventFilter::ScrollId(scroll_id)`.
/// It should receive events #2 and #3 only (not #1, because the cache was
/// empty when #1 was published).
///
/// - `uid_rx` is filtered by `EventFilter::ConnectorUid(uid)`.
/// It should receive all three events because `connector_uid` is set on
/// every event by `BusEvent::from_connector_event`.
#[tokio::test]
async fn scroll_id_late_bind_via_session_registered() {
let bus = SharingBus::new();
let uid = Uuid::new_v4();
let scroll_id = Uuid::new_v4();
let mut scroll_rx = bus
.subscribe_filtered(EventFilter::ScrollId(scroll_id), 32)
.await;
let mut uid_rx = bus
.subscribe_filtered(EventFilter::ConnectorUid(uid), 32)
.await;
// Event #1: SessionCreated — cache is empty, scroll_id will not be
// late-bound. The ScrollId subscriber must NOT see this event.
bus.publish(BusEvent::from_connector_event(
Event::SessionCreated {
connector_id: "mock".into(),
session: make_session("abc"),
},
Some(uid),
"mock".into(),
))
.await;
// Event #2: SessionRegistered — populates the cache
// (`connector_id="mock"`, `session_id="abc"`) → `scroll_id`.
// The bus also sets `routing.scroll_id` on this event itself, so the
// ScrollId subscriber DOES see it.
bus.publish(BusEvent::from_connector_event(
Event::SessionRegistered {
connector_id: "mock".into(),
session_id: "abc".into(),
scroll_id: scroll_id.to_string(),
},
Some(uid),
"mock".into(),
))
.await;
// Event #3: MessageCompleted — no scroll_id on entry, but the bus
// late-binds it from the cache via (connector_id="mock", session_id="abc").
// The ScrollId subscriber DOES see it.
bus.publish(BusEvent::from_connector_event(
Event::MessageCompleted {
connector_id: "mock".into(),
message: make_message("abc", "m1"),
},
Some(uid),
"mock".into(),
))
.await;
// Give the bus worker time to dispatch all three events.
tokio::time::sleep(Duration::from_millis(50)).await;
// Drain what each subscriber received.
let mut scroll_events: Vec<BusEvent> = Vec::new();
while let Ok(Some(e)) =
tokio::time::timeout(Duration::from_millis(20), scroll_rx.rx.recv()).await
{
scroll_events.push(e);
}
let mut uid_events: Vec<BusEvent> = Vec::new();
while let Ok(Some(e)) =
tokio::time::timeout(Duration::from_millis(20), uid_rx.rx.recv()).await
{
uid_events.push(e);
}
// ScrollId subscriber: only events #2 and #3.
assert_eq!(
scroll_events.len(),
2,
"ScrollId subscriber should see events #2 (SessionRegistered) and #3 (MessageCompleted) only; got {:?}",
scroll_events.iter().map(|e| format!("{:?}", e.event)).collect::<Vec<_>>()
);
assert!(
matches!(scroll_events[0].event.as_ref(), Event::SessionRegistered { .. }),
"first scroll event should be SessionRegistered"
);
assert!(
matches!(scroll_events[1].event.as_ref(), Event::MessageCompleted { .. }),
"second scroll event should be MessageCompleted"
);
// Both must carry the correct scroll_id.
assert_eq!(scroll_events[0].routing.scroll_id, Some(scroll_id));
assert_eq!(scroll_events[1].routing.scroll_id, Some(scroll_id));
// ConnectorUid subscriber: all three.
assert_eq!(
uid_events.len(),
3,
"ConnectorUid subscriber should see all three events; got {:?}",
uid_events.iter().map(|e| format!("{:?}", e.event)).collect::<Vec<_>>()
);
for ev in &uid_events {
assert_eq!(ev.routing.connector_uid, Some(uid));
}
}
@@ -0,0 +1,156 @@
//! Integration tests for `StreamRegistry`: scope-based filtering, health
//! drift, and `detach()` shutdown semantics.
//!
//! Uses the `MockStream` from `dirigent_core::sharing` (enabled via the
//! `test-utils` feature; see `required-features` in `Cargo.toml`).
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;
use dirigent_core::sharing::MockStream;
use dirigent_core::sharing::bus::SharingBus;
use dirigent_core::sharing::registry::StreamRegistry;
use dirigent_protocol::{
Event,
streaming::{
BusEvent, EventKind, EventOrigin, EventRouting, StreamScope,
},
};
// ─── Helpers ────────────────────────────────────────────────────────────────
/// Build a `BusEvent` with an explicit `scroll_id` in the routing so the
/// bus does not need to consult its late-bind cache. `connector_uid` is
/// populated too so tests can mix-and-match filters.
fn make_scoped_event(scroll_id: Uuid, connector_uid: Uuid) -> BusEvent {
BusEvent {
routing: EventRouting {
scroll_id: Some(scroll_id),
connector_uid: Some(connector_uid),
connector_id: Some("conn-test".to_string()),
native_session_id: None,
kind: EventKind::System,
},
origin: EventOrigin::Runtime,
event: Arc::new(Event::Connected),
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[tokio::test]
async fn mock_stream_receives_only_session_scoped_events() {
let bus = SharingBus::new();
let registry = StreamRegistry::new(Arc::clone(&bus));
let scroll_x = Uuid::now_v7();
let scroll_y = Uuid::now_v7();
let connector_uid = Uuid::now_v7();
let mock = MockStream::new("mock", StreamScope::Session {
scroll_id: scroll_x,
});
let _id = registry.attach("mock".to_string(), mock.clone()).await;
// One event in-scope (scroll_x), two out-of-scope (scroll_y).
bus.publish(make_scoped_event(scroll_x, connector_uid))
.await;
bus.publish(make_scoped_event(scroll_y, connector_uid))
.await;
bus.publish(make_scoped_event(scroll_y, connector_uid))
.await;
// Give the bus worker + stream worker a chance to process.
tokio::time::sleep(Duration::from_millis(50)).await;
let count = mock.received_count();
assert_eq!(
count,
1,
"expected 1 in-scope event, got {count}",
);
assert_eq!(
mock.received.lock().unwrap()[0].routing.scroll_id,
Some(scroll_x)
);
}
#[tokio::test]
async fn health_drifts_to_unavailable_after_five_failures() {
use dirigent_core::sharing::HealthStatus;
let bus = SharingBus::new();
let registry = StreamRegistry::new(Arc::clone(&bus));
let scroll_id = Uuid::now_v7();
let connector_uid = Uuid::now_v7();
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
mock.fail_next(5);
let _id = registry.attach("mock".to_string(), mock.clone()).await;
// Five consecutive failures should drift Healthy → Unavailable.
for _ in 0..5 {
bus.publish(make_scoped_event(scroll_id, connector_uid))
.await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
let infos = registry.list().await;
assert_eq!(infos.len(), 1);
match &infos[0].health {
HealthStatus::Unavailable { reason } => {
assert!(
reason.contains("5 failures"),
"expected reason to mention the failure count, got: {reason}"
);
}
other => panic!("expected Unavailable after 5 failures, got {:?}", other),
}
assert_eq!(infos[0].lagged_count, 5);
}
#[tokio::test]
async fn detach_invokes_shutdown_and_stops_delivery() {
let bus = SharingBus::new();
let registry = StreamRegistry::new(Arc::clone(&bus));
let scroll_id = Uuid::now_v7();
let connector_uid = Uuid::now_v7();
let mock = MockStream::new("mock", StreamScope::Session { scroll_id });
let id = registry.attach("mock".to_string(), mock.clone()).await;
// One in-scope event before detach to prove delivery works at all.
bus.publish(make_scoped_event(scroll_id, connector_uid))
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(mock.received_count(), 1, "pre-detach delivery failed");
// Detach — worker should stop, shutdown should run.
let reg = registry.detach(id).await.expect("stream should exist");
// Wait for the worker to finish so we know the post-detach publish
// cannot possibly reach the stream.
let _ = tokio::time::timeout(Duration::from_millis(500), async {
let handle = &reg.worker;
while !handle.is_finished() {
tokio::task::yield_now().await;
}
})
.await;
// Publish post-detach; the stream must not receive it.
bus.publish(make_scoped_event(scroll_id, connector_uid))
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
mock.received_count(),
1,
"stream received an event after detach"
);
assert!(registry.list().await.is_empty());
}
@@ -0,0 +1,153 @@
//! Integration tests for ACP transport layer.
//!
//! These tests verify both stdio and HTTP transport implementations work correctly.
use dirigent_core::acp::transport::{
JsonRpcError, JsonRpcErrorResponse, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse,
JsonRpcResult, TransportError, TransportState,
};
/// Test JSON-RPC request creation and serialization.
#[test]
fn test_jsonrpc_request() {
let req = JsonRpcRequest::new(
1,
"test_method",
Some(serde_json::json!({"param1": "value1"})),
);
assert_eq!(req.jsonrpc, "2.0");
assert_eq!(req.id, serde_json::Value::Number(1.into()));
assert_eq!(req.method, "test_method");
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"id\":1"));
assert!(json.contains("\"method\":\"test_method\""));
assert!(json.contains("\"param1\":\"value1\""));
}
/// Test JSON-RPC notification creation and serialization.
#[test]
fn test_jsonrpc_notification() {
let notif = JsonRpcNotification::new(
"test_notification",
Some(serde_json::json!({"data": "test"})),
);
assert_eq!(notif.jsonrpc, "2.0");
assert_eq!(notif.method, "test_notification");
let json = serde_json::to_string(&notif).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"method\":\"test_notification\""));
// Notifications don't have an "id" field
assert!(!json.contains("\"id\""));
}
/// Test JSON-RPC response deserialization.
#[test]
fn test_jsonrpc_response_deserialization() {
let json = r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok"}}"#;
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.jsonrpc, "2.0");
assert_eq!(response.id, serde_json::Value::Number(1.into()));
assert_eq!(response.result.get("status").unwrap(), "ok");
}
/// Test JSON-RPC error response deserialization.
#[test]
fn test_jsonrpc_error_response_deserialization() {
let json = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Invalid Request"}}"#;
let error_response: JsonRpcErrorResponse = serde_json::from_str(json).unwrap();
assert_eq!(error_response.jsonrpc, "2.0");
assert_eq!(error_response.id, serde_json::Value::Number(1.into()));
assert_eq!(error_response.error.code, -32600);
assert_eq!(error_response.error.message, "Invalid Request");
}
/// Test JsonRpcResult helper methods.
#[test]
fn test_jsonrpc_result_helpers() {
// Test success result
let success_response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::Value::Number(1.into()),
result: serde_json::json!({"status": "ok"}),
};
let result = JsonRpcResult::Success(success_response);
assert!(result.is_success());
assert!(!result.is_error());
assert!(result.result().is_some());
assert!(result.error().is_none());
assert_eq!(result.result().unwrap().get("status").unwrap(), "ok");
// Test error result
let error_response = JsonRpcErrorResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::Value::Number(2.into()),
error: JsonRpcError {
code: -32600,
message: "Invalid Request".to_string(),
data: None,
},
};
let result = JsonRpcResult::Error(error_response);
assert!(!result.is_success());
assert!(result.is_error());
assert!(result.result().is_none());
assert!(result.error().is_some());
assert_eq!(result.error().unwrap().code, -32600);
}
/// Test JsonRpcResult conversion to Result.
#[test]
fn test_jsonrpc_result_into_result() {
// Success case
let success_response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::Value::Number(1.into()),
result: serde_json::json!({"status": "ok"}),
};
let result = JsonRpcResult::Success(success_response);
let converted = result.into_result();
assert!(converted.is_ok());
// Error case
let error_response = JsonRpcErrorResponse {
jsonrpc: "2.0".to_string(),
id: serde_json::Value::Number(2.into()),
error: JsonRpcError {
code: -32600,
message: "Invalid Request".to_string(),
data: None,
},
};
let result = JsonRpcResult::Error(error_response);
let converted = result.into_result();
assert!(converted.is_err());
match converted {
Err(TransportError::JsonRpcError(error)) => {
assert_eq!(error.code, -32600);
assert_eq!(error.message, "Invalid Request");
}
_ => panic!("Expected JsonRpcError"),
}
}
/// Test transport state enum.
#[test]
fn test_transport_state() {
assert_eq!(TransportState::Disconnected, TransportState::Disconnected);
assert_ne!(TransportState::Connected, TransportState::Disconnected);
let state = TransportState::Connecting;
assert!(matches!(state, TransportState::Connecting));
}
// Note: SSE event extraction is tested in http.rs unit tests