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:
@@ -0,0 +1,386 @@
|
|||||||
|
# Package: dirigate
|
||||||
|
|
||||||
|
Dirigate - ACP bridge and mock server for testing and proxying ACP connections.
|
||||||
|
|
||||||
|
## Quick Facts
|
||||||
|
- **Type**: Library + Binary
|
||||||
|
- **Main Entry**: src/lib.rs
|
||||||
|
- **Binary**: src/bin/dirigate.rs
|
||||||
|
- **Status**: Bridge mode implemented
|
||||||
|
- **Dependencies**: clap, serde, tokio, axum, reqwest, thiserror, tracing
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This package provides tools for working with ACP (Agent-Client Protocol) clients and servers:
|
||||||
|
|
||||||
|
1. **Bridge Mode**: Relay stdio ACP clients to a Dirigent ACP Server via HTTP/SSE
|
||||||
|
2. **Mock Server**: Serve fixture-based responses for testing ACP clients
|
||||||
|
3. **Interactive Client**: Connect to ACP agents for debugging and exploration
|
||||||
|
4. **Session Ingestion**: Import sessions from OpenCode.ai (feature-gated)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Bridge Mode: RPC Response Buffering
|
||||||
|
|
||||||
|
The bridge implements a **minimal buffering mechanism** to maintain proper JSON-RPC request/response semantics over stdio while preserving real-time streaming.
|
||||||
|
|
||||||
|
#### The Problem: Stdio JSON-RPC Semantics
|
||||||
|
|
||||||
|
When an external ACP client (like Zed/Claude Code) sends a JSON-RPC **request** with an `id`:
|
||||||
|
```json
|
||||||
|
{"jsonrpc":"2.0","method":"session/prompt","params":{...},"id":1}
|
||||||
|
```
|
||||||
|
|
||||||
|
It expects a JSON-RPC **response** with the same `id`:
|
||||||
|
```json
|
||||||
|
{"jsonrpc":"2.0","result":{"stopReason":"end_turn"},"id":1}
|
||||||
|
```
|
||||||
|
|
||||||
|
However, the response must not arrive until the turn is **complete**. Meanwhile, chunks and events stream as JSON-RPC **notifications** (no `id` field).
|
||||||
|
|
||||||
|
#### The Solution: Buffer RPC Response Until TurnComplete
|
||||||
|
|
||||||
|
**What gets buffered**: Only the RPC response (the `{"id":1}` response) - approximately 50 bytes
|
||||||
|
**What does NOT get buffered**: Chunks, events, notifications - all forwarded immediately as they arrive via SSE
|
||||||
|
|
||||||
|
#### Timeline Example
|
||||||
|
|
||||||
|
```
|
||||||
|
Time Zed → Conductor Conductor → Zed
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
T0 session/prompt (id:1) →
|
||||||
|
T1 ← HTTP POST to server
|
||||||
|
T2 (server returns end_turn immediately)
|
||||||
|
T3 [BUFFERED: response id:1]
|
||||||
|
T4 ← notification: agent_message_chunk
|
||||||
|
T5 ← notification: agent_message_chunk
|
||||||
|
T6 ← notification: tool_call_started
|
||||||
|
T7 ← notification: tool_call_chunk
|
||||||
|
T8 ← notification: tool_call_completed
|
||||||
|
T9 ← notification: agent_message_chunk
|
||||||
|
T10 ← notification: turn.complete
|
||||||
|
T11 ← [FLUSH] response (id:1)
|
||||||
|
✓ Zed gets control back
|
||||||
|
T12 ← notification: session_idle
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Why This Design
|
||||||
|
|
||||||
|
If Conductor sent the RPC response at T3 (right after HTTP returns), Zed would:
|
||||||
|
1. Receive `{"result":{"stopReason":"end_turn"},"id":1}`
|
||||||
|
2. Think "turn is done, I can accept new input now"
|
||||||
|
3. Still be receiving chunks for the previous turn
|
||||||
|
4. Enter an inconsistent state
|
||||||
|
|
||||||
|
By **buffering the RPC response until turn.complete**, Conductor ensures:
|
||||||
|
- All chunks/events are delivered **first** (these flow immediately via SSE → stdout)
|
||||||
|
- The RPC response arrives **last**, signaling "now you can accept new input"
|
||||||
|
- Clean request/response pairing semantics for stdio JSON-RPC clients
|
||||||
|
- **Backward compatibility**: If `turn.complete` is not received, `session_idle` acts as fallback
|
||||||
|
|
||||||
|
#### Implementation Details
|
||||||
|
|
||||||
|
See `src/acp/bridge.rs`:
|
||||||
|
- Lines 87-95: `buffered_responses` hashmap (session_id → response_json)
|
||||||
|
- Lines 409-428: Buffer session/prompt responses instead of sending immediately
|
||||||
|
- Lines 681-714: Flush buffered response when turn.complete notification arrives (primary)
|
||||||
|
- Lines 716-761: Flush buffered response when session_idle notification arrives (fallback)
|
||||||
|
|
||||||
|
This buffering is **minimal and necessary** for stdio transport to work correctly with the async nature of agent responses.
|
||||||
|
|
||||||
|
**Related Pattern**: The Dirigent ACP Server (which the bridge connects to) implements a complementary bidirectional request handling pattern. While the bridge buffers responses for stdio semantics, the server's ACP connector polls command channels during `send_request()` to avoid deadlock when the agent sends permission requests mid-turn. See `crates/dirigent_core/CLAUDE.md` section "Bidirectional Request Handling Pattern" for details.
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
conductor/
|
||||||
|
├── error.rs # Error types (MockerError)
|
||||||
|
├── logging.rs # Tracing/logging setup
|
||||||
|
├── acp/ # ACP protocol implementation
|
||||||
|
│ ├── bridge.rs # Bridge mode (stdio <-> HTTP/SSE)
|
||||||
|
│ ├── server.rs # Axum HTTP server
|
||||||
|
│ ├── model.rs # Protocol types (Session, Message, etc.)
|
||||||
|
│ ├── stdio.rs # Stdio transport for editors
|
||||||
|
│ └── stream.rs # SSE streaming
|
||||||
|
├── fixture/ # Fixture system
|
||||||
|
│ ├── types.rs # Fixture definitions (YAML structure)
|
||||||
|
│ ├── loader.rs # Load fixtures from disk
|
||||||
|
│ └── responders.rs # Response behavior logic
|
||||||
|
├── ingest/ # Session ingestion (feature-gated)
|
||||||
|
│ └── opencode.rs # Import from OpenCode.ai
|
||||||
|
└── cli/ # CLI interface
|
||||||
|
├── args.rs # Clap argument definitions
|
||||||
|
└── commands.rs # Command execution
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Types
|
||||||
|
|
||||||
|
#### Fixture System
|
||||||
|
- `Fixture` - Top-level fixture definition with metadata and sessions
|
||||||
|
- `SessionFixture` - Mock session with messages and behavior mode
|
||||||
|
- `MessageFixture` - Individual message with role, content, tool calls
|
||||||
|
- `ResponseMode` - Enum: Sequential, Random, Pattern, Static
|
||||||
|
- `FixtureRegistry` - Runtime registry of loaded fixtures
|
||||||
|
- `FixtureResponder` - Generates responses based on mode
|
||||||
|
|
||||||
|
#### ACP Protocol
|
||||||
|
- `Session` - Session metadata (id, title, timestamps)
|
||||||
|
- `Message` - Message in a session (role, content, etc.)
|
||||||
|
- `CreateSessionRequest` - Request to create new session
|
||||||
|
- `SendMessageRequest` - Request to send message
|
||||||
|
- `StreamEvent` - SSE event for real-time updates
|
||||||
|
|
||||||
|
#### Errors
|
||||||
|
- `MockerError` - Error enum with variants:
|
||||||
|
- `FixtureLoad` - Failed to load fixture file
|
||||||
|
- `FixtureValidation` - Invalid fixture structure
|
||||||
|
- `AcpProtocol` - ACP protocol error
|
||||||
|
- `SessionNotFound` - Session not found in fixtures
|
||||||
|
- `Transport` - IO/network error
|
||||||
|
- `Ingest` - Session ingestion error (feature-gated)
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
|
||||||
|
#### serve
|
||||||
|
Start the mock server:
|
||||||
|
```bash
|
||||||
|
dirigent-acp-mocker serve --fixtures ./fixtures --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
#### validate
|
||||||
|
Validate fixture files without starting server:
|
||||||
|
```bash
|
||||||
|
dirigent-acp-mocker validate --path ./fixtures
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ingest (feature: ingest)
|
||||||
|
Import sessions from external sources:
|
||||||
|
```bash
|
||||||
|
dirigent-acp-mocker ingest \
|
||||||
|
--source opencode \
|
||||||
|
--url http://localhost:12225 \
|
||||||
|
--session-id my-session \
|
||||||
|
--output ./fixtures/my-session.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fixture Format
|
||||||
|
|
||||||
|
Fixtures are YAML files defining mock sessions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
metadata:
|
||||||
|
name: "Basic Chat"
|
||||||
|
description: "A simple chat session"
|
||||||
|
version: "1.0"
|
||||||
|
|
||||||
|
sessions:
|
||||||
|
- id: "session-123"
|
||||||
|
title: "Test Session"
|
||||||
|
mode: sequential # sequential, random, pattern, static
|
||||||
|
messages:
|
||||||
|
- role: "agent"
|
||||||
|
content: "Hello! How can I help you?"
|
||||||
|
delay_ms: 100
|
||||||
|
|
||||||
|
- role: "agent"
|
||||||
|
content: "I can assist with various tasks."
|
||||||
|
tool_calls:
|
||||||
|
- name: "search"
|
||||||
|
input: { "query": "example" }
|
||||||
|
output: { "results": ["item1", "item2"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Modes
|
||||||
|
|
||||||
|
1. **Sequential**: Return messages in order (default)
|
||||||
|
2. **Random**: Pick a random message
|
||||||
|
3. **Pattern**: Match user input patterns
|
||||||
|
4. **Static**: Always return the same message
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Default Features
|
||||||
|
- Basic fixture loading and validation
|
||||||
|
- ACP server implementation
|
||||||
|
- CLI commands: serve, validate
|
||||||
|
|
||||||
|
### Optional Features
|
||||||
|
|
||||||
|
#### `ingest`
|
||||||
|
Enables session ingestion from external sources:
|
||||||
|
- Adds `ingest` CLI command
|
||||||
|
- Adds `dirigent_protocol` dependency
|
||||||
|
- Adds `opencode_client` dependency
|
||||||
|
- Provides `ingest_from_opencode()` function
|
||||||
|
|
||||||
|
Enable with:
|
||||||
|
```toml
|
||||||
|
dirigent_acp_mocker = { features = ["ingest"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Or build with:
|
||||||
|
```bash
|
||||||
|
cargo build --package dirigent_acp_mocker --features ingest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
### Phase 0: Scaffold ✓ COMPLETE
|
||||||
|
- [x] Crate structure (lib + bin targets)
|
||||||
|
- [x] Core dependencies in Cargo.toml
|
||||||
|
- [x] Error handling with thiserror
|
||||||
|
- [x] Logging infrastructure with tracing
|
||||||
|
- [x] Complete module structure
|
||||||
|
- [x] CLI argument parsing with clap
|
||||||
|
- [x] All code compiles (cargo check passes)
|
||||||
|
|
||||||
|
### Phase 1: Fixture System (TODO)
|
||||||
|
- [ ] YAML fixture loading from disk
|
||||||
|
- [ ] Fixture validation logic
|
||||||
|
- [ ] FixtureRegistry implementation
|
||||||
|
- [ ] Sequential responder implementation
|
||||||
|
- [ ] Static responder implementation
|
||||||
|
- [ ] Random responder (with rand crate)
|
||||||
|
- [ ] Pattern responder (with regex)
|
||||||
|
- [ ] Unit tests for fixture loading
|
||||||
|
- [ ] Example fixtures
|
||||||
|
|
||||||
|
### Phase 2: ACP Server (TODO)
|
||||||
|
- [ ] Axum server setup with routing
|
||||||
|
- [ ] List sessions endpoint
|
||||||
|
- [ ] Create session endpoint
|
||||||
|
- [ ] Send message endpoint
|
||||||
|
- [ ] SSE streaming endpoint
|
||||||
|
- [ ] Error response handling
|
||||||
|
- [ ] CORS configuration
|
||||||
|
- [ ] Integration tests
|
||||||
|
|
||||||
|
### Phase 3: Advanced Features (TODO)
|
||||||
|
- [ ] OpenCode session ingestion
|
||||||
|
- [ ] Message conversion logic
|
||||||
|
- [ ] Tool call simulation
|
||||||
|
- [ ] Delay support for message timing
|
||||||
|
- [ ] Session state persistence
|
||||||
|
- [ ] WebSocket transport (alternative to SSE)
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### As a Library
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use dirigent_acp_mocker::{
|
||||||
|
fixture::{load_fixture, FixtureRegistry, FixtureResponder},
|
||||||
|
acp::{start_server, ServerConfig},
|
||||||
|
Result,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// Load fixtures
|
||||||
|
let fixture = load_fixture("fixtures/chat.yaml").await?;
|
||||||
|
|
||||||
|
// Create registry
|
||||||
|
let mut registry = FixtureRegistry::new();
|
||||||
|
registry.add(fixture);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
let config = ServerConfig {
|
||||||
|
port: 8080,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
};
|
||||||
|
start_server(config).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### As a CLI Tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
dirigent-acp-mocker serve -f ./fixtures -p 8080
|
||||||
|
|
||||||
|
# Validate fixtures
|
||||||
|
dirigent-acp-mocker validate -p ./fixtures/chat.yaml
|
||||||
|
|
||||||
|
# Ingest session (requires 'ingest' feature)
|
||||||
|
dirigent-acp-mocker ingest \
|
||||||
|
-s opencode \
|
||||||
|
-u http://localhost:12225 \
|
||||||
|
--session-id abc-123 \
|
||||||
|
-o ./fixtures/imported.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
cargo test --package dirigent_acp_mocker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
```bash
|
||||||
|
# Without features
|
||||||
|
cargo build --package dirigent_acp_mocker
|
||||||
|
|
||||||
|
# With all features
|
||||||
|
cargo build --package dirigent_acp_mocker --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking
|
||||||
|
```bash
|
||||||
|
cargo check --package dirigent_acp_mocker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Packages
|
||||||
|
|
||||||
|
- **dirigent_protocol** - Protocol types (optional, with `ingest` feature)
|
||||||
|
- **opencode_client** - OpenCode.ai client (optional, with `ingest` feature)
|
||||||
|
- **dirigent_core** - Uses this for testing connector implementations
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- README: ./README.md
|
||||||
|
- Main project: ../../CLAUDE.md
|
||||||
|
|
||||||
|
## Notes for Future Implementation
|
||||||
|
|
||||||
|
### Fixture Loading (Phase 1)
|
||||||
|
- Use `tokio::fs` for async file I/O
|
||||||
|
- Support both .yaml and .yml extensions
|
||||||
|
- Validate fixture structure on load
|
||||||
|
- Provide helpful error messages for malformed YAML
|
||||||
|
|
||||||
|
### Response Behavior (Phase 1)
|
||||||
|
- Sequential mode: Track state per session
|
||||||
|
- Random mode: Add `rand` crate dependency
|
||||||
|
- Pattern mode: Add `regex` crate for pattern matching
|
||||||
|
- Consider thread-safety for responder state
|
||||||
|
|
||||||
|
### Server Implementation (Phase 2)
|
||||||
|
- Use Axum's Router for endpoint setup
|
||||||
|
- Add tower-http middleware for CORS
|
||||||
|
- Implement SSE with axum::response::Sse
|
||||||
|
- Consider shared state with Arc<Mutex<FixtureRegistry>>
|
||||||
|
|
||||||
|
### Ingestion (Phase 3)
|
||||||
|
- Use opencode_client to fetch sessions
|
||||||
|
- Convert OpenCode message types to fixture format
|
||||||
|
- Handle tool calls and reasoning blocks
|
||||||
|
- Preserve timestamps for realistic playback
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. **Unit Tests**: Test individual components (responders, loaders)
|
||||||
|
2. **Integration Tests**: Test full server with fixture loading
|
||||||
|
3. **Doc Tests**: Keep examples in documentation up-to-date
|
||||||
|
4. **Example Fixtures**: Provide realistic test fixtures
|
||||||
|
5. **CLI Tests**: Test command-line interface
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Lazy-load fixtures on demand vs. pre-load all
|
||||||
|
- Consider caching parsed YAML
|
||||||
|
- Stream large responses instead of buffering
|
||||||
|
- Use connection pooling for ingestion
|
||||||
|
- Benchmark response latency for different modes
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
[package]
|
||||||
|
name = "dirigate"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Dirigate - ACP bridge and mock server for testing and proxying ACP connections"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "dirigate"
|
||||||
|
path = "src/bin/dirigate.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
|
async-stream = "0.3"
|
||||||
|
|
||||||
|
# Web server
|
||||||
|
axum = "0.8"
|
||||||
|
|
||||||
|
# HTTP client (for bridge mode)
|
||||||
|
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||||
|
reqwest-eventsource = "0.6"
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
|
# UUID generation
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# Date/time handling
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
|
||||||
|
# Random number generation
|
||||||
|
rand = "0.8"
|
||||||
|
rand_chacha = "0.3"
|
||||||
|
|
||||||
|
# CLI formatting
|
||||||
|
owo-colors = "4"
|
||||||
|
tabled = "0.16"
|
||||||
|
shell-words = "1.1"
|
||||||
|
|
||||||
|
# Internal dependencies
|
||||||
|
dirigent_protocol = { workspace = true }
|
||||||
|
dirigent_core = { workspace = true, features = ["server"] }
|
||||||
|
dirigent_tools = { workspace = true }
|
||||||
|
opencode_client = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
dirigent_acp_api = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ingest = ["dep:opencode_client"]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
# ACP Mocker - Help Feature Demo
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The ACP mocker now responds to the word "help" sent as a regular message in any session, returning comprehensive diagnostic information about the mocker's configuration.
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
### Transport Architecture
|
||||||
|
|
||||||
|
**stdio is PRIMARY**: The ACP protocol uses stdio (stdin/stdout) as the primary transport method. This is how editors like Zed communicate with agents - they launch the agent as a subprocess and use JSON-RPC over stdio.
|
||||||
|
|
||||||
|
**HTTP is ADDITIONAL**: HTTP transport with SSE (Server-Sent Events) is an additional capability we implemented for testing purposes. It's not the standard way editors interact with ACP agents.
|
||||||
|
|
||||||
|
### Content Delivery
|
||||||
|
|
||||||
|
All response content is sent via `session/update` notifications:
|
||||||
|
- **stdio mode** (primary): Notifications are automatically written to stdout
|
||||||
|
- **HTTP mode** (testing): Client must subscribe to SSE at `/events/{session_id}`
|
||||||
|
|
||||||
|
The `session/prompt` response only contains `{"stopReason": "end_turn"}` - the actual content comes through notifications.
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### 1. Start the Mocker (HTTP mode for testing)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --package dirigent_acp_mocker -- serve \
|
||||||
|
--fixtures packages/dirigent_acp_mocker/examples/basic.yaml \
|
||||||
|
--port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Start with stdio (Primary mode, for Zed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run --package dirigent_acp_mocker -- serve \
|
||||||
|
--fixtures packages/dirigent_acp_mocker/examples/basic.yaml \
|
||||||
|
--stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure Zed's `settings.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"dirigent-acp-mocker": {
|
||||||
|
"command": "G:/dev/projects/dirigent/target/debug/dirigent-acp-mocker.exe",
|
||||||
|
"args": [
|
||||||
|
"serve",
|
||||||
|
"--fixtures",
|
||||||
|
"G:/dev/projects/dirigent/packages/dirigent_acp_mocker/examples/basic.yaml",
|
||||||
|
"--stdio"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test via HTTP (Testing Only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize
|
||||||
|
curl -X POST http://localhost:8080/jsonrpc \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": 1,
|
||||||
|
"clientCapabilities": {}
|
||||||
|
},
|
||||||
|
"id": 1
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
curl -X POST http://localhost:8080/jsonrpc \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "session/new",
|
||||||
|
"params": {},
|
||||||
|
"id": 2
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Send "help" message (replace SESSION_ID with actual ID)
|
||||||
|
curl -X POST http://localhost:8080/jsonrpc \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "session/prompt",
|
||||||
|
"params": {
|
||||||
|
"sessionId": "SESSION_ID",
|
||||||
|
"prompt": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "help"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id": 3
|
||||||
|
}'
|
||||||
|
|
||||||
|
# To see the actual response content, subscribe to SSE (in separate terminal):
|
||||||
|
curl -N http://localhost:8080/events/SESSION_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test via Python Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using the test helper
|
||||||
|
python tools/test_help.py --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## What You Get
|
||||||
|
|
||||||
|
When you send "help" as a message, you'll receive a diagnostic response including:
|
||||||
|
|
||||||
|
- **Mocker Information**: Name, version, transport clarification
|
||||||
|
- **Fixture Configuration**: Version, available sessions, session IDs
|
||||||
|
- **Streaming Settings**: Enabled/disabled, chunk size, interval, jitter
|
||||||
|
- **Responder Configuration**: Default strategy, keyword mappings, random corpus
|
||||||
|
- **Active Sessions**: Currently active session count and IDs
|
||||||
|
- **Transport Explanation**: Clear explanation that stdio is primary, HTTP is for testing
|
||||||
|
- **Available Methods**: All ACP methods and extensions
|
||||||
|
- **Keywords to Try**: List of configured keywords that trigger specific responses
|
||||||
|
|
||||||
|
## Alternative: Method-Based Help
|
||||||
|
|
||||||
|
You can also call `_dirigent/help` as a JSON-RPC method:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/jsonrpc \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "_dirigent/help",
|
||||||
|
"id": 1
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
This returns the same diagnostic information but as structured JSON rather than formatted markdown.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
1. **Debugging**: Quickly check if fixtures loaded correctly
|
||||||
|
2. **Configuration Verification**: See what keywords and responders are active
|
||||||
|
3. **Testing**: Understand streaming settings and session state
|
||||||
|
4. **Learning**: Get inline documentation about available methods
|
||||||
|
5. **Transport Clarity**: Understand that stdio is primary, HTTP is for testing only
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Dirigate
|
||||||
|
|
||||||
|
ACP (Agent-Client Protocol) bridge and mock server for testing and proxying ACP connections.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`dirigate` provides tools for working with ACP clients and servers:
|
||||||
|
|
||||||
|
1. **Bridge Mode**: Relay stdio ACP clients to a Dirigent ACP Server via HTTP/SSE
|
||||||
|
2. **Mock Server**: Serve fixture-based responses for testing ACP clients
|
||||||
|
3. **Interactive Client**: Connect to ACP agents for debugging and exploration
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Bridge Mode**: Connect external ACP clients (like Claude Code) to a Dirigent ACP Server
|
||||||
|
- **Fixture-based responses**: Define sessions and message flows in YAML for testing
|
||||||
|
- **Multiple response modes**: Static, random, sequential, and pattern-based
|
||||||
|
- **Session ingestion**: Import sessions from OpenCode.ai or other sources (feature-gated)
|
||||||
|
- **ACP compliance**: Full protocol implementation for testing clients
|
||||||
|
- **CLI tool**: Easy-to-use command-line interface
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### As a CLI Tool
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install --path packages/conductor
|
||||||
|
```
|
||||||
|
|
||||||
|
### As a Library
|
||||||
|
|
||||||
|
Add to your `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
conductor = { path = "../conductor" }
|
||||||
|
```
|
||||||
|
|
||||||
|
For ingestion features:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
conductor = { path = "../conductor", features = ["ingest"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Bridge Mode (NEW)
|
||||||
|
|
||||||
|
Bridge a stdio ACP client to a Dirigent ACP Server via HTTP/SSE:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to local server (default: http://localhost:3001/acp)
|
||||||
|
conductor bridge
|
||||||
|
|
||||||
|
# Connect to remote server
|
||||||
|
conductor bridge --server-url http://remote:3001/acp
|
||||||
|
|
||||||
|
# Via environment variable
|
||||||
|
DIRIGENT_SERVER_URL=http://remote:3001/acp dirigate bridge
|
||||||
|
|
||||||
|
# With verbose logging
|
||||||
|
conductor bridge --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bridge Architecture:**
|
||||||
|
|
||||||
|
```
|
||||||
|
External ACP Client (Claude Code, etc.)
|
||||||
|
|
|
||||||
|
| stdio (stdin/stdout)
|
||||||
|
v
|
||||||
|
+-------------------+
|
||||||
|
| Conductor Bridge |
|
||||||
|
| - stdin parser |
|
||||||
|
| - HTTP client |
|
||||||
|
| - SSE subscriber |
|
||||||
|
+-------------------+
|
||||||
|
|
|
||||||
|
| HTTP/SSE
|
||||||
|
v
|
||||||
|
Dirigent ACP Server
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
- `-s, --server-url <URL>` - ACP Server URL (env: `DIRIGENT_SERVER_URL`, default: `http://localhost:3001/acp`)
|
||||||
|
- `-v, --verbose` - Enable verbose logging of JSON-RPC messages
|
||||||
|
- `--timeout <SECS>` - Timeout for HTTP requests (default: 30)
|
||||||
|
- `--auto-reconnect` - Automatically reconnect SSE stream on disconnect (default: true)
|
||||||
|
|
||||||
|
**Use Case: Connecting Claude Code to Dirigent**
|
||||||
|
|
||||||
|
To use Claude Code with Dirigent:
|
||||||
|
|
||||||
|
1. Start the Dirigent web application (which includes the ACP Server)
|
||||||
|
2. Configure Claude Code to use a command-based ACP agent:
|
||||||
|
```
|
||||||
|
dirigate bridge
|
||||||
|
```
|
||||||
|
3. Claude Code will now communicate through Conductor to Dirigent
|
||||||
|
|
||||||
|
### Mock Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conductor serve --fixtures ./fixtures --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `-f, --fixtures <DIR>` - Directory containing fixture YAML files (default: `./fixtures`)
|
||||||
|
- `-p, --port <PORT>` - Port to bind the server to (default: `8080`)
|
||||||
|
- `--host <HOST>` - Host address to bind to (default: `127.0.0.1`)
|
||||||
|
- `--stdio` - Use stdin/stdout for JSON-RPC transport
|
||||||
|
- `-l, --log-level <LEVEL>` - Log level: trace, debug, info, warn, error (default: `info`)
|
||||||
|
- `--log-format <FORMAT>` - Log format: pretty, json, compact (default: `pretty`)
|
||||||
|
|
||||||
|
### Validate Fixtures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conductor validate ./fixtures
|
||||||
|
```
|
||||||
|
|
||||||
|
Validates fixture files without starting the server.
|
||||||
|
|
||||||
|
### Interactive Client
|
||||||
|
|
||||||
|
Connect to an ACP agent for debugging and exploration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect via stdio (spawns a process)
|
||||||
|
conductor connect --command "claude --acp" --auto-session
|
||||||
|
|
||||||
|
# Connect via HTTP
|
||||||
|
conductor connect --url http://localhost:8080 --auto-session
|
||||||
|
|
||||||
|
# Custom protocol version
|
||||||
|
conductor connect --command "claude --acp" --protocol-version 1 --auto-session
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interactive Commands:**
|
||||||
|
- `/help` - Display available commands
|
||||||
|
- `/quit` - Exit the client
|
||||||
|
- `/session list` - List available sessions
|
||||||
|
- `/session new` - Create a new session
|
||||||
|
- `/session <id>` - Switch to a session
|
||||||
|
- `/cancel` - Cancel generation in current session
|
||||||
|
- Any other text - Send as message to current session
|
||||||
|
|
||||||
|
**Debugging:**
|
||||||
|
|
||||||
|
Enable detailed logging to see JSON-RPC packets:
|
||||||
|
```bash
|
||||||
|
# See all debug logs including packets
|
||||||
|
RUST_LOG=debug dirigate connect --command "claude --acp" --auto-session
|
||||||
|
|
||||||
|
# See event details
|
||||||
|
DIRIGENT_VERBOSE=1 dirigate connect --command "claude --acp" --auto-session
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingest Sessions (requires `ingest` feature)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conductor ingest \
|
||||||
|
-u http://localhost:12225 \
|
||||||
|
--session-id my-session-123 \
|
||||||
|
--output ./fixtures/my-session.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Imports a session from an external source and converts it to fixture format.
|
||||||
|
|
||||||
|
## Fixture Format
|
||||||
|
|
||||||
|
Fixtures are defined in YAML files:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: "1"
|
||||||
|
|
||||||
|
streaming:
|
||||||
|
enabled: true
|
||||||
|
tokens_per_chunk: 4
|
||||||
|
chunk_interval_ms: 50
|
||||||
|
|
||||||
|
sessions:
|
||||||
|
- id: "session-123"
|
||||||
|
title: "Test Session"
|
||||||
|
created_at: "2025-01-01T00:00:00Z"
|
||||||
|
participants:
|
||||||
|
- id: "assistant"
|
||||||
|
name: "Assistant"
|
||||||
|
role: "agent"
|
||||||
|
messages:
|
||||||
|
- role: "agent"
|
||||||
|
content: "Hello! How can I help you?"
|
||||||
|
|
||||||
|
responders:
|
||||||
|
default_strategy: Echo
|
||||||
|
keyword_map: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Modes
|
||||||
|
|
||||||
|
- **sequential**: Return messages in order (default)
|
||||||
|
- **random**: Return a random message from the fixture
|
||||||
|
- **pattern**: Match user input against patterns and return corresponding response
|
||||||
|
- **static**: Always return the same message
|
||||||
|
- **echo**: Echo back the user's input
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
conductor/
|
||||||
|
|-- src/
|
||||||
|
| |-- lib.rs # Library entry point
|
||||||
|
| |-- error.rs # Error types
|
||||||
|
| |-- logging.rs # Logging infrastructure
|
||||||
|
| |-- acp/ # ACP protocol implementation
|
||||||
|
| | |-- mod.rs
|
||||||
|
| | |-- bridge.rs # Bridge mode (stdio <-> HTTP/SSE)
|
||||||
|
| | |-- server.rs # HTTP server
|
||||||
|
| | |-- model.rs # Protocol types
|
||||||
|
| | |-- stdio.rs # Stdio transport
|
||||||
|
| | |-- stream.rs # SSE streaming
|
||||||
|
| |-- fixture/ # Fixture system
|
||||||
|
| | |-- mod.rs
|
||||||
|
| | |-- types.rs # Fixture definitions
|
||||||
|
| | |-- loader.rs # YAML loading
|
||||||
|
| | |-- responders.rs # Response logic
|
||||||
|
| |-- ingest/ # Session ingestion (feature-gated)
|
||||||
|
| | |-- mod.rs
|
||||||
|
| | |-- opencode.rs # OpenCode.ai ingestion
|
||||||
|
| |-- cli/ # CLI interface
|
||||||
|
| | |-- mod.rs
|
||||||
|
| | |-- args.rs # Argument parsing
|
||||||
|
| | |-- commands.rs # Command execution
|
||||||
|
| |-- bin/
|
||||||
|
| |-- conductor.rs # Binary entry point
|
||||||
|
|-- Cargo.toml
|
||||||
|
|-- README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `DIRIGENT_SERVER_URL` | ACP Server URL for bridge mode | `http://localhost:3001/acp` |
|
||||||
|
| `CONDUCTOR_BUFFER_SIZE` | Broadcast channel buffer size for event distribution. Increase for high-volume event scenarios or when experiencing event drops during bursts. Valid values: Any positive integer (usize). Example: `CONDUCTOR_BUFFER_SIZE=500` | `100` |
|
||||||
|
| `RUST_LOG` | Log level | `info` |
|
||||||
|
| `DIRIGENT_VERBOSE` | Enable verbose event logging | (unset) |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p dirigate
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with all features:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p dirigate --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- `clap` - CLI argument parsing
|
||||||
|
- `serde`, `serde_yaml`, `serde_json` - Serialization
|
||||||
|
- `tokio` - Async runtime
|
||||||
|
- `axum` - Web server
|
||||||
|
- `reqwest` - HTTP client (for bridge mode)
|
||||||
|
- `reqwest-eventsource` - SSE client (for bridge mode)
|
||||||
|
- `anyhow`, `thiserror` - Error handling
|
||||||
|
- `tracing`, `tracing-subscriber` - Logging
|
||||||
|
|
||||||
|
### Optional (ingest feature)
|
||||||
|
- `dirigent_protocol` - Protocol types
|
||||||
|
- `opencode_client` - OpenCode.ai client
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Same as the Dirigent project.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See the main project README for contribution guidelines.
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
# Basic Example Fixture for dirigent_acp_mocker
|
||||||
|
#
|
||||||
|
# This fixture demonstrates the core features of the ACP mocker:
|
||||||
|
# - Multiple sessions with different scenarios
|
||||||
|
# - Keyword-based response routing
|
||||||
|
# - Random response selection
|
||||||
|
# - Streaming configuration
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# dirigent-acp-mocker serve --fixtures ./examples/basic.yaml
|
||||||
|
# dirigent-acp-mocker validate ./examples/basic.yaml
|
||||||
|
# dirigent-acp-mocker print ./examples/basic.yaml
|
||||||
|
|
||||||
|
version: "0.1"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Sessions
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# Define mock sessions that clients can load or use as templates.
|
||||||
|
# Each session represents a conversation with a specific set of messages
|
||||||
|
# and participants.
|
||||||
|
|
||||||
|
sessions:
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# Session 1: Simple Greeting
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# A basic session demonstrating a simple conversation flow.
|
||||||
|
# This can be loaded by clients to test basic chat functionality.
|
||||||
|
|
||||||
|
- id: "greeting-session"
|
||||||
|
title: "Simple Greeting"
|
||||||
|
created_at: "2025-01-10T10:00:00Z"
|
||||||
|
|
||||||
|
# Participants in this session
|
||||||
|
participants:
|
||||||
|
- id: "user-1"
|
||||||
|
kind: "user"
|
||||||
|
display_name: "Test User"
|
||||||
|
|
||||||
|
- id: "assistant-1"
|
||||||
|
kind: "assistant"
|
||||||
|
display_name: "Mock Agent"
|
||||||
|
|
||||||
|
# Message history for this session
|
||||||
|
messages:
|
||||||
|
- id: "msg-1"
|
||||||
|
session_id: "greeting-session"
|
||||||
|
role: "user"
|
||||||
|
content: "Hello!"
|
||||||
|
created_at: "2025-01-10T10:00:01Z"
|
||||||
|
parent_id: null
|
||||||
|
metadata: null
|
||||||
|
|
||||||
|
- id: "msg-2"
|
||||||
|
session_id: "greeting-session"
|
||||||
|
role: "assistant"
|
||||||
|
content: "Hello! Welcome to the ACP mock agent. How can I help you today?"
|
||||||
|
created_at: "2025-01-10T10:00:02Z"
|
||||||
|
parent_id: "msg-1"
|
||||||
|
metadata: null
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# Session 2: Code Assistance
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# A session demonstrating code-related interactions.
|
||||||
|
# Shows multi-turn conversation with technical content.
|
||||||
|
|
||||||
|
- id: "code-session"
|
||||||
|
title: "Code Assistance"
|
||||||
|
created_at: "2025-01-10T11:00:00Z"
|
||||||
|
|
||||||
|
participants:
|
||||||
|
- id: "developer-1"
|
||||||
|
kind: "user"
|
||||||
|
display_name: "Developer"
|
||||||
|
|
||||||
|
- id: "coding-assistant"
|
||||||
|
kind: "assistant"
|
||||||
|
display_name: "Coding Assistant"
|
||||||
|
|
||||||
|
messages:
|
||||||
|
- id: "code-msg-1"
|
||||||
|
session_id: "code-session"
|
||||||
|
role: "user"
|
||||||
|
content: "Can you help me write a Rust function to calculate fibonacci numbers?"
|
||||||
|
created_at: "2025-01-10T11:00:01Z"
|
||||||
|
parent_id: null
|
||||||
|
metadata: null
|
||||||
|
|
||||||
|
- id: "code-msg-2"
|
||||||
|
session_id: "code-session"
|
||||||
|
role: "assistant"
|
||||||
|
content: |
|
||||||
|
Sure! Here's a recursive Rust function to calculate Fibonacci numbers:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn fibonacci(n: u32) -> u64 {
|
||||||
|
match n {
|
||||||
|
0 => 0,
|
||||||
|
1 => 1,
|
||||||
|
_ => fibonacci(n - 1) + fibonacci(n - 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the classic recursive approach. For better performance with larger numbers, consider using dynamic programming or an iterative approach.
|
||||||
|
created_at: "2025-01-10T11:00:05Z"
|
||||||
|
parent_id: "code-msg-1"
|
||||||
|
metadata: null
|
||||||
|
|
||||||
|
- id: "code-msg-3"
|
||||||
|
session_id: "code-session"
|
||||||
|
role: "user"
|
||||||
|
content: "Can you show me the iterative version?"
|
||||||
|
created_at: "2025-01-10T11:00:10Z"
|
||||||
|
parent_id: "code-msg-2"
|
||||||
|
metadata: null
|
||||||
|
|
||||||
|
- id: "code-msg-4"
|
||||||
|
session_id: "code-session"
|
||||||
|
role: "assistant"
|
||||||
|
content: |
|
||||||
|
Here's an iterative version that's much more efficient:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn fibonacci_iterative(n: u32) -> u64 {
|
||||||
|
if n == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prev = 0;
|
||||||
|
let mut curr = 1;
|
||||||
|
|
||||||
|
for _ in 1..n {
|
||||||
|
let next = prev + curr;
|
||||||
|
prev = curr;
|
||||||
|
curr = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
curr
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This has O(n) time complexity and O(1) space complexity, much better than the exponential recursive version!
|
||||||
|
created_at: "2025-01-10T11:00:15Z"
|
||||||
|
parent_id: "code-msg-3"
|
||||||
|
metadata: null
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# Session 3: Empty Template
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# An empty session that can be used as a template for new conversations.
|
||||||
|
# Useful for testing session creation functionality.
|
||||||
|
|
||||||
|
- id: "empty-template"
|
||||||
|
title: "Empty Template"
|
||||||
|
created_at: "2025-01-10T12:00:00Z"
|
||||||
|
|
||||||
|
participants:
|
||||||
|
- id: "default-user"
|
||||||
|
kind: "user"
|
||||||
|
display_name: "User"
|
||||||
|
|
||||||
|
- id: "default-assistant"
|
||||||
|
kind: "assistant"
|
||||||
|
display_name: "Assistant"
|
||||||
|
|
||||||
|
messages: []
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Responders
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# Configure how the mock agent generates responses to user prompts.
|
||||||
|
# The responder system uses a simple keyword mapping where specific keywords
|
||||||
|
# in user input trigger predefined responses.
|
||||||
|
|
||||||
|
responders:
|
||||||
|
# Default strategy for messages that don't match any keyword
|
||||||
|
# Options: "echo", "fixture_only", "random"
|
||||||
|
default_strategy: "echo"
|
||||||
|
|
||||||
|
# Keyword-based response mapping
|
||||||
|
# When user input contains a keyword, the corresponding response is returned.
|
||||||
|
# This is a simple string-to-string mapping.
|
||||||
|
keyword_map:
|
||||||
|
# If the user mentions "help"
|
||||||
|
"help": "I'm here to help! You can ask me about anything."
|
||||||
|
|
||||||
|
# If the user asks about weather
|
||||||
|
"weather": "The weather is sunny with a chance of mock responses!"
|
||||||
|
|
||||||
|
# If the user mentions "code" or "programming"
|
||||||
|
"code": "I can help with code! What language are you working with?"
|
||||||
|
|
||||||
|
# If the user says hello
|
||||||
|
"hello": "Hello! Welcome to the ACP mock agent. How can I help you today?"
|
||||||
|
|
||||||
|
# Special keyword for goodbye messages
|
||||||
|
"bye": "Goodbye! Thanks for testing with the ACP mocker."
|
||||||
|
|
||||||
|
# Testing keywords
|
||||||
|
"test": "This is a test response from the mock agent."
|
||||||
|
|
||||||
|
# Status check
|
||||||
|
"status": "The mock agent is running normally. All systems operational!"
|
||||||
|
|
||||||
|
# Random responder configuration
|
||||||
|
# Used when the default_strategy is set to "random"
|
||||||
|
random:
|
||||||
|
# Random seed for reproducibility (use same seed for consistent results)
|
||||||
|
seed: 42
|
||||||
|
# Corpus of possible responses
|
||||||
|
corpus:
|
||||||
|
- "That's an interesting point!"
|
||||||
|
- "I see what you mean."
|
||||||
|
- "Let me think about that for a moment..."
|
||||||
|
- "Good question! Here's what I think..."
|
||||||
|
- "I understand your concern."
|
||||||
|
- "That makes sense."
|
||||||
|
- "Let's explore that idea further."
|
||||||
|
- "I appreciate you bringing that up."
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Streaming Configuration
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# Configure how responses are streamed to clients via Server-Sent Events.
|
||||||
|
# Streaming simulates token-by-token generation like real LLMs.
|
||||||
|
|
||||||
|
streaming:
|
||||||
|
# Enable or disable streaming
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Number of tokens (words) to send in each chunk
|
||||||
|
# Smaller values = more frequent updates, more realistic streaming
|
||||||
|
# Larger values = fewer updates, faster overall response
|
||||||
|
tokens_per_chunk: 3
|
||||||
|
|
||||||
|
# Milliseconds to wait between chunks
|
||||||
|
# Simulates the generation time of a real LLM
|
||||||
|
# Lower values = faster streaming, higher values = more realistic delays
|
||||||
|
chunk_interval_ms: 80
|
||||||
|
|
||||||
|
# Random jitter to add to chunk intervals (milliseconds)
|
||||||
|
# Adds natural variation to timing, making it more realistic
|
||||||
|
# Set to null or 0 to disable jitter
|
||||||
|
jitter_ms: 20
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
//! Bridge mode implementation for relaying stdio ACP clients to Dirigent ACP Server.
|
||||||
|
//!
|
||||||
|
//! This module implements the stdio-to-HTTP/SSE bridge that allows external ACP clients
|
||||||
|
//! (like Claude Code configured for stdio transport) to connect to a Dirigent ACP Server.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! External ACP Client (Claude Code, etc.)
|
||||||
|
//! |
|
||||||
|
//! | stdio (stdin/stdout)
|
||||||
|
//! v
|
||||||
|
//! +-------------------+
|
||||||
|
//! | Conductor Bridge |
|
||||||
|
//! | - stdin parser | <-- Reads JSON-RPC from stdin
|
||||||
|
//! | - HTTP client | <-- POSTs to /rpc endpoint
|
||||||
|
//! | - SSE subscriber | <-- Subscribes to /events
|
||||||
|
//! +-------------------+
|
||||||
|
//! |
|
||||||
|
//! | HTTP/SSE
|
||||||
|
//! v
|
||||||
|
//! Dirigent ACP Server
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Protocol Flow
|
||||||
|
//!
|
||||||
|
//! 1. Client sends JSON-RPC request on stdin (line-delimited JSON)
|
||||||
|
//! 2. Bridge reads the request, POSTs to `{server_url}/rpc`
|
||||||
|
//! 3. Bridge writes the HTTP response to stdout
|
||||||
|
//! 4. Meanwhile, SSE subscriber writes server notifications to stdout
|
||||||
|
|
||||||
|
use dirigent_core::acp::transport::json_reader::{JsonLineReader, ReadResult};
|
||||||
|
use crate::Result;
|
||||||
|
use dirigent_protocol::log_utils::mask_json_string;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use reqwest::Client;
|
||||||
|
use reqwest_eventsource::{Event, EventSource};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::io::{AsyncWriteExt, BufReader};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// Configuration for the bridge.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BridgeConfig {
|
||||||
|
/// Base URL of the ACP Server (e.g., "http://localhost:3001/acp").
|
||||||
|
pub server_url: String,
|
||||||
|
|
||||||
|
/// Timeout for HTTP requests.
|
||||||
|
pub timeout: Duration,
|
||||||
|
|
||||||
|
/// Enable verbose logging of JSON-RPC messages.
|
||||||
|
pub verbose: bool,
|
||||||
|
|
||||||
|
/// Automatically reconnect SSE stream on disconnect.
|
||||||
|
pub auto_reconnect: bool,
|
||||||
|
|
||||||
|
/// Optional connector selection by ID or agent type magic word
|
||||||
|
pub select_connector: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeConfig {
|
||||||
|
/// Create a new bridge configuration.
|
||||||
|
pub fn new(server_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
server_url,
|
||||||
|
timeout: Duration::from_secs(30),
|
||||||
|
verbose: false,
|
||||||
|
auto_reconnect: true,
|
||||||
|
select_connector: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the RPC endpoint URL.
|
||||||
|
pub fn rpc_url(&self) -> String {
|
||||||
|
format!("{}/rpc", self.server_url.trim_end_matches('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the SSE events endpoint URL.
|
||||||
|
pub fn events_url(&self) -> String {
|
||||||
|
let base = format!("{}/events", self.server_url.trim_end_matches('/'));
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the SSE events endpoint URL with client_id query parameter.
|
||||||
|
pub fn events_url_with_client(&self, client_id: &str) -> String {
|
||||||
|
format!("{}/events?client_id={}", self.server_url.trim_end_matches('/'), client_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the agent response endpoint URL.
|
||||||
|
pub fn agent_response_url(&self) -> String {
|
||||||
|
format!("{}/agent_response", self.server_url.trim_end_matches('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge state shared between tasks.
|
||||||
|
struct BridgeState {
|
||||||
|
/// Client ID received from initialize response.
|
||||||
|
client_id: Option<String>,
|
||||||
|
|
||||||
|
/// Buffered RPC responses for session/prompt requests (session_id -> response_json)
|
||||||
|
/// These are held until we see the corresponding turn.complete notification
|
||||||
|
/// (with session_idle as fallback for backward compatibility)
|
||||||
|
buffered_responses: std::collections::HashMap<String, String>,
|
||||||
|
|
||||||
|
/// Pending agent requests awaiting responses from Zed
|
||||||
|
/// Maps request_id -> insertion timestamp for timeout tracking
|
||||||
|
pending_agent_requests: std::collections::HashMap<serde_json::Value, Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client_id: None,
|
||||||
|
buffered_responses: std::collections::HashMap::new(),
|
||||||
|
pending_agent_requests: std::collections::HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC request structure (minimal for parsing).
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct JsonRpcRequest {
|
||||||
|
jsonrpc: String,
|
||||||
|
method: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
id: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC response structure (minimal for parsing).
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct JsonRpcResponse {
|
||||||
|
jsonrpc: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
error: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
id: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC notification structure for SSE events.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct JsonRpcNotification {
|
||||||
|
jsonrpc: String,
|
||||||
|
method: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the bridge, relaying between stdio and the ACP Server.
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Spawns an SSE subscriber task that writes notifications to stdout
|
||||||
|
/// 2. Reads JSON-RPC requests from stdin in a loop
|
||||||
|
/// 3. Forwards requests to the ACP Server via HTTP
|
||||||
|
/// 4. Writes responses to stdout
|
||||||
|
pub async fn run_bridge(config: BridgeConfig) -> Result<()> {
|
||||||
|
tracing::info!(
|
||||||
|
server_url = %config.server_url,
|
||||||
|
"Starting bridge to Dirigent ACP Server"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create HTTP client
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(config.timeout)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| crate::MockerError::Internal(format!("Failed to create HTTP client: {}", e)))?;
|
||||||
|
|
||||||
|
// Shared state
|
||||||
|
let state = Arc::new(Mutex::new(BridgeState::new()));
|
||||||
|
|
||||||
|
// Channel for stdout writes (to serialize output from multiple tasks)
|
||||||
|
let (stdout_tx, mut stdout_rx) = mpsc::channel::<String>(100);
|
||||||
|
|
||||||
|
// Spawn stdout writer task
|
||||||
|
let stdout_handle = tokio::spawn(async move {
|
||||||
|
let mut stdout = tokio::io::stdout();
|
||||||
|
while let Some(line) = stdout_rx.recv().await {
|
||||||
|
// T019: Start timing for stdout write
|
||||||
|
let write_start = Instant::now();
|
||||||
|
|
||||||
|
if let Err(e) = stdout.write_all(line.as_bytes()).await {
|
||||||
|
tracing::error!(error = %e, "Failed to write to stdout");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Err(e) = stdout.write_all(b"\n").await {
|
||||||
|
tracing::error!(error = %e, "Failed to write newline to stdout");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Err(e) = stdout.flush().await {
|
||||||
|
tracing::error!(error = %e, "Failed to flush stdout");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// T019: Trace logging after flush
|
||||||
|
let elapsed_ms = write_start.elapsed().as_millis();
|
||||||
|
tracing::trace!(
|
||||||
|
elapsed_ms = elapsed_ms,
|
||||||
|
line_len = line.len(),
|
||||||
|
"Flushed event to stdout"
|
||||||
|
);
|
||||||
|
|
||||||
|
// T020: Warning for slow stdout writes
|
||||||
|
if elapsed_ms > 100 {
|
||||||
|
tracing::warn!(
|
||||||
|
elapsed_ms = elapsed_ms,
|
||||||
|
line_len = line.len(),
|
||||||
|
"Slow stdout write + flush detected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn SSE subscriber task
|
||||||
|
let sse_config = config.clone();
|
||||||
|
let sse_stdout_tx = stdout_tx.clone();
|
||||||
|
let sse_state = Arc::clone(&state);
|
||||||
|
let sse_handle = tokio::spawn(async move {
|
||||||
|
run_sse_subscriber(sse_config, sse_stdout_tx, sse_state).await
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn timeout checker task
|
||||||
|
let timeout_state = Arc::clone(&state);
|
||||||
|
let timeout_handle = tokio::spawn(async move {
|
||||||
|
run_timeout_checker(timeout_state).await
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run stdin reader in main task
|
||||||
|
let stdin_result = run_stdin_reader(config, client, stdout_tx, state).await;
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
sse_handle.abort();
|
||||||
|
timeout_handle.abort();
|
||||||
|
stdout_handle.abort();
|
||||||
|
|
||||||
|
stdin_result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read JSON-RPC requests from stdin and forward to the ACP Server.
|
||||||
|
async fn run_stdin_reader(
|
||||||
|
config: BridgeConfig,
|
||||||
|
client: Client,
|
||||||
|
stdout_tx: mpsc::Sender<String>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let stdin = tokio::io::stdin();
|
||||||
|
let mut reader = BufReader::new(stdin);
|
||||||
|
let mut json_reader = JsonLineReader::new();
|
||||||
|
|
||||||
|
tracing::debug!("Stdin reader started, waiting for JSON-RPC requests");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Read next JSON message (handles multi-line JSON from clients)
|
||||||
|
let message: serde_json::Value = match json_reader.read_message(&mut reader).await {
|
||||||
|
Ok(ReadResult::Message(msg)) => msg,
|
||||||
|
Ok(ReadResult::Eof) => break,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to parse JSON-RPC message from stdin");
|
||||||
|
let error_response = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32700,
|
||||||
|
"message": format!("Parse error: {}", e)
|
||||||
|
},
|
||||||
|
"id": null
|
||||||
|
});
|
||||||
|
let _ = stdout_tx.send(error_response.to_string()).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serialize for forwarding (always compact single-line)
|
||||||
|
let line = serde_json::to_string(&message).unwrap_or_default();
|
||||||
|
|
||||||
|
if config.verbose {
|
||||||
|
tracing::debug!(request = %mask_json_string(&line), "Received JSON-RPC request from stdin");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a response to an agent request (has id but no method)
|
||||||
|
let is_response = message.get("id").is_some() && message.get("method").is_none();
|
||||||
|
|
||||||
|
if is_response {
|
||||||
|
// This might be a response to an agent request
|
||||||
|
if let Some(response_id) = message.get("id") {
|
||||||
|
let mut state_lock = state.lock().await;
|
||||||
|
|
||||||
|
// Check if this response_id matches a pending agent request
|
||||||
|
if state_lock.pending_agent_requests.contains_key(response_id) {
|
||||||
|
tracing::info!(
|
||||||
|
response_id = ?response_id,
|
||||||
|
"Detected Zed response to agent request"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from pending requests
|
||||||
|
state_lock.pending_agent_requests.remove(response_id);
|
||||||
|
tracing::debug!(
|
||||||
|
response_id = ?response_id,
|
||||||
|
remaining_pending = state_lock.pending_agent_requests.len(),
|
||||||
|
"Removed agent request from pending set"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get client_id
|
||||||
|
let client_id = state_lock.client_id.clone();
|
||||||
|
drop(state_lock); // Release lock before HTTP request
|
||||||
|
|
||||||
|
if let Some(client_id) = client_id {
|
||||||
|
// POST response to /agent_response
|
||||||
|
let agent_response_url = config.agent_response_url();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
url = %agent_response_url,
|
||||||
|
response_id = ?response_id,
|
||||||
|
"POSTing agent response to Dirigent"
|
||||||
|
);
|
||||||
|
|
||||||
|
match client
|
||||||
|
.post(&agent_response_url)
|
||||||
|
.header("X-Client-ID", &client_id)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(line.to_string())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
if status.is_success() {
|
||||||
|
tracing::info!(
|
||||||
|
response_id = ?response_id,
|
||||||
|
status = %status,
|
||||||
|
"Successfully posted agent response to Dirigent"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let body = resp.text().await.unwrap_or_else(|_| "".to_string());
|
||||||
|
tracing::error!(
|
||||||
|
response_id = ?response_id,
|
||||||
|
status = %status,
|
||||||
|
body = %body,
|
||||||
|
"Failed to post agent response to Dirigent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
response_id = ?response_id,
|
||||||
|
"HTTP request failed when posting agent response"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
response_id = ?response_id,
|
||||||
|
"Cannot post agent response: client_id not set"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip forwarding to /acp/rpc - this is a response flow, not a new request
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as request for normal processing
|
||||||
|
let request: JsonRpcRequest = match serde_json::from_value(message) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, line = %line, "Failed to parse JSON-RPC request");
|
||||||
|
// Send error response
|
||||||
|
let error_response = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32700,
|
||||||
|
"message": format!("Parse error: {}", e)
|
||||||
|
},
|
||||||
|
"id": null
|
||||||
|
});
|
||||||
|
let _ = stdout_tx.send(error_response.to_string()).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward request to server (with client_id if available and not initialize)
|
||||||
|
let client_id = if request.method != "initialize" {
|
||||||
|
state.lock().await.client_id.clone()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// For initialize requests, pass select_connector as header
|
||||||
|
let select_connector = if request.method == "initialize" {
|
||||||
|
config.select_connector.as_deref()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = forward_request(&client, &config, &line, client_id.as_deref(), select_connector).await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(response_text) => {
|
||||||
|
if config.verbose {
|
||||||
|
tracing::debug!(response = %mask_json_string(&response_text), "Received response from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an initialize response to extract client_id
|
||||||
|
if request.method == "initialize" {
|
||||||
|
if let Ok(resp) = serde_json::from_str::<JsonRpcResponse>(&response_text) {
|
||||||
|
if let Some(result) = resp.result {
|
||||||
|
// Try both camelCase (ACP spec) and snake_case
|
||||||
|
if let Some(client_id) = result.get("clientId")
|
||||||
|
.or_else(|| result.get("client_id"))
|
||||||
|
.and_then(|v| v.as_str()) {
|
||||||
|
let mut state = state.lock().await;
|
||||||
|
state.client_id = Some(client_id.to_string());
|
||||||
|
tracing::info!(client_id = %client_id, "Received client_id from initialize response");
|
||||||
|
} else {
|
||||||
|
tracing::warn!("No clientId found in initialize response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a deferred response (e.g., gateway session/list waiting
|
||||||
|
// for transfer). The real response will arrive later via SSE _rpc_response.
|
||||||
|
if let Ok(resp) = serde_json::from_str::<JsonRpcResponse>(&response_text) {
|
||||||
|
if let Some(ref result) = resp.result {
|
||||||
|
if result.get("_deferred").and_then(|v| v.as_bool()) == Some(true) {
|
||||||
|
tracing::info!(
|
||||||
|
id = ?resp.id,
|
||||||
|
method = %request.method,
|
||||||
|
"Dropping deferred response - real response will arrive via SSE"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a session/prompt response - buffer it instead of sending immediately
|
||||||
|
if request.method == "session/prompt" {
|
||||||
|
// Extract session_id from request params
|
||||||
|
if let Some(params) = &request.params {
|
||||||
|
if let Some(session_id) = params.get("sessionId").and_then(|v| v.as_str()) {
|
||||||
|
tracing::debug!(
|
||||||
|
session_id = %session_id,
|
||||||
|
"Buffering session/prompt response until turn.complete"
|
||||||
|
);
|
||||||
|
let mut state = state.lock().await;
|
||||||
|
state.buffered_responses.insert(session_id.to_string(), response_text);
|
||||||
|
// Don't send to stdout yet - wait for turn.complete (or session_idle as fallback)
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
tracing::warn!("session/prompt request missing sessionId in params");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("session/prompt request missing params");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response to stdout (for non-session/prompt responses)
|
||||||
|
if stdout_tx.send(response_text).await.is_err() {
|
||||||
|
tracing::error!("Stdout channel closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, method = %request.method, "Failed to forward request to server");
|
||||||
|
// Send error response
|
||||||
|
let error_response = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": format!("Internal error: {}", e)
|
||||||
|
},
|
||||||
|
"id": request.id
|
||||||
|
});
|
||||||
|
let _ = stdout_tx.send(error_response.to_string()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: Clear all pending agent requests when stdin closes (Zed disconnected)
|
||||||
|
{
|
||||||
|
let mut state = state.lock().await;
|
||||||
|
let pending_count = state.pending_agent_requests.len();
|
||||||
|
if pending_count > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
pending_count = pending_count,
|
||||||
|
"Stdin closed with {} pending agent requests - clearing all",
|
||||||
|
pending_count
|
||||||
|
);
|
||||||
|
state.pending_agent_requests.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Stdin closed, shutting down bridge");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward a JSON-RPC request to the ACP Server.
|
||||||
|
async fn forward_request(
|
||||||
|
client: &Client,
|
||||||
|
config: &BridgeConfig,
|
||||||
|
request_body: &str,
|
||||||
|
client_id: Option<&str>,
|
||||||
|
select_connector: Option<&str>,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mut request_builder = client
|
||||||
|
.post(config.rpc_url())
|
||||||
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// Add client_id header if available
|
||||||
|
if let Some(id) = client_id {
|
||||||
|
request_builder = request_builder.header("X-Client-ID", id);
|
||||||
|
tracing::debug!(client_id = %id, "Forwarding request with client_id header");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add select_connector header if available (for initialize requests)
|
||||||
|
if let Some(connector) = select_connector {
|
||||||
|
request_builder = request_builder.header("X-Select-Connector", connector);
|
||||||
|
tracing::info!(select_connector = %connector, "Forwarding initialize with X-Select-Connector header");
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request_builder
|
||||||
|
.body(request_body.to_string())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::MockerError::Internal(format!("HTTP request failed: {}", e)))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::MockerError::Internal(format!("Failed to read response body: {}", e)))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
tracing::warn!(status = %status, body = %body, "Server returned non-success status");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to SSE events and write notifications to stdout.
|
||||||
|
async fn run_sse_subscriber(
|
||||||
|
config: BridgeConfig,
|
||||||
|
stdout_tx: mpsc::Sender<String>,
|
||||||
|
state: Arc<Mutex<BridgeState>>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
// Wait for client_id to be set before subscribing
|
||||||
|
let client_id = loop {
|
||||||
|
let state = state.lock().await;
|
||||||
|
if let Some(ref id) = state.client_id {
|
||||||
|
break id.clone();
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
};
|
||||||
|
|
||||||
|
let events_url = config.events_url_with_client(&client_id);
|
||||||
|
tracing::info!(url = %events_url, "Connecting to SSE events endpoint");
|
||||||
|
|
||||||
|
let mut es = EventSource::get(&events_url);
|
||||||
|
|
||||||
|
while let Some(event) = es.next().await {
|
||||||
|
match event {
|
||||||
|
Ok(Event::Open) => {
|
||||||
|
tracing::info!("SSE connection opened");
|
||||||
|
}
|
||||||
|
Ok(Event::Message(message)) => {
|
||||||
|
// T016: Trace logging when SSE event is received from dirigent
|
||||||
|
tracing::trace!(
|
||||||
|
event_type = %message.event,
|
||||||
|
data_len = message.data.len(),
|
||||||
|
"Received SSE event from dirigent"
|
||||||
|
);
|
||||||
|
|
||||||
|
if config.verbose {
|
||||||
|
tracing::debug!(
|
||||||
|
event_type = %message.event,
|
||||||
|
data = %mask_json_string(&message.data),
|
||||||
|
"Received SSE event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T017: Start timing for notification processing
|
||||||
|
let notification_start = Instant::now();
|
||||||
|
|
||||||
|
// _rpc_response events are complete JSON-RPC responses pushed via SSE.
|
||||||
|
// Forward the data directly to stdout without wrapping as a notification.
|
||||||
|
if message.event == "_rpc_response" {
|
||||||
|
tracing::info!(
|
||||||
|
data_len = message.data.len(),
|
||||||
|
"Received _rpc_response via SSE - forwarding directly to stdout"
|
||||||
|
);
|
||||||
|
if stdout_tx.send(message.data.clone()).await.is_err() {
|
||||||
|
tracing::error!("Stdout channel closed while forwarding _rpc_response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert SSE event to JSON-RPC notification
|
||||||
|
// Note: Use event type as-is for ACP compliance (don't add acp/ prefix)
|
||||||
|
let notification = JsonRpcNotification {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
method: message.event.clone(),
|
||||||
|
params: serde_json::from_str(&message.data).ok(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let notification_json = match serde_json::to_string(¬ification) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to serialize notification");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// T017: Trace logging after notification serialization
|
||||||
|
tracing::trace!(
|
||||||
|
elapsed_ms = notification_start.elapsed().as_millis(),
|
||||||
|
"Notification JSON serialization completed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// T021: Warning for large notifications
|
||||||
|
if notification_json.len() > 10240 {
|
||||||
|
tracing::warn!(
|
||||||
|
size_bytes = notification_json.len(),
|
||||||
|
"Large notification being sent to stdout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T018: Trace logging before writing to stdout
|
||||||
|
tracing::trace!(
|
||||||
|
notification_len = notification_json.len(),
|
||||||
|
elapsed_since_receive_ms = notification_start.elapsed().as_millis(),
|
||||||
|
"Sending notification to stdout channel"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send the notification to stdout
|
||||||
|
if stdout_tx.send(notification_json).await.is_err() {
|
||||||
|
tracing::error!("Stdout channel closed, stopping SSE subscriber");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an agent_request - forward to Zed as JSON-RPC request
|
||||||
|
if message.event == "session/update" {
|
||||||
|
if let Some(params) = ¬ification.params {
|
||||||
|
if let Some(update) = params.get("update") {
|
||||||
|
if update.get("sessionUpdate") == Some(&serde_json::json!("agent_request")) {
|
||||||
|
tracing::info!("Detected agent_request from SSE");
|
||||||
|
|
||||||
|
// Extract agent request fields
|
||||||
|
let request_id = match update.get("requestId") {
|
||||||
|
Some(id) => id.clone(),
|
||||||
|
None => {
|
||||||
|
tracing::warn!("agent_request missing requestId, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let method = match update.get("method").and_then(|m| m.as_str()) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("agent_request missing method, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let request_params = match update.get("params") {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => {
|
||||||
|
tracing::warn!("agent_request missing params, skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format as JSON-RPC request for Zed
|
||||||
|
let rpc_request = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"method": method,
|
||||||
|
"params": request_params
|
||||||
|
});
|
||||||
|
|
||||||
|
let rpc_request_json = match serde_json::to_string(&rpc_request) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to serialize agent request");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
request_id = ?request_id,
|
||||||
|
method = %method,
|
||||||
|
"Forwarding agent request to Zed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// T022: Start timing for agent request forwarding
|
||||||
|
let agent_request_start = Instant::now();
|
||||||
|
|
||||||
|
// Send formatted request to Zed's stdin
|
||||||
|
if stdout_tx.send(rpc_request_json).await.is_err() {
|
||||||
|
tracing::error!("Stdout channel closed while sending agent request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// T022: Trace logging after agent request forwarded
|
||||||
|
tracing::trace!(
|
||||||
|
elapsed_ms = agent_request_start.elapsed().as_millis(),
|
||||||
|
"Agent request forwarded to Zed stdout"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track pending agent request with timestamp for timeout tracking
|
||||||
|
{
|
||||||
|
let mut state = state.lock().await;
|
||||||
|
state.pending_agent_requests.insert(request_id.clone(), Instant::now());
|
||||||
|
tracing::debug!(
|
||||||
|
request_id = ?request_id,
|
||||||
|
pending_count = state.pending_agent_requests.len(),
|
||||||
|
"Tracking pending agent request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip regular notification handling for agent requests
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a turn.complete notification - if so, flush buffered response
|
||||||
|
// This is the primary signal that all content has been received
|
||||||
|
if message.event == "turn.complete" {
|
||||||
|
tracing::debug!("Received turn.complete notification");
|
||||||
|
if let Some(params) = ¬ification.params {
|
||||||
|
if let Some(session_id) = params.get("session_id").and_then(|v| v.as_str()) {
|
||||||
|
tracing::debug!("Session ID from turn.complete: {}", session_id);
|
||||||
|
// Check if we have a buffered response for this session
|
||||||
|
let mut state = state.lock().await;
|
||||||
|
tracing::debug!(
|
||||||
|
"Looking for buffered response for session {}, have {} buffered",
|
||||||
|
session_id,
|
||||||
|
state.buffered_responses.len()
|
||||||
|
);
|
||||||
|
if let Some(buffered_response) = state.buffered_responses.remove(session_id) {
|
||||||
|
tracing::info!(
|
||||||
|
session_id = %session_id,
|
||||||
|
"Flushing buffered session/prompt response after turn.complete"
|
||||||
|
);
|
||||||
|
drop(state); // Release lock before sending
|
||||||
|
if stdout_tx.send(buffered_response).await.is_err() {
|
||||||
|
tracing::error!("Stdout channel closed while flushing buffered response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::debug!("No buffered response for session {} (already flushed or no pending response)", session_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("No 'session_id' in turn.complete params");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("turn.complete params is None, raw data: {}", message.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a session_idle notification - fallback flush for backward compatibility
|
||||||
|
if message.event == "session/update" {
|
||||||
|
tracing::debug!("Received session/update notification, checking for session_idle");
|
||||||
|
// Parse the notification params to check for session_idle
|
||||||
|
if let Some(params) = ¬ification.params {
|
||||||
|
tracing::debug!("Notification params: {:?}", params);
|
||||||
|
if let Some(session_id) = params.get("sessionId").and_then(|v| v.as_str()) {
|
||||||
|
tracing::debug!("Session ID from notification: {}", session_id);
|
||||||
|
if let Some(update) = params.get("update") {
|
||||||
|
if let Some(session_update) = update.get("sessionUpdate").and_then(|v| v.as_str()) {
|
||||||
|
tracing::debug!("Session update type: {}", session_update);
|
||||||
|
if session_update == "session_idle" {
|
||||||
|
// Check if we have a buffered response for this session
|
||||||
|
let mut state = state.lock().await;
|
||||||
|
tracing::debug!(
|
||||||
|
"Looking for buffered response for session {}, have {} buffered (fallback path)",
|
||||||
|
session_id,
|
||||||
|
state.buffered_responses.len()
|
||||||
|
);
|
||||||
|
if let Some(buffered_response) = state.buffered_responses.remove(session_id) {
|
||||||
|
tracing::info!(
|
||||||
|
session_id = %session_id,
|
||||||
|
"Flushing buffered session/prompt response after session_idle (fallback)"
|
||||||
|
);
|
||||||
|
drop(state); // Release lock before sending
|
||||||
|
if stdout_tx.send(buffered_response).await.is_err() {
|
||||||
|
tracing::error!("Stdout channel closed while flushing buffered response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::debug!("No buffered response found for session {} (already flushed by turn.complete)", session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Could not extract sessionUpdate from update");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("No 'update' field in params");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("No 'sessionId' in params");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Notification params is None, raw data: {}", message.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(reqwest_eventsource::Error::StreamEnded) => {
|
||||||
|
tracing::info!("SSE stream ended");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "SSE error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: Clear pending agent requests when SSE stream closes
|
||||||
|
{
|
||||||
|
let mut state = state.lock().await;
|
||||||
|
let pending_count = state.pending_agent_requests.len();
|
||||||
|
if pending_count > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
pending_count = pending_count,
|
||||||
|
"SSE stream closed with {} pending agent requests - clearing all",
|
||||||
|
pending_count
|
||||||
|
);
|
||||||
|
state.pending_agent_requests.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.auto_reconnect {
|
||||||
|
tracing::info!("Auto-reconnect disabled, stopping SSE subscriber");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Reconnecting to SSE endpoint in 1 second...");
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Periodically check for and cleanup stale pending agent requests.
|
||||||
|
async fn run_timeout_checker(state: Arc<Mutex<BridgeState>>) {
|
||||||
|
const TIMEOUT_DURATION: Duration = Duration::from_secs(30);
|
||||||
|
const CHECK_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
tracing::debug!("Timeout checker started");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(CHECK_INTERVAL).await;
|
||||||
|
|
||||||
|
let mut state = state.lock().await;
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut stale_requests = Vec::new();
|
||||||
|
|
||||||
|
// Find stale requests
|
||||||
|
for (request_id, inserted_at) in state.pending_agent_requests.iter() {
|
||||||
|
let elapsed = now.duration_since(*inserted_at);
|
||||||
|
if elapsed >= TIMEOUT_DURATION {
|
||||||
|
stale_requests.push(request_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale requests
|
||||||
|
for request_id in stale_requests {
|
||||||
|
state.pending_agent_requests.remove(&request_id);
|
||||||
|
tracing::error!(
|
||||||
|
request_id = ?request_id,
|
||||||
|
timeout_secs = TIMEOUT_DURATION.as_secs(),
|
||||||
|
"Agent request timed out - removing from pending set"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.pending_agent_requests.is_empty() {
|
||||||
|
tracing::debug!(
|
||||||
|
pending_count = state.pending_agent_requests.len(),
|
||||||
|
"Timeout checker: {} pending agent requests",
|
||||||
|
state.pending_agent_requests.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bridge_config_urls() {
|
||||||
|
let config = BridgeConfig::new("http://localhost:3001/acp".to_string());
|
||||||
|
assert_eq!(config.rpc_url(), "http://localhost:3001/acp/rpc");
|
||||||
|
assert_eq!(config.events_url(), "http://localhost:3001/acp/events");
|
||||||
|
assert_eq!(config.agent_response_url(), "http://localhost:3001/acp/agent_response");
|
||||||
|
|
||||||
|
// Test with trailing slash
|
||||||
|
let config = BridgeConfig::new("http://localhost:3001/acp/".to_string());
|
||||||
|
assert_eq!(config.rpc_url(), "http://localhost:3001/acp/rpc");
|
||||||
|
assert_eq!(config.events_url(), "http://localhost:3001/acp/events");
|
||||||
|
assert_eq!(config.agent_response_url(), "http://localhost:3001/acp/agent_response");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_rpc_request_parsing() {
|
||||||
|
let json = r#"{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}"#;
|
||||||
|
let request: JsonRpcRequest = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(request.method, "initialize");
|
||||||
|
assert_eq!(request.id, Some(serde_json::json!(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_rpc_notification_serialization() {
|
||||||
|
let notification = JsonRpcNotification {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
method: "acp/messageChunk".to_string(),
|
||||||
|
params: Some(serde_json::json!({"content": "Hello"})),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(¬ification).unwrap();
|
||||||
|
assert!(json.contains("acp/messageChunk"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! ACP (Agent-Client Protocol) server implementation.
|
||||||
|
//!
|
||||||
|
//! This module provides the HTTP/WebSocket server that implements the ACP protocol.
|
||||||
|
//! It handles incoming client connections, routes requests to fixture responders,
|
||||||
|
//! and streams responses back to clients.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! - `server.rs` - Axum HTTP server setup and routing
|
||||||
|
//! - `model.rs` - ACP protocol type definitions and serialization
|
||||||
|
//! - `stream.rs` - Server-Sent Events (SSE) streaming for real-time updates
|
||||||
|
//! - `stdio.rs` - Stdin/stdout transport for editors like Zed
|
||||||
|
//! - `bridge.rs` - Bridge mode for relaying stdio to HTTP/SSE
|
||||||
|
|
||||||
|
pub mod bridge;
|
||||||
|
pub mod model;
|
||||||
|
pub mod server;
|
||||||
|
pub mod stdio;
|
||||||
|
pub mod stream;
|
||||||
|
|
||||||
|
// Re-exports for convenience
|
||||||
|
pub use bridge::{run_bridge, BridgeConfig};
|
||||||
|
pub use model::*;
|
||||||
|
pub use server::*;
|
||||||
|
pub use stdio::serve_stdio;
|
||||||
|
pub use stream::{
|
||||||
|
chunk_text, stream_session, StreamConfig, StreamController, StreamEvent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
//! ACP protocol type definitions.
|
||||||
|
//!
|
||||||
|
//! This module defines the core types for the Agent-Client Protocol (ACP) v0.1,
|
||||||
|
//! including JSON-RPC message structures and ACP-specific request/response types.
|
||||||
|
//!
|
||||||
|
//! These types are aligned with the official ACP specification and designed to
|
||||||
|
//! work with the mocker's fixture system for predictable testing scenarios.
|
||||||
|
//!
|
||||||
|
//! # References
|
||||||
|
//! - ACP Specification: https://github.com/agent-client-protocol/spec
|
||||||
|
//! - JSON-RPC 2.0: https://www.jsonrpc.org/specification
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Re-export types from fixture module that are used in ACP protocol
|
||||||
|
use crate::fixture::types::{Message, Participant};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JSON-RPC Core Types
|
||||||
|
// ============================================================================
|
||||||
|
// Reference: https://www.jsonrpc.org/specification
|
||||||
|
|
||||||
|
/// JSON-RPC 2.0 request message.
|
||||||
|
///
|
||||||
|
/// Represents a client request with a method name, optional parameters, and an ID.
|
||||||
|
/// The ID is used to correlate requests with responses.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Specification
|
||||||
|
/// A Request object has the following members:
|
||||||
|
/// - `jsonrpc`: MUST be exactly "2.0"
|
||||||
|
/// - `method`: A String containing the name of the method to be invoked
|
||||||
|
/// - `params`: A Structured value (Array or Object) holding the parameter values
|
||||||
|
/// - `id`: An identifier established by the Client (String, Number, or NULL)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JsonRpcRequest {
|
||||||
|
/// JSON-RPC version (always "2.0").
|
||||||
|
pub jsonrpc: String,
|
||||||
|
|
||||||
|
/// The name of the method to be invoked.
|
||||||
|
pub method: String,
|
||||||
|
|
||||||
|
/// Optional parameters for the method (structured as JSON value).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub params: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
/// Request identifier (number or string). Used to correlate with response.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC 2.0 response message.
|
||||||
|
///
|
||||||
|
/// Represents a server response to a request. Contains either a result or an error,
|
||||||
|
/// but never both.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Specification
|
||||||
|
/// A Response object has the following members:
|
||||||
|
/// - `jsonrpc`: MUST be exactly "2.0"
|
||||||
|
/// - `result`: Required on success, absent on error
|
||||||
|
/// - `error`: Required on error, absent on success
|
||||||
|
/// - `id`: MUST be the same as the value of the id member in the Request Object
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JsonRpcResponse {
|
||||||
|
/// JSON-RPC version (always "2.0").
|
||||||
|
pub jsonrpc: String,
|
||||||
|
|
||||||
|
/// The result of the method invocation (present on success).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub result: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
/// Error object (present on error).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<JsonRpcError>,
|
||||||
|
|
||||||
|
/// Request identifier (must match the request ID).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC 2.0 notification message.
|
||||||
|
///
|
||||||
|
/// A notification is a request without an ID. The server does not send a response
|
||||||
|
/// to notifications.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Specification
|
||||||
|
/// A Notification is a Request object without an "id" member. The Server MUST NOT
|
||||||
|
/// reply to a Notification.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JsonRpcNotification {
|
||||||
|
/// JSON-RPC version (always "2.0").
|
||||||
|
pub jsonrpc: String,
|
||||||
|
|
||||||
|
/// The name of the method to be invoked.
|
||||||
|
pub method: String,
|
||||||
|
|
||||||
|
/// Optional parameters for the method (structured as JSON value).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub params: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-RPC 2.0 error object.
|
||||||
|
///
|
||||||
|
/// Represents an error that occurred during method execution.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Specification
|
||||||
|
/// An Error object has the following members:
|
||||||
|
/// - `code`: A Number indicating the error type
|
||||||
|
/// - `message`: A String providing a short description
|
||||||
|
/// - `data`: Optional additional information about the error
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JsonRpcError {
|
||||||
|
/// A numeric error code indicating the type of error.
|
||||||
|
///
|
||||||
|
/// Standard codes:
|
||||||
|
/// - `-32700`: Parse error (invalid JSON)
|
||||||
|
/// - `-32600`: Invalid Request
|
||||||
|
/// - `-32601`: Method not found
|
||||||
|
/// - `-32602`: Invalid params
|
||||||
|
/// - `-32603`: Internal error
|
||||||
|
/// - `-32000` to `-32099`: Server error (implementation-defined)
|
||||||
|
pub code: i32,
|
||||||
|
|
||||||
|
/// A short description of the error.
|
||||||
|
pub message: String,
|
||||||
|
|
||||||
|
/// Optional additional information about the error.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ACP Content Types
|
||||||
|
// ============================================================================
|
||||||
|
// Reference: ACP spec section on content blocks
|
||||||
|
|
||||||
|
/// A block of content in an ACP message.
|
||||||
|
///
|
||||||
|
/// Content blocks represent different types of message content. In ACP v0.1,
|
||||||
|
/// only text content is supported, but the enum structure allows for future
|
||||||
|
/// extensions.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// Content blocks are the building blocks of messages. Each block has a type
|
||||||
|
/// and type-specific fields.
|
||||||
|
///
|
||||||
|
/// # TODO
|
||||||
|
/// Future content block types to implement:
|
||||||
|
/// - `Image { source: ImageSource, alt_text: Option<String> }`
|
||||||
|
/// - `Tool { id: String, name: String, input: Value }`
|
||||||
|
/// - `ToolResult { tool_call_id: String, content: Vec<ContentBlock> }`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ContentBlock {
|
||||||
|
/// Plain text content.
|
||||||
|
Text {
|
||||||
|
/// The text content.
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
// TODO: Add Image variant
|
||||||
|
// TODO: Add Tool variant
|
||||||
|
// TODO: Add ToolResult variant
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reason why an agent stopped generating content.
|
||||||
|
///
|
||||||
|
/// Indicates the condition that caused the agent to stop producing output.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// The stop reason indicates why message generation terminated. This helps
|
||||||
|
/// clients understand whether the message is complete or was interrupted.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StopReason {
|
||||||
|
/// The language model finishes responding without requesting more tools.
|
||||||
|
EndTurn,
|
||||||
|
|
||||||
|
/// The maximum token limit is reached.
|
||||||
|
MaxTokens,
|
||||||
|
|
||||||
|
/// The maximum number of model requests in a single turn is exceeded.
|
||||||
|
MaxTurnRequests,
|
||||||
|
|
||||||
|
/// The Agent refuses to continue.
|
||||||
|
Refusal,
|
||||||
|
|
||||||
|
/// The Client cancels the turn.
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ACP Initialize Types
|
||||||
|
// ============================================================================
|
||||||
|
// Reference: ACP spec section on initialization handshake
|
||||||
|
|
||||||
|
/// Parameters for the `acp.initialize` method.
|
||||||
|
///
|
||||||
|
/// Sent by the client to initiate an ACP session and negotiate capabilities.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// The initialize request is the first message in an ACP session. It establishes
|
||||||
|
/// the protocol version and allows client and server to negotiate capabilities.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Method
|
||||||
|
/// Method: `acp.initialize`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InitializeParams {
|
||||||
|
/// The ACP protocol version the client supports (protocol version 1 = integer).
|
||||||
|
pub protocol_version: serde_json::Value, // Can be integer or string
|
||||||
|
|
||||||
|
/// Client capabilities and configuration.
|
||||||
|
///
|
||||||
|
/// This is intentionally flexible to allow for client-specific capabilities.
|
||||||
|
/// The server should examine this and respond with compatible agent capabilities.
|
||||||
|
pub client_capabilities: serde_json::Value,
|
||||||
|
|
||||||
|
/// Optional client information (name, title, version).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub client_info: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of the `acp.initialize` method.
|
||||||
|
///
|
||||||
|
/// Returned by the server in response to an initialize request.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// The initialize response confirms the protocol version and declares the
|
||||||
|
/// agent's capabilities. This allows the client to adapt its behavior based
|
||||||
|
/// on what the agent supports.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Method
|
||||||
|
/// Method: `acp.initialize`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InitializeResult {
|
||||||
|
/// The ACP protocol version the server supports (protocol version 1 = integer).
|
||||||
|
pub protocol_version: serde_json::Value, // Can be integer or string
|
||||||
|
|
||||||
|
/// Agent capabilities.
|
||||||
|
pub agent_capabilities: AgentCapabilities,
|
||||||
|
|
||||||
|
/// Optional agent information (name, title, version).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_info: Option<serde_json::Value>,
|
||||||
|
|
||||||
|
/// Optional authentication methods supported.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auth_methods: Option<Vec<String>>,
|
||||||
|
|
||||||
|
/// Optional metadata for protocol extensions.
|
||||||
|
///
|
||||||
|
/// This field uses a leading underscore to indicate it's for extensions
|
||||||
|
/// and not part of the core protocol.
|
||||||
|
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub _meta: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent capabilities declaration.
|
||||||
|
///
|
||||||
|
/// Describes what features the agent supports.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// Capabilities allow the client to discover what features are available.
|
||||||
|
/// The agent should accurately report its capabilities to avoid client errors.
|
||||||
|
///
|
||||||
|
/// # TODO
|
||||||
|
/// Future capabilities to implement:
|
||||||
|
/// - `tools: bool` - Agent supports tool calls
|
||||||
|
/// - `files: bool` - Agent supports file attachments
|
||||||
|
/// - `streaming: bool` - Agent supports streaming responses
|
||||||
|
/// - `context_window: Option<usize>` - Maximum context size
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AgentCapabilities {
|
||||||
|
/// Whether the agent supports loading existing sessions.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub load_session: Option<bool>,
|
||||||
|
|
||||||
|
/// Prompt capabilities (what input types are supported).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub prompt_capabilities: Option<PromptCapabilities>,
|
||||||
|
|
||||||
|
/// MCP (Model Context Protocol) capabilities.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mcp: Option<serde_json::Value>,
|
||||||
|
// TODO: Add tools capability
|
||||||
|
// TODO: Add files capability
|
||||||
|
// TODO: Add streaming capability
|
||||||
|
// TODO: Add context_window capability
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt capabilities declaration.
|
||||||
|
///
|
||||||
|
/// Describes what types of content the agent can accept in prompts.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// In v0.1, only text prompts are required. Future versions may support
|
||||||
|
/// images, audio, video, etc.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PromptCapabilities {
|
||||||
|
/// Whether the agent supports image prompts.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image: Option<bool>,
|
||||||
|
|
||||||
|
/// Whether the agent supports audio prompts.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub audio: Option<bool>,
|
||||||
|
|
||||||
|
/// Whether the agent supports embedded context (resources).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub embedded_context: Option<bool>,
|
||||||
|
// Note: text and resourceLink are baseline requirements, not listed here
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ACP Session Types
|
||||||
|
// ============================================================================
|
||||||
|
// Reference: ACP spec sections on session management
|
||||||
|
|
||||||
|
/// Parameters for the `session/new` method.
|
||||||
|
///
|
||||||
|
/// Sent by the client to create a new session.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// Creates a new conversation session. The session ID is generated by the
|
||||||
|
/// agent and returned in the response.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Method
|
||||||
|
/// Method: `session/new`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionNewParams {
|
||||||
|
/// The working directory for the session (absolute path).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
|
||||||
|
/// MCP servers the agent should connect to.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mcp_servers: Option<Vec<serde_json::Value>>,
|
||||||
|
|
||||||
|
/// Optional fixture template ID (mocker-specific extension).
|
||||||
|
///
|
||||||
|
/// When provided, the mocker will create a session based on the specified
|
||||||
|
/// fixture template. If not provided, a default session is created.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub template_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of the `session/new` method.
|
||||||
|
///
|
||||||
|
/// Returns the ID of the newly created session.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// The server generates a unique session ID and returns it to the client.
|
||||||
|
/// The client uses this ID in subsequent requests to interact with the session.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Method
|
||||||
|
/// Method: `session/new`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionNewResult {
|
||||||
|
/// The unique identifier for the new session.
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for the `session/load` method.
|
||||||
|
///
|
||||||
|
/// Sent by the client to load an existing session.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// Loads a previously created session, including its full message history.
|
||||||
|
/// This requires the agent to support the `loadSession` capability.
|
||||||
|
/// The agent replays the conversation via session/update notifications.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Method
|
||||||
|
/// Method: `session/load`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionLoadParams {
|
||||||
|
/// The ID of the session to load.
|
||||||
|
pub session_id: String,
|
||||||
|
|
||||||
|
/// The working directory for the session (absolute path).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cwd: Option<String>,
|
||||||
|
|
||||||
|
/// MCP servers the agent should connect to.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mcp_servers: Option<Vec<serde_json::Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete information about a session.
|
||||||
|
///
|
||||||
|
/// Represents a full conversation session with all its data.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// A session contains metadata, participants, and the full message history.
|
||||||
|
/// This is the primary data structure for representing conversations in ACP.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
/// Unique session identifier.
|
||||||
|
pub id: String,
|
||||||
|
|
||||||
|
/// Human-readable session title.
|
||||||
|
pub title: String,
|
||||||
|
|
||||||
|
/// ISO8601 timestamp when session was created.
|
||||||
|
pub created_at: String,
|
||||||
|
|
||||||
|
/// Participants in this session.
|
||||||
|
pub participants: Vec<Participant>,
|
||||||
|
|
||||||
|
/// Messages in this session.
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for the `session/prompt` method.
|
||||||
|
///
|
||||||
|
/// Sent by the client to send a prompt (user message) to the agent.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// The prompt method is the primary way to interact with an agent. The client
|
||||||
|
/// sends content blocks (usually text) and the agent responds with generated
|
||||||
|
/// content via session/update notifications during processing.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Method
|
||||||
|
/// Method: `session/prompt`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionPromptParams {
|
||||||
|
/// The ID of the session to send the prompt to.
|
||||||
|
pub session_id: String,
|
||||||
|
|
||||||
|
/// The content blocks to send (prompt content).
|
||||||
|
pub prompt: Vec<ContentBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of the `session/prompt` method.
|
||||||
|
///
|
||||||
|
/// Returns why the agent stopped processing the prompt.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// The response only contains the stop reason. All content is sent via
|
||||||
|
/// session/update notifications during processing.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Method
|
||||||
|
/// Method: `session/prompt`
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionPromptResult {
|
||||||
|
/// The reason why the agent stopped generating.
|
||||||
|
pub stop_reason: StopReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for the `session/update` notification.
|
||||||
|
///
|
||||||
|
/// Sent by the agent to notify the client of session state changes.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// Session updates are server-to-client notifications (not requests) that
|
||||||
|
/// inform the client about changes to a session. These are typically sent
|
||||||
|
/// during streaming responses. The update field contains the type-specific
|
||||||
|
/// update information.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Method
|
||||||
|
/// Method: `session/update` (notification, no response expected)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionUpdateParams {
|
||||||
|
/// The ID of the session that was updated.
|
||||||
|
pub session_id: String,
|
||||||
|
|
||||||
|
/// The update information (type-specific).
|
||||||
|
/// Common types: agent_message_chunk, user_message_chunk, tool_call, etc.
|
||||||
|
pub update: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for the `session/cancel` notification.
|
||||||
|
///
|
||||||
|
/// Sent by the client to request cancellation of an in-progress generation.
|
||||||
|
///
|
||||||
|
/// # ACP Specification
|
||||||
|
/// The cancel notification tells the agent to stop generating content for
|
||||||
|
/// the specified session. The agent should stop as soon as possible and
|
||||||
|
/// return the content generated so far.
|
||||||
|
///
|
||||||
|
/// # JSON-RPC Method
|
||||||
|
/// Method: `session/cancel` (notification, no response expected)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SessionCancelParams {
|
||||||
|
/// The ID of the session to cancel.
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Implementations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
impl JsonRpcRequest {
|
||||||
|
/// Creates a new JSON-RPC request with the given method and parameters.
|
||||||
|
pub fn new(method: impl Into<String>, params: Option<serde_json::Value>, id: impl Into<serde_json::Value>) -> Self {
|
||||||
|
Self {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
method: method.into(),
|
||||||
|
params,
|
||||||
|
id: Some(id.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcResponse {
|
||||||
|
/// Creates a successful JSON-RPC response with the given result.
|
||||||
|
pub fn success(result: serde_json::Value, id: Option<serde_json::Value>) -> Self {
|
||||||
|
Self {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
result: Some(result),
|
||||||
|
error: None,
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an error JSON-RPC response with the given error.
|
||||||
|
pub fn error(error: JsonRpcError, id: Option<serde_json::Value>) -> Self {
|
||||||
|
Self {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
result: None,
|
||||||
|
error: Some(error),
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcNotification {
|
||||||
|
/// Creates a new JSON-RPC notification with the given method and parameters.
|
||||||
|
pub fn new(method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
|
||||||
|
Self {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
method: method.into(),
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonRpcError {
|
||||||
|
/// Creates a new JSON-RPC error with the given code and message.
|
||||||
|
pub fn new(code: i32, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
data: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new JSON-RPC error with additional data.
|
||||||
|
pub fn with_data(code: i32, message: impl Into<String>, data: serde_json::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
data: Some(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard error: Parse error (invalid JSON).
|
||||||
|
pub fn parse_error() -> Self {
|
||||||
|
Self::new(-32700, "Parse error")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard error: Invalid Request.
|
||||||
|
pub fn invalid_request() -> Self {
|
||||||
|
Self::new(-32600, "Invalid Request")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard error: Method not found.
|
||||||
|
pub fn method_not_found(method: &str) -> Self {
|
||||||
|
Self::new(-32601, format!("Method not found: {}", method))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard error: Invalid params.
|
||||||
|
pub fn invalid_params(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(-32602, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard error: Internal error.
|
||||||
|
pub fn internal_error(message: impl Into<String>) -> Self {
|
||||||
|
Self::new(-32603, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentBlock {
|
||||||
|
/// Creates a new text content block.
|
||||||
|
pub fn text(text: impl Into<String>) -> Self {
|
||||||
|
Self::Text { text: text.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jsonrpc_request_serde() {
|
||||||
|
let req = JsonRpcRequest::new("test.method", None, 1);
|
||||||
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
|
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||||
|
assert!(json.contains("\"method\":\"test.method\""));
|
||||||
|
assert!(json.contains("\"id\":1"));
|
||||||
|
|
||||||
|
let parsed: JsonRpcRequest = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.method, "test.method");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jsonrpc_response_success() {
|
||||||
|
let resp = JsonRpcResponse::success(serde_json::json!({"result": "ok"}), Some(1.into()));
|
||||||
|
let json = serde_json::to_string(&resp).unwrap();
|
||||||
|
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||||
|
assert!(json.contains("\"result\""));
|
||||||
|
assert!(!json.contains("\"error\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jsonrpc_response_error() {
|
||||||
|
let err = JsonRpcError::method_not_found("test.method");
|
||||||
|
let resp = JsonRpcResponse::error(err, Some(1.into()));
|
||||||
|
let json = serde_json::to_string(&resp).unwrap();
|
||||||
|
assert!(json.contains("\"error\""));
|
||||||
|
assert!(json.contains("-32601"));
|
||||||
|
assert!(!json.contains("\"result\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_content_block_text() {
|
||||||
|
let block = ContentBlock::text("Hello, world!");
|
||||||
|
let json = serde_json::to_string(&block).unwrap();
|
||||||
|
assert!(json.contains("\"type\":\"text\""));
|
||||||
|
assert!(json.contains("\"text\":\"Hello, world!\""));
|
||||||
|
|
||||||
|
let parsed: ContentBlock = serde_json::from_str(&json).unwrap();
|
||||||
|
match parsed {
|
||||||
|
ContentBlock::Text { text } => assert_eq!(text, "Hello, world!"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stop_reason_serde() {
|
||||||
|
let reason = StopReason::EndTurn;
|
||||||
|
let json = serde_json::to_string(&reason).unwrap();
|
||||||
|
assert_eq!(json, "\"end_turn\"");
|
||||||
|
|
||||||
|
let parsed: StopReason = serde_json::from_str("\"cancelled\"").unwrap();
|
||||||
|
matches!(parsed, StopReason::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initialize_params_serde() {
|
||||||
|
let params = InitializeParams {
|
||||||
|
protocol_version: serde_json::json!(1),
|
||||||
|
client_capabilities: serde_json::json!({"streaming": true}),
|
||||||
|
client_info: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(¶ms).unwrap();
|
||||||
|
assert!(json.contains("\"protocolVersion\":1"));
|
||||||
|
assert!(json.contains("\"clientCapabilities\""));
|
||||||
|
|
||||||
|
let _parsed: InitializeParams = serde_json::from_str(&json).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_new_params_with_template() {
|
||||||
|
let params = SessionNewParams {
|
||||||
|
cwd: Some(".".to_string()),
|
||||||
|
mcp_servers: Some(vec![]),
|
||||||
|
template_id: Some("test-template".to_string()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(¶ms).unwrap();
|
||||||
|
eprintln!("JSON: {}", json);
|
||||||
|
assert!(json.contains("\"templateId\":\"test-template\"")); // camelCase
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_new_params_without_template() {
|
||||||
|
let params = SessionNewParams {
|
||||||
|
cwd: Some(".".to_string()),
|
||||||
|
mcp_servers: Some(vec![]),
|
||||||
|
template_id: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(¶ms).unwrap();
|
||||||
|
// Should not include template_id field when None
|
||||||
|
assert!(!json.contains("template_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_prompt_params() {
|
||||||
|
let params = SessionPromptParams {
|
||||||
|
session_id: "session-123".to_string(),
|
||||||
|
prompt: vec![ContentBlock::text("Hello!")],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(¶ms).unwrap();
|
||||||
|
assert!(json.contains("\"sessionId\":\"session-123\"")); // camelCase
|
||||||
|
assert!(json.contains("\"prompt\""));
|
||||||
|
assert!(json.contains("\"type\":\"text\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2211
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
|||||||
|
//! Stdio transport for JSON-RPC communication.
|
||||||
|
//!
|
||||||
|
//! This module implements JSON-RPC over stdin/stdout for use with editors like Zed
|
||||||
|
//! that launch agent servers as child processes.
|
||||||
|
|
||||||
|
use dirigent_core::acp::transport::json_reader::{JsonLineReader, ReadResult};
|
||||||
|
use crate::{MockerState, Result};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncWriteExt, BufReader};
|
||||||
|
|
||||||
|
/// Run the JSON-RPC server using stdin/stdout transport.
|
||||||
|
///
|
||||||
|
/// This reads JSON-RPC requests from stdin (one per line) and writes responses to stdout.
|
||||||
|
/// This is the transport mode used by editors like Zed.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `state` - The mocker state containing fixtures and sessions
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if stdin/stdout I/O fails or JSON parsing fails.
|
||||||
|
pub async fn serve_stdio(state: Arc<MockerState>) -> Result<()> {
|
||||||
|
tracing::info!("Starting ACP server in stdio mode");
|
||||||
|
tracing::debug!("Reading JSON-RPC requests from stdin, writing responses to stdout");
|
||||||
|
|
||||||
|
let stdin = tokio::io::stdin();
|
||||||
|
let mut reader = BufReader::new(stdin);
|
||||||
|
let mut json_reader = JsonLineReader::new();
|
||||||
|
|
||||||
|
// Use a shared stdout wrapped in Mutex to serialize writes
|
||||||
|
let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
|
||||||
|
|
||||||
|
// Subscribe to session/update notifications
|
||||||
|
let mut event_rx = state.subscribe_events();
|
||||||
|
|
||||||
|
// Spawn a task to forward session/update notifications to stdout
|
||||||
|
let stdout_clone = stdout.clone();
|
||||||
|
let notification_task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match event_rx.recv().await {
|
||||||
|
Ok(notification) => {
|
||||||
|
tracing::info!(
|
||||||
|
session_id = %notification.session_id,
|
||||||
|
notification = %notification.notification,
|
||||||
|
"📤 Forwarding session/update notification to stdout"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Acquire lock and write notification
|
||||||
|
let mut stdout_guard = stdout_clone.lock().await;
|
||||||
|
if let Err(e) = stdout_guard.write_all(notification.notification.as_bytes()).await {
|
||||||
|
tracing::error!(error = %e, "Failed to write notification to stdout");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Err(e) = stdout_guard.write_all(b"\n").await {
|
||||||
|
tracing::error!(error = %e, "Failed to write newline");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Err(e) = stdout_guard.flush().await {
|
||||||
|
tracing::error!(error = %e, "Failed to flush stdout");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
drop(stdout_guard); // Release lock
|
||||||
|
|
||||||
|
tracing::info!("✅ Notification written and flushed to stdout");
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
tracing::warn!(skipped, "Event receiver lagged, some notifications were skipped");
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||||
|
tracing::info!("Event channel closed, notification task shutting down");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Read next JSON message (handles multi-line JSON from clients)
|
||||||
|
let request = match json_reader.read_message(&mut reader).await {
|
||||||
|
Ok(ReadResult::Message(msg)) => msg,
|
||||||
|
Ok(ReadResult::Eof) => {
|
||||||
|
tracing::info!("Stdin closed, shutting down");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to read JSON-RPC request from stdin");
|
||||||
|
// Send error response
|
||||||
|
let error_response = serde_json::json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32700,
|
||||||
|
"message": "Parse error",
|
||||||
|
"data": e
|
||||||
|
},
|
||||||
|
"id": null
|
||||||
|
});
|
||||||
|
let error_json = serde_json::to_string(&error_response).unwrap();
|
||||||
|
let mut stdout_guard = stdout.lock().await;
|
||||||
|
stdout_guard.write_all(error_json.as_bytes()).await
|
||||||
|
.map_err(crate::MockerError::Transport)?;
|
||||||
|
stdout_guard.write_all(b"\n").await
|
||||||
|
.map_err(crate::MockerError::Transport)?;
|
||||||
|
stdout_guard.flush().await.map_err(crate::MockerError::Transport)?;
|
||||||
|
drop(stdout_guard);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let masked_request = dirigent_protocol::log_utils::mask_json_string(&request.to_string());
|
||||||
|
tracing::info!(request = %masked_request, "Received JSON-RPC request");
|
||||||
|
|
||||||
|
// Handle the request using the same handler as HTTP transport
|
||||||
|
let response = super::server::handle_jsonrpc_inner(state.clone(), request).await;
|
||||||
|
|
||||||
|
// Check if this is a notification (no response expected)
|
||||||
|
if response.is_none() {
|
||||||
|
tracing::debug!("Request was a notification, no response sent");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the response to stdout
|
||||||
|
let response_json = serde_json::to_string(&response.unwrap()).map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to serialize response");
|
||||||
|
crate::MockerError::Internal(format!("Failed to serialize response: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let masked_response = dirigent_protocol::log_utils::mask_json_string(&response_json);
|
||||||
|
tracing::info!(response = %masked_response, "Sending JSON-RPC response");
|
||||||
|
|
||||||
|
let mut stdout_guard = stdout.lock().await;
|
||||||
|
stdout_guard.write_all(response_json.as_bytes()).await
|
||||||
|
.map_err(crate::MockerError::Transport)?;
|
||||||
|
stdout_guard.write_all(b"\n").await
|
||||||
|
.map_err(crate::MockerError::Transport)?;
|
||||||
|
stdout_guard.flush().await.map_err(crate::MockerError::Transport)?;
|
||||||
|
drop(stdout_guard);
|
||||||
|
|
||||||
|
tracing::info!("Response sent and flushed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up notification task
|
||||||
|
notification_task.abort();
|
||||||
|
|
||||||
|
tracing::info!("Stdio server shutting down (stdin closed)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
//! Server-Sent Events (SSE) streaming for real-time updates.
|
||||||
|
//!
|
||||||
|
//! This module handles streaming of ACP events to clients using SSE,
|
||||||
|
//! allowing real-time delivery of message chunks, tool executions,
|
||||||
|
//! and other protocol events.
|
||||||
|
//!
|
||||||
|
//! It also provides text chunking and timing logic for simulating
|
||||||
|
//! streaming responses with configurable delays and jitter.
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
|
use rand_chacha::ChaCha8Rng;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
use tokio_stream::Stream;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Streaming Configuration
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Configuration for streaming behavior.
|
||||||
|
///
|
||||||
|
/// Controls how text is chunked and the timing between chunk emissions.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StreamConfig {
|
||||||
|
/// Number of tokens (words) per chunk.
|
||||||
|
pub tokens_per_chunk: usize,
|
||||||
|
|
||||||
|
/// Base interval between chunks in milliseconds.
|
||||||
|
pub chunk_interval_ms: u64,
|
||||||
|
|
||||||
|
/// Optional random jitter to add/subtract from chunk interval (in milliseconds).
|
||||||
|
/// The actual jitter will be uniformly distributed in the range [-jitter_ms, +jitter_ms].
|
||||||
|
pub jitter_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamConfig {
|
||||||
|
/// Create a new stream configuration.
|
||||||
|
pub fn new(tokens_per_chunk: usize, chunk_interval_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
tokens_per_chunk,
|
||||||
|
chunk_interval_ms,
|
||||||
|
jitter_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new stream configuration with jitter.
|
||||||
|
pub fn with_jitter(tokens_per_chunk: usize, chunk_interval_ms: u64, jitter_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
tokens_per_chunk,
|
||||||
|
chunk_interval_ms,
|
||||||
|
jitter_ms: Some(jitter_ms),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StreamConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
tokens_per_chunk: 5,
|
||||||
|
chunk_interval_ms: 100,
|
||||||
|
jitter_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Text Chunking
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Splits text into chunks of approximately N tokens (words).
|
||||||
|
///
|
||||||
|
/// Uses whitespace splitting as a simplified proxy for tokenization.
|
||||||
|
/// Chunks preserve word boundaries (no mid-word splits).
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `text` - The text to chunk
|
||||||
|
/// * `tokens_per_chunk` - Approximate number of words per chunk
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A vector of text chunks. If `tokens_per_chunk` is 0 or text is empty,
|
||||||
|
/// returns a single-element vector containing the entire text.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// let text = "Hello world this is a test";
|
||||||
|
/// let chunks = chunk_text(text, 2);
|
||||||
|
/// assert_eq!(chunks, vec!["Hello world", "this is", "a test"]);
|
||||||
|
/// ```
|
||||||
|
pub fn chunk_text(text: &str, tokens_per_chunk: usize) -> Vec<String> {
|
||||||
|
if tokens_per_chunk == 0 || text.is_empty() {
|
||||||
|
return vec![text.to_string()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let words: Vec<&str> = text.split_whitespace().collect();
|
||||||
|
|
||||||
|
if words.is_empty() {
|
||||||
|
return vec![text.to_string()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
let mut current_chunk = Vec::new();
|
||||||
|
|
||||||
|
for word in words {
|
||||||
|
current_chunk.push(word);
|
||||||
|
|
||||||
|
if current_chunk.len() >= tokens_per_chunk {
|
||||||
|
chunks.push(current_chunk.join(" "));
|
||||||
|
current_chunk.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining words as final chunk
|
||||||
|
if !current_chunk.is_empty() {
|
||||||
|
chunks.push(current_chunk.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Stream Controller
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Controls the streaming of text chunks with configurable timing and cancellation.
|
||||||
|
///
|
||||||
|
/// The stream controller manages the emission of text chunks with configured
|
||||||
|
/// delays and optional jitter. It supports cancellation to stop streaming
|
||||||
|
/// in response to client requests.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StreamController {
|
||||||
|
/// Streaming configuration
|
||||||
|
config: StreamConfig,
|
||||||
|
|
||||||
|
/// Random number generator for jitter (seeded for reproducibility)
|
||||||
|
rng: Arc<std::sync::Mutex<ChaCha8Rng>>,
|
||||||
|
|
||||||
|
/// Cancellation flag shared across stream instances
|
||||||
|
cancelled: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamController {
|
||||||
|
/// Create a new stream controller with the given configuration.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `config` - Streaming configuration
|
||||||
|
/// * `seed` - Seed for the random number generator (for reproducible jitter)
|
||||||
|
pub fn new(config: StreamConfig, seed: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
rng: Arc::new(std::sync::Mutex::new(ChaCha8Rng::seed_from_u64(seed))),
|
||||||
|
cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the stream.
|
||||||
|
///
|
||||||
|
/// Sets the cancellation flag, causing the stream to stop emitting
|
||||||
|
/// chunks after the current chunk completes.
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.cancelled.store(true, Ordering::SeqCst);
|
||||||
|
tracing::debug!("Stream cancellation requested");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the stream has been cancelled.
|
||||||
|
pub fn is_cancelled(&self) -> bool {
|
||||||
|
self.cancelled.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the cancellation flag.
|
||||||
|
///
|
||||||
|
/// This allows the controller to be reused for a new stream.
|
||||||
|
pub fn reset(&self) {
|
||||||
|
self.cancelled.store(false, Ordering::SeqCst);
|
||||||
|
tracing::debug!("Stream cancellation flag reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream text chunks with configured timing.
|
||||||
|
///
|
||||||
|
/// Emits chunks with the configured interval between each, applying
|
||||||
|
/// random jitter if configured. Checks the cancellation flag before
|
||||||
|
/// each emission and stops immediately if cancelled.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `chunks` - The chunks to stream
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// An async stream that yields chunks as Strings
|
||||||
|
pub fn stream_chunks(
|
||||||
|
&self,
|
||||||
|
chunks: Vec<String>,
|
||||||
|
) -> impl Stream<Item = String> + '_ {
|
||||||
|
let cancelled = self.cancelled.clone();
|
||||||
|
let rng = self.rng.clone();
|
||||||
|
let config = self.config.clone();
|
||||||
|
let total_chunks = chunks.len();
|
||||||
|
|
||||||
|
async_stream::stream! {
|
||||||
|
for (idx, chunk) in chunks.into_iter().enumerate() {
|
||||||
|
// Check cancellation before each chunk
|
||||||
|
if cancelled.load(Ordering::SeqCst) {
|
||||||
|
tracing::info!(chunks_emitted = idx, "Stream cancelled");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate delay with optional jitter
|
||||||
|
let base_delay = config.chunk_interval_ms;
|
||||||
|
let delay_ms = if let Some(jitter) = config.jitter_ms {
|
||||||
|
let mut rng = rng.lock().unwrap();
|
||||||
|
let jitter_amount = rng.gen_range(-(jitter as i64)..=(jitter as i64));
|
||||||
|
(base_delay as i64 + jitter_amount).max(0) as u64
|
||||||
|
} else {
|
||||||
|
base_delay
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
chunk_idx = idx,
|
||||||
|
delay_ms,
|
||||||
|
chunk_len = chunk.len(),
|
||||||
|
"Emitting chunk"
|
||||||
|
);
|
||||||
|
|
||||||
|
yield chunk;
|
||||||
|
|
||||||
|
// Sleep after emitting chunk (except for last chunk)
|
||||||
|
if idx < total_chunks - 1 {
|
||||||
|
sleep(Duration::from_millis(delay_ms)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for StreamController {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("StreamController")
|
||||||
|
.field("config", &self.config)
|
||||||
|
.field("cancelled", &self.cancelled.load(Ordering::SeqCst))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSE Event (Legacy from previous implementation)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// SSE event sent to clients.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StreamEvent {
|
||||||
|
/// Event type (e.g., "message", "tool_call", "error").
|
||||||
|
pub event: String,
|
||||||
|
/// Event data (JSON-serialized).
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamEvent {
|
||||||
|
/// Create a new stream event.
|
||||||
|
pub fn new(event: impl Into<String>, data: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
event: event.into(),
|
||||||
|
data: data.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format as SSE text.
|
||||||
|
pub fn to_sse(&self) -> String {
|
||||||
|
format!("event: {}\ndata: {}\n\n", self.event, self.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle SSE connection for a session.
|
||||||
|
///
|
||||||
|
/// This function manages the lifecycle of an SSE connection,
|
||||||
|
/// streaming events from fixtures to the connected client.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `session_id` - ID of the session to stream
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// An async stream of SSE events.
|
||||||
|
pub async fn stream_session(_session_id: String) -> Result<()> {
|
||||||
|
// TODO: Implement SSE streaming using StreamController
|
||||||
|
// - Subscribe to session events from fixture responder
|
||||||
|
// - Convert events to SSE format
|
||||||
|
// - Handle client disconnection
|
||||||
|
// - Clean up resources
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Text Chunking Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunk_text_basic() {
|
||||||
|
let text = "Hello world this is a test";
|
||||||
|
let chunks = chunk_text(text, 2);
|
||||||
|
assert_eq!(chunks, vec!["Hello world", "this is", "a test"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunk_text_single_chunk() {
|
||||||
|
let text = "Hello world";
|
||||||
|
let chunks = chunk_text(text, 5);
|
||||||
|
assert_eq!(chunks, vec!["Hello world"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunk_text_exact_fit() {
|
||||||
|
let text = "one two three four";
|
||||||
|
let chunks = chunk_text(text, 2);
|
||||||
|
assert_eq!(chunks, vec!["one two", "three four"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunk_text_empty() {
|
||||||
|
let text = "";
|
||||||
|
let chunks = chunk_text(text, 2);
|
||||||
|
assert_eq!(chunks, vec![""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunk_text_zero_tokens_per_chunk() {
|
||||||
|
let text = "Hello world";
|
||||||
|
let chunks = chunk_text(text, 0);
|
||||||
|
assert_eq!(chunks, vec!["Hello world"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunk_text_single_word() {
|
||||||
|
let text = "Hello";
|
||||||
|
let chunks = chunk_text(text, 2);
|
||||||
|
assert_eq!(chunks, vec!["Hello"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunk_text_multiple_spaces() {
|
||||||
|
let text = "Hello world test";
|
||||||
|
let chunks = chunk_text(text, 2);
|
||||||
|
assert_eq!(chunks, vec!["Hello world", "test"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunk_text_preserves_word_boundaries() {
|
||||||
|
let text = "supercalifragilisticexpialidocious test";
|
||||||
|
let chunks = chunk_text(text, 1);
|
||||||
|
assert_eq!(chunks, vec!["supercalifragilisticexpialidocious", "test"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// StreamController Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stream_controller_basic() {
|
||||||
|
let config = StreamConfig::new(2, 10);
|
||||||
|
let controller = StreamController::new(config, 42);
|
||||||
|
|
||||||
|
let chunks = vec!["chunk1".to_string(), "chunk2".to_string(), "chunk3".to_string()];
|
||||||
|
let stream = controller.stream_chunks(chunks);
|
||||||
|
tokio::pin!(stream);
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
results.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(results, vec!["chunk1", "chunk2", "chunk3"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stream_controller_timing() {
|
||||||
|
let config = StreamConfig::new(2, 50); // 50ms delay
|
||||||
|
let controller = StreamController::new(config, 42);
|
||||||
|
|
||||||
|
let chunks = vec!["chunk1".to_string(), "chunk2".to_string()];
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let stream = controller.stream_chunks(chunks);
|
||||||
|
tokio::pin!(stream);
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some(_chunk) = stream.next().await {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
assert_eq!(count, 2);
|
||||||
|
// Should take at least 50ms for the delay between chunks
|
||||||
|
// (we allow some tolerance for timing variations)
|
||||||
|
assert!(elapsed.as_millis() >= 40, "Expected at least 40ms, got {}ms", elapsed.as_millis());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stream_controller_with_jitter() {
|
||||||
|
let config = StreamConfig::with_jitter(2, 50, 10);
|
||||||
|
let controller = StreamController::new(config, 42);
|
||||||
|
|
||||||
|
let chunks = vec!["chunk1".to_string(), "chunk2".to_string(), "chunk3".to_string()];
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let stream = controller.stream_chunks(chunks);
|
||||||
|
tokio::pin!(stream);
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some(_chunk) = stream.next().await {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
assert_eq!(count, 3);
|
||||||
|
// With jitter of ±10ms on 50ms base, we expect roughly 80-120ms total
|
||||||
|
// (2 delays between 3 chunks)
|
||||||
|
assert!(elapsed.as_millis() >= 60, "Timing too short: {}ms", elapsed.as_millis());
|
||||||
|
assert!(elapsed.as_millis() <= 200, "Timing too long: {}ms", elapsed.as_millis());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stream_controller_cancellation() {
|
||||||
|
let config = StreamConfig::new(2, 20);
|
||||||
|
let controller = StreamController::new(config, 42);
|
||||||
|
|
||||||
|
let chunks = vec![
|
||||||
|
"chunk1".to_string(),
|
||||||
|
"chunk2".to_string(),
|
||||||
|
"chunk3".to_string(),
|
||||||
|
"chunk4".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let controller_clone = controller.clone();
|
||||||
|
|
||||||
|
// Spawn a task to cancel after 30ms
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
controller_clone.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = controller.stream_chunks(chunks);
|
||||||
|
tokio::pin!(stream);
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
results.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be cancelled before all chunks are emitted
|
||||||
|
assert!(results.len() < 4, "Expected cancellation, got {} chunks", results.len());
|
||||||
|
assert!(results.len() >= 1, "Should have at least 1 chunk before cancellation");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stream_controller_empty_chunks() {
|
||||||
|
let config = StreamConfig::new(2, 10);
|
||||||
|
let controller = StreamController::new(config, 42);
|
||||||
|
|
||||||
|
let chunks: Vec<String> = vec![];
|
||||||
|
let stream = controller.stream_chunks(chunks);
|
||||||
|
tokio::pin!(stream);
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some(_chunk) = stream.next().await {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stream_controller_reset_cancellation() {
|
||||||
|
let config = StreamConfig::new(2, 10);
|
||||||
|
let controller = StreamController::new(config, 42);
|
||||||
|
|
||||||
|
controller.cancel();
|
||||||
|
assert!(controller.is_cancelled());
|
||||||
|
|
||||||
|
controller.reset();
|
||||||
|
assert!(!controller.is_cancelled());
|
||||||
|
|
||||||
|
// Should be able to stream after reset
|
||||||
|
let chunks = vec!["chunk1".to_string(), "chunk2".to_string()];
|
||||||
|
let stream = controller.stream_chunks(chunks);
|
||||||
|
tokio::pin!(stream);
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
while let Some(_chunk) = stream.next().await {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_stream_controller_jitter_reproducibility() {
|
||||||
|
let config = StreamConfig::with_jitter(2, 50, 10);
|
||||||
|
|
||||||
|
// Same seed should produce same jitter sequence
|
||||||
|
let controller1 = StreamController::new(config.clone(), 42);
|
||||||
|
let controller2 = StreamController::new(config, 42);
|
||||||
|
|
||||||
|
let chunks1 = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||||
|
let chunks2 = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||||
|
|
||||||
|
let start1 = std::time::Instant::now();
|
||||||
|
let stream1 = controller1.stream_chunks(chunks1);
|
||||||
|
tokio::pin!(stream1);
|
||||||
|
while let Some(_) = stream1.next().await {}
|
||||||
|
let elapsed1 = start1.elapsed();
|
||||||
|
|
||||||
|
let start2 = std::time::Instant::now();
|
||||||
|
let stream2 = controller2.stream_chunks(chunks2);
|
||||||
|
tokio::pin!(stream2);
|
||||||
|
while let Some(_) = stream2.next().await {}
|
||||||
|
let elapsed2 = start2.elapsed();
|
||||||
|
|
||||||
|
// Same seed should produce similar timing (within 20ms tolerance)
|
||||||
|
let diff = if elapsed1 > elapsed2 {
|
||||||
|
elapsed1 - elapsed2
|
||||||
|
} else {
|
||||||
|
elapsed2 - elapsed1
|
||||||
|
};
|
||||||
|
assert!(diff.as_millis() < 20, "Timing difference too large: {}ms", diff.as_millis());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// StreamEvent Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stream_event_formatting() {
|
||||||
|
let event = StreamEvent::new("message", r#"{"text":"hello"}"#);
|
||||||
|
assert_eq!(
|
||||||
|
event.to_sse(),
|
||||||
|
"event: message\ndata: {\"text\":\"hello\"}\n\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//! # dirigate
|
||||||
|
//!
|
||||||
|
//! Dirigate - ACP bridge and mock server CLI tool.
|
||||||
|
//!
|
||||||
|
//! This binary provides a CLI interface for:
|
||||||
|
//! - Running an ACP mock server with fixture-based responses
|
||||||
|
//! - Bridging stdio ACP clients to a Dirigent ACP Server via HTTP/SSE
|
||||||
|
//! - Connecting to ACP agents as an interactive client
|
||||||
|
//!
|
||||||
|
//! ## Bridge Mode
|
||||||
|
//!
|
||||||
|
//! The bridge mode allows external ACP clients (like Claude Code configured for stdio)
|
||||||
|
//! to connect to a Dirigent ACP Server over HTTP/SSE:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! External ACP Client (Claude Code, etc.)
|
||||||
|
//! |
|
||||||
|
//! | stdio (stdin/stdout)
|
||||||
|
//! v
|
||||||
|
//! +-------------------+
|
||||||
|
//! | Dirigate Bridge |
|
||||||
|
//! | - stdin parser |
|
||||||
|
//! | - HTTP client |
|
||||||
|
//! | - SSE subscriber |
|
||||||
|
//! +-------------------+
|
||||||
|
//! |
|
||||||
|
//! | HTTP/SSE
|
||||||
|
//! v
|
||||||
|
//! Dirigent ACP Server
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use dirigate::{
|
||||||
|
cli::{execute_command, parse_log_format, Cli},
|
||||||
|
logging::init_logging,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Parse CLI arguments
|
||||||
|
let cli = Cli::parse_args();
|
||||||
|
|
||||||
|
// Set log level via RUST_LOG if not already set
|
||||||
|
if std::env::var("RUST_LOG").is_err() {
|
||||||
|
std::env::set_var("RUST_LOG", &cli.log_level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize logging (must be done after setting RUST_LOG)
|
||||||
|
let log_format = parse_log_format(&cli.log_format);
|
||||||
|
init_logging(log_format);
|
||||||
|
|
||||||
|
tracing::info!("dirigate v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
// Execute command and handle errors
|
||||||
|
if let Err(e) = execute_command(cli.command).await {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
//! CLI argument definitions.
|
||||||
|
//!
|
||||||
|
//! This module defines the command-line interface structure using `clap`.
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Default ACP Server URL for bridge mode.
|
||||||
|
/// By default, ACP is nested at /acp on the main Dioxus server (port 8080).
|
||||||
|
/// Use DIRIGENT_ACP_PORT env var to run ACP on a separate port.
|
||||||
|
pub const DEFAULT_SERVER_URL: &str = "http://localhost:8080/acp";
|
||||||
|
|
||||||
|
/// Dirigate - ACP bridge and mock server for testing and proxying ACP connections.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "dirigate")]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Log level (trace, debug, info, warn, error).
|
||||||
|
#[arg(short, long, default_value = "info", global = true)]
|
||||||
|
pub log_level: String,
|
||||||
|
|
||||||
|
/// Log format (pretty, json, compact).
|
||||||
|
#[arg(long, default_value = "pretty", global = true)]
|
||||||
|
pub log_format: String,
|
||||||
|
|
||||||
|
/// Subcommand to execute.
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Available commands.
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum Commands {
|
||||||
|
/// Start the mock server with fixture-based responses.
|
||||||
|
Serve {
|
||||||
|
/// Directory or file containing fixture YAML files.
|
||||||
|
#[arg(short, long, default_value = "./fixtures")]
|
||||||
|
fixtures: PathBuf,
|
||||||
|
|
||||||
|
/// Use stdin/stdout for JSON-RPC transport (for Zed and similar editors).
|
||||||
|
#[arg(long)]
|
||||||
|
stdio: bool,
|
||||||
|
|
||||||
|
/// Port to bind the server to (ignored if --stdio is set).
|
||||||
|
#[arg(short, long, default_value_t = 8080)]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Host address to bind to (ignored if --stdio is set).
|
||||||
|
#[arg(long, default_value = "127.0.0.1")]
|
||||||
|
host: String,
|
||||||
|
|
||||||
|
/// Tokens per chunk for streaming (overrides fixture setting).
|
||||||
|
#[arg(long)]
|
||||||
|
tokens_per_chunk: Option<usize>,
|
||||||
|
|
||||||
|
/// Milliseconds between chunks for streaming (overrides fixture setting).
|
||||||
|
#[arg(long)]
|
||||||
|
chunk_interval_ms: Option<u64>,
|
||||||
|
|
||||||
|
/// Enable or disable streaming (overrides fixture setting).
|
||||||
|
#[arg(long)]
|
||||||
|
streaming: Option<bool>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Validate fixture files without starting the server.
|
||||||
|
Validate {
|
||||||
|
/// Directory or file to validate. Can be specified multiple times.
|
||||||
|
#[arg(value_name = "PATH")]
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Print fixture contents in various formats.
|
||||||
|
Print {
|
||||||
|
/// Path to the fixture file.
|
||||||
|
#[arg(value_name = "FIXTURE")]
|
||||||
|
fixture: PathBuf,
|
||||||
|
|
||||||
|
/// Output format.
|
||||||
|
#[arg(short, long, value_enum, default_value_t = PrintFormat::Table)]
|
||||||
|
format: PrintFormat,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Ingest sessions from external sources (requires 'ingest' feature).
|
||||||
|
#[cfg(feature = "ingest")]
|
||||||
|
Ingest {
|
||||||
|
/// Base URL of the OpenCode API.
|
||||||
|
#[arg(short = 'u', long)]
|
||||||
|
base_url: String,
|
||||||
|
|
||||||
|
/// Specific session ID to ingest (if not provided, use --all).
|
||||||
|
#[arg(short = 's', long)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
|
||||||
|
/// Ingest all sessions from the API.
|
||||||
|
#[arg(short = 'a', long)]
|
||||||
|
all: bool,
|
||||||
|
|
||||||
|
/// Output file path for the generated fixture.
|
||||||
|
#[arg(short = 'o', long)]
|
||||||
|
output: PathBuf,
|
||||||
|
|
||||||
|
/// Merge with existing fixture file if it exists.
|
||||||
|
#[arg(short = 'm', long)]
|
||||||
|
merge: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Connect to an ACP agent as a client (interactive mode).
|
||||||
|
Connect {
|
||||||
|
/// Command to spawn for stdio transport (e.g., "claude --acp").
|
||||||
|
#[arg(long, conflicts_with = "url")]
|
||||||
|
command: Option<String>,
|
||||||
|
|
||||||
|
/// HTTP URL for HTTP+SSE transport (e.g., "http://localhost:8080").
|
||||||
|
#[arg(long, conflicts_with = "command")]
|
||||||
|
url: Option<String>,
|
||||||
|
|
||||||
|
/// Protocol version to use.
|
||||||
|
#[arg(long, default_value = "2025-01-01")]
|
||||||
|
protocol_version: String,
|
||||||
|
|
||||||
|
/// Automatically create a new session on connect.
|
||||||
|
#[arg(long)]
|
||||||
|
auto_session: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Bridge stdio ACP client to a Dirigent ACP Server via HTTP/SSE.
|
||||||
|
///
|
||||||
|
/// This mode allows external ACP clients (like Claude Code configured for stdio)
|
||||||
|
/// to connect to a Dirigent ACP Server. The bridge reads JSON-RPC requests from
|
||||||
|
/// stdin, forwards them to the server via HTTP, and writes responses and SSE
|
||||||
|
/// notifications to stdout.
|
||||||
|
///
|
||||||
|
/// ## Example Usage
|
||||||
|
///
|
||||||
|
/// ```bash
|
||||||
|
/// # Connect to local server (default: http://localhost:8080/acp)
|
||||||
|
/// dirigate bridge
|
||||||
|
///
|
||||||
|
/// # Connect to remote server (use actual Dioxus server port)
|
||||||
|
/// dirigate bridge --server-url http://remote:8080/acp
|
||||||
|
///
|
||||||
|
/// # Connect to ACP on separate port (if configured with DIRIGENT_ACP_PORT)
|
||||||
|
/// dirigate bridge --server-url http://localhost:3001/acp
|
||||||
|
///
|
||||||
|
/// # Via environment variable
|
||||||
|
/// DIRIGENT_SERVER_URL=http://remote:8080/acp dirigate bridge
|
||||||
|
/// ```
|
||||||
|
Bridge {
|
||||||
|
/// ACP Server URL to connect to.
|
||||||
|
///
|
||||||
|
/// Can also be set via DIRIGENT_SERVER_URL environment variable.
|
||||||
|
#[arg(short = 's', long, env = "DIRIGENT_SERVER_URL", default_value = DEFAULT_SERVER_URL)]
|
||||||
|
server_url: String,
|
||||||
|
|
||||||
|
/// Enable verbose logging of JSON-RPC messages.
|
||||||
|
#[arg(short, long)]
|
||||||
|
verbose: bool,
|
||||||
|
|
||||||
|
/// Timeout in seconds for HTTP requests to the server.
|
||||||
|
#[arg(long, default_value_t = 30)]
|
||||||
|
timeout: u64,
|
||||||
|
|
||||||
|
/// Automatically reconnect SSE stream on disconnect.
|
||||||
|
#[arg(long, default_value_t = true)]
|
||||||
|
auto_reconnect: bool,
|
||||||
|
|
||||||
|
/// Select a specific connector by ID or agent type magic word.
|
||||||
|
///
|
||||||
|
/// Magic words: claude, codex, gemini
|
||||||
|
/// When set, sessions will be routed directly to a connector of this type,
|
||||||
|
/// bypassing the gateway.
|
||||||
|
#[arg(long)]
|
||||||
|
select_connector: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output format for the print command.
|
||||||
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
|
pub enum PrintFormat {
|
||||||
|
/// Human-readable table format.
|
||||||
|
Table,
|
||||||
|
/// JSON format.
|
||||||
|
Json,
|
||||||
|
/// YAML format.
|
||||||
|
Yaml,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cli {
|
||||||
|
/// Parse CLI arguments from the environment.
|
||||||
|
pub fn parse_args() -> Self {
|
||||||
|
Self::parse()
|
||||||
|
}
|
||||||
|
}
|
||||||
+1274
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
//! Command-line interface for the ACP mocker.
|
||||||
|
//!
|
||||||
|
//! This module provides the CLI structure and command definitions for the
|
||||||
|
//! dirigate binary. It uses `clap` for argument parsing and
|
||||||
|
//! command dispatch.
|
||||||
|
//!
|
||||||
|
//! ## Commands
|
||||||
|
//!
|
||||||
|
//! - `serve` - Start the mock server
|
||||||
|
//! - `validate` - Validate fixture files without starting server
|
||||||
|
//! - `ingest` - Ingest sessions from external sources (feature-gated)
|
||||||
|
|
||||||
|
pub mod args;
|
||||||
|
pub mod commands;
|
||||||
|
|
||||||
|
// Re-exports for convenience
|
||||||
|
pub use args::*;
|
||||||
|
pub use commands::*;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//! Error types for the ACP mocker.
|
||||||
|
//!
|
||||||
|
//! This module defines all error types that can occur during fixture loading,
|
||||||
|
//! validation, server operation, and optional ingestion from external sources.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Result type alias using MockerError.
|
||||||
|
pub type Result<T> = std::result::Result<T, MockerError>;
|
||||||
|
|
||||||
|
/// Errors that can occur in the ACP mocker.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum MockerError {
|
||||||
|
/// Error loading fixture files from disk.
|
||||||
|
#[error("Failed to load fixture: {0}")]
|
||||||
|
FixtureLoad(String),
|
||||||
|
|
||||||
|
/// Error validating fixture structure or content.
|
||||||
|
#[error("Invalid fixture: {0}")]
|
||||||
|
FixtureValidation(String),
|
||||||
|
|
||||||
|
/// Error in ACP protocol handling.
|
||||||
|
#[error("ACP protocol error: {0}")]
|
||||||
|
AcpProtocol(String),
|
||||||
|
|
||||||
|
/// Requested session was not found in fixtures.
|
||||||
|
#[error("Session not found: {session_id}")]
|
||||||
|
SessionNotFound { session_id: String },
|
||||||
|
|
||||||
|
/// Error in transport layer (HTTP, WebSocket, etc.).
|
||||||
|
#[error("Transport error: {0}")]
|
||||||
|
Transport(#[from] std::io::Error),
|
||||||
|
|
||||||
|
/// Error during session ingestion from external sources.
|
||||||
|
#[cfg(feature = "ingest")]
|
||||||
|
#[error("Ingestion error: {0}")]
|
||||||
|
Ingest(String),
|
||||||
|
|
||||||
|
/// Error parsing YAML fixtures.
|
||||||
|
#[error("YAML parsing error: {0}")]
|
||||||
|
YamlParse(#[from] serde_yaml::Error),
|
||||||
|
|
||||||
|
/// Error parsing JSON data.
|
||||||
|
#[error("JSON parsing error: {0}")]
|
||||||
|
JsonParse(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// Generic error for cases not covered by specific variants.
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement From for common error types
|
||||||
|
impl From<String> for MockerError {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
MockerError::Internal(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for MockerError {
|
||||||
|
fn from(s: &str) -> Self {
|
||||||
|
MockerError::Internal(s.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_display() {
|
||||||
|
let err = MockerError::SessionNotFound {
|
||||||
|
session_id: "test-123".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(err.to_string(), "Session not found: test-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_load_error() {
|
||||||
|
let err = MockerError::FixtureLoad("file not found".to_string());
|
||||||
|
assert_eq!(err.to_string(), "Failed to load fixture: file not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,593 @@
|
|||||||
|
//! Fixture loading from YAML files.
|
||||||
|
//!
|
||||||
|
//! This module handles loading fixture definitions from the filesystem,
|
||||||
|
//! parsing YAML, and validating fixture structure.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
fixture::{Fixture, Message, Session},
|
||||||
|
MockerError, Result,
|
||||||
|
};
|
||||||
|
use std::{collections::HashSet, path::Path};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Load a fixture from a YAML file.
|
||||||
|
///
|
||||||
|
/// This function reads the file, parses it as YAML, and returns the fixture
|
||||||
|
/// without performing validation.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Path to the YAML fixture file
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MockerError::FixtureLoad` if:
|
||||||
|
/// - The file cannot be read
|
||||||
|
/// - The YAML is malformed
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use dirigate::fixture::load_fixture;
|
||||||
|
///
|
||||||
|
/// # async fn example() -> dirigate::Result<()> {
|
||||||
|
/// let fixture = load_fixture("fixtures/basic_session.yaml").await?;
|
||||||
|
/// println!("Loaded fixture version: {}", fixture.version);
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub async fn load_fixture<P: AsRef<Path>>(path: P) -> Result<Fixture> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
|
||||||
|
// Read file contents
|
||||||
|
let contents = tokio::fs::read_to_string(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MockerError::FixtureLoad(format!("Failed to read file '{}': {}", path.display(), e)))?;
|
||||||
|
|
||||||
|
// Parse YAML
|
||||||
|
let fixture: Fixture = serde_yaml::from_str(&contents)
|
||||||
|
.map_err(|e| MockerError::FixtureLoad(format!("Failed to parse YAML in '{}': {}", path.display(), e)))?;
|
||||||
|
|
||||||
|
info!("Loaded fixture from '{}'", path.display());
|
||||||
|
|
||||||
|
Ok(fixture)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a fixture structure.
|
||||||
|
///
|
||||||
|
/// Performs comprehensive validation of the fixture including:
|
||||||
|
/// - Version check (must be "0.1")
|
||||||
|
/// - Duplicate ID detection (sessions, messages, participants)
|
||||||
|
/// - Reference validation (session_id, parent_id, participant references)
|
||||||
|
/// - Timestamp format validation (ISO8601)
|
||||||
|
/// - Required field validation (non-empty strings)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `fixture` - The fixture to validate
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MockerError::FixtureValidation` with a detailed message listing all validation errors.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use dirigate::fixture::{load_fixture, validate_fixture};
|
||||||
|
///
|
||||||
|
/// # async fn example() -> dirigate::Result<()> {
|
||||||
|
/// let fixture = load_fixture("fixtures/basic_session.yaml").await?;
|
||||||
|
/// validate_fixture(&fixture)?;
|
||||||
|
/// println!("Fixture is valid!");
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub fn validate_fixture(fixture: &Fixture) -> Result<()> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
// 1. Validate version
|
||||||
|
if fixture.version != "0.1" {
|
||||||
|
errors.push(format!(
|
||||||
|
"Invalid version '{}', expected '0.1'",
|
||||||
|
fixture.version
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for duplicate session IDs
|
||||||
|
let mut session_ids = HashSet::new();
|
||||||
|
for session in &fixture.sessions {
|
||||||
|
if !session_ids.insert(&session.id) {
|
||||||
|
errors.push(format!("Duplicate session ID '{}'", session.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate each session
|
||||||
|
for session in &fixture.sessions {
|
||||||
|
validate_session(session, &mut errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are errors, return them all
|
||||||
|
if !errors.is_empty() {
|
||||||
|
let error_message = format!(
|
||||||
|
"Fixture validation failed with {} error(s):\n - {}",
|
||||||
|
errors.len(),
|
||||||
|
errors.join("\n - ")
|
||||||
|
);
|
||||||
|
warn!("{}", error_message);
|
||||||
|
return Err(MockerError::FixtureValidation(error_message));
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Fixture validation passed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and validate a fixture in one operation.
|
||||||
|
///
|
||||||
|
/// This is a convenience function that combines `load_fixture` and `validate_fixture`.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `path` - Path to the YAML fixture file
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MockerError::FixtureLoad` or `MockerError::FixtureValidation` as appropriate.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use dirigate::fixture::load_and_validate;
|
||||||
|
///
|
||||||
|
/// # async fn example() -> dirigate::Result<()> {
|
||||||
|
/// let fixture = load_and_validate("fixtures/basic_session.yaml").await?;
|
||||||
|
/// println!("Loaded and validated fixture with {} sessions", fixture.sessions.len());
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub async fn load_and_validate<P: AsRef<Path>>(path: P) -> Result<Fixture> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
let fixture = load_fixture(path).await?;
|
||||||
|
validate_fixture(&fixture)?;
|
||||||
|
info!("Successfully loaded and validated fixture from '{}'", path.display());
|
||||||
|
Ok(fixture)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all fixtures from a directory.
|
||||||
|
///
|
||||||
|
/// Scans a directory for YAML fixture files (.yaml and .yml extensions),
|
||||||
|
/// loads and validates each one, and returns a vector of valid fixtures.
|
||||||
|
/// Invalid files are logged as warnings and skipped.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `dir` - Directory containing YAML fixture files
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MockerError::FixtureLoad` if the directory cannot be read.
|
||||||
|
/// Individual file errors are logged but do not stop processing.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use dirigate::fixture::load_fixtures_from_dir;
|
||||||
|
///
|
||||||
|
/// # async fn example() -> dirigate::Result<()> {
|
||||||
|
/// let fixtures = load_fixtures_from_dir("fixtures/").await?;
|
||||||
|
/// println!("Loaded {} fixtures", fixtures.len());
|
||||||
|
/// # Ok(())
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub async fn load_fixtures_from_dir<P: AsRef<Path>>(dir: P) -> Result<Vec<Fixture>> {
|
||||||
|
let dir = dir.as_ref();
|
||||||
|
|
||||||
|
// Read directory entries
|
||||||
|
let mut entries = tokio::fs::read_dir(dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MockerError::FixtureLoad(format!("Failed to read directory '{}': {}", dir.display(), e)))?;
|
||||||
|
|
||||||
|
let mut fixtures = Vec::new();
|
||||||
|
|
||||||
|
// Process each entry
|
||||||
|
while let Some(entry) = entries.next_entry().await
|
||||||
|
.map_err(|e| MockerError::FixtureLoad(format!("Failed to read directory entry: {}", e)))? {
|
||||||
|
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
// Skip non-files
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extension
|
||||||
|
let extension = path.extension().and_then(|e| e.to_str());
|
||||||
|
if !matches!(extension, Some("yaml") | Some("yml")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load and validate the fixture
|
||||||
|
match load_and_validate(&path).await {
|
||||||
|
Ok(fixture) => {
|
||||||
|
info!("Successfully loaded fixture from '{}'", path.display());
|
||||||
|
fixtures.push(fixture);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to load fixture from '{}': {}", path.display(), e);
|
||||||
|
// Continue processing other files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Loaded {} fixture(s) from '{}'", fixtures.len(), dir.display());
|
||||||
|
Ok(fixtures)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a single session.
|
||||||
|
fn validate_session(session: &Session, errors: &mut Vec<String>) {
|
||||||
|
// Validate required fields are non-empty
|
||||||
|
if session.id.is_empty() {
|
||||||
|
errors.push("Session has empty ID".to_string());
|
||||||
|
}
|
||||||
|
if session.title.is_empty() {
|
||||||
|
errors.push(format!("Session '{}' has empty title", session.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate timestamp
|
||||||
|
if !is_valid_iso8601(&session.created_at) {
|
||||||
|
errors.push(format!(
|
||||||
|
"Session '{}' has invalid ISO8601 timestamp: '{}'",
|
||||||
|
session.id, session.created_at
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate participant IDs
|
||||||
|
let mut participant_ids = HashSet::new();
|
||||||
|
for participant in &session.participants {
|
||||||
|
if participant.id.is_empty() {
|
||||||
|
errors.push(format!(
|
||||||
|
"Session '{}' has participant with empty ID",
|
||||||
|
session.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !participant_ids.insert(&participant.id) {
|
||||||
|
errors.push(format!(
|
||||||
|
"Session '{}' has duplicate participant ID '{}'",
|
||||||
|
session.id, participant.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate message IDs
|
||||||
|
let mut message_ids = HashSet::new();
|
||||||
|
for message in &session.messages {
|
||||||
|
if !message_ids.insert(&message.id) {
|
||||||
|
errors.push(format!(
|
||||||
|
"Session '{}' has duplicate message ID '{}'",
|
||||||
|
session.id, message.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each message
|
||||||
|
for message in &session.messages {
|
||||||
|
validate_message(message, session, &participant_ids, &message_ids, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a single message.
|
||||||
|
fn validate_message(
|
||||||
|
message: &Message,
|
||||||
|
session: &Session,
|
||||||
|
_participant_ids: &HashSet<&String>,
|
||||||
|
all_message_ids: &HashSet<&String>,
|
||||||
|
errors: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
// Validate required fields
|
||||||
|
if message.id.is_empty() {
|
||||||
|
errors.push(format!(
|
||||||
|
"Session '{}' has message with empty ID",
|
||||||
|
session.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if message.content.is_empty() {
|
||||||
|
errors.push(format!(
|
||||||
|
"Message '{}' in session '{}' has empty content",
|
||||||
|
message.id, session.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate session_id matches parent session
|
||||||
|
if message.session_id != session.id {
|
||||||
|
errors.push(format!(
|
||||||
|
"Message '{}' has session_id '{}' but is in session '{}'",
|
||||||
|
message.id, message.session_id, session.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate timestamp
|
||||||
|
if !is_valid_iso8601(&message.created_at) {
|
||||||
|
errors.push(format!(
|
||||||
|
"Message '{}' has invalid ISO8601 timestamp: '{}'",
|
||||||
|
message.id, message.created_at
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parent_id reference if present
|
||||||
|
if let Some(parent_id) = &message.parent_id {
|
||||||
|
if !all_message_ids.contains(parent_id) {
|
||||||
|
errors.push(format!(
|
||||||
|
"Message '{}' references non-existent parent_id '{}'",
|
||||||
|
message.id, parent_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Check for self-reference
|
||||||
|
if parent_id == &message.id {
|
||||||
|
errors.push(format!(
|
||||||
|
"Message '{}' cannot reference itself as parent",
|
||||||
|
message.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: We don't validate that message role corresponds to a participant
|
||||||
|
// because roles (user/assistant/system) are independent of participant IDs
|
||||||
|
// in this schema. Participants are entities, roles are message types.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a string is a valid ISO8601 timestamp.
|
||||||
|
///
|
||||||
|
/// This performs a basic format validation. It checks for common ISO8601 patterns:
|
||||||
|
/// - YYYY-MM-DDTHH:MM:SS
|
||||||
|
/// - YYYY-MM-DDTHH:MM:SSZ
|
||||||
|
/// - YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||||
|
/// - YYYY-MM-DDTHH:MM:SS.sss...
|
||||||
|
///
|
||||||
|
/// For production use, you might want to use a proper datetime parser like `chrono` or `time`.
|
||||||
|
fn is_valid_iso8601(timestamp: &str) -> bool {
|
||||||
|
// Basic regex-like validation without adding regex dependency
|
||||||
|
// ISO8601 basic format: YYYY-MM-DDTHH:MM:SS with optional timezone/fractional seconds
|
||||||
|
|
||||||
|
if timestamp.len() < 19 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check basic structure: YYYY-MM-DDTHH:MM:SS
|
||||||
|
let chars: Vec<char> = timestamp.chars().collect();
|
||||||
|
|
||||||
|
// Year (4 digits)
|
||||||
|
if !chars[0..4].iter().all(|c| c.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Dash
|
||||||
|
if chars.get(4) != Some(&'-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Month (2 digits)
|
||||||
|
if !chars[5..7].iter().all(|c| c.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Dash
|
||||||
|
if chars.get(7) != Some(&'-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Day (2 digits)
|
||||||
|
if !chars[8..10].iter().all(|c| c.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// T separator
|
||||||
|
if chars.get(10) != Some(&'T') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Hour (2 digits)
|
||||||
|
if !chars[11..13].iter().all(|c| c.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Colon
|
||||||
|
if chars.get(13) != Some(&':') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Minute (2 digits)
|
||||||
|
if !chars[14..16].iter().all(|c| c.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Colon
|
||||||
|
if chars.get(16) != Some(&':') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Second (2 digits)
|
||||||
|
if !chars[17..19].iter().all(|c| c.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest is optional (fractional seconds, timezone)
|
||||||
|
// We'll accept anything after the basic format
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::fixture::{
|
||||||
|
Message, MessageRole, Participant, ParticipantKind, ResponderStrategy,
|
||||||
|
Responders, Session, Streaming,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn create_minimal_valid_fixture() -> Fixture {
|
||||||
|
Fixture {
|
||||||
|
version: "0.1".to_string(),
|
||||||
|
sessions: vec![Session {
|
||||||
|
id: "session-1".to_string(),
|
||||||
|
title: "Test Session".to_string(),
|
||||||
|
created_at: "2024-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![Message {
|
||||||
|
id: "msg-1".to_string(),
|
||||||
|
session_id: "session-1".to_string(),
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: "Hello".to_string(),
|
||||||
|
created_at: "2024-01-01T00:00:01Z".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
metadata: None,
|
||||||
|
}],
|
||||||
|
behavior: None,
|
||||||
|
}],
|
||||||
|
responders: Responders {
|
||||||
|
keyword_map: HashMap::new(),
|
||||||
|
default_strategy: ResponderStrategy::Echo,
|
||||||
|
random: None,
|
||||||
|
},
|
||||||
|
streaming: Streaming {
|
||||||
|
enabled: false,
|
||||||
|
tokens_per_chunk: 1,
|
||||||
|
chunk_interval_ms: 100,
|
||||||
|
jitter_ms: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_valid_fixture() {
|
||||||
|
let fixture = create_minimal_valid_fixture();
|
||||||
|
assert!(validate_fixture(&fixture).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_version() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
fixture.version = "0.2".to_string();
|
||||||
|
|
||||||
|
let result = validate_fixture(&fixture);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("Invalid version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_duplicate_session_ids() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
let session2 = fixture.sessions[0].clone();
|
||||||
|
fixture.sessions.push(session2);
|
||||||
|
|
||||||
|
let result = validate_fixture(&fixture);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("Duplicate session ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_duplicate_message_ids() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
let message2 = fixture.sessions[0].messages[0].clone();
|
||||||
|
fixture.sessions[0].messages.push(message2);
|
||||||
|
|
||||||
|
let result = validate_fixture(&fixture);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
let err_str = err.to_string();
|
||||||
|
assert!(err_str.contains("duplicate message ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_mismatched_session_id() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
fixture.sessions[0].messages[0].session_id = "wrong-session".to_string();
|
||||||
|
|
||||||
|
let result = validate_fixture(&fixture);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("has session_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_parent_reference() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
fixture.sessions[0].messages[0].parent_id = Some("non-existent".to_string());
|
||||||
|
|
||||||
|
let result = validate_fixture(&fixture);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("non-existent parent_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_valid_parent_reference() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
let mut message2 = fixture.sessions[0].messages[0].clone();
|
||||||
|
message2.id = "msg-2".to_string();
|
||||||
|
message2.parent_id = Some("msg-1".to_string());
|
||||||
|
fixture.sessions[0].messages.push(message2);
|
||||||
|
|
||||||
|
assert!(validate_fixture(&fixture).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_self_reference_parent() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
fixture.sessions[0].messages[0].parent_id = Some("msg-1".to_string());
|
||||||
|
|
||||||
|
let result = validate_fixture(&fixture);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("cannot reference itself"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_empty_required_fields() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
fixture.sessions[0].id = "".to_string();
|
||||||
|
|
||||||
|
let result = validate_fixture(&fixture);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("empty ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_valid_iso8601() {
|
||||||
|
assert!(is_valid_iso8601("2024-01-01T00:00:00Z"));
|
||||||
|
assert!(is_valid_iso8601("2024-01-01T00:00:00"));
|
||||||
|
assert!(is_valid_iso8601("2024-01-01T00:00:00.123Z"));
|
||||||
|
assert!(is_valid_iso8601("2024-01-01T00:00:00+05:30"));
|
||||||
|
assert!(is_valid_iso8601("2024-12-31T23:59:59.999999Z"));
|
||||||
|
|
||||||
|
assert!(!is_valid_iso8601("2024-01-01"));
|
||||||
|
assert!(!is_valid_iso8601("not-a-date"));
|
||||||
|
assert!(!is_valid_iso8601(""));
|
||||||
|
assert!(!is_valid_iso8601("2024/01/01T00:00:00Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_invalid_timestamp() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
fixture.sessions[0].created_at = "not-a-date".to_string();
|
||||||
|
|
||||||
|
let result = validate_fixture(&fixture);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("invalid ISO8601 timestamp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_duplicate_participant_ids() {
|
||||||
|
let mut fixture = create_minimal_valid_fixture();
|
||||||
|
let participant2 = fixture.sessions[0].participants[0].clone();
|
||||||
|
fixture.sessions[0].participants.push(participant2);
|
||||||
|
|
||||||
|
let result = validate_fixture(&fixture);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("duplicate participant ID"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//! Fixture system for defining mock session behaviors.
|
||||||
|
//!
|
||||||
|
//! This module provides the core fixture system that defines how the mock server
|
||||||
|
//! responds to client requests. Fixtures are loaded from YAML files and specify:
|
||||||
|
//!
|
||||||
|
//! - Session metadata (ID, title, timestamps)
|
||||||
|
//! - Message sequences (user inputs and agent responses)
|
||||||
|
//! - Response behaviors (static, random, sequential, pattern-based)
|
||||||
|
//! - Tool call definitions and outcomes
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! - `types.rs` - Core fixture type definitions and validation
|
||||||
|
//! - `loader.rs` - Loading fixtures from YAML files
|
||||||
|
//! - `responders.rs` - Behavior logic for generating responses
|
||||||
|
|
||||||
|
pub mod loader;
|
||||||
|
pub mod responders;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
// Re-exports for convenience
|
||||||
|
pub use loader::*;
|
||||||
|
pub use responders::*;
|
||||||
|
pub use types::*;
|
||||||
@@ -0,0 +1,912 @@
|
|||||||
|
//! Response behavior logic for fixtures.
|
||||||
|
//!
|
||||||
|
//! This module implements different response strategies for generating mock assistant
|
||||||
|
//! responses based on user input. Each strategy is represented by a struct implementing
|
||||||
|
//! the `Responder` trait.
|
||||||
|
//!
|
||||||
|
//! ## Available Strategies
|
||||||
|
//!
|
||||||
|
//! - **Echo**: Simply echoes back the user's input with a prefix
|
||||||
|
//! - **Keywords**: Matches keywords in user input to predefined responses
|
||||||
|
//! - **Random**: Returns random responses from a corpus (seeded for reproducibility)
|
||||||
|
//! - **FixtureOnly**: Replays assistant messages from fixture data in sequence
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use dirigate::fixture::{ResponderFactory, ResponderStrategy, Responders};
|
||||||
|
//!
|
||||||
|
//! let responders_config = Responders {
|
||||||
|
//! keyword_map: HashMap::new(),
|
||||||
|
//! default_strategy: ResponderStrategy::Echo,
|
||||||
|
//! random: None,
|
||||||
|
//! };
|
||||||
|
//!
|
||||||
|
//! let responder = ResponderFactory::create_responder(
|
||||||
|
//! &ResponderStrategy::Echo,
|
||||||
|
//! &responders_config,
|
||||||
|
//! &session,
|
||||||
|
//! )?;
|
||||||
|
//!
|
||||||
|
//! let response = responder.respond("Hello!", &session);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::error::{MockerError, Result};
|
||||||
|
use crate::fixture::types::{MessageRole, Responders, ResponderStrategy, Session};
|
||||||
|
use rand::{Rng, SeedableRng};
|
||||||
|
use rand_chacha::ChaCha8Rng;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
/// Trait for response generation strategies.
|
||||||
|
///
|
||||||
|
/// Implementors of this trait define how the mock server generates responses
|
||||||
|
/// to user messages. Different strategies can be used for different testing scenarios.
|
||||||
|
pub trait Responder: Send + Sync {
|
||||||
|
/// Generate a response based on user input and session context.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `user_input` - The message content from the user
|
||||||
|
/// * `session` - The current session context (for accessing fixture data)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The generated response string that will be sent back to the user
|
||||||
|
fn respond(&mut self, user_input: &str, session: &Session) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Echo Responder (Task 1.3)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Responder that echoes back user input with a prefix.
|
||||||
|
///
|
||||||
|
/// This is the simplest responder strategy, useful for basic connectivity
|
||||||
|
/// testing and debugging. It returns the user's message prefixed with "Echo: ".
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// let responder = EchoResponder;
|
||||||
|
/// assert_eq!(responder.respond("Hello", &session), "Echo: Hello");
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EchoResponder;
|
||||||
|
|
||||||
|
impl Responder for EchoResponder {
|
||||||
|
fn respond(&mut self, user_input: &str, _session: &Session) -> String {
|
||||||
|
tracing::debug!(user_input, "Echo responder invoked");
|
||||||
|
format!("Echo: {}", user_input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Keywords Responder (Task 1.4)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Responder that matches keywords in user input to predefined responses.
|
||||||
|
///
|
||||||
|
/// This strategy searches the user input for keywords and returns the corresponding
|
||||||
|
/// response. Matching is case-insensitive and supports substring matching.
|
||||||
|
/// If multiple keywords match, the first one found (by map iteration order) is used.
|
||||||
|
/// If no keywords match, a default response is returned.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// let mut keyword_map = HashMap::new();
|
||||||
|
/// keyword_map.insert("hello".to_string(), "Hi there!".to_string());
|
||||||
|
/// keyword_map.insert("help".to_string(), "How can I assist?".to_string());
|
||||||
|
///
|
||||||
|
/// let responder = KeywordsResponder::new(
|
||||||
|
/// keyword_map,
|
||||||
|
/// "I don't understand.".to_string()
|
||||||
|
/// );
|
||||||
|
///
|
||||||
|
/// assert_eq!(responder.respond("HELLO world", &session), "Hi there!");
|
||||||
|
/// assert_eq!(responder.respond("random text", &session), "I don't understand.");
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct KeywordsResponder {
|
||||||
|
/// Map of keywords (lowercase) to their responses
|
||||||
|
keyword_map: HashMap<String, String>,
|
||||||
|
/// Response to use when no keyword matches
|
||||||
|
default_response: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeywordsResponder {
|
||||||
|
/// Create a new keywords responder.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `keyword_map` - Map of keywords to their responses (will be converted to lowercase)
|
||||||
|
/// * `default_response` - Response to use when no keyword matches
|
||||||
|
pub fn new(keyword_map: HashMap<String, String>, default_response: String) -> Self {
|
||||||
|
// Convert all keywords to lowercase for case-insensitive matching
|
||||||
|
let lowercase_map = keyword_map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k.to_lowercase(), v))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
keyword_map: lowercase_map,
|
||||||
|
default_response,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Responder for KeywordsResponder {
|
||||||
|
fn respond(&mut self, user_input: &str, _session: &Session) -> String {
|
||||||
|
let input_lower = user_input.to_lowercase();
|
||||||
|
|
||||||
|
// Search for the first matching keyword
|
||||||
|
for (keyword, response) in &self.keyword_map {
|
||||||
|
if input_lower.contains(keyword) {
|
||||||
|
tracing::debug!(
|
||||||
|
keyword = %keyword,
|
||||||
|
user_input,
|
||||||
|
"Keywords responder matched keyword"
|
||||||
|
);
|
||||||
|
return response.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!(user_input, "Keywords responder using default response");
|
||||||
|
self.default_response.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Random Responder (Task 1.5)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Responder that returns random responses from a corpus.
|
||||||
|
///
|
||||||
|
/// This strategy uses a seeded random number generator to select responses
|
||||||
|
/// from a predefined corpus. The seeded RNG ensures reproducible behavior
|
||||||
|
/// for testing purposes.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// let corpus = vec!["Response A".to_string(), "Response B".to_string()];
|
||||||
|
/// let responder = RandomResponder::new(42, corpus);
|
||||||
|
///
|
||||||
|
/// // Same seed produces same sequence
|
||||||
|
/// let response1 = responder.respond("anything", &session);
|
||||||
|
/// let response2 = responder.respond("anything", &session);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the corpus is empty when `respond()` is called.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RandomResponder {
|
||||||
|
/// Corpus of possible responses
|
||||||
|
corpus: Vec<String>,
|
||||||
|
/// Seeded random number generator for reproducibility
|
||||||
|
rng: ChaCha8Rng,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RandomResponder {
|
||||||
|
/// Create a new random responder with a seeded RNG.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `seed` - Seed for the random number generator (for reproducibility)
|
||||||
|
/// * `corpus` - List of possible responses to randomly select from
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Will panic during `respond()` if corpus is empty.
|
||||||
|
pub fn new(seed: u64, corpus: Vec<String>) -> Self {
|
||||||
|
let rng = ChaCha8Rng::seed_from_u64(seed);
|
||||||
|
Self { corpus, rng }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Responder for RandomResponder {
|
||||||
|
fn respond(&mut self, _user_input: &str, _session: &Session) -> String {
|
||||||
|
if self.corpus.is_empty() {
|
||||||
|
tracing::error!("Random responder has empty corpus");
|
||||||
|
panic!("RandomResponder corpus is empty - cannot generate response");
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = self.rng.gen_range(0..self.corpus.len());
|
||||||
|
let response = self.corpus[index].clone();
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
index,
|
||||||
|
corpus_size = self.corpus.len(),
|
||||||
|
"Random responder selected corpus entry"
|
||||||
|
);
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FixtureOnly Responder (Task 1.6)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Responder that replays assistant messages from fixture data in sequence.
|
||||||
|
///
|
||||||
|
/// This strategy returns pre-recorded assistant responses from the fixture's
|
||||||
|
/// message history. It maintains a turn counter to track position in the sequence.
|
||||||
|
/// When all fixture messages are exhausted, it returns an error message.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// let responder = FixtureOnlyResponder::new(&session);
|
||||||
|
///
|
||||||
|
/// // Returns first assistant message
|
||||||
|
/// let response1 = responder.respond("user input 1", &session);
|
||||||
|
///
|
||||||
|
/// // Returns second assistant message
|
||||||
|
/// let response2 = responder.respond("user input 2", &session);
|
||||||
|
///
|
||||||
|
/// // Returns error when exhausted
|
||||||
|
/// let response3 = responder.respond("user input 3", &session);
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FixtureOnlyResponder {
|
||||||
|
/// Pre-filtered list of assistant messages from the fixture
|
||||||
|
assistant_messages: Vec<String>,
|
||||||
|
/// Current position in the message sequence (atomic for thread-safety)
|
||||||
|
turn_counter: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FixtureOnlyResponder {
|
||||||
|
/// Create a new fixture-only responder.
|
||||||
|
///
|
||||||
|
/// This extracts all assistant messages from the session's message history
|
||||||
|
/// and prepares them for sequential replay.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `session` - The session containing fixture messages to replay
|
||||||
|
pub fn new(session: &Session) -> Self {
|
||||||
|
let assistant_messages: Vec<String> = session
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.filter(|msg| msg.role == MessageRole::Assistant)
|
||||||
|
.map(|msg| msg.content.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
message_count = assistant_messages.len(),
|
||||||
|
session_id = %session.id,
|
||||||
|
"FixtureOnly responder initialized"
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
assistant_messages,
|
||||||
|
turn_counter: AtomicUsize::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Responder for FixtureOnlyResponder {
|
||||||
|
fn respond(&mut self, _user_input: &str, _session: &Session) -> String {
|
||||||
|
let current_turn = self.turn_counter.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
|
if current_turn >= self.assistant_messages.len() {
|
||||||
|
tracing::warn!(
|
||||||
|
turn = current_turn,
|
||||||
|
total_messages = self.assistant_messages.len(),
|
||||||
|
"FixtureOnly responder exhausted all messages"
|
||||||
|
);
|
||||||
|
return "[ERROR: No more fixture messages available]".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.assistant_messages[current_turn].clone();
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
turn = current_turn,
|
||||||
|
total_messages = self.assistant_messages.len(),
|
||||||
|
"FixtureOnly responder replaying message"
|
||||||
|
);
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Responder Factory (Task 1.7)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Factory for creating responder instances based on strategy configuration.
|
||||||
|
///
|
||||||
|
/// This factory handles the logic of creating the appropriate responder type
|
||||||
|
/// based on the strategy enum and associated configuration. It also handles
|
||||||
|
/// session-level behavior overrides.
|
||||||
|
pub struct ResponderFactory;
|
||||||
|
|
||||||
|
impl ResponderFactory {
|
||||||
|
/// Create a responder instance based on strategy and configuration.
|
||||||
|
///
|
||||||
|
/// This function creates the appropriate responder type based on the strategy
|
||||||
|
/// and validates that required configuration is present.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `strategy` - The responder strategy to use
|
||||||
|
/// * `responders` - Global responder configuration (keyword_map, random config)
|
||||||
|
/// * `session` - Session context (for FixtureOnly and session overrides)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A boxed responder instance implementing the `Responder` trait
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `MockerError::FixtureValidation` if:
|
||||||
|
/// - Random strategy is requested but no random config is provided
|
||||||
|
/// - Random config has an empty corpus
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// let responder = ResponderFactory::create_responder(
|
||||||
|
/// &ResponderStrategy::Echo,
|
||||||
|
/// &responders_config,
|
||||||
|
/// &session,
|
||||||
|
/// )?;
|
||||||
|
/// ```
|
||||||
|
pub fn create_responder(
|
||||||
|
strategy: &ResponderStrategy,
|
||||||
|
responders: &Responders,
|
||||||
|
session: &Session,
|
||||||
|
) -> Result<Box<dyn Responder>> {
|
||||||
|
tracing::info!(
|
||||||
|
strategy = ?strategy,
|
||||||
|
session_id = %session.id,
|
||||||
|
"Creating responder"
|
||||||
|
);
|
||||||
|
|
||||||
|
match strategy {
|
||||||
|
ResponderStrategy::Echo => {
|
||||||
|
tracing::info!("Selected Echo responder");
|
||||||
|
Ok(Box::new(EchoResponder))
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponderStrategy::Keywords => {
|
||||||
|
tracing::info!(
|
||||||
|
keyword_count = responders.keyword_map.len(),
|
||||||
|
"Selected Keywords responder"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use keyword map with a default response
|
||||||
|
let default_response = "[No matching keyword found]".to_string();
|
||||||
|
Ok(Box::new(KeywordsResponder::new(
|
||||||
|
responders.keyword_map.clone(),
|
||||||
|
default_response,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponderStrategy::Random => {
|
||||||
|
let random_config = responders.random.as_ref().ok_or_else(|| {
|
||||||
|
MockerError::FixtureValidation(
|
||||||
|
"Random strategy requires 'random' configuration".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if random_config.corpus.is_empty() {
|
||||||
|
return Err(MockerError::FixtureValidation(
|
||||||
|
"Random responder corpus cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
seed = random_config.seed,
|
||||||
|
corpus_size = random_config.corpus.len(),
|
||||||
|
"Selected Random responder"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Box::new(RandomResponder::new(
|
||||||
|
random_config.seed,
|
||||||
|
random_config.corpus.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponderStrategy::FixtureOnly => {
|
||||||
|
let assistant_count = session
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.filter(|msg| msg.role == MessageRole::Assistant)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
assistant_messages = assistant_count,
|
||||||
|
"Selected FixtureOnly responder"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Box::new(FixtureOnlyResponder::new(session)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a responder for a session, handling session-level behavior overrides.
|
||||||
|
///
|
||||||
|
/// This is a convenience method that checks for session-level responder overrides
|
||||||
|
/// before falling back to the default strategy.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `responders` - Global responder configuration
|
||||||
|
/// * `session` - Session context (may contain behavior overrides)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A boxed responder instance
|
||||||
|
pub fn create_for_session(responders: &Responders, session: &Session) -> Result<Box<dyn Responder>> {
|
||||||
|
// Check if session has a behavior override
|
||||||
|
let strategy = session
|
||||||
|
.behavior
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| b.responder.as_ref())
|
||||||
|
.unwrap_or(&responders.default_strategy);
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
session_id = %session.id,
|
||||||
|
strategy = ?strategy,
|
||||||
|
has_override = session.behavior.is_some(),
|
||||||
|
"Creating responder for session"
|
||||||
|
);
|
||||||
|
|
||||||
|
Self::create_responder(strategy, responders, session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::fixture::types::{Message, RandomConfig};
|
||||||
|
|
||||||
|
// Helper to create a minimal test session
|
||||||
|
fn create_test_session(messages: Vec<Message>) -> Session {
|
||||||
|
Session {
|
||||||
|
id: "test-session".to_string(),
|
||||||
|
title: "Test Session".to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
participants: vec![],
|
||||||
|
messages,
|
||||||
|
behavior: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a test message
|
||||||
|
fn create_message(id: &str, role: MessageRole, content: &str) -> Message {
|
||||||
|
Message {
|
||||||
|
id: id.to_string(),
|
||||||
|
session_id: "test-session".to_string(),
|
||||||
|
role,
|
||||||
|
content: content.to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
metadata: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Task 1.3: Echo Responder Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_echo_responder_basic() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut responder = EchoResponder;
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("Hello", &session), "Echo: Hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_echo_responder_empty_input() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut responder = EchoResponder;
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("", &session), "Echo: ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_echo_responder_special_characters() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut responder = EchoResponder;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
responder.respond("Hello, world! @#$%", &session),
|
||||||
|
"Echo: Hello, world! @#$%"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_echo_responder_multiline() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut responder = EchoResponder;
|
||||||
|
|
||||||
|
let input = "Line 1\nLine 2\nLine 3";
|
||||||
|
assert_eq!(responder.respond(input, &session), "Echo: Line 1\nLine 2\nLine 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Task 1.4: Keywords Responder Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keywords_responder_exact_match() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut keyword_map = HashMap::new();
|
||||||
|
keyword_map.insert("hello".to_string(), "Hi there!".to_string());
|
||||||
|
|
||||||
|
let mut responder = KeywordsResponder::new(keyword_map, "Default".to_string());
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("hello", &session), "Hi there!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keywords_responder_partial_match() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut keyword_map = HashMap::new();
|
||||||
|
keyword_map.insert("help".to_string(), "How can I assist?".to_string());
|
||||||
|
|
||||||
|
let mut responder = KeywordsResponder::new(keyword_map, "Default".to_string());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
responder.respond("I need help with something", &session),
|
||||||
|
"How can I assist?"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keywords_responder_case_insensitive() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut keyword_map = HashMap::new();
|
||||||
|
keyword_map.insert("hello".to_string(), "Hi!".to_string());
|
||||||
|
|
||||||
|
let mut responder = KeywordsResponder::new(keyword_map, "Default".to_string());
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("HELLO", &session), "Hi!");
|
||||||
|
assert_eq!(responder.respond("HeLLo", &session), "Hi!");
|
||||||
|
assert_eq!(responder.respond("hello", &session), "Hi!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keywords_responder_no_match_returns_default() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut keyword_map = HashMap::new();
|
||||||
|
keyword_map.insert("hello".to_string(), "Hi!".to_string());
|
||||||
|
|
||||||
|
let mut responder = KeywordsResponder::new(keyword_map, "I don't understand".to_string());
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("goodbye", &session), "I don't understand");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keywords_responder_multiple_keywords() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut keyword_map = HashMap::new();
|
||||||
|
keyword_map.insert("hello".to_string(), "Response A".to_string());
|
||||||
|
keyword_map.insert("help".to_string(), "Response B".to_string());
|
||||||
|
|
||||||
|
let mut responder = KeywordsResponder::new(keyword_map, "Default".to_string());
|
||||||
|
|
||||||
|
// Should match one of them (order depends on HashMap iteration)
|
||||||
|
let response = responder.respond("hello help", &session);
|
||||||
|
assert!(response == "Response A" || response == "Response B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_keywords_responder_empty_map() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let keyword_map = HashMap::new();
|
||||||
|
|
||||||
|
let mut responder = KeywordsResponder::new(keyword_map, "Always default".to_string());
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("anything", &session), "Always default");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Task 1.5: Random Responder Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_random_responder_same_seed_produces_same_sequence() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let corpus = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||||
|
|
||||||
|
let mut responder1 = RandomResponder::new(42, corpus.clone());
|
||||||
|
let mut responder2 = RandomResponder::new(42, corpus);
|
||||||
|
|
||||||
|
// Same seed should produce same sequence
|
||||||
|
for _ in 0..10 {
|
||||||
|
assert_eq!(
|
||||||
|
responder1.respond("test", &session),
|
||||||
|
responder2.respond("test", &session)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_random_responder_different_seeds_differ() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let corpus = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||||
|
|
||||||
|
let mut responder1 = RandomResponder::new(42, corpus.clone());
|
||||||
|
let mut responder2 = RandomResponder::new(123, corpus);
|
||||||
|
|
||||||
|
// Collect responses
|
||||||
|
let responses1: Vec<String> = (0..20).map(|_| responder1.respond("test", &session)).collect();
|
||||||
|
let responses2: Vec<String> = (0..20).map(|_| responder2.respond("test", &session)).collect();
|
||||||
|
|
||||||
|
// Different seeds should produce different sequences
|
||||||
|
assert_ne!(responses1, responses2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_random_responder_all_corpus_entries_selected() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let corpus = vec!["A".to_string(), "B".to_string(), "C".to_string()];
|
||||||
|
|
||||||
|
let mut responder = RandomResponder::new(42, corpus.clone());
|
||||||
|
|
||||||
|
// Collect many responses
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for _ in 0..100 {
|
||||||
|
seen.insert(responder.respond("test", &session));
|
||||||
|
}
|
||||||
|
|
||||||
|
// All corpus entries should eventually be selected
|
||||||
|
assert_eq!(seen.len(), corpus.len());
|
||||||
|
for entry in corpus {
|
||||||
|
assert!(seen.contains(&entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "RandomResponder corpus is empty")]
|
||||||
|
fn test_random_responder_empty_corpus_panics() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let corpus = vec![];
|
||||||
|
|
||||||
|
let mut responder = RandomResponder::new(42, corpus);
|
||||||
|
responder.respond("test", &session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Task 1.6: FixtureOnly Responder Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_only_responder_sequential_replay() {
|
||||||
|
let messages = vec![
|
||||||
|
create_message("1", MessageRole::Assistant, "First response"),
|
||||||
|
create_message("2", MessageRole::User, "User message"),
|
||||||
|
create_message("3", MessageRole::Assistant, "Second response"),
|
||||||
|
create_message("4", MessageRole::Assistant, "Third response"),
|
||||||
|
];
|
||||||
|
let session = create_test_session(messages);
|
||||||
|
|
||||||
|
let mut responder = FixtureOnlyResponder::new(&session);
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("input 1", &session), "First response");
|
||||||
|
assert_eq!(responder.respond("input 2", &session), "Second response");
|
||||||
|
assert_eq!(responder.respond("input 3", &session), "Third response");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_only_responder_end_of_fixture() {
|
||||||
|
let messages = vec![create_message("1", MessageRole::Assistant, "Only response")];
|
||||||
|
let session = create_test_session(messages);
|
||||||
|
|
||||||
|
let mut responder = FixtureOnlyResponder::new(&session);
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("input 1", &session), "Only response");
|
||||||
|
assert_eq!(
|
||||||
|
responder.respond("input 2", &session),
|
||||||
|
"[ERROR: No more fixture messages available]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_only_responder_empty_fixture() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
|
||||||
|
let mut responder = FixtureOnlyResponder::new(&session);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
responder.respond("input", &session),
|
||||||
|
"[ERROR: No more fixture messages available]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_only_responder_filters_assistant_only() {
|
||||||
|
let messages = vec![
|
||||||
|
create_message("1", MessageRole::User, "User 1"),
|
||||||
|
create_message("2", MessageRole::Assistant, "Assistant 1"),
|
||||||
|
create_message("3", MessageRole::System, "System"),
|
||||||
|
create_message("4", MessageRole::User, "User 2"),
|
||||||
|
create_message("5", MessageRole::Assistant, "Assistant 2"),
|
||||||
|
];
|
||||||
|
let session = create_test_session(messages);
|
||||||
|
|
||||||
|
let mut responder = FixtureOnlyResponder::new(&session);
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("input 1", &session), "Assistant 1");
|
||||||
|
assert_eq!(responder.respond("input 2", &session), "Assistant 2");
|
||||||
|
assert_eq!(
|
||||||
|
responder.respond("input 3", &session),
|
||||||
|
"[ERROR: No more fixture messages available]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Task 1.7: Responder Factory Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_creates_echo_responder() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let responders = Responders {
|
||||||
|
keyword_map: HashMap::new(),
|
||||||
|
default_strategy: ResponderStrategy::Echo,
|
||||||
|
random: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut responder =
|
||||||
|
ResponderFactory::create_responder(&ResponderStrategy::Echo, &responders, &session)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("test", &session), "Echo: test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_creates_keywords_responder() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let mut keyword_map = HashMap::new();
|
||||||
|
keyword_map.insert("test".to_string(), "Test response".to_string());
|
||||||
|
|
||||||
|
let responders = Responders {
|
||||||
|
keyword_map,
|
||||||
|
default_strategy: ResponderStrategy::Keywords,
|
||||||
|
random: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut responder =
|
||||||
|
ResponderFactory::create_responder(&ResponderStrategy::Keywords, &responders, &session)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("test", &session), "Test response");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_creates_random_responder() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let responders = Responders {
|
||||||
|
keyword_map: HashMap::new(),
|
||||||
|
default_strategy: ResponderStrategy::Random,
|
||||||
|
random: Some(RandomConfig {
|
||||||
|
seed: 42,
|
||||||
|
corpus: vec!["Response 1".to_string(), "Response 2".to_string()],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut responder =
|
||||||
|
ResponderFactory::create_responder(&ResponderStrategy::Random, &responders, &session)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = responder.respond("test", &session);
|
||||||
|
assert!(response == "Response 1" || response == "Response 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_creates_fixture_only_responder() {
|
||||||
|
let messages = vec![create_message("1", MessageRole::Assistant, "Fixture response")];
|
||||||
|
let session = create_test_session(messages);
|
||||||
|
|
||||||
|
let responders = Responders {
|
||||||
|
keyword_map: HashMap::new(),
|
||||||
|
default_strategy: ResponderStrategy::FixtureOnly,
|
||||||
|
random: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut responder = ResponderFactory::create_responder(
|
||||||
|
&ResponderStrategy::FixtureOnly,
|
||||||
|
&responders,
|
||||||
|
&session,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("test", &session), "Fixture response");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_random_without_config_fails() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let responders = Responders {
|
||||||
|
keyword_map: HashMap::new(),
|
||||||
|
default_strategy: ResponderStrategy::Random,
|
||||||
|
random: None, // Missing required config
|
||||||
|
};
|
||||||
|
|
||||||
|
let result =
|
||||||
|
ResponderFactory::create_responder(&ResponderStrategy::Random, &responders, &session);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result {
|
||||||
|
Err(MockerError::FixtureValidation(msg)) => {
|
||||||
|
assert!(msg.contains("Random strategy requires"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected FixtureValidation error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_random_with_empty_corpus_fails() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let responders = Responders {
|
||||||
|
keyword_map: HashMap::new(),
|
||||||
|
default_strategy: ResponderStrategy::Random,
|
||||||
|
random: Some(RandomConfig {
|
||||||
|
seed: 42,
|
||||||
|
corpus: vec![], // Empty corpus
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result =
|
||||||
|
ResponderFactory::create_responder(&ResponderStrategy::Random, &responders, &session);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result {
|
||||||
|
Err(MockerError::FixtureValidation(msg)) => {
|
||||||
|
assert!(msg.contains("corpus cannot be empty"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected FixtureValidation error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_create_for_session_uses_default() {
|
||||||
|
let session = create_test_session(vec![]);
|
||||||
|
let responders = Responders {
|
||||||
|
keyword_map: HashMap::new(),
|
||||||
|
default_strategy: ResponderStrategy::Echo,
|
||||||
|
random: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut responder = ResponderFactory::create_for_session(&responders, &session).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(responder.respond("test", &session), "Echo: test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_create_for_session_uses_override() {
|
||||||
|
use crate::fixture::types::SessionBehavior;
|
||||||
|
|
||||||
|
let mut session = create_test_session(vec![]);
|
||||||
|
session.behavior = Some(SessionBehavior {
|
||||||
|
responder: Some(ResponderStrategy::Echo),
|
||||||
|
streaming: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut keyword_map = HashMap::new();
|
||||||
|
keyword_map.insert("test".to_string(), "Keyword response".to_string());
|
||||||
|
|
||||||
|
let responders = Responders {
|
||||||
|
keyword_map,
|
||||||
|
default_strategy: ResponderStrategy::Keywords, // Default is Keywords
|
||||||
|
random: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut responder = ResponderFactory::create_for_session(&responders, &session).unwrap();
|
||||||
|
|
||||||
|
// Should use Echo (override) not Keywords (default)
|
||||||
|
assert_eq!(responder.respond("test", &session), "Echo: test");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
//! Core fixture type definitions.
|
||||||
|
//!
|
||||||
|
//! This module defines the structure of fixture files and the types
|
||||||
|
//! used to represent mock sessions and their behaviors.
|
||||||
|
//!
|
||||||
|
//! ## Fixture Schema v0.1
|
||||||
|
//!
|
||||||
|
//! Fixtures define mock sessions with messages, participants, and behavior configuration.
|
||||||
|
//! They support:
|
||||||
|
//! - Multiple sessions with participants and message histories
|
||||||
|
//! - Configurable response strategies (echo, keywords, random, fixture-only)
|
||||||
|
//! - Streaming simulation with configurable chunking
|
||||||
|
//! - Per-session behavior overrides
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Top-level fixture definition.
|
||||||
|
///
|
||||||
|
/// A fixture represents a complete test scenario with sessions, response behavior,
|
||||||
|
/// and streaming configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Fixture {
|
||||||
|
/// Fixture version (must be "0.1").
|
||||||
|
pub version: String,
|
||||||
|
|
||||||
|
/// List of sessions defined in this fixture.
|
||||||
|
pub sessions: Vec<Session>,
|
||||||
|
|
||||||
|
/// Response behavior configuration.
|
||||||
|
pub responders: Responders,
|
||||||
|
|
||||||
|
/// Streaming behavior configuration.
|
||||||
|
pub streaming: Streaming,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session fixture defining a mock session.
|
||||||
|
///
|
||||||
|
/// Represents a single conversation session with participants and messages.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Session {
|
||||||
|
/// Unique session identifier.
|
||||||
|
pub id: String,
|
||||||
|
|
||||||
|
/// Human-readable session title.
|
||||||
|
pub title: String,
|
||||||
|
|
||||||
|
/// ISO8601 timestamp when session was created.
|
||||||
|
pub created_at: String,
|
||||||
|
|
||||||
|
/// Participants in this session.
|
||||||
|
pub participants: Vec<Participant>,
|
||||||
|
|
||||||
|
/// Messages in this session.
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
|
||||||
|
/// Optional per-session behavior overrides.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub behavior: Option<SessionBehavior>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Participant in a session.
|
||||||
|
///
|
||||||
|
/// Represents an entity that can send or receive messages.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Participant {
|
||||||
|
/// Unique participant identifier.
|
||||||
|
pub id: String,
|
||||||
|
|
||||||
|
/// Type of participant.
|
||||||
|
pub kind: ParticipantKind,
|
||||||
|
|
||||||
|
/// Optional display name for the participant.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of participant.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ParticipantKind {
|
||||||
|
/// Human user.
|
||||||
|
User,
|
||||||
|
|
||||||
|
/// AI assistant.
|
||||||
|
Assistant,
|
||||||
|
|
||||||
|
/// System message source.
|
||||||
|
System,
|
||||||
|
|
||||||
|
/// Tool or function.
|
||||||
|
Tool,
|
||||||
|
|
||||||
|
/// Other participant type.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message in a session.
|
||||||
|
///
|
||||||
|
/// Represents a single message with content, role, and metadata.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Message {
|
||||||
|
/// Unique message identifier.
|
||||||
|
pub id: String,
|
||||||
|
|
||||||
|
/// Session this message belongs to.
|
||||||
|
pub session_id: String,
|
||||||
|
|
||||||
|
/// Message role.
|
||||||
|
pub role: MessageRole,
|
||||||
|
|
||||||
|
/// Message content (text-only in v0.1).
|
||||||
|
pub content: String,
|
||||||
|
|
||||||
|
/// ISO8601 timestamp when message was created.
|
||||||
|
pub created_at: String,
|
||||||
|
|
||||||
|
/// Optional parent message ID for threading.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
|
||||||
|
/// Optional metadata (arbitrary JSON).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message role.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MessageRole {
|
||||||
|
/// Message from user.
|
||||||
|
User,
|
||||||
|
|
||||||
|
/// Message from assistant.
|
||||||
|
Assistant,
|
||||||
|
|
||||||
|
/// System message.
|
||||||
|
System,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response behavior configuration.
|
||||||
|
///
|
||||||
|
/// Defines how the mocker generates responses to incoming messages.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Responders {
|
||||||
|
/// Map of keywords to response strings.
|
||||||
|
/// When user input contains a keyword, the corresponding response is used.
|
||||||
|
#[serde(default)]
|
||||||
|
pub keyword_map: HashMap<String, String>,
|
||||||
|
|
||||||
|
/// Default response strategy when no keyword matches.
|
||||||
|
pub default_strategy: ResponderStrategy,
|
||||||
|
|
||||||
|
/// Configuration for random responses (required if default_strategy is Random).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub random: Option<RandomConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response generation strategy.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ResponderStrategy {
|
||||||
|
/// Echo back the user's message.
|
||||||
|
Echo,
|
||||||
|
|
||||||
|
/// Use keyword matching from keyword_map.
|
||||||
|
Keywords,
|
||||||
|
|
||||||
|
/// Return random responses from corpus.
|
||||||
|
Random,
|
||||||
|
|
||||||
|
/// Only respond with messages from fixtures (no dynamic generation).
|
||||||
|
FixtureOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for random response generation.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RandomConfig {
|
||||||
|
/// Random seed for reproducibility.
|
||||||
|
pub seed: u64,
|
||||||
|
|
||||||
|
/// Corpus of possible responses.
|
||||||
|
pub corpus: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming behavior configuration.
|
||||||
|
///
|
||||||
|
/// Controls how responses are streamed to clients.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Streaming {
|
||||||
|
/// Whether streaming is enabled.
|
||||||
|
pub enabled: bool,
|
||||||
|
|
||||||
|
/// Number of tokens per chunk.
|
||||||
|
pub tokens_per_chunk: usize,
|
||||||
|
|
||||||
|
/// Interval between chunks in milliseconds.
|
||||||
|
pub chunk_interval_ms: u64,
|
||||||
|
|
||||||
|
/// Optional random jitter to add to chunk intervals (in milliseconds).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub jitter_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-session behavior overrides.
|
||||||
|
///
|
||||||
|
/// Allows specific sessions to override global responder and streaming settings.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionBehavior {
|
||||||
|
/// Override default responder strategy for this session.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub responder: Option<ResponderStrategy>,
|
||||||
|
|
||||||
|
/// Override streaming configuration for this session.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub streaming: Option<Streaming>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_participant_kind_serde() {
|
||||||
|
// Test snake_case serialization
|
||||||
|
let kind = ParticipantKind::Assistant;
|
||||||
|
let json = serde_json::to_string(&kind).unwrap();
|
||||||
|
assert_eq!(json, "\"assistant\"");
|
||||||
|
|
||||||
|
let kind: ParticipantKind = serde_json::from_str("\"user\"").unwrap();
|
||||||
|
assert_eq!(kind, ParticipantKind::User);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_role_serde() {
|
||||||
|
// Test snake_case serialization
|
||||||
|
let role = MessageRole::Assistant;
|
||||||
|
let json = serde_json::to_string(&role).unwrap();
|
||||||
|
assert_eq!(json, "\"assistant\"");
|
||||||
|
|
||||||
|
let role: MessageRole = serde_json::from_str("\"user\"").unwrap();
|
||||||
|
assert_eq!(role, MessageRole::User);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_responder_strategy_serde() {
|
||||||
|
// Test snake_case serialization
|
||||||
|
let strategy = ResponderStrategy::FixtureOnly;
|
||||||
|
let json = serde_json::to_string(&strategy).unwrap();
|
||||||
|
assert_eq!(json, "\"fixture_only\"");
|
||||||
|
|
||||||
|
let strategy: ResponderStrategy = serde_json::from_str("\"echo\"").unwrap();
|
||||||
|
assert_eq!(strategy, ResponderStrategy::Echo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! Session ingestion from external sources (feature-gated).
|
||||||
|
//!
|
||||||
|
//! This module provides functionality to import sessions from live agent systems
|
||||||
|
//! (like OpenCode.ai) and convert them into fixture format. This is useful for:
|
||||||
|
//!
|
||||||
|
//! - Creating realistic test fixtures from production sessions
|
||||||
|
//! - Recording complex interaction flows for regression testing
|
||||||
|
//! - Building fixture libraries from existing conversations
|
||||||
|
//!
|
||||||
|
//! **Note**: This module is only available when the `ingest` feature is enabled.
|
||||||
|
|
||||||
|
#[cfg(feature = "ingest")]
|
||||||
|
pub mod opencode;
|
||||||
|
|
||||||
|
#[cfg(feature = "ingest")]
|
||||||
|
pub use opencode::run_ingest;
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
//! OpenCode.ai session ingestion.
|
||||||
|
//!
|
||||||
|
//! This module provides functionality to fetch sessions from OpenCode.ai
|
||||||
|
//! and convert them into fixture format.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
fixture::{
|
||||||
|
types::{
|
||||||
|
Fixture, Message, MessageRole, Participant, ParticipantKind, Responders,
|
||||||
|
ResponderStrategy, Session, Streaming,
|
||||||
|
},
|
||||||
|
validate_fixture,
|
||||||
|
},
|
||||||
|
MockerError, Result,
|
||||||
|
};
|
||||||
|
use opencode_client::{MessageWithParts, OpenCodeClient};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Run the complete ingestion workflow.
|
||||||
|
///
|
||||||
|
/// This is the main entry point for ingesting sessions from OpenCode.ai.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `base_url` - Base URL of the OpenCode API
|
||||||
|
/// * `session_id` - Optional specific session ID to ingest
|
||||||
|
/// * `all` - Whether to ingest all sessions
|
||||||
|
/// * `output` - Output file path for the fixture
|
||||||
|
/// * `merge` - Whether to merge with existing fixture
|
||||||
|
///
|
||||||
|
/// # Workflow
|
||||||
|
///
|
||||||
|
/// 1. Fetch session(s) from OpenCode API
|
||||||
|
/// 2. Map to fixture format
|
||||||
|
/// 3. Load existing fixture if merge is enabled
|
||||||
|
/// 4. Merge fixtures if necessary
|
||||||
|
/// 5. Validate the final fixture
|
||||||
|
/// 6. Export to YAML file
|
||||||
|
pub async fn run_ingest(
|
||||||
|
base_url: &str,
|
||||||
|
session_id: Option<String>,
|
||||||
|
all: bool,
|
||||||
|
output: &Path,
|
||||||
|
merge: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Step 1: Create ingestor and fetch sessions
|
||||||
|
tracing::info!("Creating OpenCode client");
|
||||||
|
let ingestor = OpenCodeIngestor::new(base_url);
|
||||||
|
|
||||||
|
let opencode_sessions = if all {
|
||||||
|
tracing::info!("Fetching all sessions from OpenCode");
|
||||||
|
ingestor.fetch_all_sessions().await?
|
||||||
|
} else if let Some(id) = session_id {
|
||||||
|
tracing::info!("Fetching single session: {}", id);
|
||||||
|
vec![ingestor.fetch_session(&id).await?]
|
||||||
|
} else {
|
||||||
|
return Err(MockerError::Ingest(
|
||||||
|
"Must specify either session_id or all".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Fetched {} session(s)", opencode_sessions.len());
|
||||||
|
|
||||||
|
// Step 2: Map sessions to fixture format
|
||||||
|
tracing::info!("Mapping sessions to fixture format");
|
||||||
|
let new_fixture = map_sessions_to_fixture(opencode_sessions)?;
|
||||||
|
|
||||||
|
// Step 3: Load existing fixture if merge is enabled
|
||||||
|
let final_fixture = if merge && output.exists() {
|
||||||
|
tracing::info!("Loading existing fixture for merge: {}", output.display());
|
||||||
|
let existing_fixture = load_or_create_fixture(output).await?;
|
||||||
|
tracing::info!(
|
||||||
|
"Merging {} existing sessions with {} new sessions",
|
||||||
|
existing_fixture.sessions.len(),
|
||||||
|
new_fixture.sessions.len()
|
||||||
|
);
|
||||||
|
merge_fixtures(existing_fixture, new_fixture)?
|
||||||
|
} else {
|
||||||
|
tracing::info!("Creating new fixture (no merge)");
|
||||||
|
new_fixture
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Final fixture has {} session(s)",
|
||||||
|
final_fixture.sessions.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 4: Validate the fixture
|
||||||
|
tracing::info!("Validating fixture");
|
||||||
|
validate_fixture(&final_fixture)?;
|
||||||
|
|
||||||
|
// Step 5: Export to YAML
|
||||||
|
tracing::info!("Exporting fixture to: {}", output.display());
|
||||||
|
export_fixture(&final_fixture, output).await?;
|
||||||
|
|
||||||
|
tracing::info!("Ingestion complete!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenCodeIngestor
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// OpenCode session ingestor.
|
||||||
|
///
|
||||||
|
/// Handles fetching sessions and messages from OpenCode.ai API.
|
||||||
|
struct OpenCodeIngestor {
|
||||||
|
client: OpenCodeClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenCodeIngestor {
|
||||||
|
/// Create a new OpenCode ingestor.
|
||||||
|
fn new(base_url: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
client: OpenCodeClient::new(base_url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a single session by ID.
|
||||||
|
async fn fetch_session(&self, session_id: &str) -> Result<OpenCodeSession> {
|
||||||
|
tracing::debug!("Fetching session: {}", session_id);
|
||||||
|
|
||||||
|
let session = self
|
||||||
|
.client
|
||||||
|
.get_session(session_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MockerError::Ingest(format!("Failed to fetch session: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::debug!("Fetching messages for session: {}", session_id);
|
||||||
|
let messages = self
|
||||||
|
.client
|
||||||
|
.list_messages(session_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MockerError::Ingest(format!("Failed to fetch messages: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"Fetched {} messages for session {}",
|
||||||
|
messages.len(),
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(OpenCodeSession { session, messages })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all sessions from the API.
|
||||||
|
async fn fetch_all_sessions(&self) -> Result<Vec<OpenCodeSession>> {
|
||||||
|
tracing::debug!("Fetching all sessions");
|
||||||
|
|
||||||
|
let sessions = self
|
||||||
|
.client
|
||||||
|
.list_sessions()
|
||||||
|
.await
|
||||||
|
.map_err(|e| MockerError::Ingest(format!("Failed to list sessions: {}", e)))?;
|
||||||
|
|
||||||
|
tracing::info!("Found {} sessions to ingest", sessions.len());
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for session_info in sessions {
|
||||||
|
match self.fetch_session(&session_info.id).await {
|
||||||
|
Ok(session) => results.push(session),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to fetch session {}: {}", session_info.id, e);
|
||||||
|
// Continue with other sessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenCode session with messages.
|
||||||
|
struct OpenCodeSession {
|
||||||
|
session: opencode_client::Session,
|
||||||
|
messages: Vec<MessageWithParts>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session Mapping
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Map OpenCode sessions to fixture format.
|
||||||
|
fn map_sessions_to_fixture(opencode_sessions: Vec<OpenCodeSession>) -> Result<Fixture> {
|
||||||
|
let sessions: Vec<Session> = opencode_sessions
|
||||||
|
.into_iter()
|
||||||
|
.map(|oc_session| map_session(oc_session))
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
// Create default responders and streaming config
|
||||||
|
let responders = create_default_responders();
|
||||||
|
let streaming = create_default_streaming();
|
||||||
|
|
||||||
|
Ok(Fixture {
|
||||||
|
version: "0.1".to_string(),
|
||||||
|
sessions,
|
||||||
|
responders,
|
||||||
|
streaming,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a single OpenCode session to fixture format.
|
||||||
|
fn map_session(oc_session: OpenCodeSession) -> Result<Session> {
|
||||||
|
let session_id = oc_session.session.id.clone();
|
||||||
|
|
||||||
|
// Create participants (user and assistant)
|
||||||
|
let participants = vec![
|
||||||
|
Participant {
|
||||||
|
id: "user".to_string(),
|
||||||
|
kind: ParticipantKind::User,
|
||||||
|
display_name: Some("User".to_string()),
|
||||||
|
},
|
||||||
|
Participant {
|
||||||
|
id: "assistant".to_string(),
|
||||||
|
kind: ParticipantKind::Assistant,
|
||||||
|
display_name: Some("Assistant".to_string()),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map messages
|
||||||
|
let messages: Vec<Message> = oc_session
|
||||||
|
.messages
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(idx, msg_with_parts)| map_message(&session_id, idx, msg_with_parts))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Convert Unix timestamp (milliseconds) to ISO8601
|
||||||
|
let created_at = chrono::DateTime::from_timestamp_millis(
|
||||||
|
oc_session.session.time.created as i64
|
||||||
|
)
|
||||||
|
.map(|dt| dt.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
|
||||||
|
|
||||||
|
Ok(Session {
|
||||||
|
id: session_id,
|
||||||
|
title: oc_session.session.title,
|
||||||
|
created_at,
|
||||||
|
participants,
|
||||||
|
messages,
|
||||||
|
behavior: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an OpenCode message to fixture format.
|
||||||
|
///
|
||||||
|
/// Returns None if the message has no text content.
|
||||||
|
fn map_message(
|
||||||
|
session_id: &str,
|
||||||
|
index: usize,
|
||||||
|
msg_with_parts: MessageWithParts,
|
||||||
|
) -> Option<Message> {
|
||||||
|
// Extract role and timestamp from message info
|
||||||
|
let (role, created_ms) = match msg_with_parts.info {
|
||||||
|
opencode_client::Message::User(user_msg) => {
|
||||||
|
(MessageRole::User, user_msg.time.created)
|
||||||
|
}
|
||||||
|
opencode_client::Message::Assistant(assistant_msg) => {
|
||||||
|
(MessageRole::Assistant, assistant_msg.time.created)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract text content from parts
|
||||||
|
// We concatenate all text and reasoning parts for simplicity in v0.1
|
||||||
|
let mut content_parts = Vec::new();
|
||||||
|
|
||||||
|
for part in msg_with_parts.parts {
|
||||||
|
match part {
|
||||||
|
opencode_client::Part::Text(text_part) => {
|
||||||
|
content_parts.push(text_part.text);
|
||||||
|
}
|
||||||
|
opencode_client::Part::Reasoning(reasoning_part) => {
|
||||||
|
// Include reasoning in content for v0.1
|
||||||
|
content_parts.push(format!("[Reasoning]\n{}", reasoning_part.text));
|
||||||
|
}
|
||||||
|
opencode_client::Part::Tool(tool_part) => {
|
||||||
|
// Include tool information for context
|
||||||
|
let tool_info = match tool_part.state {
|
||||||
|
opencode_client::ToolState::Completed { output, title, .. } => {
|
||||||
|
format!("[Tool: {}]\n{}\nOutput: {}", tool_part.tool, title, output)
|
||||||
|
}
|
||||||
|
opencode_client::ToolState::Error { error, .. } => {
|
||||||
|
format!("[Tool: {} - Error]\n{}", tool_part.tool, error)
|
||||||
|
}
|
||||||
|
opencode_client::ToolState::Running { title, .. } => {
|
||||||
|
format!(
|
||||||
|
"[Tool: {} - Running]\n{}",
|
||||||
|
tool_part.tool,
|
||||||
|
title.unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
opencode_client::ToolState::Pending => {
|
||||||
|
format!("[Tool: {} - Pending]", tool_part.tool)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
content_parts.push(tool_info);
|
||||||
|
}
|
||||||
|
// Ignore other part types for v0.1
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no content, skip this message
|
||||||
|
if content_parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = content_parts.join("\n\n");
|
||||||
|
|
||||||
|
// Convert timestamp
|
||||||
|
let created_at = chrono::DateTime::from_timestamp_millis(created_ms as i64)
|
||||||
|
.map(|dt| dt.to_rfc3339())
|
||||||
|
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
|
||||||
|
|
||||||
|
// Generate message ID
|
||||||
|
let message_id = format!("msg-{}", index);
|
||||||
|
|
||||||
|
// Parent ID for threading (assistant messages follow user messages)
|
||||||
|
let parent_id = if role == MessageRole::Assistant && index > 0 {
|
||||||
|
Some(format!("msg-{}", index - 1))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Message {
|
||||||
|
id: message_id,
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
created_at,
|
||||||
|
parent_id,
|
||||||
|
metadata: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create default responder configuration.
|
||||||
|
///
|
||||||
|
/// Uses Echo strategy for simplicity.
|
||||||
|
fn create_default_responders() -> Responders {
|
||||||
|
Responders {
|
||||||
|
keyword_map: HashMap::new(),
|
||||||
|
default_strategy: ResponderStrategy::Echo,
|
||||||
|
random: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create default streaming configuration.
|
||||||
|
fn create_default_streaming() -> Streaming {
|
||||||
|
Streaming {
|
||||||
|
enabled: true,
|
||||||
|
tokens_per_chunk: 5,
|
||||||
|
chunk_interval_ms: 50,
|
||||||
|
jitter_ms: Some(10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fixture Merging
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Merge two fixtures together.
|
||||||
|
///
|
||||||
|
/// Combines sessions from both fixtures, handling duplicate session IDs
|
||||||
|
/// by renaming with a numeric suffix.
|
||||||
|
fn merge_fixtures(existing: Fixture, new: Fixture) -> Result<Fixture> {
|
||||||
|
let mut merged = existing.clone();
|
||||||
|
let mut existing_ids: HashMap<String, usize> = HashMap::new();
|
||||||
|
|
||||||
|
// Track existing session IDs
|
||||||
|
for session in &merged.sessions {
|
||||||
|
existing_ids.insert(session.id.clone(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new sessions, renaming duplicates
|
||||||
|
for mut session in new.sessions {
|
||||||
|
if existing_ids.contains_key(&session.id) {
|
||||||
|
// Find a unique ID by appending a suffix
|
||||||
|
let original_id = session.id.clone();
|
||||||
|
let mut suffix = 1;
|
||||||
|
let mut new_id = format!("{}-{}", original_id, suffix);
|
||||||
|
|
||||||
|
while existing_ids.contains_key(&new_id) {
|
||||||
|
suffix += 1;
|
||||||
|
new_id = format!("{}-{}", original_id, suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Renaming duplicate session {} to {}", original_id, new_id);
|
||||||
|
|
||||||
|
// Update session ID and message session IDs
|
||||||
|
session.id = new_id.clone();
|
||||||
|
for message in &mut session.messages {
|
||||||
|
message.session_id = new_id.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
existing_ids.insert(new_id, 0);
|
||||||
|
} else {
|
||||||
|
existing_ids.insert(session.id.clone(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.sessions.push(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge keyword maps (new takes precedence)
|
||||||
|
for (keyword, response) in new.responders.keyword_map {
|
||||||
|
merged.responders.keyword_map.insert(keyword, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use new fixture's responder strategy and streaming if different
|
||||||
|
// (In practice, we use the existing fixture's settings)
|
||||||
|
|
||||||
|
Ok(merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load an existing fixture from a file, or create a new empty one.
|
||||||
|
async fn load_or_create_fixture(path: &Path) -> Result<Fixture> {
|
||||||
|
if path.exists() {
|
||||||
|
tracing::debug!("Loading existing fixture from: {}", path.display());
|
||||||
|
let content = tokio::fs::read_to_string(path).await.map_err(|e| {
|
||||||
|
MockerError::FixtureLoad(format!("Failed to read fixture file: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let fixture: Fixture = serde_yaml::from_str(&content).map_err(|e| {
|
||||||
|
MockerError::FixtureLoad(format!("Failed to parse fixture YAML: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(fixture)
|
||||||
|
} else {
|
||||||
|
tracing::debug!("Creating new empty fixture");
|
||||||
|
Ok(Fixture {
|
||||||
|
version: "0.1".to_string(),
|
||||||
|
sessions: Vec::new(),
|
||||||
|
responders: create_default_responders(),
|
||||||
|
streaming: create_default_streaming(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// YAML Export
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Export a fixture to a YAML file.
|
||||||
|
///
|
||||||
|
/// Creates parent directories if they don't exist.
|
||||||
|
async fn export_fixture(fixture: &Fixture, path: &Path) -> Result<()> {
|
||||||
|
// Create parent directories if necessary
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||||
|
MockerError::FixtureLoad(format!("Failed to create parent directories: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize to YAML with pretty formatting
|
||||||
|
let yaml = serde_yaml::to_string(fixture).map_err(|e| {
|
||||||
|
MockerError::FixtureLoad(format!("Failed to serialize fixture to YAML: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
tokio::fs::write(path, yaml).await.map_err(|e| {
|
||||||
|
MockerError::Transport(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to write fixture file: {}", e),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_default_responders() {
|
||||||
|
let responders = create_default_responders();
|
||||||
|
assert_eq!(responders.default_strategy, ResponderStrategy::Echo);
|
||||||
|
assert!(responders.keyword_map.is_empty());
|
||||||
|
assert!(responders.random.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_default_streaming() {
|
||||||
|
let streaming = create_default_streaming();
|
||||||
|
assert!(streaming.enabled);
|
||||||
|
assert_eq!(streaming.tokens_per_chunk, 5);
|
||||||
|
assert_eq!(streaming.chunk_interval_ms, 50);
|
||||||
|
assert_eq!(streaming.jitter_ms, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_fixtures_no_duplicates() {
|
||||||
|
let existing = Fixture {
|
||||||
|
version: "0.1".to_string(),
|
||||||
|
sessions: vec![Session {
|
||||||
|
id: "session-1".to_string(),
|
||||||
|
title: "Session 1".to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
participants: vec![],
|
||||||
|
messages: vec![],
|
||||||
|
behavior: None,
|
||||||
|
}],
|
||||||
|
responders: create_default_responders(),
|
||||||
|
streaming: create_default_streaming(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let new = Fixture {
|
||||||
|
version: "0.1".to_string(),
|
||||||
|
sessions: vec![Session {
|
||||||
|
id: "session-2".to_string(),
|
||||||
|
title: "Session 2".to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
participants: vec![],
|
||||||
|
messages: vec![],
|
||||||
|
behavior: None,
|
||||||
|
}],
|
||||||
|
responders: create_default_responders(),
|
||||||
|
streaming: create_default_streaming(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged = merge_fixtures(existing, new).unwrap();
|
||||||
|
assert_eq!(merged.sessions.len(), 2);
|
||||||
|
assert_eq!(merged.sessions[0].id, "session-1");
|
||||||
|
assert_eq!(merged.sessions[1].id, "session-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_fixtures_with_duplicates() {
|
||||||
|
let existing = Fixture {
|
||||||
|
version: "0.1".to_string(),
|
||||||
|
sessions: vec![Session {
|
||||||
|
id: "session-1".to_string(),
|
||||||
|
title: "Session 1".to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
participants: vec![],
|
||||||
|
messages: vec![],
|
||||||
|
behavior: None,
|
||||||
|
}],
|
||||||
|
responders: create_default_responders(),
|
||||||
|
streaming: create_default_streaming(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let new = Fixture {
|
||||||
|
version: "0.1".to_string(),
|
||||||
|
sessions: vec![Session {
|
||||||
|
id: "session-1".to_string(),
|
||||||
|
title: "Session 1 (New)".to_string(),
|
||||||
|
created_at: "2025-01-02T00:00:00Z".to_string(),
|
||||||
|
participants: vec![],
|
||||||
|
messages: vec![],
|
||||||
|
behavior: None,
|
||||||
|
}],
|
||||||
|
responders: create_default_responders(),
|
||||||
|
streaming: create_default_streaming(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged = merge_fixtures(existing, new).unwrap();
|
||||||
|
assert_eq!(merged.sessions.len(), 2);
|
||||||
|
assert_eq!(merged.sessions[0].id, "session-1");
|
||||||
|
assert_eq!(merged.sessions[1].id, "session-1-1"); // Renamed
|
||||||
|
}
|
||||||
|
}
|
||||||
+894
@@ -0,0 +1,894 @@
|
|||||||
|
//! # dirigate
|
||||||
|
//!
|
||||||
|
//! ACP (Agent-Client Protocol) mock agent server for testing clients without real agents.
|
||||||
|
//!
|
||||||
|
//! This library provides a configurable mock server that responds to ACP requests
|
||||||
|
//! based on YAML fixture definitions. It supports:
|
||||||
|
//!
|
||||||
|
//! - **Fixture-based responses**: Define sessions and message flows in YAML
|
||||||
|
//! - **Multiple response modes**: Static, random, sequential, and pattern-based
|
||||||
|
//! - **Session ingestion**: Import sessions from OpenCode.ai or other sources (feature-gated)
|
||||||
|
//! - **ACP compliance**: Full protocol implementation for testing clients
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//!
|
||||||
|
//! As a library:
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use dirigate::Result;
|
||||||
|
//!
|
||||||
|
//! # async fn example() -> Result<()> {
|
||||||
|
//! // TODO: Add usage example once API is implemented
|
||||||
|
//! # Ok(())
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! As a CLI tool:
|
||||||
|
//! ```bash
|
||||||
|
//! dirigate serve --fixtures ./fixtures --port 8080
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use rand_chacha::ChaCha8Rng;
|
||||||
|
|
||||||
|
// Core modules
|
||||||
|
pub mod error;
|
||||||
|
pub mod logging;
|
||||||
|
|
||||||
|
// ACP server implementation
|
||||||
|
pub mod acp;
|
||||||
|
|
||||||
|
// Fixture system
|
||||||
|
pub mod fixture;
|
||||||
|
|
||||||
|
// Optional ingestion module (feature-gated)
|
||||||
|
#[cfg(feature = "ingest")]
|
||||||
|
pub mod ingest;
|
||||||
|
|
||||||
|
// CLI module
|
||||||
|
pub mod cli;
|
||||||
|
|
||||||
|
// Re-export key types
|
||||||
|
pub use error::{MockerError, Result};
|
||||||
|
pub use acp::stream::{chunk_text, StreamConfig, StreamController, StreamEvent};
|
||||||
|
pub use fixture::types::{Fixture, Message, Participant};
|
||||||
|
pub use fixture::responders::Responder;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Configuration Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Server configuration for the ACP mocker.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MockerConfig {
|
||||||
|
/// Port to bind the server to.
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Host address to bind to.
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
/// Default streaming configuration.
|
||||||
|
pub default_stream_config: StreamConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MockerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
port: 8080,
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
default_stream_config: StreamConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockerConfig {
|
||||||
|
/// Create a new mocker configuration.
|
||||||
|
pub fn new(port: u16, host: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
port,
|
||||||
|
host: host.into(),
|
||||||
|
default_stream_config: StreamConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the default streaming configuration.
|
||||||
|
pub fn with_stream_config(mut self, config: StreamConfig) -> Self {
|
||||||
|
self.default_stream_config = config;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Runtime state for a single session.
|
||||||
|
///
|
||||||
|
/// Contains all information about an active session, including its messages,
|
||||||
|
/// participants, and the responder used to generate responses.
|
||||||
|
pub struct SessionState {
|
||||||
|
/// Session identifier.
|
||||||
|
pub id: String,
|
||||||
|
|
||||||
|
/// Human-readable session title.
|
||||||
|
pub title: String,
|
||||||
|
|
||||||
|
/// ISO8601 timestamp when session was created.
|
||||||
|
pub created_at: String,
|
||||||
|
|
||||||
|
/// Participants in this session.
|
||||||
|
pub participants: Vec<Participant>,
|
||||||
|
|
||||||
|
/// Messages in this session (grows as turns progress).
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
|
||||||
|
/// Responder assigned to this session.
|
||||||
|
responder: Box<dyn Responder>,
|
||||||
|
|
||||||
|
/// Stream controller for ongoing streams (if any).
|
||||||
|
pub stream_controller: Option<StreamController>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionState {
|
||||||
|
/// Create a new session state.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `id` - Unique session identifier
|
||||||
|
/// * `title` - Human-readable session title
|
||||||
|
/// * `created_at` - ISO8601 timestamp
|
||||||
|
/// * `participants` - List of session participants
|
||||||
|
/// * `messages` - Initial message list
|
||||||
|
/// * `responder` - Responder for generating responses
|
||||||
|
pub fn new(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
created_at: String,
|
||||||
|
participants: Vec<Participant>,
|
||||||
|
messages: Vec<Message>,
|
||||||
|
responder: Box<dyn Responder>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
created_at,
|
||||||
|
participants,
|
||||||
|
messages,
|
||||||
|
responder,
|
||||||
|
stream_controller: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a mutable reference to the responder.
|
||||||
|
pub fn responder_mut(&mut self) -> &mut Box<dyn Responder> {
|
||||||
|
&mut self.responder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a message to this session.
|
||||||
|
pub fn add_message(&mut self, message: Message) {
|
||||||
|
tracing::debug!(
|
||||||
|
session_id = %self.id,
|
||||||
|
message_id = %message.id,
|
||||||
|
role = ?message.role,
|
||||||
|
"Adding message to session"
|
||||||
|
);
|
||||||
|
self.messages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for SessionState {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("SessionState")
|
||||||
|
.field("id", &self.id)
|
||||||
|
.field("title", &self.title)
|
||||||
|
.field("created_at", &self.created_at)
|
||||||
|
.field("participants", &self.participants)
|
||||||
|
.field("message_count", &self.messages.len())
|
||||||
|
.field("has_stream_controller", &self.stream_controller.is_some())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mocker State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Runtime state for the ACP mocker.
|
||||||
|
///
|
||||||
|
/// Manages active sessions, fixture data, and configuration. This is the
|
||||||
|
/// central coordination point for the mock server.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MockerState {
|
||||||
|
/// Active sessions (thread-safe).
|
||||||
|
sessions: Arc<RwLock<HashMap<String, SessionState>>>,
|
||||||
|
|
||||||
|
/// Loaded fixtures (immutable after creation).
|
||||||
|
fixtures: Arc<Fixture>,
|
||||||
|
|
||||||
|
/// Server configuration.
|
||||||
|
config: MockerConfig,
|
||||||
|
|
||||||
|
/// Global random number generator for ID generation.
|
||||||
|
_global_rng: Arc<Mutex<ChaCha8Rng>>,
|
||||||
|
|
||||||
|
/// Broadcast channel for SSE notifications.
|
||||||
|
/// Clients subscribe to this channel to receive session updates.
|
||||||
|
event_tx: Arc<tokio::sync::broadcast::Sender<SseNotification>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSE notification message.
|
||||||
|
///
|
||||||
|
/// Represents a server-sent event that is broadcast to all connected clients.
|
||||||
|
/// Clients filter events based on session_id to only process relevant updates.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SseNotification {
|
||||||
|
/// Session ID this event relates to.
|
||||||
|
pub session_id: String,
|
||||||
|
|
||||||
|
/// JSON-RPC notification payload.
|
||||||
|
pub notification: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockerState {
|
||||||
|
/// Create a new mocker state.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `config` - Server configuration
|
||||||
|
/// * `fixtures` - Loaded fixture data
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A new mocker state instance ready for use
|
||||||
|
pub fn new(config: MockerConfig, fixtures: Fixture) -> Self {
|
||||||
|
// Create broadcast channel for SSE events
|
||||||
|
// Read buffer size from environment variable, default to 100
|
||||||
|
let capacity = std::env::var("CONDUCTOR_BUFFER_SIZE")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
|
.unwrap_or(100);
|
||||||
|
let (event_tx, _) = tokio::sync::broadcast::channel(capacity);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
sessions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
fixtures: Arc::new(fixtures),
|
||||||
|
config,
|
||||||
|
_global_rng: Arc::new(Mutex::new(ChaCha8Rng::from_entropy())),
|
||||||
|
event_tx: Arc::new(event_tx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to SSE notifications.
|
||||||
|
///
|
||||||
|
/// Returns a receiver that can be used to listen for session update events.
|
||||||
|
pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<SseNotification> {
|
||||||
|
self.event_tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast an SSE notification to all connected clients.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `notification` - The SSE notification to broadcast
|
||||||
|
///
|
||||||
|
/// # Note
|
||||||
|
///
|
||||||
|
/// This is a fire-and-forget operation. If no clients are connected,
|
||||||
|
/// the notification is simply dropped.
|
||||||
|
pub fn broadcast_event(&self, notification: SseNotification) {
|
||||||
|
// send() returns Err if there are no receivers, which is fine
|
||||||
|
let _ = self.event_tx.send(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new session.
|
||||||
|
///
|
||||||
|
/// Creates a new session, optionally based on a fixture template.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `template_id` - Optional fixture template ID to base the session on
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The ID of the newly created session
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the template is not found or session creation fails
|
||||||
|
pub async fn create_session(&self, template_id: Option<String>) -> Result<String> {
|
||||||
|
// Generate a new session ID
|
||||||
|
let session_id = self.generate_session_id().await;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
session_id = %session_id,
|
||||||
|
template_id = ?template_id,
|
||||||
|
"Creating new session"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load fixture template if specified
|
||||||
|
let session_fixture = if let Some(template_id) = &template_id {
|
||||||
|
self.fixtures
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| &s.id == template_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
MockerError::FixtureValidation(format!(
|
||||||
|
"Template session not found: {}",
|
||||||
|
template_id
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.clone()
|
||||||
|
} else {
|
||||||
|
// Create a minimal default session
|
||||||
|
fixture::types::Session {
|
||||||
|
id: session_id.clone(),
|
||||||
|
title: "New Session".to_string(),
|
||||||
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
participants: vec![],
|
||||||
|
messages: vec![],
|
||||||
|
behavior: None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create responder for this session
|
||||||
|
let responder = fixture::responders::ResponderFactory::create_for_session(
|
||||||
|
&self.fixtures.responders,
|
||||||
|
&session_fixture,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Create session state
|
||||||
|
let session_state = SessionState::new(
|
||||||
|
session_id.clone(),
|
||||||
|
session_fixture.title,
|
||||||
|
session_fixture.created_at,
|
||||||
|
session_fixture.participants,
|
||||||
|
session_fixture.messages,
|
||||||
|
responder,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the session
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
sessions.insert(session_id.clone(), session_state);
|
||||||
|
|
||||||
|
Ok(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a session from fixtures.
|
||||||
|
///
|
||||||
|
/// Loads an existing session from the fixture data. The session must
|
||||||
|
/// exist in the fixtures.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `session_id` - ID of the session to load from fixtures
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A clone of the session state (not a reference, to avoid lock issues)
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `SessionNotFound` if the session doesn't exist in fixtures
|
||||||
|
pub async fn load_session(&self, session_id: &str) -> Result<SessionState> {
|
||||||
|
tracing::info!(session_id, "Loading session from fixtures");
|
||||||
|
|
||||||
|
// Find session in fixtures
|
||||||
|
let session_fixture = self
|
||||||
|
.fixtures
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == session_id)
|
||||||
|
.ok_or_else(|| MockerError::SessionNotFound {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Create responder for this session
|
||||||
|
let responder = fixture::responders::ResponderFactory::create_for_session(
|
||||||
|
&self.fixtures.responders,
|
||||||
|
session_fixture,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Create session state
|
||||||
|
let session_state = SessionState::new(
|
||||||
|
session_fixture.id.clone(),
|
||||||
|
session_fixture.title.clone(),
|
||||||
|
session_fixture.created_at.clone(),
|
||||||
|
session_fixture.participants.clone(),
|
||||||
|
session_fixture.messages.clone(),
|
||||||
|
responder,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store in active sessions
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
sessions.insert(session_id.to_string(), session_state);
|
||||||
|
|
||||||
|
// Return a clone (we need to drop the write lock first)
|
||||||
|
drop(sessions);
|
||||||
|
self.get_session(session_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a session from active sessions.
|
||||||
|
///
|
||||||
|
/// Retrieves a session that has been created or loaded.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `session_id` - ID of the session to retrieve
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A clone of the session state
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `SessionNotFound` if the session is not active
|
||||||
|
pub async fn get_session(&self, session_id: &str) -> Result<SessionState> {
|
||||||
|
let sessions = self.sessions.read().await;
|
||||||
|
|
||||||
|
// We can't return a reference due to the lock, so we need to clone
|
||||||
|
// In a real implementation, you might want to use Arc<SessionState> or
|
||||||
|
// return specific data rather than the whole state
|
||||||
|
sessions
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| MockerError::SessionNotFound {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
})
|
||||||
|
.map(|s| {
|
||||||
|
// Create a shallow clone for return
|
||||||
|
// Note: This is a temporary solution. In practice, you'd want
|
||||||
|
// to either use Arc or return specific fields
|
||||||
|
SessionState {
|
||||||
|
id: s.id.clone(),
|
||||||
|
title: s.title.clone(),
|
||||||
|
created_at: s.created_at.clone(),
|
||||||
|
participants: s.participants.clone(),
|
||||||
|
messages: s.messages.clone(),
|
||||||
|
responder: fixture::responders::ResponderFactory::create_for_session(
|
||||||
|
&self.fixtures.responders,
|
||||||
|
&fixture::types::Session {
|
||||||
|
id: s.id.clone(),
|
||||||
|
title: s.title.clone(),
|
||||||
|
created_at: s.created_at.clone(),
|
||||||
|
participants: s.participants.clone(),
|
||||||
|
messages: s.messages.clone(),
|
||||||
|
behavior: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
Box::new(fixture::responders::EchoResponder)
|
||||||
|
}),
|
||||||
|
stream_controller: None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a message to a session.
|
||||||
|
///
|
||||||
|
/// Adds a message to an active session's message history.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `session_id` - ID of the session
|
||||||
|
/// * `message` - The message to add
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `SessionNotFound` if the session is not active
|
||||||
|
pub async fn add_message(&self, session_id: &str, message: Message) -> Result<()> {
|
||||||
|
let mut sessions = self.sessions.write().await;
|
||||||
|
|
||||||
|
let session = sessions
|
||||||
|
.get_mut(session_id)
|
||||||
|
.ok_or_else(|| MockerError::SessionNotFound {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
session.add_message(message);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel an ongoing stream for a session.
|
||||||
|
///
|
||||||
|
/// Sets the cancellation flag on the session's stream controller,
|
||||||
|
/// causing streaming to stop.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `session_id` - ID of the session to cancel
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `SessionNotFound` if the session is not active
|
||||||
|
pub async fn cancel_stream(&self, session_id: &str) -> Result<()> {
|
||||||
|
let sessions = self.sessions.read().await;
|
||||||
|
|
||||||
|
let session = sessions
|
||||||
|
.get(session_id)
|
||||||
|
.ok_or_else(|| MockerError::SessionNotFound {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(controller) = &session.stream_controller {
|
||||||
|
tracing::info!(session_id, "Cancelling stream");
|
||||||
|
controller.cancel();
|
||||||
|
} else {
|
||||||
|
tracing::warn!(session_id, "No active stream to cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the fixture data.
|
||||||
|
pub fn fixtures(&self) -> &Fixture {
|
||||||
|
&self.fixtures
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the server configuration.
|
||||||
|
pub fn config(&self) -> &MockerConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new session ID.
|
||||||
|
async fn generate_session_id(&self) -> String {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for MockerState {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("MockerState")
|
||||||
|
.field("config", &self.config)
|
||||||
|
.field("fixture_count", &self.fixtures.sessions.len())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use fixture::types::{
|
||||||
|
MessageRole, Participant, ParticipantKind, Responders, ResponderStrategy,
|
||||||
|
Session, Streaming,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to create a minimal test fixture
|
||||||
|
fn create_test_fixture() -> Fixture {
|
||||||
|
Fixture {
|
||||||
|
version: "0.1".to_string(),
|
||||||
|
sessions: vec![
|
||||||
|
Session {
|
||||||
|
id: "test-session-1".to_string(),
|
||||||
|
title: "Test Session 1".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()),
|
||||||
|
}],
|
||||||
|
messages: vec![],
|
||||||
|
behavior: None,
|
||||||
|
},
|
||||||
|
Session {
|
||||||
|
id: "test-session-2".to_string(),
|
||||||
|
title: "Test Session 2".to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
participants: vec![],
|
||||||
|
messages: vec![Message {
|
||||||
|
id: "msg-1".to_string(),
|
||||||
|
session_id: "test-session-2".to_string(),
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: "Hello!".to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
metadata: None,
|
||||||
|
}],
|
||||||
|
behavior: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responders: Responders {
|
||||||
|
keyword_map: std::collections::HashMap::new(),
|
||||||
|
default_strategy: ResponderStrategy::Echo,
|
||||||
|
random: None,
|
||||||
|
},
|
||||||
|
streaming: Streaming {
|
||||||
|
enabled: true,
|
||||||
|
tokens_per_chunk: 5,
|
||||||
|
chunk_interval_ms: 100,
|
||||||
|
jitter_ms: Some(10),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// MockerConfig Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mocker_config_default() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
assert_eq!(config.port, 8080);
|
||||||
|
assert_eq!(config.host, "127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mocker_config_new() {
|
||||||
|
let config = MockerConfig::new(3000, "0.0.0.0");
|
||||||
|
assert_eq!(config.port, 3000);
|
||||||
|
assert_eq!(config.host, "0.0.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mocker_config_with_stream_config() {
|
||||||
|
let stream_config = StreamConfig::new(10, 200);
|
||||||
|
let config = MockerConfig::default().with_stream_config(stream_config);
|
||||||
|
assert_eq!(config.default_stream_config.tokens_per_chunk, 10);
|
||||||
|
assert_eq!(config.default_stream_config.chunk_interval_ms, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// SessionState Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_state_creation() {
|
||||||
|
let responder = Box::new(fixture::responders::EchoResponder);
|
||||||
|
let session = SessionState::new(
|
||||||
|
"test-id".to_string(),
|
||||||
|
"Test Title".to_string(),
|
||||||
|
"2025-01-01T00:00:00Z".to_string(),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
responder,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(session.id, "test-id");
|
||||||
|
assert_eq!(session.title, "Test Title");
|
||||||
|
assert_eq!(session.messages.len(), 0);
|
||||||
|
assert!(session.stream_controller.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_state_add_message() {
|
||||||
|
let responder = Box::new(fixture::responders::EchoResponder);
|
||||||
|
let mut session = SessionState::new(
|
||||||
|
"test-id".to_string(),
|
||||||
|
"Test Title".to_string(),
|
||||||
|
"2025-01-01T00:00:00Z".to_string(),
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
responder,
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = Message {
|
||||||
|
id: "msg-1".to_string(),
|
||||||
|
session_id: "test-id".to_string(),
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: "Hello".to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
session.add_message(message);
|
||||||
|
assert_eq!(session.messages.len(), 1);
|
||||||
|
assert_eq!(session.messages[0].content, "Hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// MockerState Tests
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_creation() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
assert_eq!(state.fixtures().sessions.len(), 2);
|
||||||
|
assert_eq!(state.config().port, 8080);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_create_session_without_template() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
let session_id = state.create_session(None).await.unwrap();
|
||||||
|
|
||||||
|
assert!(!session_id.is_empty());
|
||||||
|
|
||||||
|
// Session should be retrievable
|
||||||
|
let session = state.get_session(&session_id).await.unwrap();
|
||||||
|
assert_eq!(session.id, session_id);
|
||||||
|
assert_eq!(session.title, "New Session");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_create_session_with_template() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
let session_id = state
|
||||||
|
.create_session(Some("test-session-1".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!session_id.is_empty());
|
||||||
|
|
||||||
|
// Session should have template data
|
||||||
|
let session = state.get_session(&session_id).await.unwrap();
|
||||||
|
assert_eq!(session.title, "Test Session 1");
|
||||||
|
assert_eq!(session.participants.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_create_session_invalid_template() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
let result = state
|
||||||
|
.create_session(Some("non-existent-template".to_string()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result {
|
||||||
|
Err(MockerError::FixtureValidation(msg)) => {
|
||||||
|
assert!(msg.contains("Template session not found"));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected FixtureValidation error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_load_session() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
let session = state.load_session("test-session-1").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(session.id, "test-session-1");
|
||||||
|
assert_eq!(session.title, "Test Session 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_load_session_not_found() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
let result = state.load_session("non-existent").await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result {
|
||||||
|
Err(MockerError::SessionNotFound { session_id }) => {
|
||||||
|
assert_eq!(session_id, "non-existent");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected SessionNotFound error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_get_session() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
// Create a session first
|
||||||
|
let session_id = state.create_session(None).await.unwrap();
|
||||||
|
|
||||||
|
// Should be able to retrieve it
|
||||||
|
let session = state.get_session(&session_id).await.unwrap();
|
||||||
|
assert_eq!(session.id, session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_get_session_not_found() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
let result = state.get_session("non-existent").await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_add_message() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
// Create a session
|
||||||
|
let session_id = state.create_session(None).await.unwrap();
|
||||||
|
|
||||||
|
// Add a message
|
||||||
|
let message = Message {
|
||||||
|
id: "msg-1".to_string(),
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: "Test message".to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.add_message(&session_id, message).await.unwrap();
|
||||||
|
|
||||||
|
// Verify message was added
|
||||||
|
let session = state.get_session(&session_id).await.unwrap();
|
||||||
|
assert_eq!(session.messages.len(), 1);
|
||||||
|
assert_eq!(session.messages[0].content, "Test message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_add_message_not_found() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
let message = Message {
|
||||||
|
id: "msg-1".to_string(),
|
||||||
|
session_id: "non-existent".to_string(),
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: "Test message".to_string(),
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
metadata: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = state.add_message("non-existent", message).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_cancel_stream() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
// Create a session
|
||||||
|
let session_id = state.create_session(None).await.unwrap();
|
||||||
|
|
||||||
|
// Cancel stream (should not error even if no stream exists)
|
||||||
|
let result = state.cancel_stream(&session_id).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mocker_state_cancel_stream_not_found() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
let result = state.cancel_stream("non-existent").await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_session_id_generation_is_unique() {
|
||||||
|
let config = MockerConfig::default();
|
||||||
|
let fixtures = create_test_fixture();
|
||||||
|
let state = MockerState::new(config, fixtures);
|
||||||
|
|
||||||
|
let id1 = state.create_session(None).await.unwrap();
|
||||||
|
let id2 = state.create_session(None).await.unwrap();
|
||||||
|
let id3 = state.create_session(None).await.unwrap();
|
||||||
|
|
||||||
|
assert_ne!(id1, id2);
|
||||||
|
assert_ne!(id2, id3);
|
||||||
|
assert_ne!(id1, id3);
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
//! Logging infrastructure for the ACP mocker.
|
||||||
|
//!
|
||||||
|
//! This module provides utilities for initializing and configuring structured logging
|
||||||
|
//! using the `tracing` ecosystem. It supports multiple output formats and log levels
|
||||||
|
//! configurable via environment variables.
|
||||||
|
//!
|
||||||
|
//! ## Important: stdio Mode
|
||||||
|
//!
|
||||||
|
//! When using stdio transport (the primary ACP communication method), ALL logs are
|
||||||
|
//! automatically written to stderr to keep stdout clean for JSON-RPC messages.
|
||||||
|
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||||
|
|
||||||
|
/// Output format for logs.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum LogFormat {
|
||||||
|
/// Human-readable pretty format with colors (default for development).
|
||||||
|
#[default]
|
||||||
|
Pretty,
|
||||||
|
/// JSON format for structured logging (recommended for production/CI).
|
||||||
|
Json,
|
||||||
|
/// Compact format for minimal output.
|
||||||
|
Compact,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the logging system with the specified format.
|
||||||
|
///
|
||||||
|
/// # Log Levels
|
||||||
|
///
|
||||||
|
/// Log levels can be configured via the `RUST_LOG` environment variable:
|
||||||
|
/// - `RUST_LOG=trace` - Most verbose, includes all internal details
|
||||||
|
/// - `RUST_LOG=debug` - Detailed debugging information
|
||||||
|
/// - `RUST_LOG=info` - General informational messages (default)
|
||||||
|
/// - `RUST_LOG=warn` - Warning messages only
|
||||||
|
/// - `RUST_LOG=error` - Error messages only
|
||||||
|
///
|
||||||
|
/// You can also filter by module:
|
||||||
|
/// ```bash
|
||||||
|
/// RUST_LOG=dirigate=debug,axum=info
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use dirigate::logging::{init_logging, LogFormat};
|
||||||
|
///
|
||||||
|
/// // Initialize with pretty format (default)
|
||||||
|
/// init_logging(LogFormat::Pretty);
|
||||||
|
///
|
||||||
|
/// // Initialize with JSON format for production
|
||||||
|
/// init_logging(LogFormat::Json);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if logging has already been initialized. This should be called
|
||||||
|
/// exactly once at the start of the application.
|
||||||
|
pub fn init_logging(format: LogFormat) {
|
||||||
|
let env_filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("dirigate=info,axum=info"));
|
||||||
|
|
||||||
|
// Try to create log file in system temp directory
|
||||||
|
// If this fails, we'll just log to stderr only
|
||||||
|
let log_file_path = std::env::temp_dir().join("dirigate.log");
|
||||||
|
let file_writer = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&log_file_path)
|
||||||
|
.ok()
|
||||||
|
.map(Arc::new);
|
||||||
|
|
||||||
|
// CRITICAL: All logs go to stderr (and optionally to file if available)
|
||||||
|
// In stdio mode, stdout is reserved for JSON-RPC messages only
|
||||||
|
match (format, file_writer) {
|
||||||
|
(LogFormat::Pretty, Some(file_writer)) => {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt::layer().pretty().with_writer(std::io::stderr))
|
||||||
|
.with(fmt::layer().with_ansi(false).with_writer(file_writer))
|
||||||
|
.init();
|
||||||
|
tracing::info!(
|
||||||
|
"Logging initialized with Pretty format, writing to {:?}",
|
||||||
|
log_file_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(LogFormat::Pretty, None) => {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt::layer().pretty().with_writer(std::io::stderr))
|
||||||
|
.init();
|
||||||
|
tracing::info!("Logging initialized with Pretty format (file logging unavailable)");
|
||||||
|
}
|
||||||
|
(LogFormat::Json, Some(file_writer)) => {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt::layer().json().with_writer(std::io::stderr))
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.json()
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(file_writer),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
tracing::info!(
|
||||||
|
"Logging initialized with Json format, writing to {:?}",
|
||||||
|
log_file_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(LogFormat::Json, None) => {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt::layer().json().with_writer(std::io::stderr))
|
||||||
|
.init();
|
||||||
|
tracing::info!("Logging initialized with Json format (file logging unavailable)");
|
||||||
|
}
|
||||||
|
(LogFormat::Compact, Some(file_writer)) => {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt::layer().compact().with_writer(std::io::stderr))
|
||||||
|
.with(
|
||||||
|
fmt::layer()
|
||||||
|
.compact()
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(file_writer),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
tracing::info!(
|
||||||
|
"Logging initialized with Compact format, writing to {:?}",
|
||||||
|
log_file_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(LogFormat::Compact, None) => {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt::layer().compact().with_writer(std::io::stderr))
|
||||||
|
.init();
|
||||||
|
tracing::info!("Logging initialized with Compact format (file logging unavailable)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize logging with default settings (pretty format, info level).
|
||||||
|
///
|
||||||
|
/// This is a convenience function equivalent to `init_logging(LogFormat::Pretty)`.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use dirigate::logging::init_default_logging;
|
||||||
|
///
|
||||||
|
/// init_default_logging();
|
||||||
|
/// ```
|
||||||
|
pub fn init_default_logging() {
|
||||||
|
init_logging(LogFormat::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_log_format_default() {
|
||||||
|
assert_eq!(LogFormat::default(), LogFormat::Pretty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
version: "0.1"
|
||||||
|
sessions:
|
||||||
|
- id: "session-1
|
||||||
|
title: "Unclosed quote
|
||||||
|
this is not valid yaml: [broken
|
||||||
+22
@@ -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
|
||||||
Vendored
+17
@@ -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
|
||||||
Vendored
+37
@@ -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
|
||||||
Vendored
+82
@@ -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
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user