sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# Package: dirigent_testing
|
||||
|
||||
Testing utilities for Dirigent with replay-based e2e test support.
|
||||
|
||||
## Quick Facts
|
||||
- **Type**: Library (dev/test utility)
|
||||
- **Main Entry**: src/lib.rs
|
||||
- **Dependencies**: serde, serde_json, thiserror, uuid
|
||||
- **Status**: Initial — replay framework only
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides testing infrastructure for Dirigent, starting with replay-based end-to-end tests that use recorded ACP (Agent-Client Protocol) interactions. Fixtures are stored as JSON files and can be loaded, filtered, and round-tripped through serde.
|
||||
|
||||
## Module Organization
|
||||
|
||||
- **`lib.rs`**: Public API surface and re-exports
|
||||
- **`replay.rs`**: Core replay types — `AcpReplay`, `ReplayMessage`, `Direction`, `ReplaySource`
|
||||
- **`fixtures.rs`**: Fixture loading utilities — `load_fixture`, `fixture_path`, `list_fixtures`
|
||||
|
||||
## Fixtures
|
||||
|
||||
Fixture files live in `fixtures/` and are JSON files conforming to the `AcpReplay` schema. Each fixture contains:
|
||||
- `name`: Human-readable identifier
|
||||
- `source`: Origin system (`zed`, `claude`, or custom)
|
||||
- `messages`: Ordered sequence of `ReplayMessage` with direction, payload, and optional delay
|
||||
|
||||
### Available Fixtures
|
||||
- `minimal_init.json` — Minimal MCP/ACP initialize handshake (client request + server response)
|
||||
- `zed_claude_session.json` — Real Zed-Claude ACP session adapted from recorded traffic (9 messages: initialize, session/load with updates, session/list)
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
use dirigent_testing::{load_fixture, AcpReplay, Direction};
|
||||
|
||||
let replay = load_fixture("minimal_init.json").unwrap();
|
||||
assert_eq!(replay.client_messages().len(), 1);
|
||||
assert_eq!(replay.agent_messages().len(), 1);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cargo test -p dirigent_testing
|
||||
```
|
||||
|
||||
## Related Packages
|
||||
|
||||
- **dirigent_acp_api**: ACP server that these replays exercise
|
||||
- **dirigent_core**: Runtime under test in integration scenarios
|
||||
- **dirigent_protocol**: Shared protocol types
|
||||
|
||||
## Integration Tests
|
||||
|
||||
- `tests/zed_claude_replay.rs` — Tests for the Zed-Claude session fixture: loading, message counts, direction filtering, protocol structure validation, serde roundtrip
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Replay runner that drives an ACP server with recorded traffic
|
||||
- Assertion helpers for validating ACP response sequences
|
||||
- Timing simulation with `delay_ms` support
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "dirigent_testing"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Testing utilities for Dirigent — replay-based e2e tests from recorded ACP traffic"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
uuid = { version = "1.0", features = ["v4", "v7"] }
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "minimal_init",
|
||||
"source": "zed",
|
||||
"messages": [
|
||||
{
|
||||
"direction": "client_to_agent",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-01-01",
|
||||
"capabilities": {},
|
||||
"clientInfo": { "name": "test", "version": "0.1.0" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"direction": "agent_to_client",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"protocolVersion": "2025-01-01",
|
||||
"capabilities": {},
|
||||
"serverInfo": { "name": "test-agent", "version": "0.1.0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"name": "zed_claude_session",
|
||||
"source": "zed",
|
||||
"messages": [
|
||||
{
|
||||
"direction": "client_to_agent",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 0,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": {
|
||||
"fs": {
|
||||
"readTextFile": true,
|
||||
"writeTextFile": true
|
||||
},
|
||||
"terminal": true,
|
||||
"_meta": {
|
||||
"terminal_output": true,
|
||||
"terminal-auth": true
|
||||
}
|
||||
},
|
||||
"clientInfo": {
|
||||
"name": "zed",
|
||||
"title": "Zed",
|
||||
"version": "0.225.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"direction": "agent_to_client",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 0,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": 1,
|
||||
"agentCapabilities": {
|
||||
"promptCapabilities": {
|
||||
"image": true,
|
||||
"embeddedContext": true
|
||||
},
|
||||
"mcpCapabilities": {
|
||||
"http": true,
|
||||
"sse": true
|
||||
},
|
||||
"loadSession": true,
|
||||
"sessionCapabilities": {
|
||||
"fork": {},
|
||||
"list": {},
|
||||
"resume": {}
|
||||
}
|
||||
},
|
||||
"agentInfo": {
|
||||
"name": "@zed-industries/claude-agent-acp",
|
||||
"title": "Claude Agent",
|
||||
"version": "0.19.2"
|
||||
},
|
||||
"authMethods": [
|
||||
{
|
||||
"description": "Run `claude /login` in the terminal",
|
||||
"name": "Log in with Claude",
|
||||
"id": "claude-login"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"delay_ms": 120
|
||||
},
|
||||
{
|
||||
"direction": "client_to_agent",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "session/load",
|
||||
"params": {
|
||||
"mcpServers": [],
|
||||
"cwd": "/dev/projects/dirigent",
|
||||
"sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786"
|
||||
}
|
||||
},
|
||||
"delay_ms": 50
|
||||
},
|
||||
{
|
||||
"direction": "agent_to_client",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786",
|
||||
"update": {
|
||||
"sessionUpdate": "user_message_chunk",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "hi"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delay_ms": 200
|
||||
},
|
||||
{
|
||||
"direction": "agent_to_client",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786",
|
||||
"update": {
|
||||
"sessionUpdate": "agent_message_chunk",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Hi! I'm here to help you with the Dirigent project. What would you like to work on today?"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delay_ms": 800
|
||||
},
|
||||
{
|
||||
"direction": "agent_to_client",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "session/load",
|
||||
"params": {
|
||||
"modes": {
|
||||
"currentModeId": "default",
|
||||
"availableModes": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Default",
|
||||
"description": "Standard behavior, prompts for dangerous operations"
|
||||
},
|
||||
{
|
||||
"id": "plan",
|
||||
"name": "Plan Mode",
|
||||
"description": "Planning mode, no actual tool execution"
|
||||
}
|
||||
]
|
||||
},
|
||||
"models": {
|
||||
"availableModels": [
|
||||
{
|
||||
"modelId": "default",
|
||||
"name": "Default (recommended)",
|
||||
"description": "Opus 4.6"
|
||||
},
|
||||
{
|
||||
"modelId": "sonnet",
|
||||
"name": "Sonnet",
|
||||
"description": "Sonnet 4.6"
|
||||
}
|
||||
],
|
||||
"currentModelId": "default"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delay_ms": 300
|
||||
},
|
||||
{
|
||||
"direction": "client_to_agent",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/list",
|
||||
"params": {}
|
||||
},
|
||||
"delay_ms": 100
|
||||
},
|
||||
{
|
||||
"direction": "agent_to_client",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786",
|
||||
"update": {
|
||||
"sessionUpdate": "available_commands_update",
|
||||
"availableCommands": [
|
||||
{
|
||||
"name": "compact",
|
||||
"description": "Clear conversation history but keep a summary in context.",
|
||||
"input": {
|
||||
"hint": "<optional custom summarization instructions>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "context",
|
||||
"description": "Show current context usage",
|
||||
"input": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"delay_ms": 150
|
||||
},
|
||||
{
|
||||
"direction": "agent_to_client",
|
||||
"payload": {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/list",
|
||||
"params": {
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": "cb878ad6-d72b-43c9-93e0-8228f309a786",
|
||||
"cwd": "/dev/projects/dirigent",
|
||||
"title": "hi",
|
||||
"updatedAt": "2026-03-03T14:03:24.740Z"
|
||||
},
|
||||
{
|
||||
"sessionId": "838b10b2-2f58-4dad-8652-9df81c880a96",
|
||||
"cwd": "/dev/projects/dirigent",
|
||||
"title": "Session list investigation",
|
||||
"updatedAt": "2026-03-03T13:56:15.751Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"delay_ms": 250
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use crate::replay::AcpReplay;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Load an ACP replay fixture by filename from the `fixtures/` directory.
|
||||
pub fn load_fixture(name: &str) -> Result<AcpReplay, FixtureError> {
|
||||
let path = fixture_path(name);
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| FixtureError::ReadError {
|
||||
path: path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
serde_json::from_str(&content).map_err(|e| FixtureError::ParseError { path, source: e })
|
||||
}
|
||||
|
||||
/// Return the absolute path to a fixture file by name.
|
||||
pub fn fixture_path(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
/// List all `.json` fixture filenames in the `fixtures/` directory.
|
||||
pub fn list_fixtures() -> Vec<String> {
|
||||
let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures");
|
||||
std::fs::read_dir(fixtures_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Errors that can occur when loading fixture files.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FixtureError {
|
||||
#[error("Failed to read fixture at {path}: {source}")]
|
||||
ReadError {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("Failed to parse fixture at {path}: {source}")]
|
||||
ParseError {
|
||||
path: PathBuf,
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Testing utilities for Dirigent.
|
||||
//! Provides replay-based e2e test support using recorded ACP interactions.
|
||||
|
||||
pub mod fixtures;
|
||||
pub mod replay;
|
||||
|
||||
pub use fixtures::{load_fixture, list_fixtures};
|
||||
pub use replay::{AcpReplay, Direction, ReplayMessage, ReplaySource};
|
||||
@@ -0,0 +1,88 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Direction of a message in an ACP interaction replay.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Direction {
|
||||
ClientToAgent,
|
||||
AgentToClient,
|
||||
}
|
||||
|
||||
/// Source system that the replay was recorded from.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ReplaySource {
|
||||
Zed,
|
||||
Claude,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
/// A single message in an ACP replay sequence.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReplayMessage {
|
||||
pub direction: Direction,
|
||||
pub payload: serde_json::Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub delay_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// A complete ACP interaction replay containing a named sequence of messages.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AcpReplay {
|
||||
pub name: String,
|
||||
pub source: ReplaySource,
|
||||
pub messages: Vec<ReplayMessage>,
|
||||
}
|
||||
|
||||
impl AcpReplay {
|
||||
/// Returns only the messages sent from client to agent.
|
||||
pub fn client_messages(&self) -> Vec<&ReplayMessage> {
|
||||
self.messages
|
||||
.iter()
|
||||
.filter(|m| m.direction == Direction::ClientToAgent)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns only the messages sent from agent to client.
|
||||
pub fn agent_messages(&self) -> Vec<&ReplayMessage> {
|
||||
self.messages
|
||||
.iter()
|
||||
.filter(|m| m.direction == Direction::AgentToClient)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fixtures;
|
||||
|
||||
#[test]
|
||||
fn test_load_minimal_fixture() {
|
||||
let replay = fixtures::load_fixture("minimal_init.json").unwrap();
|
||||
assert_eq!(replay.name, "minimal_init");
|
||||
assert_eq!(replay.messages.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_by_direction() {
|
||||
let replay = fixtures::load_fixture("minimal_init.json").unwrap();
|
||||
assert_eq!(replay.client_messages().len(), 1);
|
||||
assert_eq!(replay.agent_messages().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_fixtures() {
|
||||
let fixtures = fixtures::list_fixtures();
|
||||
assert!(fixtures.contains(&"minimal_init.json".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip() {
|
||||
let replay = fixtures::load_fixture("minimal_init.json").unwrap();
|
||||
let json = serde_json::to_string_pretty(&replay).unwrap();
|
||||
let parsed: AcpReplay = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, replay.name);
|
||||
assert_eq!(parsed.messages.len(), replay.messages.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use dirigent_testing::{load_fixture, Direction};
|
||||
|
||||
#[test]
|
||||
fn test_zed_claude_fixture_loads() {
|
||||
let replay = load_fixture("zed_claude_session.json").unwrap();
|
||||
assert!(!replay.messages.is_empty());
|
||||
assert!(!replay.client_messages().is_empty());
|
||||
assert!(!replay.agent_messages().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_claude_starts_with_initialize() {
|
||||
let replay = load_fixture("zed_claude_session.json").unwrap();
|
||||
let first = &replay.messages[0];
|
||||
assert_eq!(first.direction, Direction::ClientToAgent);
|
||||
let method = first.payload.get("method").and_then(|m| m.as_str());
|
||||
assert_eq!(method, Some("initialize"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_claude_message_counts() {
|
||||
let replay = load_fixture("zed_claude_session.json").unwrap();
|
||||
// 3 client messages: initialize, session/load, session/list
|
||||
assert_eq!(replay.client_messages().len(), 3);
|
||||
// 6 agent messages: initialize response, 2x session/update notifications,
|
||||
// session/load response, available_commands_update, session/list response
|
||||
assert_eq!(replay.agent_messages().len(), 6);
|
||||
// Total: 9 messages
|
||||
assert_eq!(replay.messages.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_claude_initialize_has_client_info() {
|
||||
let replay = load_fixture("zed_claude_session.json").unwrap();
|
||||
let init = &replay.messages[0].payload;
|
||||
let client_info = init
|
||||
.pointer("/params/clientInfo/name")
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(client_info, Some("zed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_claude_initialize_response_has_agent_info() {
|
||||
let replay = load_fixture("zed_claude_session.json").unwrap();
|
||||
let init_resp = &replay.messages[1].payload;
|
||||
assert_eq!(init_resp.get("id").and_then(|v| v.as_u64()), Some(0));
|
||||
let agent_name = init_resp
|
||||
.pointer("/params/agentInfo/name")
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(agent_name, Some("@zed-industries/claude-agent-acp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_claude_session_load_flow() {
|
||||
let replay = load_fixture("zed_claude_session.json").unwrap();
|
||||
// Message at index 2 is the session/load request
|
||||
let session_load = &replay.messages[2];
|
||||
assert_eq!(session_load.direction, Direction::ClientToAgent);
|
||||
let method = session_load
|
||||
.payload
|
||||
.get("method")
|
||||
.and_then(|m| m.as_str());
|
||||
assert_eq!(method, Some("session/load"));
|
||||
|
||||
let session_id = session_load
|
||||
.payload
|
||||
.pointer("/params/sessionId")
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(
|
||||
session_id,
|
||||
Some("cb878ad6-d72b-43c9-93e0-8228f309a786")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_claude_contains_session_list() {
|
||||
let replay = load_fixture("zed_claude_session.json").unwrap();
|
||||
let list_request = replay
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| {
|
||||
m.direction == Direction::ClientToAgent
|
||||
&& m.payload.get("method").and_then(|v| v.as_str()) == Some("session/list")
|
||||
})
|
||||
.expect("should contain a session/list request");
|
||||
|
||||
assert_eq!(list_request.payload.get("id").and_then(|v| v.as_u64()), Some(2));
|
||||
|
||||
// Find the matching response
|
||||
let list_response = replay
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| {
|
||||
m.direction == Direction::AgentToClient
|
||||
&& m.payload.get("method").and_then(|v| v.as_str()) == Some("session/list")
|
||||
&& m.payload.get("id").and_then(|v| v.as_u64()) == Some(2)
|
||||
})
|
||||
.expect("should contain a session/list response");
|
||||
|
||||
let sessions = list_response
|
||||
.payload
|
||||
.pointer("/params/sessions")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("should have sessions array");
|
||||
assert_eq!(sessions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_claude_has_delay_timings() {
|
||||
let replay = load_fixture("zed_claude_session.json").unwrap();
|
||||
// First message (initialize request from client) has no delay
|
||||
assert!(replay.messages[0].delay_ms.is_none());
|
||||
// Most subsequent messages should have delay_ms set
|
||||
let messages_with_delay = replay
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.delay_ms.is_some())
|
||||
.count();
|
||||
assert!(
|
||||
messages_with_delay > 0,
|
||||
"at least some messages should have delay timing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zed_claude_serde_roundtrip() {
|
||||
let replay = load_fixture("zed_claude_session.json").unwrap();
|
||||
let json = serde_json::to_string_pretty(&replay).unwrap();
|
||||
let parsed: dirigent_testing::AcpReplay = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, replay.name);
|
||||
assert_eq!(parsed.messages.len(), replay.messages.len());
|
||||
|
||||
for (original, roundtripped) in replay.messages.iter().zip(parsed.messages.iter()) {
|
||||
assert_eq!(original.direction, roundtripped.direction);
|
||||
assert_eq!(original.payload, roundtripped.payload);
|
||||
assert_eq!(original.delay_ms, roundtripped.delay_ms);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user