chore: rename packages/ to crates/

Move all 29 workspace members from packages/<name>/ to crates/<name>/.
Updates: workspace Cargo.toml (members + path deps), justfile, root
CLAUDE.md, scripts/build/CARGO_INSTALL.md, docs/architecture/crates.md
(renamed from packages.md), structural references in docs/architecture
and docs/configuration, per-crate CLAUDE.md self-references. Historical
plans, reports, and building/ docs are left untouched.

No behavior change; just check-all stays green and fermata tests pass.
This commit is contained in:
2026-04-30 21:58:57 +02:00
commit c62d8daea8
34 changed files with 12268 additions and 0 deletions
+433
View File
@@ -0,0 +1,433 @@
//! Integration test for dirigate bridge connecting to Dirigent ACP Server.
//!
//! This test validates the end-to-end flow:
//! 1. Start Dirigent ACP Server on a test port (or assume one is running)
//! 2. Use dirigate bridge to connect via stdio
//! 3. Verify connection and communication works
//!
//! ## Running the tests
//!
//! ### Quick tests (no server required)
//!
//! These tests verify the bridge binary can be spawned and shows help:
//!
//! ```bash
//! # Run all non-ignored tests
//! cargo test --package dirigate --test dirigent_bridge_test
//!
//! # Or run specific tests
//! cargo test --package dirigate --test dirigent_bridge_test test_conductor_bridge_spawns
//! cargo test --package dirigate --test dirigent_bridge_test test_conductor_bridge_protocol
//! ```
//!
//! ### Full integration test (requires running server)
//!
//! The main integration test requires a Dirigent ACP Server running on http://localhost:3001/acp.
//!
//! **Step 1:** Start the Dirigent server
//! ```bash
//! # Option A: Via web UI
//! cargo run --package web
//! # Then enable ACP Server in the UI at Configuration > ACP Server
//!
//! # Option B: Via dirigent_core directly (if implemented)
//! # cargo run --package dirigent_core -- acp-server --port 3001
//! ```
//!
//! **Step 2:** Run the integration test
//! ```bash
//! cargo test --package dirigate --test dirigent_bridge_test test_conductor_bridge_to_dirigent -- --ignored --nocapture
//! ```
//!
//! ### Programmatic server test (future)
//!
//! This test demonstrates starting a minimal ACP server programmatically for testing.
//! It's currently marked as ignored until the full implementation is complete.
//!
//! ```bash
//! cargo test --package dirigate --test dirigent_bridge_test test_bridge_with_programmatic_server -- --ignored --nocapture
//! ```
//!
//! ## Test overview
//!
//! - `test_conductor_bridge_spawns` - Verifies the bridge binary exists and can show help (always runs)
//! - `test_conductor_bridge_protocol` - Basic protocol verification (always runs)
//! - `test_conductor_bridge_to_dirigent` - Full end-to-end test with real server (ignored by default)
//! - `test_bridge_with_programmatic_server` - Test with programmatically started server (ignored, future)
use anyhow::Result;
use serde_json::{json, Value};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
/// Path to the dirigate binary (debug build).
fn conductor_binary() -> std::path::PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
std::path::Path::new(manifest_dir)
.parent()
.unwrap()
.parent()
.unwrap()
.join("target")
.join("debug")
.join(if cfg!(windows) {
"dirigate.exe"
} else {
"dirigate"
})
}
/// Helper to send a JSON-RPC request to the bridge and read the response.
///
/// This function writes a JSON-RPC request to stdin and reads responses from stdout.
/// It skips notifications (messages with "method" but no "id") and returns only
/// the response matching the request ID.
async fn send_bridge_request(
stdin: &mut tokio::process::ChildStdin,
stdout: &mut BufReader<tokio::process::ChildStdout>,
request: Value,
timeout_secs: u64,
) -> Result<Value> {
// Send request
let request_json = serde_json::to_string(&request)?;
let request_id = request.get("id").cloned();
stdin.write_all(request_json.as_bytes()).await?;
stdin.write_all(b"\n").await?;
stdin.flush().await?;
// Read response, skipping notifications
let start = std::time::Instant::now();
loop {
if start.elapsed() > std::time::Duration::from_secs(timeout_secs) {
anyhow::bail!("Timeout waiting for response to request {:?}", request_id);
}
let mut line = String::new();
match tokio::time::timeout(
std::time::Duration::from_secs(2),
stdout.read_line(&mut line),
)
.await
{
Ok(Ok(0)) => {
anyhow::bail!("EOF reached before receiving response");
}
Ok(Ok(_)) => {
let msg: Value = match serde_json::from_str(line.trim()) {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to parse line: {}", line.trim());
eprintln!("Error: {}", e);
return Err(e.into());
}
};
// Skip notifications (they have "method" but no "id")
if msg.get("method").is_some() && msg.get("id").is_none() {
eprintln!("Skipping notification: {:?}", msg.get("method"));
continue;
}
// Check if this is the response we're looking for
if let Some(id) = request_id.as_ref() {
if msg.get("id") == Some(id) {
return Ok(msg);
} else {
eprintln!(
"Got response with id {:?}, expected {:?}",
msg.get("id"),
id
);
continue;
}
} else {
// No ID means we're looking for any response
return Ok(msg);
}
}
Ok(Err(e)) => {
anyhow::bail!("IO error reading from stdout: {}", e);
}
Err(_) => {
// Timeout on this read, loop and try again
continue;
}
}
}
}
/// Test that dirigate bridge can be spawned and shows help.
#[tokio::test]
async fn test_conductor_bridge_spawns() -> Result<()> {
let binary = conductor_binary();
if !binary.exists() {
eprintln!(
"Conductor binary not found at {:?}. Run 'cargo build --package dirigate' first.",
binary
);
return Ok(()); // Skip test if binary doesn't exist
}
// Test that --help works
let output = Command::new(&binary)
.args(&["bridge", "--help"])
.output()
.await?;
assert!(
output.status.success(),
"conductor bridge --help should succeed"
);
let stdout = String::from_utf8(output.stdout)?;
assert!(
stdout.contains("bridge") || stdout.contains("Bridge"),
"Help output should mention bridge. Got: {}",
stdout
);
assert!(
stdout.contains("server") || stdout.contains("ACP"),
"Help output should mention server or ACP"
);
println!("✓ Conductor bridge binary spawns successfully");
Ok(())
}
/// Test that dirigate bridge can connect to Dirigent ACP Server.
///
/// **Prerequisites:**
/// - A Dirigent ACP Server must be running on http://localhost:8080/acp
/// - Start the live server with ACP enabled (Configuration > ACP Server)
///
/// **Note:** By default, ACP is nested at /acp on the main Dioxus server (port 8080).
/// If you configured ACP to run on a separate port, adjust the server_url below.
///
/// **To run this test:**
/// ```bash
/// # Start server first (enable ACP Server in Configuration UI)
/// dx serve --package web
///
/// # Then run test
/// cargo test --package dirigate --test dirigent_bridge_test test_conductor_bridge_to_dirigent -- --ignored --nocapture
/// ```
#[tokio::test]
#[ignore = "Requires running Dirigent ACP Server on localhost:8080"]
async fn test_conductor_bridge_to_dirigent() -> Result<()> {
let binary = conductor_binary();
if !binary.exists() {
eprintln!(
"Conductor binary not found at {:?}. Run 'cargo build --package dirigate' first.",
binary
);
return Ok(()); // Skip test if binary doesn't exist
}
let server_url = "http://localhost:8080/acp";
println!("Starting dirigate bridge to {}", server_url);
// Start dirigate bridge process
let mut dirigate = Command::new(&binary)
.args(&["bridge", "--server-url", server_url, "--verbose"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped()) // Capture stderr for debugging
.spawn()?;
// Get handles for stdin/stdout
let mut stdin = dirigate.stdin.take().expect("Failed to get stdin");
let stdout = dirigate.stdout.take().expect("Failed to get stdout");
let mut reader = BufReader::new(stdout);
println!("Bridge process started, sending initialize request");
// Send initialize request
let initialize_request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": 1,
"clientCapabilities": {
"tools": {
"supported": true
}
}
}
});
let response = send_bridge_request(&mut stdin, &mut reader, initialize_request, 10).await?;
// Verify we got a valid JSON-RPC response
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 1);
// Check for result or error
if response.get("result").is_some() {
println!("✓ Successfully connected to Dirigent server via dirigate bridge");
println!(
"Initialize response: {}",
serde_json::to_string_pretty(&response)?
);
// Verify the response has expected fields
let result = &response["result"];
assert!(
result.get("protocolVersion").is_some()
|| result.get("agentCapabilities").is_some()
|| result.get("client_id").is_some(),
"Initialize result should have protocol version, capabilities, or client_id"
);
} else if response.get("error").is_some() {
eprintln!("✗ Got error response from server:");
eprintln!("{}", serde_json::to_string_pretty(&response)?);
anyhow::bail!("Server returned error: {:?}", response["error"]);
} else {
anyhow::bail!("Response has neither result nor error: {:?}", response);
}
// Optional: Test creating a session
println!("\nTesting session creation...");
let session_request = json!({
"jsonrpc": "2.0",
"method": "session/new",
"id": 2,
"params": {
"cwd": ".",
"mcpServers": []
}
});
let session_response = send_bridge_request(&mut stdin, &mut reader, session_request, 10).await?;
assert_eq!(session_response["jsonrpc"], "2.0");
assert_eq!(session_response["id"], 2);
if session_response.get("result").is_some() {
let session_id = session_response["result"]["sessionId"]
.as_str()
.expect("sessionId should be a string");
println!("✓ Created session: {}", session_id);
println!(
"Session response: {}",
serde_json::to_string_pretty(&session_response)?
);
} else if session_response.get("error").is_some() {
eprintln!("Got error creating session:");
eprintln!("{}", serde_json::to_string_pretty(&session_response)?);
// Don't fail the test - session creation might not be implemented yet
println!("⚠ Session creation returned error (may not be implemented yet)");
}
// Clean up
dirigate.kill().await?;
Ok(())
}
/// Test dirigate bridge with a simulated server response (unit test style).
///
/// This test doesn't require a real server - it just verifies the bridge
/// can be spawned and interacts correctly at the protocol level.
#[tokio::test]
async fn test_conductor_bridge_protocol() -> Result<()> {
let binary = conductor_binary();
if !binary.exists() {
return Ok(()); // Skip if binary not built
}
// This test would ideally mock the HTTP server, but that's complex.
// For now, we just verify the binary exists and can be invoked.
// A full mock server test could be added later.
let output = Command::new(&binary)
.args(&["bridge", "--help"])
.output()
.await?;
assert!(output.status.success());
println!("✓ Bridge protocol test passed (basic verification)");
Ok(())
}
/// Test that demonstrates how to start a minimal Dirigent ACP Server programmatically.
///
/// **NOTE:** This test is currently a placeholder showing the structure.
/// It requires the full dirigent_acp_api to be implemented with a way to
/// start the server programmatically.
#[tokio::test]
#[ignore = "Requires full dirigent_acp_api implementation"]
async fn test_bridge_with_programmatic_server() -> Result<()> {
use dirigent_acp_api::{create_acp_server_router, AcpServerConfig, AcpServerState, NoOpConnectorOperations};
// Create server configuration
let config = AcpServerConfig::enabled().set_port(0); // Use random port
// Create server state
let state = AcpServerState::new(config.clone());
// Create the router with no-op connector operations
let router = create_acp_server_router(state, NoOpConnectorOperations);
// Start server in background
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let server_addr = listener.local_addr()?;
let server_url = format!("http://{}/acp", server_addr);
println!("Started test server at {}", server_url);
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
// Give server time to start
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Now test the bridge
let binary = conductor_binary();
if !binary.exists() {
return Ok(());
}
let mut dirigate = Command::new(&binary)
.args(&["bridge", "--server-url", &server_url])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = dirigate.stdin.take().unwrap();
let stdout = dirigate.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
// Send initialize
let init_request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": 1,
"clientCapabilities": {}
}
});
let response = send_bridge_request(&mut stdin, &mut reader, init_request, 5).await?;
assert_eq!(response["jsonrpc"], "2.0");
assert!(
response.get("result").is_some() || response.get("error").is_some(),
"Should get a valid response"
);
println!("✓ Bridge successfully connected to programmatic server");
dirigate.kill().await?;
Ok(())
}
+278
View File
@@ -0,0 +1,278 @@
//! Integration tests for fixture loading and validation.
use dirigate::fixture::{load_and_validate, load_fixture, load_fixtures_from_dir, validate_fixture};
use std::path::PathBuf;
fn test_fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join(name)
}
#[tokio::test]
async fn test_load_valid_basic_fixture() {
let path = test_fixture_path("valid_basic.yaml");
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
assert_eq!(fixture.version, "0.1");
assert_eq!(fixture.sessions.len(), 1);
assert_eq!(fixture.sessions[0].id, "session-1");
assert_eq!(fixture.sessions[0].title, "Basic Test Session");
assert_eq!(fixture.sessions[0].participants.len(), 2);
assert_eq!(fixture.sessions[0].messages.len(), 2);
}
#[tokio::test]
async fn test_load_valid_complex_fixture() {
let path = test_fixture_path("valid_complex.yaml");
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
assert_eq!(fixture.version, "0.1");
assert_eq!(fixture.sessions.len(), 2);
// Check first session
let session1 = &fixture.sessions[0];
assert_eq!(session1.id, "session-chat");
assert_eq!(session1.participants.len(), 3);
assert_eq!(session1.messages.len(), 4);
// Check message metadata
let msg3 = &session1.messages[2];
assert!(msg3.metadata.is_some());
// Check second session with behavior override
let session2 = &fixture.sessions[1];
assert_eq!(session2.id, "session-debug");
assert!(session2.behavior.is_some());
// Check responders config
assert_eq!(fixture.responders.keyword_map.len(), 2);
assert!(fixture.responders.random.is_some());
}
#[tokio::test]
async fn test_validate_valid_basic_fixture() {
let path = test_fixture_path("valid_basic.yaml");
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
// Should validate successfully
validate_fixture(&fixture).expect("Fixture validation failed");
}
#[tokio::test]
async fn test_validate_valid_complex_fixture() {
let path = test_fixture_path("valid_complex.yaml");
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
// Should validate successfully
validate_fixture(&fixture).expect("Fixture validation failed");
}
#[tokio::test]
async fn test_load_and_validate_valid() {
let path = test_fixture_path("valid_basic.yaml");
let fixture = load_and_validate(&path).await.expect("Failed to load and validate");
assert_eq!(fixture.version, "0.1");
}
#[tokio::test]
async fn test_load_invalid_version() {
let path = test_fixture_path("invalid_version.yaml");
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
let result = validate_fixture(&fixture);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Invalid version"));
}
#[tokio::test]
async fn test_load_and_validate_invalid_version() {
let path = test_fixture_path("invalid_version.yaml");
let result = load_and_validate(&path).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Invalid version"));
}
#[tokio::test]
async fn test_validate_duplicate_session_ids() {
let path = test_fixture_path("invalid_duplicate_session.yaml");
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
let result = validate_fixture(&fixture);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Duplicate session ID"));
}
#[tokio::test]
async fn test_load_bad_yaml() {
let path = test_fixture_path("invalid_bad_yaml.yaml");
let result = load_fixture(&path).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to parse YAML"));
}
#[tokio::test]
async fn test_validate_bad_timestamp() {
let path = test_fixture_path("invalid_bad_timestamp.yaml");
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
let result = validate_fixture(&fixture);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("invalid ISO8601 timestamp"));
}
#[tokio::test]
async fn test_load_nonexistent_file() {
let path = test_fixture_path("does_not_exist.yaml");
let result = load_fixture(&path).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to read file"));
}
#[tokio::test]
async fn test_load_fixtures_from_directory() {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures");
let fixtures = load_fixtures_from_dir(&dir).await.expect("Failed to load fixtures from directory");
// Should load the 2 valid fixtures, skipping the invalid ones
assert_eq!(fixtures.len(), 2);
// Verify we got the right fixtures
let ids: Vec<_> = fixtures.iter()
.flat_map(|f| f.sessions.iter().map(|s| s.id.as_str()))
.collect();
assert!(ids.contains(&"session-1") || ids.contains(&"session-chat"));
}
#[tokio::test]
async fn test_load_fixtures_from_empty_directory() {
// Create a temporary empty directory
let temp_dir = std::env::temp_dir().join("dirigent_test_empty");
tokio::fs::create_dir_all(&temp_dir).await.ok();
let fixtures = load_fixtures_from_dir(&temp_dir).await.expect("Failed to load from empty directory");
// Should return empty vector, not error
assert_eq!(fixtures.len(), 0);
// Cleanup
tokio::fs::remove_dir_all(&temp_dir).await.ok();
}
#[tokio::test]
async fn test_message_parent_references() {
let path = test_fixture_path("valid_basic.yaml");
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
// Check that parent_id references are valid
let session = &fixture.sessions[0];
let msg2 = &session.messages[1];
assert_eq!(msg2.parent_id, Some("msg-1".to_string()));
}
#[tokio::test]
async fn test_session_behavior_overrides() {
let path = test_fixture_path("valid_complex.yaml");
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
// Second session should have behavior overrides
let session2 = &fixture.sessions[1];
assert!(session2.behavior.is_some());
let behavior = session2.behavior.as_ref().unwrap();
assert!(behavior.responder.is_some());
assert!(behavior.streaming.is_some());
}
#[tokio::test]
async fn test_participant_kinds() {
use dirigate::fixture::ParticipantKind;
let path = test_fixture_path("valid_complex.yaml");
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
let session = &fixture.sessions[0];
let participant_kinds: Vec<_> = session.participants.iter()
.map(|p| &p.kind)
.collect();
assert!(participant_kinds.contains(&&ParticipantKind::User));
assert!(participant_kinds.contains(&&ParticipantKind::Assistant));
assert!(participant_kinds.contains(&&ParticipantKind::System));
}
#[tokio::test]
async fn test_message_roles() {
use dirigate::fixture::MessageRole;
let path = test_fixture_path("valid_basic.yaml");
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
let messages = &fixture.sessions[0].messages;
assert_eq!(messages[0].role, MessageRole::User);
assert_eq!(messages[1].role, MessageRole::Assistant);
}
#[tokio::test]
async fn test_responder_strategies() {
use dirigate::fixture::ResponderStrategy;
let path = test_fixture_path("valid_basic.yaml");
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
assert_eq!(fixture.responders.default_strategy, ResponderStrategy::Echo);
let path2 = test_fixture_path("valid_complex.yaml");
let fixture2 = load_and_validate(&path2).await.expect("Failed to load fixture");
assert_eq!(fixture2.responders.default_strategy, ResponderStrategy::Keywords);
}
#[tokio::test]
async fn test_streaming_configuration() {
let path = test_fixture_path("valid_basic.yaml");
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
assert!(fixture.streaming.enabled);
assert_eq!(fixture.streaming.tokens_per_chunk, 5);
assert_eq!(fixture.streaming.chunk_interval_ms, 100);
assert_eq!(fixture.streaming.jitter_ms, Some(20));
}
#[tokio::test]
async fn test_keyword_map() {
let path = test_fixture_path("valid_basic.yaml");
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
assert_eq!(fixture.responders.keyword_map.get("hello"), Some(&"Hi there!".to_string()));
assert_eq!(fixture.responders.keyword_map.get("help"), Some(&"I can assist you with various tasks.".to_string()));
}
#[tokio::test]
async fn test_random_config() {
let path = test_fixture_path("valid_complex.yaml");
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
let random_config = fixture.responders.random.as_ref().expect("Missing random config");
assert_eq!(random_config.seed, 42);
assert_eq!(random_config.corpus.len(), 3);
}
+17
View File
@@ -0,0 +1,17 @@
version: "0.1"
sessions:
- id: "session-1"
title: "Test Session"
created_at: "not-a-valid-date"
participants: []
messages: []
responders:
keyword_map: {}
default_strategy: echo
streaming:
enabled: false
tokens_per_chunk: 1
chunk_interval_ms: 100
+5
View File
@@ -0,0 +1,5 @@
version: "0.1"
sessions:
- id: "session-1
title: "Unclosed quote
this is not valid yaml: [broken
+22
View File
@@ -0,0 +1,22 @@
version: "0.1"
sessions:
- id: "session-1"
title: "First Session"
created_at: "2024-01-01T00:00:00Z"
participants: []
messages: []
- id: "session-1"
title: "Duplicate Session"
created_at: "2024-01-01T00:00:00Z"
participants: []
messages: []
responders:
keyword_map: {}
default_strategy: echo
streaming:
enabled: false
tokens_per_chunk: 1
chunk_interval_ms: 100
+17
View File
@@ -0,0 +1,17 @@
version: "0.2"
sessions:
- id: "session-1"
title: "Test Session"
created_at: "2024-01-01T00:00:00Z"
participants: []
messages: []
responders:
keyword_map: {}
default_strategy: echo
streaming:
enabled: false
tokens_per_chunk: 1
chunk_interval_ms: 100
+37
View File
@@ -0,0 +1,37 @@
version: "0.1"
sessions:
- id: "session-1"
title: "Basic Test Session"
created_at: "2024-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: "session-1"
role: user
content: "Hello, assistant!"
created_at: "2024-01-01T00:00:01Z"
- id: "msg-2"
session_id: "session-1"
role: assistant
content: "Hello! How can I help you today?"
created_at: "2024-01-01T00:00:02Z"
parent_id: "msg-1"
responders:
keyword_map:
hello: "Hi there!"
help: "I can assist you with various tasks."
default_strategy: echo
streaming:
enabled: true
tokens_per_chunk: 5
chunk_interval_ms: 100
jitter_ms: 20
+82
View File
@@ -0,0 +1,82 @@
version: "0.1"
sessions:
- id: "session-chat"
title: "Complex Chat Session"
created_at: "2024-01-01T10:00:00Z"
participants:
- id: "user-1"
kind: user
display_name: "Alice"
- id: "assistant-1"
kind: assistant
display_name: "Bob AI"
- id: "system-1"
kind: system
messages:
- id: "msg-1"
session_id: "session-chat"
role: system
content: "System initialized"
created_at: "2024-01-01T10:00:00Z"
- id: "msg-2"
session_id: "session-chat"
role: user
content: "What's the weather?"
created_at: "2024-01-01T10:00:05Z"
parent_id: "msg-1"
- id: "msg-3"
session_id: "session-chat"
role: assistant
content: "Let me check that for you."
created_at: "2024-01-01T10:00:06Z"
parent_id: "msg-2"
metadata:
thinking: true
confidence: 0.95
- id: "msg-4"
session_id: "session-chat"
role: assistant
content: "The weather is sunny with a temperature of 72°F."
created_at: "2024-01-01T10:00:08Z"
parent_id: "msg-3"
- id: "session-debug"
title: "Debug Session"
created_at: "2024-01-02T00:00:00Z"
participants:
- id: "dev-1"
kind: user
display_name: "Developer"
- id: "tool-1"
kind: tool
display_name: "Debugger"
messages:
- id: "debug-1"
session_id: "session-debug"
role: user
content: "Run diagnostic"
created_at: "2024-01-02T00:00:01Z"
behavior:
responder: fixture_only
streaming:
enabled: false
tokens_per_chunk: 1
chunk_interval_ms: 50
responders:
keyword_map:
weather: "The weather is sunny."
time: "The current time is noon."
default_strategy: keywords
random:
seed: 42
corpus:
- "Random response 1"
- "Random response 2"
- "Random response 3"
streaming:
enabled: true
tokens_per_chunk: 10
chunk_interval_ms: 50
+159
View File
@@ -0,0 +1,159 @@
//! Integration tests for the ACP JSON-RPC server.
//!
//! These tests verify that the server correctly handles JSON-RPC requests
//! over HTTP and returns proper responses.
use dirigate::acp::model::{JsonRpcError, JsonRpcRequest, JsonRpcResponse};
use dirigate::acp::server::AcpServer;
use dirigate::fixture::types::*;
use dirigate::{MockerConfig, MockerState};
use std::collections::HashMap;
use std::sync::Arc;
/// Helper to create a minimal test fixture with a template session.
fn create_test_fixture_with_template() -> Fixture {
Fixture {
version: "0.1".to_string(),
sessions: vec![Session {
id: "test-template".to_string(),
title: "Test Template Session".to_string(),
created_at: "2025-01-01T00:00:00Z".to_string(),
participants: vec![
Participant {
id: "user-1".to_string(),
kind: ParticipantKind::User,
display_name: Some("Test User".to_string()),
},
Participant {
id: "assistant-1".to_string(),
kind: ParticipantKind::Assistant,
display_name: Some("Test Assistant".to_string()),
},
],
messages: vec![],
behavior: None,
}],
responders: Responders {
keyword_map: HashMap::new(),
default_strategy: ResponderStrategy::Echo,
random: None,
},
streaming: Streaming {
enabled: true,
tokens_per_chunk: 5,
chunk_interval_ms: 100,
jitter_ms: Some(10),
},
}
}
#[tokio::test]
async fn test_server_initialize_request() {
// Create state
let config = MockerConfig::default();
let fixtures = create_test_fixture_with_template();
let state = Arc::new(MockerState::new(config, fixtures));
// Create server on a random port
let server = AcpServer::new(state.clone(), 0);
// Start server in background
tokio::spawn(async move {
let _ = server.serve().await;
});
// Give server time to start
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Note: We can't actually test the HTTP endpoint without binding to a known port
// This test verifies the server compiles and runs without panicking
}
#[tokio::test]
async fn test_jsonrpc_initialize_response_format() {
// Create a mock initialize request
let request = JsonRpcRequest::new(
"initialize",
Some(serde_json::json!({
"protocol_version": "0.1",
"client_capabilities": {}
})),
1,
);
// Verify request serialization
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"method\":\"initialize\""));
assert!(json.contains("\"id\":1"));
// Parse it back
let parsed: JsonRpcRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.method, "initialize");
assert_eq!(parsed.jsonrpc, "2.0");
}
#[tokio::test]
async fn test_jsonrpc_session_new_response_format() {
// Create a mock session/new request
let request = JsonRpcRequest::new(
"session/new",
Some(serde_json::json!({
"template_id": "test-template"
})),
2,
);
// Verify request serialization
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"method\":\"session/new\""));
assert!(json.contains("\"template_id\":\"test-template\""));
assert!(json.contains("\"id\":2"));
// Parse it back
let parsed: JsonRpcRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.method, "session/new");
assert_eq!(parsed.jsonrpc, "2.0");
}
#[tokio::test]
async fn test_jsonrpc_error_response_format() {
// Create an error response
let error = JsonRpcError::method_not_found("unknown_method");
let response = JsonRpcResponse::error(error, Some(serde_json::json!(1)));
// Verify response serialization
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"error\""));
assert!(json.contains("-32601")); // Method not found code
assert!(!json.contains("\"result\"")); // Should not have result field
// Parse it back
let parsed: JsonRpcResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.error.is_some());
assert!(parsed.result.is_none());
assert_eq!(parsed.error.unwrap().code, -32601);
}
#[tokio::test]
async fn test_jsonrpc_success_response_format() {
// Create a success response
let result = serde_json::json!({
"session_id": "test-session-123"
});
let response = JsonRpcResponse::success(result, Some(serde_json::json!(1)));
// Verify response serialization
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"jsonrpc\":\"2.0\""));
assert!(json.contains("\"result\""));
assert!(json.contains("\"session_id\":\"test-session-123\""));
assert!(!json.contains("\"error\"")); // Should not have error field
// Parse it back
let parsed: JsonRpcResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.result.is_some());
assert!(parsed.error.is_none());
}
+439
View File
@@ -0,0 +1,439 @@
//! Integration tests for stdio transport
//!
//! These tests spawn the actual mocker binary and communicate via stdin/stdout
//! to verify end-to-end behavior including the "help" message feature.
use serde_json::{json, Value};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
/// Path to the mocker binary (debug build)
fn mocker_binary() -> std::path::PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
std::path::Path::new(manifest_dir)
.parent()
.unwrap()
.parent()
.unwrap()
.join("target")
.join("debug")
.join(if cfg!(windows) {
"dirigate.exe"
} else {
"dirigate"
})
}
/// Path to the basic fixture file
fn fixture_path() -> std::path::PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
std::path::Path::new(manifest_dir)
.join("examples")
.join("basic.yaml")
}
/// Helper to send a JSON-RPC request and read the response
/// Skips any session/update notifications and returns only the response matching the request ID
async fn send_request(
stdin: &mut tokio::process::ChildStdin,
stdout: &mut BufReader<tokio::process::ChildStdout>,
request: Value,
) -> anyhow::Result<Value> {
// Send request
let request_json = serde_json::to_string(&request)?;
let request_id = request.get("id").cloned();
stdin.write_all(request_json.as_bytes()).await?;
stdin.write_all(b"\n").await?;
stdin.flush().await?;
// Read response, skipping notifications
loop {
let mut line = String::new();
stdout.read_line(&mut line).await?;
// Parse the message
let msg: Value = match serde_json::from_str(line.trim()) {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to parse line: {}", line.trim());
eprintln!("Error: {}", e);
return Err(e.into());
}
};
// Skip notifications (they have "method" but no "id")
if msg.get("method").is_some() && msg.get("id").is_none() {
continue;
}
// Check if this is the response we're looking for
if let Some(id) = request_id.as_ref() {
if msg.get("id") == Some(id) {
return Ok(msg);
}
} else {
// No ID means we're looking for any response
return Ok(msg);
}
}
}
#[tokio::test]
async fn test_stdio_basic_flow() -> anyhow::Result<()> {
let binary = mocker_binary();
if !binary.exists() {
eprintln!(
"Mocker binary not found at {:?}. Run 'cargo build' first.",
binary
);
return Ok(()); // Skip test if binary doesn't exist
}
let fixture = fixture_path();
assert!(fixture.exists(), "Fixture file not found: {:?}", fixture);
// Spawn the mocker process
let mut child = Command::new(&binary)
.args(&["serve", "--fixtures", fixture.to_str().unwrap(), "--stdio"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null()) // Suppress logs for cleaner test output
.spawn()?;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
let stdout = child.stdout.take().expect("Failed to open stdout");
let mut stdout = BufReader::new(stdout);
// Test 1: Initialize
let init_request = json!({
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": 1,
"clientCapabilities": {}
}
});
let init_response = send_request(&mut stdin, &mut stdout, init_request).await?;
assert_eq!(init_response["jsonrpc"], "2.0");
assert_eq!(init_response["id"], 1);
assert!(init_response["result"]["protocolVersion"].is_number());
assert!(init_response["result"]["agentCapabilities"].is_object());
// Test 2: Create session
let session_request = json!({
"jsonrpc": "2.0",
"method": "session/new",
"id": 2,
"params": {
"cwd": ".",
"mcpServers": []
}
});
let session_response = send_request(&mut stdin, &mut stdout, session_request).await?;
assert_eq!(session_response["jsonrpc"], "2.0");
assert_eq!(session_response["id"], 2);
let session_id = session_response["result"]["sessionId"]
.as_str()
.expect("sessionId should be a string");
assert!(!session_id.is_empty());
// Test 3: Send a prompt
let prompt_request = json!({
"jsonrpc": "2.0",
"method": "session/prompt",
"id": 3,
"params": {
"sessionId": session_id,
"prompt": [
{
"type": "text",
"text": "hello world"
}
]
}
});
let prompt_response = send_request(&mut stdin, &mut stdout, prompt_request).await?;
assert_eq!(prompt_response["jsonrpc"], "2.0");
assert_eq!(prompt_response["id"], 3);
assert!(prompt_response["result"]["stopReason"].is_string());
// Clean up
child.kill().await?;
Ok(())
}
#[tokio::test]
async fn test_stdio_help_message() -> anyhow::Result<()> {
let binary = mocker_binary();
if !binary.exists() {
eprintln!(
"Mocker binary not found at {:?}. Run 'cargo build' first.",
binary
);
return Ok(()); // Skip test if binary doesn't exist
}
let fixture = fixture_path();
assert!(fixture.exists(), "Fixture file not found: {:?}", fixture);
// Spawn the mocker process with stderr redirected to a temp file so we can read it
let stderr_path = std::env::temp_dir().join("mocker_stderr.log");
let stderr_file = std::fs::File::create(&stderr_path)?;
let mut child = Command::new(&binary)
.args(&["serve", "--fixtures", fixture.to_str().unwrap(), "--stdio"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(stderr_file) // Write to file
.spawn()?;
let mut stdin = child.stdin.take().expect("Failed to open stdin");
let stdout = child.stdout.take().expect("Failed to open stdout");
let mut stdout = BufReader::new(stdout);
// Initialize
let init_response = send_request(
&mut stdin,
&mut stdout,
json!({
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": 1,
"clientCapabilities": {}
}
}),
)
.await?;
assert!(init_response["result"].is_object());
// Create session
let session_response = send_request(
&mut stdin,
&mut stdout,
json!({
"jsonrpc": "2.0",
"method": "session/new",
"id": 2,
"params": {
"cwd": ".",
"mcpServers": []
}
}),
)
.await?;
let session_id = session_response["result"]["sessionId"]
.as_str()
.expect("sessionId should be a string")
.to_string(); // Store as owned string
eprintln!("Created session: {}", session_id);
// Send "help" message - now we have the session ID from THIS process
let help_request = json!({
"jsonrpc": "2.0",
"method": "session/prompt",
"id": 3,
"params": {
"sessionId": session_id,
"prompt": [
{
"type": "text",
"text": "help"
}
]
}
});
// Write the request
let request_json = serde_json::to_string(&help_request)?;
eprintln!("Sending help request: {}", request_json);
stdin.write_all(request_json.as_bytes()).await?;
stdin.write_all(b"\n").await?;
stdin.flush().await?;
eprintln!("Request sent and flushed");
// Read the response AND the session/update notifications (streaming mode)
let mut help_content = String::new();
let mut response_received = false;
let mut notifications_count = 0;
// With streaming enabled, we'll receive multiple session/update notifications
// and then finally the response with stopReason
// Read up to 200 lines to capture all chunks (with 10 second total timeout)
let start = std::time::Instant::now();
while start.elapsed() < std::time::Duration::from_secs(10) {
let mut line = String::new();
match tokio::time::timeout(
std::time::Duration::from_millis(100),
stdout.read_line(&mut line),
)
.await
{
Ok(Ok(0)) => {
eprintln!("Got EOF");
break;
}
Ok(Ok(_)) => {
eprintln!("Received line: {}", line.trim());
let msg: Value = match serde_json::from_str(line.trim()) {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to parse JSON: {}", e);
continue;
}
};
// Check if this is the response
if msg.get("id") == Some(&json!(3)) {
eprintln!("Got response!");
assert_eq!(msg["jsonrpc"], "2.0");
assert!(msg["result"]["stopReason"].is_string());
response_received = true;
break; // Response is the last message
}
// Check if this is a session/update notification (streaming chunk)
if msg.get("method") == Some(&json!("session/update")) {
eprintln!("Got session/update notification #{}", notifications_count + 1);
notifications_count += 1;
let params = &msg["params"];
if let Some(update) = params.get("update") {
if let Some(content) = update.get("content") {
if let Some(text) = content.get("text") {
help_content.push_str(text.as_str().unwrap_or(""));
}
}
}
}
}
Err(_) => {
// Timeout - if we have content and response, we're done
if response_received {
break;
}
// Otherwise keep trying
}
Ok(Err(_)) => break, // Read error
}
}
assert!(
response_received,
"Should receive response to help request (got {} notifications)",
notifications_count
);
assert!(
!help_content.is_empty(),
"Should receive help content via notifications"
);
// Note: streaming may break words across chunks, so check for partial matches
assert!(
help_content.contains("ACP Mocker") || help_content.contains("ACPMocker"),
"Help content should contain 'ACP Mocker'. Got: {}",
help_content
);
assert!(
help_content.contains("Diagnostics"),
"Help content should contain 'Diagnostics'"
);
assert!(
help_content.contains("Configuration") || help_content.contains("CurrentConfiguration"),
"Help content should contain configuration section"
);
assert!(
help_content.contains("Available") && help_content.contains("Methods"),
"Help content should contain methods list"
);
// Clean up
child.kill().await?;
// Print stderr for debugging
eprintln!("\n=== Mocker stderr log ===");
if let Ok(log_content) = std::fs::read_to_string(&stderr_path) {
eprintln!("{}", log_content);
}
eprintln!("=== End stderr log ===\n");
Ok(())
}
#[tokio::test]
async fn test_stdio_session_not_found() -> anyhow::Result<()> {
let binary = mocker_binary();
if !binary.exists() {
return Ok(());
}
let fixture = fixture_path();
assert!(fixture.exists());
let mut child = Command::new(&binary)
.args(&["serve", "--fixtures", fixture.to_str().unwrap(), "--stdio"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut stdout = BufReader::new(stdout);
// Initialize
send_request(
&mut stdin,
&mut stdout,
json!({
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": 1,
"clientCapabilities": {}
}
}),
)
.await?;
// Try to use a non-existent session
let error_response = send_request(
&mut stdin,
&mut stdout,
json!({
"jsonrpc": "2.0",
"method": "session/prompt",
"id": 2,
"params": {
"sessionId": "non-existent-session-id",
"prompt": [
{
"type": "text",
"text": "hello"
}
]
}
}),
)
.await?;
// Should get an error response
assert_eq!(error_response["jsonrpc"], "2.0");
assert_eq!(error_response["id"], 2);
assert!(error_response["error"].is_object());
assert!(error_response["error"]["message"]
.as_str()
.unwrap()
.contains("Session not found"));
child.kill().await?;
Ok(())
}