commit c62d8daea8425ab924b4adca76ffa39c1aa7677a Author: Gabor Körber Date: Thu Apr 30 21:58:57 2026 +0200 chore: rename packages/ to crates/ Move all 29 workspace members from packages// to crates//. 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1f1ecea --- /dev/null +++ b/CLAUDE.md @@ -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> + +### 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 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3c29397 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/HELP_DEMO.md b/HELP_DEMO.md new file mode 100644 index 0000000..e773c1b --- /dev/null +++ b/HELP_DEMO.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc60221 --- /dev/null +++ b/README.md @@ -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 ` - ACP Server URL (env: `DIRIGENT_SERVER_URL`, default: `http://localhost:3001/acp`) +- `-v, --verbose` - Enable verbose logging of JSON-RPC messages +- `--timeout ` - 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 ` - Directory containing fixture YAML files (default: `./fixtures`) +- `-p, --port ` - Port to bind the server to (default: `8080`) +- `--host ` - Host address to bind to (default: `127.0.0.1`) +- `--stdio` - Use stdin/stdout for JSON-RPC transport +- `-l, --log-level ` - Log level: trace, debug, info, warn, error (default: `info`) +- `--log-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 ` - 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. diff --git a/examples/basic.yaml b/examples/basic.yaml new file mode 100644 index 0000000..bd96fc9 --- /dev/null +++ b/examples/basic.yaml @@ -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 diff --git a/src/acp/bridge.rs b/src/acp/bridge.rs new file mode 100644 index 0000000..bbf64ea --- /dev/null +++ b/src/acp/bridge.rs @@ -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, +} + +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, + + /// 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, + + /// Pending agent requests awaiting responses from Zed + /// Maps request_id -> insertion timestamp for timeout tracking + pending_agent_requests: std::collections::HashMap, +} + +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(skip_serializing_if = "Option::is_none")] + id: Option, +} + +/// 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(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, +} + +/// 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, +} + +/// 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::(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, + state: Arc>, +) -> 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::(&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::(&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 { + 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, + state: Arc>, +) { + 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>) { + 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")); + } +} diff --git a/src/acp/mod.rs b/src/acp/mod.rs new file mode 100644 index 0000000..7ecb010 --- /dev/null +++ b/src/acp/mod.rs @@ -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, +}; diff --git a/src/acp/model.rs b/src/acp/model.rs new file mode 100644 index 0000000..3ee43ff --- /dev/null +++ b/src/acp/model.rs @@ -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, + + /// Request identifier (number or string). Used to correlate with response. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +/// 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, + + /// Error object (present on error). + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + + /// Request identifier (must match the request ID). + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +/// 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, +} + +/// 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, +} + +// ============================================================================ +// 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 }` +/// - `Tool { id: String, name: String, input: Value }` +/// - `ToolResult { tool_call_id: String, content: Vec }` +#[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, +} + +/// 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, + + /// Optional authentication methods supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_methods: Option>, + + /// 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, +} + +/// 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` - 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, + + /// Prompt capabilities (what input types are supported). + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_capabilities: Option, + + /// MCP (Model Context Protocol) capabilities. + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp: Option, + // 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, + + /// Whether the agent supports audio prompts. + #[serde(skip_serializing_if = "Option::is_none")] + pub audio: Option, + + /// Whether the agent supports embedded context (resources). + #[serde(skip_serializing_if = "Option::is_none")] + pub embedded_context: Option, + // 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, + + /// MCP servers the agent should connect to. + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_servers: Option>, + + /// 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, +} + +/// 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, + + /// MCP servers the agent should connect to. + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_servers: Option>, +} + +/// 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, + + /// Messages in this session. + pub messages: Vec, +} + +/// 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, +} + +/// 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, params: Option, id: impl Into) -> 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) -> 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) -> 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, params: Option) -> 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) -> 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, 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) -> Self { + Self::new(-32602, message) + } + + /// Standard error: Internal error. + pub fn internal_error(message: impl Into) -> Self { + Self::new(-32603, message) + } +} + +impl ContentBlock { + /// Creates a new text content block. + pub fn text(text: impl Into) -> 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\"")); + } +} diff --git a/src/acp/server.rs b/src/acp/server.rs new file mode 100644 index 0000000..8c17b15 --- /dev/null +++ b/src/acp/server.rs @@ -0,0 +1,2211 @@ +//! Axum HTTP server for ACP protocol endpoints. +//! +//! This module sets up the web server that exposes ACP endpoints for: +//! - JSON-RPC endpoint for ACP protocol methods +//! - Session creation and management +//! - Protocol initialization and capability negotiation +//! - Server-Sent Events for real-time updates (future) + +use crate::acp::model::{ + AgentCapabilities, InitializeParams, InitializeResult, JsonRpcError, JsonRpcRequest, + JsonRpcResponse, SessionLoadParams, + SessionNewParams, SessionNewResult, SessionPromptParams, SessionPromptResult, + ContentBlock, StopReason, +}; +use crate::{MockerState, Result}; +use axum::{ + extract::{Path, State}, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, Response, + }, + routing::{get, post}, + Json, Router, +}; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio_stream::{wrappers::BroadcastStream, StreamExt}; + +/// ACP server instance. +/// +/// Manages the HTTP server lifecycle and routes JSON-RPC requests to +/// appropriate handlers. +pub struct AcpServer { + /// Shared application state. + state: Arc, + + /// Address to bind the server to. + addr: SocketAddr, +} + +impl AcpServer { + /// Create a new ACP server. + /// + /// # Arguments + /// + /// * `state` - Shared mocker state + /// * `port` - Port to bind the server to + /// + /// # Returns + /// + /// A new server instance ready to serve requests + pub fn new(state: Arc, port: u16) -> Self { + let host = state.config().host.as_str(); + let addr = format!("{}:{}", host, port) + .parse() + .unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], port))); + + Self { state, addr } + } + + /// Start the server and listen for requests. + /// + /// This function will block until the server is stopped with Ctrl+C. + /// + /// # Errors + /// + /// Returns an error if: + /// - The server fails to bind to the configured address + /// - An error occurs during request handling + /// + /// # Examples + /// + /// ```rust,no_run + /// use dirigate::{MockerState, MockerConfig}; + /// use dirigate::acp::server::AcpServer; + /// use dirigate::fixture::types::*; + /// use std::sync::Arc; + /// use std::collections::HashMap; + /// + /// # async fn example() -> dirigate::Result<()> { + /// let config = MockerConfig::default(); + /// let fixtures = Fixture { + /// version: "0.1".to_string(), + /// sessions: vec![], + /// 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), + /// }, + /// }; + /// let state = Arc::new(MockerState::new(config, fixtures)); + /// let server = AcpServer::new(state, 8080); + /// server.serve().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn serve(self) -> Result<()> { + tracing::info!(addr = %self.addr, "Starting ACP server"); + + // Create router with JSON-RPC endpoint and SSE endpoint + let app = Router::new() + .route("/jsonrpc", post(handle_jsonrpc)) + .route("/events/{session_id}", get(handle_sse)) + .with_state(self.state.clone()); + + // Create server + let listener = tokio::net::TcpListener::bind(self.addr) + .await + .map_err(crate::MockerError::Transport)?; + + tracing::info!(addr = %self.addr, "ACP server listening"); + + // Serve with graceful shutdown + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .map_err(|e| crate::MockerError::Transport(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string() + )))?; + + tracing::info!("ACP server stopped"); + + Ok(()) + } +} + +/// Handle incoming JSON-RPC requests. +/// +/// This is the main entry point for all ACP protocol requests. It: +/// 1. Validates the JSON-RPC request structure +/// 2. Dispatches to the appropriate method handler +/// 3. Returns a JSON-RPC response +/// Handle a JSON-RPC request (public interface for stdio transport). +/// +/// This function processes a JSON-RPC request and returns the response. +/// It's used by both HTTP and stdio transports. +pub async fn handle_jsonrpc_inner( + state: Arc, + request: serde_json::Value, +) -> Option { + // Parse the request + let request: JsonRpcRequest = match serde_json::from_value(request) { + Ok(req) => req, + Err(e) => { + tracing::error!(error = %e, "Failed to parse JSON-RPC request"); + let error = JsonRpcError::parse_error(); + let response = JsonRpcResponse::error(error, None); + return Some(serde_json::to_value(response).unwrap()); + } + }; + + tracing::debug!(method = %request.method, id = ?request.id, "Received JSON-RPC request"); + + // Validate JSON-RPC version + if request.jsonrpc != "2.0" { + tracing::warn!(version = %request.jsonrpc, "Invalid JSON-RPC version"); + let error = JsonRpcError::invalid_request(); + let response = JsonRpcResponse::error(error, request.id.clone()); + return Some(serde_json::to_value(response).unwrap()); + } + + // Check if this is a notification (no id field) + let is_notification = request.id.is_none(); + + // Dispatch to method handler + let result = match request.method.as_str() { + "initialize" => handle_initialize(state, request.params).await.map(Some), + "session/new" => handle_session_new(state, request.params).await.map(Some), + "session/load" => handle_session_load(state, request.params).await.map(Some), + "session/prompt" => handle_session_prompt(state, request.params).await.map(Some), + "session/cancel" => { + // Notification - no response expected + handle_session_cancel(state, request.params).await.map(|_| None) + } + "_dirigent/sessions" => handle_dirigent_sessions(state, request.params).await.map(Some), + "_dirigent/fixture/validate" => handle_dirigent_fixture_validate(state, request.params).await.map(Some), + "_dirigent/help" => handle_dirigent_help(state, request.params).await.map(Some), + _ => { + tracing::warn!(method = %request.method, "Method not found"); + Err(crate::MockerError::AcpProtocol(format!( + "Method not found: {}", + request.method + ))) + .map(Some) + } + }; + + // Handle result + match result { + Ok(Some(result_value)) => { + // Success with result + let response = JsonRpcResponse::success(result_value, request.id); + Some(serde_json::to_value(response).unwrap()) + } + Ok(None) => { + // Notification - no response + if is_notification { + None + } else { + // Non-notification with no result shouldn't happen + let error = JsonRpcError::internal_error("Handler returned no result"); + let response = JsonRpcResponse::error(error, request.id); + Some(serde_json::to_value(response).unwrap()) + } + } + Err(e) => { + // Error + tracing::error!(error = %e, method = %request.method, "Request handler failed"); + let error = JsonRpcError::internal_error(e.to_string()); + let response = JsonRpcResponse::error(error, request.id); + Some(serde_json::to_value(response).unwrap()) + } + } +} + +async fn handle_jsonrpc( + State(state): State>, + Json(request): Json, +) -> Response { + use axum::http::StatusCode; + + let response_opt = handle_jsonrpc_inner(state, request).await; + + match response_opt { + Some(response) => Json(response).into_response(), + None => { + // Notification - return 204 No Content + (StatusCode::NO_CONTENT, ()).into_response() + } + } +} + +// Old implementation replaced by the above +async fn _old_handle_jsonrpc( + State(state): State>, + Json(request): Json, +) -> Response { + tracing::debug!(method = %request.method, id = ?request.id, "Received JSON-RPC request"); + + // Validate JSON-RPC version + if request.jsonrpc != "2.0" { + tracing::warn!(version = %request.jsonrpc, "Invalid JSON-RPC version"); + let error = JsonRpcError::invalid_request(); + let response = JsonRpcResponse::error(error, request.id.clone()); + return Json(response).into_response(); + } + + // Check if this is a notification (no id field) + let is_notification = request.id.is_none(); + + // Dispatch to method handler + let result = match request.method.as_str() { + "initialize" => handle_initialize(state, request.params).await.map(Some), + "session/new" => handle_session_new(state, request.params).await.map(Some), + "session/load" => handle_session_load(state, request.params).await.map(Some), + "session/prompt" => handle_session_prompt(state, request.params).await.map(Some), + "session/cancel" => { + // Notification - no response expected + handle_session_cancel(state, request.params).await.map(|_| None) + } + "_dirigent/sessions" => handle_dirigent_sessions(state, request.params).await.map(Some), + "_dirigent/fixture/validate" => handle_dirigent_fixture_validate(state, request.params).await.map(Some), + _ => { + tracing::warn!(method = %request.method, "Method not found"); + let error = JsonRpcError::method_not_found(&request.method); + let response = JsonRpcResponse::error(error, request.id.clone()); + return Json(response).into_response(); + } + }; + + // Return response + match result { + Ok(Some(result)) => { + // Request with result - send response + let response = JsonRpcResponse::success(result, request.id); + Json(response).into_response() + } + Ok(None) => { + // Notification - no response expected + if is_notification { + tracing::debug!(method = %request.method, "Notification processed, no response"); + // For notifications, we could return 204 No Content, but JSON-RPC spec + // says server MUST NOT reply to notifications, so we return an empty response + axum::http::StatusCode::NO_CONTENT.into_response() + } else { + // This shouldn't happen - request has id but handler returned None + tracing::warn!(method = %request.method, "Request has id but handler returned None"); + let error = JsonRpcError::internal_error("Method returned no result"); + let response = JsonRpcResponse::error(error, request.id); + Json(response).into_response() + } + } + Err(e) => { + tracing::error!(error = %e, "Error handling method"); + + // For notifications, we still don't send a response even on error + if is_notification { + tracing::debug!(method = %request.method, "Notification error, no response"); + return axum::http::StatusCode::NO_CONTENT.into_response(); + } + + let error = JsonRpcError::internal_error(e.to_string()); + let response = JsonRpcResponse::error(error, request.id); + Json(response).into_response() + } + } +} + +/// Handle the `initialize` method. +/// +/// Performs ACP protocol negotiation and capability advertisement. +/// +/// # ACP Protocol +/// +/// Method: `initialize` +/// +/// This is the first method called by clients to establish a connection. +/// It validates the protocol version and returns the agent's capabilities. +/// +/// # Arguments +/// +/// * `state` - Shared mocker state +/// * `params` - Initialization parameters (protocol version, client capabilities) +/// +/// # Returns +/// +/// A JSON value containing the initialization result with agent capabilities +/// +/// # Errors +/// +/// Returns an error if: +/// - Parameters are missing or invalid +/// - Protocol version is not compatible +async fn handle_initialize( + state: Arc, + params: Option, +) -> Result { + // Parse parameters + let params: InitializeParams = match params { + Some(p) => serde_json::from_value(p) + .map_err(|e| crate::MockerError::AcpProtocol(format!("Invalid params: {}", e)))?, + None => { + return Err(crate::MockerError::AcpProtocol( + "Missing initialize parameters".to_string(), + )) + } + }; + + tracing::info!( + protocol_version = ?params.protocol_version, + "Initializing ACP session" + ); + + // Parse protocol version (can be integer 1 or string "0.1") + let protocol_version_num = match ¶ms.protocol_version { + serde_json::Value::Number(n) if n.as_i64() == Some(1) => 1, + serde_json::Value::String(s) if s == "0.1" || s == "1" => 1, + _ => { + return Err(crate::MockerError::AcpProtocol(format!( + "Unsupported protocol version: {:?} (expected 1 or \"0.1\")", + params.protocol_version + ))); + } + }; + + tracing::info!( + protocol_version = protocol_version_num, + "Protocol version accepted" + ); + + // Check if we have sessions available for loading + let has_sessions = !state.fixtures().sessions.is_empty(); + + // Build agent capabilities (match ACP spec) + let agent_capabilities = AgentCapabilities { + load_session: if has_sessions { Some(true) } else { None }, + prompt_capabilities: None, // Text/ResourceLink are baseline, not listed + mcp: None, // We don't support MCP + }; + + tracing::info!( + load_session = ?agent_capabilities.load_session, + "Agent capabilities negotiated" + ); + + // Build extension metadata + let meta = serde_json::json!({ + "extensions": [ + "_dirigent/sessions", + "_dirigent/fixture/validate" + ] + }); + + // Build result (respond with integer 1 for protocol version 1) + let result = InitializeResult { + protocol_version: serde_json::json!(1), // Return integer 1 + agent_capabilities, + agent_info: Some(serde_json::json!({ + "name": "dirigate", + "title": "Dirigate", + "version": env!("CARGO_PKG_VERSION") + })), + auth_methods: Some(vec![]), // No authentication required + _meta: Some(meta), + }; + + // Serialize to JSON value + serde_json::to_value(result) + .map_err(|e| crate::MockerError::AcpProtocol(format!("Failed to serialize result: {}", e))) +} + +/// Handle the `session/new` method. +/// +/// Creates a new session, optionally based on a fixture template. +/// +/// # ACP Protocol +/// +/// Method: `session/new` +/// +/// Creates a new conversation session. If a `template_id` is provided +/// (mocker-specific extension), the session will be based on that fixture +/// template. Otherwise, a default empty session is created. +/// +/// # Arguments +/// +/// * `state` - Shared mocker state +/// * `params` - Session creation parameters (optional template_id) +/// +/// # Returns +/// +/// A JSON value containing the session ID +/// +/// # Errors +/// +/// Returns an error if: +/// - The template ID is invalid +/// - Session creation fails +async fn handle_session_new( + state: Arc, + params: Option, +) -> Result { + // Parse parameters (may be empty) + let params: SessionNewParams = match params { + Some(p) => serde_json::from_value(p) + .map_err(|e| crate::MockerError::AcpProtocol(format!("Invalid params: {}", e)))?, + None => SessionNewParams { + cwd: None, + mcp_servers: None, + template_id: None, + }, + }; + + tracing::info!( + template_id = ?params.template_id, + "Creating new session" + ); + + // Create session + let session_id = state.create_session(params.template_id).await?; + + tracing::info!(session_id = %session_id, "Session created"); + + // Build result + let result = SessionNewResult { session_id }; + + // Serialize to JSON value + serde_json::to_value(result) + .map_err(|e| crate::MockerError::AcpProtocol(format!("Failed to serialize result: {}", e))) +} + +/// Handle the `session/load` method. +/// +/// Loads an existing session from fixture data. +/// +/// # ACP Protocol +/// +/// Method: `session/load` +/// +/// Loads a session that exists in the fixture data. The session must be +/// defined in the fixtures for this method to succeed. Once loaded, the +/// session becomes active in state and can receive prompts. +/// +/// # Arguments +/// +/// * `state` - Shared mocker state +/// * `params` - Session load parameters (session_id) +/// +/// # Returns +/// +/// A JSON value containing the complete session information +/// +/// # Errors +/// +/// Returns an error if: +/// - Parameters are missing or invalid +/// - Session is not found in fixtures +async fn handle_session_load( + state: Arc, + params: Option, +) -> Result { + // Parse parameters + let params: SessionLoadParams = match params { + Some(p) => serde_json::from_value(p) + .map_err(|e| crate::MockerError::AcpProtocol(format!("Invalid params: {}", e)))?, + None => { + return Err(crate::MockerError::AcpProtocol( + "Missing session/load parameters".to_string(), + )) + } + }; + + tracing::info!( + session_id = %params.session_id, + "Loading session from fixtures" + ); + + // Load session from fixtures + let session = state.load_session(¶ms.session_id).await?; + + tracing::info!( + session_id = %params.session_id, + message_count = session.messages.len(), + "Session loaded successfully, replaying conversation history" + ); + + // Replay conversation history via session/update notifications + // Per ACP spec: stream all messages, then return null + for message in &session.messages { + let session_update = match message.role { + crate::fixture::types::MessageRole::User => "user_message_chunk", + crate::fixture::types::MessageRole::Assistant => "agent_message_chunk", + crate::fixture::types::MessageRole::System => "system_message_chunk", + }; + + let update_params = crate::acp::model::SessionUpdateParams { + session_id: params.session_id.clone(), + update: serde_json::json!({ + "sessionUpdate": session_update, + "content": { + "type": "text", + "text": message.content.clone() + } + }), + }; + + let notification = crate::acp::model::JsonRpcNotification::new( + "session/update", + Some(serde_json::to_value(&update_params).unwrap()), + ); + + let notification_json = serde_json::to_string(¬ification).unwrap(); + + // Broadcast the notification + state.broadcast_event(crate::SseNotification { + session_id: params.session_id.clone(), + notification: notification_json, + }); + } + + tracing::info!( + session_id = %params.session_id, + "All conversation history replayed" + ); + + // Per ACP spec: return null after streaming all content + Ok(serde_json::Value::Null) +} + +/// Handle the `session/prompt` method. +/// +/// Processes a user prompt and generates an assistant response. +/// +/// # ACP Protocol +/// +/// Method: `session/prompt` +/// +/// Sends a user message to the agent and receives a response. The response +/// is generated based on the session's configured responder strategy. +/// +/// If streaming is enabled in the fixture configuration, the response will +/// be chunked and sent via SSE notifications before the final response is +/// returned. +/// +/// # Arguments +/// +/// * `state` - Shared mocker state +/// * `params` - Session prompt parameters (session_id, content) +/// +/// # Returns +/// +/// A JSON value containing the generated response +/// +/// # Errors +/// +/// Returns an error if: +/// - Parameters are missing or invalid +/// - Session is not found +/// - Content is empty +async fn handle_session_prompt( + state: Arc, + params: Option, +) -> Result { + // Parse parameters + let params: SessionPromptParams = match params { + Some(p) => serde_json::from_value(p) + .map_err(|e| crate::MockerError::AcpProtocol(format!("Invalid params: {}", e)))?, + None => { + return Err(crate::MockerError::AcpProtocol( + "Missing session/prompt parameters".to_string(), + )) + } + }; + + tracing::info!( + session_id = %params.session_id, + content_blocks = params.prompt.len(), + "Processing prompt" + ); + + // Extract text from content blocks + let mut user_text = String::new(); + for block in ¶ms.prompt { + match block { + ContentBlock::Text { text } => { + if !user_text.is_empty() { + user_text.push(' '); + } + user_text.push_str(text); + } + } + } + + if user_text.is_empty() { + return Err(crate::MockerError::AcpProtocol( + "Content cannot be empty".to_string(), + )); + } + + tracing::debug!( + session_id = %params.session_id, + user_text = %user_text, + "Extracted user text from content blocks" + ); + + // Get session and verify it exists + let mut session_state = state.get_session(¶ms.session_id).await?; + + // Create user message + let user_message = crate::fixture::types::Message { + id: uuid::Uuid::new_v4().to_string(), + session_id: params.session_id.clone(), + role: crate::fixture::types::MessageRole::User, + content: user_text.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + parent_id: None, + metadata: None, + }; + + // Add user message to session + state.add_message(¶ms.session_id, user_message.clone()).await?; + + tracing::debug!( + session_id = %params.session_id, + message_id = %user_message.id, + "User message added to session" + ); + + // Check if user is asking for help (special case) + let response_text = if user_text.trim().eq_ignore_ascii_case("help") { + tracing::info!( + session_id = %params.session_id, + "User requested help, generating diagnostic response" + ); + + // Generate help response with mocker diagnostics + let fixtures = state.fixtures(); + let active_session_ids: Vec = { + let sessions = state.sessions.read().await; + sessions.keys().cloned().collect() + }; + + format!( + "# ACP Mocker Diagnostics\n\n\ + **Mocker**: Conductor v{}\n\ + **Transport**: stdio (primary) / HTTP (testing)\n\n\ + ## Current Configuration\n\n\ + - **Fixture Version**: {}\n\ + - **Fixture Sessions**: {} available ({})\n\ + - **Streaming**: {} (chunks: {}, interval: {}ms, jitter: {}ms)\n\ + - **Active Sessions**: {}\n\n\ + ## Responder Configuration\n\n\ + - **Default Strategy**: {:?}\n\ + - **Keywords**: {} configured ({})\n\ + - **Random Corpus**: {}\n\n\ + ## How It Works\n\n\ + The ACP protocol uses **stdio transport** as the primary communication method. \ + Editors like Zed launch the agent as a subprocess and communicate via stdin/stdout using JSON-RPC.\n\n\ + All content is sent via `session/update` notifications:\n\ + - **stdio mode**: Notifications are written to stdout automatically\n\ + - **HTTP mode**: Subscribe to SSE at `/events/{{session_id}}` (for testing only)\n\n\ + ## Available Methods\n\n\ + - `initialize` - Protocol capability negotiation\n\ + - `session/new` - Create new session (optional: template_id)\n\ + - `session/load` - Load session and replay history\n\ + - `session/prompt` - Send prompt, receive via session/update\n\ + - `session/cancel` - Cancel in-progress generation\n\ + - `_dirigent/sessions` - List all sessions (extension)\n\ + - `_dirigent/fixture/validate` - Validate fixture (extension)\n\ + - `_dirigent/help` - Get diagnostics via method call (extension)\n\n\ + Try sending messages with these keywords to see different responses: {}", + env!("CARGO_PKG_VERSION"), + fixtures.version, + fixtures.sessions.len(), + fixtures.sessions.iter().map(|s| s.id.as_str()).collect::>().join(", "), + if fixtures.streaming.enabled { "enabled" } else { "disabled" }, + fixtures.streaming.tokens_per_chunk, + fixtures.streaming.chunk_interval_ms, + fixtures.streaming.jitter_ms.unwrap_or(0), + active_session_ids.len(), + fixtures.responders.default_strategy, + fixtures.responders.keyword_map.len(), + fixtures.responders.keyword_map.keys().map(|k| k.as_str()).collect::>().join(", "), + if fixtures.responders.random.is_some() { + format!("yes ({} responses)", fixtures.responders.random.as_ref().unwrap().corpus.len()) + } else { + "no".to_string() + }, + if fixtures.responders.keyword_map.is_empty() { + "none (using default strategy)".to_string() + } else { + fixtures.responders.keyword_map.keys().map(|k| format!("`{}`", k)).collect::>().join(", ") + } + ) + } else { + // Generate normal response using responder + let fixture_session = crate::fixture::types::Session { + id: session_state.id.clone(), + title: session_state.title.clone(), + created_at: session_state.created_at.clone(), + participants: session_state.participants.clone(), + messages: session_state.messages.clone(), + behavior: None, + }; + + session_state.responder_mut().respond(&user_text, &fixture_session) + }; + + tracing::debug!( + session_id = %params.session_id, + response_len = response_text.len(), + "Generated response" + ); + + // Check if streaming is enabled + let streaming_enabled = state.fixtures().streaming.enabled; + let mut stop_reason = StopReason::EndTurn; + + // Send response via session/update notifications + // Per ACP spec: ALL content must be sent via session/update, whether streaming or not + if streaming_enabled { + tracing::info!( + session_id = %params.session_id, + "Streaming enabled, emitting chunks" + ); + + // Create stream controller with fixture configuration + let stream_config = crate::StreamConfig { + tokens_per_chunk: state.fixtures().streaming.tokens_per_chunk, + chunk_interval_ms: state.fixtures().streaming.chunk_interval_ms, + jitter_ms: state.fixtures().streaming.jitter_ms, + }; + + let controller = crate::StreamController::new(stream_config, 42); + + // Chunk the response text + let chunks = crate::chunk_text(&response_text, state.fixtures().streaming.tokens_per_chunk); + + tracing::debug!( + session_id = %params.session_id, + chunk_count = chunks.len(), + "Chunked response for streaming" + ); + + // Stream chunks via SSE + let chunk_stream = controller.stream_chunks(chunks); + tokio::pin!(chunk_stream); + + use tokio_stream::StreamExt; + while let Some(chunk) = chunk_stream.next().await { + if controller.is_cancelled() { + tracing::info!( + session_id = %params.session_id, + "Streaming cancelled" + ); + stop_reason = StopReason::Cancelled; + break; + } + + // Create session/update notification + let update_params = crate::acp::model::SessionUpdateParams { + session_id: params.session_id.clone(), + update: serde_json::json!({ + "type": "agent_message_chunk", + "messageId": format!("msg-{}", uuid::Uuid::new_v4()), + "content": { + "type": "text", + "text": chunk.clone() + } + }), + }; + + let notification = crate::acp::model::JsonRpcNotification::new( + "session/update", + Some(serde_json::to_value(&update_params).unwrap()), + ); + + let notification_json = serde_json::to_string(¬ification).unwrap(); + + tracing::debug!( + session_id = %params.session_id, + chunk_len = chunk.len(), + "Emitting SSE chunk" + ); + + // Broadcast the notification + state.broadcast_event(crate::SseNotification { + session_id: params.session_id.clone(), + notification: notification_json, + }); + } + } else { + tracing::info!( + session_id = %params.session_id, + "Streaming disabled, sending response as single update" + ); + + // Send entire response as single session/update notification + let update_params = crate::acp::model::SessionUpdateParams { + session_id: params.session_id.clone(), + update: serde_json::json!({ + "type": "agent_message_chunk", + "messageId": format!("msg-{}", uuid::Uuid::new_v4()), + "content": { + "type": "text", + "text": response_text.clone() + } + }), + }; + + let notification = crate::acp::model::JsonRpcNotification::new( + "session/update", + Some(serde_json::to_value(&update_params).unwrap()), + ); + + let notification_json = serde_json::to_string(¬ification).unwrap(); + + // Broadcast the notification + state.broadcast_event(crate::SseNotification { + session_id: params.session_id.clone(), + notification: notification_json, + }); + } + + // Create assistant message + let assistant_message = crate::fixture::types::Message { + id: uuid::Uuid::new_v4().to_string(), + session_id: params.session_id.clone(), + role: crate::fixture::types::MessageRole::Assistant, + content: response_text.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + parent_id: Some(user_message.id), + metadata: None, + }; + + // Add assistant message to session + state.add_message(¶ms.session_id, assistant_message.clone()).await?; + + tracing::info!( + session_id = %params.session_id, + message_id = %assistant_message.id, + streaming = streaming_enabled, + "Assistant message added to session" + ); + + // Build response (only stop_reason, content was sent via session/update) + let result = SessionPromptResult { + stop_reason, + }; + + // Serialize to JSON value + serde_json::to_value(result) + .map_err(|e| crate::MockerError::AcpProtocol(format!("Failed to serialize result: {}", e))) +} + +/// Handle SSE connection for session updates. +/// +/// This endpoint allows clients to subscribe to real-time updates for a specific +/// session. Updates are sent as server-sent events (SSE) using the JSON-RPC +/// notification format. +/// +/// # SSE Protocol +/// +/// Clients connect to `/events/{session_id}` to receive notifications about +/// session state changes. Each event is a JSON-RPC notification with: +/// - `method`: "session/update" +/// - `params`: Session update parameters including content chunks +/// +/// # Arguments +/// +/// * `session_id` - Path parameter specifying which session to subscribe to +/// * `state` - Shared mocker state +/// +/// # Returns +/// +/// An SSE stream of session update events +/// +/// # Example +/// +/// ```text +/// GET /events/session-123 +/// +/// event: notification +/// data: {"jsonrpc":"2.0","method":"session/update","params":{...}} +/// +/// event: notification +/// data: {"jsonrpc":"2.0","method":"session/update","params":{...}} +/// ``` +async fn handle_sse( + Path(session_id): Path, + State(state): State>, +) -> Sse>> { + tracing::info!(session_id = %session_id, "Client subscribed to SSE events"); + + // Subscribe to broadcast events + let rx = state.subscribe_events(); + + // Convert broadcast receiver into a stream + let stream = BroadcastStream::new(rx); + + // Filter events for this session and convert to SSE events + let session_id_clone = session_id.clone(); + let event_stream = stream.filter_map(move |result| { + use tokio_stream::wrappers::errors::BroadcastStreamRecvError; + + match result { + Ok(notification) => { + // Only emit events for this session + if notification.session_id == session_id_clone { + tracing::debug!( + session_id = %session_id_clone, + "Emitting SSE event" + ); + Some(Ok(Event::default() + .event("notification") + .data(notification.notification))) + } else { + None + } + } + Err(BroadcastStreamRecvError::Lagged(skipped)) => { + tracing::warn!( + session_id = %session_id_clone, + skipped, + "SSE client lagged, some events were skipped" + ); + None + } + } + }); + + Sse::new(event_stream).keep_alive(KeepAlive::default()) +} + +/// Handle the `session/cancel` notification. +/// +/// Cancels an ongoing prompt streaming operation. +/// +/// # ACP Protocol +/// +/// Method: `session/cancel` (notification) +/// +/// This is a notification (not a request), so no response is expected. +/// It signals the agent to stop streaming content for the specified session. +/// +/// # Arguments +/// +/// * `state` - Shared mocker state +/// * `params` - Session cancel parameters (session_id) +/// +/// # Errors +/// +/// Returns an error if the session is not found. If no streaming is in +/// progress, the operation is a no-op (idempotent). +async fn handle_session_cancel( + state: Arc, + params: Option, +) -> Result<()> { + use crate::acp::model::SessionCancelParams; + + // Parse parameters + let params: SessionCancelParams = match params { + Some(p) => serde_json::from_value(p) + .map_err(|e| crate::MockerError::AcpProtocol(format!("Invalid params: {}", e)))?, + None => { + return Err(crate::MockerError::AcpProtocol( + "Missing session/cancel parameters".to_string(), + )) + } + }; + + tracing::warn!( + session_id = %params.session_id, + "Received cancellation request" + ); + + // Cancel the stream + state.cancel_stream(¶ms.session_id).await?; + + tracing::warn!( + session_id = %params.session_id, + "Cancellation processed" + ); + + Ok(()) +} + +/// Handle the `_dirigent/sessions` extension method. +/// +/// Lists all available fixture sessions. +/// +/// # Extension Method +/// +/// Method: `_dirigent/sessions` +/// +/// This is a mocker-specific extension that allows clients to discover +/// which sessions are available in the loaded fixtures. Useful for testing +/// and development workflows. +/// +/// # Arguments +/// +/// * `state` - Shared mocker state +/// * `params` - No parameters expected +/// +/// # Returns +/// +/// A JSON value containing a list of session summaries with id, title, +/// created_at, and message_count fields. +async fn handle_dirigent_sessions( + state: Arc, + _params: Option, +) -> Result { + tracing::debug!("Listing fixture sessions"); + + // Get all sessions from fixtures + let sessions = &state.fixtures().sessions; + + // Map to summary objects + let mut summaries: Vec = sessions + .iter() + .map(|session| { + serde_json::json!({ + "id": session.id, + "title": session.title, + "created_at": session.created_at, + "message_count": session.messages.len(), + }) + }) + .collect(); + + // Sort by created_at descending (newest first) + summaries.sort_by(|a, b| { + let a_created = a.get("created_at").and_then(|v| v.as_str()).unwrap_or(""); + let b_created = b.get("created_at").and_then(|v| v.as_str()).unwrap_or(""); + b_created.cmp(a_created) // Reverse order for descending + }); + + tracing::debug!(session_count = summaries.len(), "Listed fixture sessions"); + + // Return as JSON array + Ok(serde_json::json!(summaries)) +} + +/// Handle the `_dirigent/fixture/validate` extension method. +/// +/// Validates the currently loaded fixtures. +/// +/// # Extension Method +/// +/// Method: `_dirigent/fixture/validate` +/// +/// This is a mocker-specific extension that validates the fixture structure +/// without requiring filesystem access. Useful for checking fixture validity +/// during runtime. +/// +/// # Arguments +/// +/// * `state` - Shared mocker state +/// * `params` - No parameters expected +/// +/// # Returns +/// +/// A JSON value containing validation results with: +/// - `valid: bool` - Whether the fixture is valid +/// - `errors: Vec` - List of validation errors (empty if valid) +/// - `warnings: Vec` - List of non-fatal warnings +async fn handle_dirigent_fixture_validate( + state: Arc, + _params: Option, +) -> Result { + tracing::debug!("Validating loaded fixtures"); + + // Run validation + let validation_result = crate::fixture::loader::validate_fixture(state.fixtures()); + + let (valid, errors) = match validation_result { + Ok(()) => { + tracing::debug!("Fixture validation passed"); + (true, Vec::new()) + } + Err(e) => { + tracing::debug!(error = %e, "Fixture validation failed"); + // Extract error messages from the validation error + // The error message format is: "Fixture validation failed with N error(s):\n - error1\n - error2" + let error_msg = e.to_string(); + let errors: Vec = if error_msg.contains("error(s):") { + // Parse the error messages + error_msg + .split('\n') + .skip(1) // Skip the summary line + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with("- ") { + Some(trimmed[2..].to_string()) + } else { + None + } + }) + .collect() + } else { + // Fallback: just include the whole error message + vec![error_msg] + }; + (false, errors) + } + }; + + // Build response + let result = serde_json::json!({ + "valid": valid, + "errors": errors, + "warnings": [], // No warnings for now + }); + + Ok(result) +} + +/// Handle the `_dirigent/help` method (extension). +/// +/// Returns diagnostic information about the mocker configuration. +/// +/// This is a mocker-specific extension method for debugging and testing. +async fn handle_dirigent_help( + state: Arc, + _params: Option, +) -> Result { + tracing::debug!("Returning mocker help/diagnostic information"); + + let fixtures = state.fixtures(); + + // Get active session count from internal state + let active_session_ids: Vec = { + let sessions = state.sessions.read().await; + sessions.keys().cloned().collect() + }; + + // Build response with comprehensive diagnostic info + let result = serde_json::json!({ + "mocker": { + "name": "dirigate", + "version": env!("CARGO_PKG_VERSION"), + "transport": "stdio/http" + }, + "fixture": { + "version": fixtures.version, + "sessions": fixtures.sessions.len(), + "session_ids": fixtures.sessions.iter().map(|s| &s.id).collect::>(), + }, + "responders": { + "default_strategy": format!("{:?}", fixtures.responders.default_strategy), + "keyword_count": fixtures.responders.keyword_map.len(), + "keywords": fixtures.responders.keyword_map.keys().collect::>(), + "has_random": fixtures.responders.random.is_some(), + }, + "streaming": { + "enabled": fixtures.streaming.enabled, + "tokens_per_chunk": fixtures.streaming.tokens_per_chunk, + "chunk_interval_ms": fixtures.streaming.chunk_interval_ms, + "jitter_ms": fixtures.streaming.jitter_ms.unwrap_or(0), + }, + "active_sessions": { + "count": active_session_ids.len(), + "session_ids": active_session_ids, + }, + "usage": { + "transport": "ACP protocol uses stdio as the PRIMARY transport. Editors like Zed launch the agent as a subprocess and communicate via stdin/stdout using JSON-RPC. HTTP transport is an ADDITIONAL capability for testing purposes only.", + "content_delivery": "All content is sent via session/update notifications. In stdio mode (primary), these are written to stdout automatically. In HTTP mode (testing), subscribe to SSE at /events/{session_id}", + "help_trigger": "Send 'help' as a regular message in any session to see this diagnostic information", + "methods": { + "initialize": "Capability negotiation", + "session/new": "Create new session (optional: template_id for fixture-based)", + "session/load": "Load session and replay history via session/update", + "session/prompt": "Send prompt, receive response via session/update notifications", + "session/cancel": "Cancel in-progress generation", + "_dirigent/sessions": "List all sessions (extension)", + "_dirigent/fixture/validate": "Validate loaded fixture (extension)", + "_dirigent/help": "Get diagnostics via method call (extension)" + } + } + }); + + Ok(result) +} + +/// Wait for shutdown signal (Ctrl+C). +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("Failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + tracing::info!("Received Ctrl+C signal"); + }, + _ = terminate => { + tracing::info!("Received terminate signal"); + }, + } +} + +// ============================================================================ +// Legacy API (for backward compatibility) +// ============================================================================ + +/// Server configuration (legacy). +#[derive(Debug, Clone)] +pub struct ServerConfig { + /// Port to bind the server to. + pub port: u16, + /// Host address to bind to. + pub host: String, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + port: 8080, + host: "127.0.0.1".to_string(), + } + } +} + +/// Start the ACP mock server (legacy API). +/// +/// This is a convenience function that creates a server with default fixtures. +/// For more control, use `AcpServer` directly. +pub async fn start_server(_config: ServerConfig) -> Result<()> { + tracing::warn!("start_server() is deprecated, use AcpServer directly"); + Ok(()) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixture::types::*; + use crate::MockerConfig; + use std::collections::HashMap; + + // Helper to create a minimal test fixture + fn create_test_fixture() -> Fixture { + Fixture { + version: "0.1".to_string(), + sessions: vec![Session { + id: "test-template".to_string(), + title: "Test Template".to_string(), + created_at: "2025-01-01T00:00:00Z".to_string(), + participants: vec![], + 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), + }, + } + } + + // Helper to create test state + fn create_test_state() -> Arc { + let config = MockerConfig::default(); + let fixtures = create_test_fixture(); + Arc::new(MockerState::new(config, fixtures)) + } + + #[tokio::test] + async fn test_handle_initialize_success() { + let state = create_test_state(); + + let params = serde_json::json!({ + "protocolVersion": 1, + "clientCapabilities": {} + }); + + let result = handle_initialize(state, Some(params)).await; + assert!(result.is_ok()); + + let result = result.unwrap(); + assert!(result.get("protocolVersion").is_some()); + assert!(result.get("agentCapabilities").is_some()); + assert!(result.get("_meta").is_some()); + } + + #[tokio::test] + async fn test_handle_initialize_unsupported_version() { + let state = create_test_state(); + + let params = serde_json::json!({ + "protocolVersion": 99, + "clientCapabilities": {} + }); + + let result = handle_initialize(state, Some(params)).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Unsupported protocol version")); + } + + #[tokio::test] + async fn test_handle_initialize_missing_params() { + let state = create_test_state(); + + let result = handle_initialize(state, None).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Missing initialize parameters")); + } + + #[tokio::test] + async fn test_handle_session_new_without_template() { + let state = create_test_state(); + + let result = handle_session_new(state.clone(), None).await; + assert!(result.is_ok()); + + let result = result.unwrap(); + let session_id = result.get("sessionId").unwrap().as_str().unwrap(); + assert!(!session_id.is_empty()); + + // Verify session exists in state + let session = state.get_session(session_id).await; + assert!(session.is_ok()); + } + + #[tokio::test] + async fn test_handle_session_new_with_template() { + let state = create_test_state(); + + let params = serde_json::json!({ + "templateId":"test-template" + }); + + let result = handle_session_new(state.clone(), Some(params)).await; + assert!(result.is_ok()); + + let result = result.unwrap(); + let session_id = result.get("sessionId").unwrap().as_str().unwrap(); + assert!(!session_id.is_empty()); + + // Verify session exists and has template data + let session = state.get_session(session_id).await.unwrap(); + assert_eq!(session.title, "Test Template"); + } + + #[tokio::test] + async fn test_handle_session_new_invalid_template() { + let state = create_test_state(); + + let params = serde_json::json!({ + "templateId":"non-existent" + }); + + let result = handle_session_new(state, Some(params)).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Template session not found")); + } + + #[test] + fn test_jsonrpc_error_codes() { + assert_eq!(JsonRpcError::parse_error().code, -32700); + assert_eq!(JsonRpcError::invalid_request().code, -32600); + assert_eq!(JsonRpcError::method_not_found("test").code, -32601); + assert_eq!(JsonRpcError::invalid_params("test").code, -32602); + assert_eq!(JsonRpcError::internal_error("test").code, -32603); + } + + // ======================================================================== + // session/load Tests (Task 2.7) + // ======================================================================== + + #[tokio::test] + async fn test_handle_session_load_success() { + let state = create_test_state(); + + let params = serde_json::json!({ + "sessionId": "test-template" + }); + + let result = handle_session_load(state.clone(), Some(params)).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), serde_json::Value::Null); + } + + #[tokio::test] + async fn test_handle_session_load_not_found() { + let state = create_test_state(); + + let params = serde_json::json!({ + "sessionId": "non-existent-session" + }); + + let result = handle_session_load(state, Some(params)).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Session not found")); + } + + #[tokio::test] + async fn test_handle_session_load_missing_params() { + let state = create_test_state(); + + let result = handle_session_load(state, None).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Missing session/load parameters")); + } + + #[tokio::test] + async fn test_handle_session_load_invalid_params() { + let state = create_test_state(); + + let params = serde_json::json!({ + "wrong_field": "value" + }); + + let result = handle_session_load(state, Some(params)).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Invalid params")); + } + + #[tokio::test] + async fn test_handle_session_load_makes_session_active() { + let state = create_test_state(); + + let params = serde_json::json!({ + "sessionId": "test-template" + }); + + // Load the session + let result = handle_session_load(state.clone(), Some(params)).await; + assert!(result.is_ok()); + + // Verify session is now active (can be retrieved) + let session = state.get_session("test-template").await; + assert!(session.is_ok()); + } + + #[tokio::test] + async fn test_handle_session_load_returns_all_messages() { + let _state = create_test_state(); + + // Create a fixture with multiple sessions, including one with messages + let config = MockerConfig::default(); + let mut fixtures = create_test_fixture(); + + // Add a session with messages to test + fixtures.sessions.push(Session { + id: "session-with-messages".to_string(), + title: "Session With Messages".to_string(), + created_at: "2025-01-01T00:00:00Z".to_string(), + participants: vec![], + messages: vec![ + Message { + id: "msg-1".to_string(), + session_id: "session-with-messages".to_string(), + role: MessageRole::User, + content: "Hello".to_string(), + created_at: "2025-01-01T00:00:01Z".to_string(), + parent_id: None, + metadata: None, + }, + Message { + id: "msg-2".to_string(), + session_id: "session-with-messages".to_string(), + role: MessageRole::Assistant, + content: "Hi there!".to_string(), + created_at: "2025-01-01T00:00:02Z".to_string(), + parent_id: Some("msg-1".to_string()), + metadata: None, + }, + ], + behavior: None, + }); + + let state = Arc::new(MockerState::new(config, fixtures)); + + let params = serde_json::json!({ + "sessionId": "session-with-messages" + }); + + let result = handle_session_load(state, Some(params)).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), serde_json::Value::Null); + } + + // ======================================================================== + // session/prompt Tests (Task 2.8) + // ======================================================================== + + #[tokio::test] + async fn test_handle_session_prompt_success() { + let state = create_test_state(); + + // First create a session + let session_id = state.create_session(None).await.unwrap(); + + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": "Hello, world!" + } + ] + }); + + let result = handle_session_prompt(state.clone(), Some(params)).await; + assert!(result.is_ok()); + + let result = result.unwrap(); + assert_eq!(result.get("stopReason").unwrap().as_str().unwrap(), "end_turn"); + } + + #[tokio::test] + async fn test_handle_session_prompt_updates_message_history() { + let state = create_test_state(); + + // Create a session + let session_id = state.create_session(None).await.unwrap(); + + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": "Test message" + } + ] + }); + + // Send prompt + let _ = handle_session_prompt(state.clone(), Some(params)).await.unwrap(); + + // Verify message history was updated + let session = state.get_session(&session_id).await.unwrap(); + assert_eq!(session.messages.len(), 2); // User message + assistant response + assert_eq!(session.messages[0].role, MessageRole::User); + assert_eq!(session.messages[0].content, "Test message"); + assert_eq!(session.messages[1].role, MessageRole::Assistant); + } + + #[tokio::test] + async fn test_handle_session_prompt_multiple_content_blocks() { + let state = create_test_state(); + + // Create a session + let session_id = state.create_session(None).await.unwrap(); + + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": "Hello" + }, + { + "type": "text", + "text": "world" + } + ] + }); + + let result = handle_session_prompt(state.clone(), Some(params)).await; + assert!(result.is_ok()); + + // Verify the texts were concatenated + let session = state.get_session(&session_id).await.unwrap(); + assert_eq!(session.messages[0].content, "Hello world"); + } + + #[tokio::test] + async fn test_handle_session_prompt_empty_content() { + let state = create_test_state(); + + // Create a session + let session_id = state.create_session(None).await.unwrap(); + + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [] + }); + + let result = handle_session_prompt(state, Some(params)).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Content cannot be empty")); + } + + #[tokio::test] + async fn test_handle_session_prompt_session_not_found() { + let state = create_test_state(); + + let params = serde_json::json!({ + "sessionId": "non-existent-session", + "prompt": [ + { + "type": "text", + "text": "Hello" + } + ] + }); + + let result = handle_session_prompt(state, Some(params)).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Session not found")); + } + + #[tokio::test] + async fn test_handle_session_prompt_missing_params() { + let state = create_test_state(); + + let result = handle_session_prompt(state, None).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Missing session/prompt parameters")); + } + + #[tokio::test] + async fn test_handle_session_prompt_responder_behavior() { + let state = create_test_state(); + + // Create a session (will use Echo responder by default) + let session_id = state.create_session(None).await.unwrap(); + + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": "Test input" + } + ] + }); + + let result = handle_session_prompt(state, Some(params)).await; + assert!(result.is_ok()); + + let result = result.unwrap(); + assert!(result.get("stopReason").is_some()); + } + + // ======================================================================== + // Streaming Tests (Task 2.9) + // ======================================================================== + + #[tokio::test] + async fn test_handle_session_prompt_with_streaming_disabled() { + // Create state with streaming disabled + let config = MockerConfig::default(); + let mut fixtures = create_test_fixture(); + fixtures.streaming.enabled = false; + + let state = Arc::new(MockerState::new(config, fixtures)); + + // Create a session + let session_id = state.create_session(None).await.unwrap(); + + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": "Hello world" + } + ] + }); + + let result = handle_session_prompt(state, Some(params)).await; + assert!(result.is_ok()); + + let result = result.unwrap(); + assert_eq!(result.get("stopReason").unwrap().as_str().unwrap(), "end_turn"); + } + + #[tokio::test] + async fn test_handle_session_prompt_with_streaming_enabled() { + // Create state with streaming enabled + let config = MockerConfig::default(); + let mut fixtures = create_test_fixture(); + fixtures.streaming.enabled = true; + fixtures.streaming.tokens_per_chunk = 2; + fixtures.streaming.chunk_interval_ms = 10; // Fast for testing + fixtures.streaming.jitter_ms = None; + + let state = Arc::new(MockerState::new(config, fixtures)); + + // Subscribe to events to verify they're emitted + let mut event_rx = state.subscribe_events(); + + // Create a session + let session_id = state.create_session(None).await.unwrap(); + let session_id_clone = session_id.clone(); + + // Spawn a task to collect events + let events_handle = tokio::spawn(async move { + let mut events = Vec::new(); + let timeout = tokio::time::sleep(tokio::time::Duration::from_millis(500)); + tokio::pin!(timeout); + + loop { + tokio::select! { + result = event_rx.recv() => { + match result { + Ok(notification) if notification.session_id == session_id_clone => { + events.push(notification); + } + Ok(_) => {} // Different session + Err(_) => break, + } + } + _ = &mut timeout => { + break; + } + } + } + events + }); + + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": "Hello world test message" + } + ] + }); + + let result = handle_session_prompt(state, Some(params)).await; + assert!(result.is_ok()); + + // Verify final response + let result = result.unwrap(); + assert_eq!(result.get("stopReason").unwrap().as_str().unwrap(), "end_turn"); + + // Wait for events to be collected + let events = events_handle.await.unwrap(); + + // Should have received multiple chunks + assert!(events.len() > 0, "Expected streaming chunks to be emitted"); + + // Verify each event is a valid session/update notification + for event in &events { + let notification: serde_json::Value = serde_json::from_str(&event.notification).unwrap(); + assert_eq!(notification.get("jsonrpc").unwrap().as_str().unwrap(), "2.0"); + assert_eq!(notification.get("method").unwrap().as_str().unwrap(), "session/update"); + + let params = notification.get("params").unwrap(); + assert_eq!(params.get("sessionId").unwrap().as_str().unwrap(), session_id); + + let update = params.get("update").unwrap(); + assert_eq!(update.get("sessionUpdate").unwrap().as_str().unwrap(), "agent_message_chunk"); + assert!(update.get("content").is_some()); + } + } + + #[tokio::test] + async fn test_streaming_respects_chunk_interval() { + // Create state with streaming enabled + let config = MockerConfig::default(); + let mut fixtures = create_test_fixture(); + fixtures.streaming.enabled = true; + fixtures.streaming.tokens_per_chunk = 2; + fixtures.streaming.chunk_interval_ms = 50; // 50ms between chunks + fixtures.streaming.jitter_ms = None; + + let state = Arc::new(MockerState::new(config, fixtures)); + + // Create a session + let session_id = state.create_session(None).await.unwrap(); + + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": "one two three four" + } + ] + }); + + let start = std::time::Instant::now(); + let result = handle_session_prompt(state, Some(params)).await; + let elapsed = start.elapsed(); + + assert!(result.is_ok()); + + // With 4 words and 2 tokens per chunk, we should have 2 chunks + // With 50ms interval, should take at least 50ms (1 delay between 2 chunks) + assert!(elapsed.as_millis() >= 40, "Expected at least 40ms for streaming, got {}ms", elapsed.as_millis()); + } + + #[tokio::test] + async fn test_streaming_notification_format() { + // Create state with streaming enabled + let config = MockerConfig::default(); + let mut fixtures = create_test_fixture(); + fixtures.streaming.enabled = true; + fixtures.streaming.tokens_per_chunk = 3; + fixtures.streaming.chunk_interval_ms = 10; + fixtures.streaming.jitter_ms = None; + + let state = Arc::new(MockerState::new(config, fixtures)); + + // Subscribe to events + let mut event_rx = state.subscribe_events(); + + // Create a session + let session_id = state.create_session(None).await.unwrap(); + let session_id_clone = session_id.clone(); + + // Spawn event collector + let events_handle = tokio::spawn(async move { + let mut first_event = None; + let timeout = tokio::time::sleep(tokio::time::Duration::from_millis(200)); + tokio::pin!(timeout); + + loop { + tokio::select! { + result = event_rx.recv() => { + match result { + Ok(notification) if notification.session_id == session_id_clone => { + if first_event.is_none() { + first_event = Some(notification); + } + } + Ok(_) => {} + Err(_) => break, + } + } + _ = &mut timeout => break, + } + } + first_event + }); + + let params = serde_json::json!({ + "sessionId": session_id, + "prompt": [ + { + "type": "text", + "text": "Test message" + } + ] + }); + + let _ = handle_session_prompt(state, Some(params)).await.unwrap(); + + // Get first event + let first_event = events_handle.await.unwrap(); + assert!(first_event.is_some(), "Expected at least one streaming event"); + + let event = first_event.unwrap(); + + // Parse and verify structure + let notification: serde_json::Value = serde_json::from_str(&event.notification).unwrap(); + + assert_eq!(notification.get("jsonrpc").unwrap(), "2.0"); + assert_eq!(notification.get("method").unwrap(), "session/update"); + + let params = notification.get("params").unwrap(); + assert_eq!(params.get("sessionId").unwrap().as_str().unwrap(), session_id); + + let update = params.get("update").unwrap(); + assert_eq!(update.get("sessionUpdate").unwrap().as_str().unwrap(), "agent_message_chunk"); + + let content = update.get("content").unwrap(); + assert_eq!(content.get("type").unwrap(), "text"); + assert!(content.get("text").unwrap().as_str().unwrap().len() > 0); + } + + // ======================================================================== + // session/cancel Tests (Task 2.10) + // ======================================================================== + + #[tokio::test] + async fn test_handle_session_cancel_success() { + let state = create_test_state(); + + // Create a session + let session_id = state.create_session(None).await.unwrap(); + + let params = serde_json::json!({ + "sessionId": session_id + }); + + // Cancel should succeed even if no streaming is active (idempotent) + let result = handle_session_cancel(state, Some(params)).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_session_cancel_session_not_found() { + let state = create_test_state(); + + let params = serde_json::json!({ + "sessionId": "non-existent-session" + }); + + let result = handle_session_cancel(state, Some(params)).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Session not found")); + } + + #[tokio::test] + async fn test_handle_session_cancel_missing_params() { + let state = create_test_state(); + + let result = handle_session_cancel(state, None).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Missing session/cancel parameters")); + } + + #[tokio::test] + async fn test_handle_session_cancel_invalid_params() { + let state = create_test_state(); + + let params = serde_json::json!({ + "wrong_field": "value" + }); + + let result = handle_session_cancel(state, Some(params)).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("Invalid params")); + } + + // Note: Testing actual cancellation during streaming would require + // a more complex test setup with concurrent streaming and cancellation. + // The core cancel_stream() method is tested in lib.rs tests. + + // ======================================================================== + // _dirigent/sessions Tests (Task 2.11) + // ======================================================================== + + #[tokio::test] + async fn test_handle_dirigent_sessions_success() { + let state = create_test_state(); + + let result = handle_dirigent_sessions(state, None).await; + assert!(result.is_ok()); + + let result = result.unwrap(); + let sessions = result.as_array().unwrap(); + + // Should have one session from create_test_fixture + assert_eq!(sessions.len(), 1); + + let session = &sessions[0]; + assert_eq!(session.get("id").unwrap().as_str().unwrap(), "test-template"); + assert_eq!(session.get("title").unwrap().as_str().unwrap(), "Test Template"); + assert!(session.get("created_at").is_some()); + assert!(session.get("message_count").is_some()); + assert_eq!(session.get("message_count").unwrap().as_u64().unwrap(), 0); + } + + #[tokio::test] + async fn test_handle_dirigent_sessions_with_multiple_sessions() { + // Create fixture with multiple sessions + let config = MockerConfig::default(); + let mut fixtures = create_test_fixture(); + + fixtures.sessions.push(Session { + id: "session-2".to_string(), + title: "Session 2".to_string(), + created_at: "2025-01-02T00:00:00Z".to_string(), + participants: vec![], + messages: vec![], + behavior: None, + }); + + fixtures.sessions.push(Session { + id: "session-3".to_string(), + title: "Session 3".to_string(), + created_at: "2025-01-03T00:00:00Z".to_string(), + participants: vec![], + messages: vec![ + Message { + id: "msg-1".to_string(), + session_id: "session-3".to_string(), + role: MessageRole::User, + content: "Hello".to_string(), + created_at: "2025-01-03T00:00:01Z".to_string(), + parent_id: None, + metadata: None, + }, + Message { + id: "msg-2".to_string(), + session_id: "session-3".to_string(), + role: MessageRole::Assistant, + content: "Hi!".to_string(), + created_at: "2025-01-03T00:00:02Z".to_string(), + parent_id: Some("msg-1".to_string()), + metadata: None, + }, + ], + behavior: None, + }); + + let state = Arc::new(MockerState::new(config, fixtures)); + + let result = handle_dirigent_sessions(state, None).await.unwrap(); + let sessions = result.as_array().unwrap(); + + assert_eq!(sessions.len(), 3); + + // Verify sorting (newest first) + assert_eq!(sessions[0].get("id").unwrap().as_str().unwrap(), "session-3"); + assert_eq!(sessions[1].get("id").unwrap().as_str().unwrap(), "session-2"); + assert_eq!(sessions[2].get("id").unwrap().as_str().unwrap(), "test-template"); + + // Verify message counts + assert_eq!(sessions[0].get("message_count").unwrap().as_u64().unwrap(), 2); + assert_eq!(sessions[1].get("message_count").unwrap().as_u64().unwrap(), 0); + assert_eq!(sessions[2].get("message_count").unwrap().as_u64().unwrap(), 0); + } + + #[tokio::test] + async fn test_handle_dirigent_sessions_empty_fixtures() { + // Create fixture with no sessions + let config = MockerConfig::default(); + let fixtures = Fixture { + version: "0.1".to_string(), + sessions: vec![], + 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, + }, + }; + + let state = Arc::new(MockerState::new(config, fixtures)); + + let result = handle_dirigent_sessions(state, None).await.unwrap(); + let sessions = result.as_array().unwrap(); + + assert_eq!(sessions.len(), 0); + } + + // ======================================================================== + // _dirigent/fixture/validate Tests (Task 2.12) + // ======================================================================== + + #[tokio::test] + async fn test_handle_dirigent_fixture_validate_valid() { + let state = create_test_state(); + + let result = handle_dirigent_fixture_validate(state, None).await; + assert!(result.is_ok()); + + let result = result.unwrap(); + assert_eq!(result.get("valid").unwrap().as_bool().unwrap(), true); + + let errors = result.get("errors").unwrap().as_array().unwrap(); + assert_eq!(errors.len(), 0); + + let warnings = result.get("warnings").unwrap().as_array().unwrap(); + assert_eq!(warnings.len(), 0); + } + + #[tokio::test] + async fn test_handle_dirigent_fixture_validate_invalid_version() { + // Create fixture with invalid version + let config = MockerConfig::default(); + let mut fixtures = create_test_fixture(); + fixtures.version = "99.0".to_string(); + + let state = Arc::new(MockerState::new(config, fixtures)); + + let result = handle_dirigent_fixture_validate(state, None).await.unwrap(); + + assert_eq!(result.get("valid").unwrap().as_bool().unwrap(), false); + + let errors = result.get("errors").unwrap().as_array().unwrap(); + assert!(errors.len() > 0); + + let error_str = errors[0].as_str().unwrap(); + assert!(error_str.contains("Invalid version")); + } + + #[tokio::test] + async fn test_handle_dirigent_fixture_validate_multiple_errors() { + // Create fixture with multiple validation errors + let config = MockerConfig::default(); + let mut fixtures = create_test_fixture(); + + // Invalid version + fixtures.version = "99.0".to_string(); + + // Add session with empty ID + fixtures.sessions.push(Session { + id: "".to_string(), // Empty ID - validation error + title: "Bad Session".to_string(), + created_at: "2025-01-01T00:00:00Z".to_string(), + participants: vec![], + messages: vec![], + behavior: None, + }); + + // Add session with invalid timestamp + fixtures.sessions.push(Session { + id: "session-bad-timestamp".to_string(), + title: "Bad Timestamp".to_string(), + created_at: "not-a-date".to_string(), // Invalid timestamp + participants: vec![], + messages: vec![], + behavior: None, + }); + + let state = Arc::new(MockerState::new(config, fixtures)); + + let result = handle_dirigent_fixture_validate(state, None).await.unwrap(); + + assert_eq!(result.get("valid").unwrap().as_bool().unwrap(), false); + + let errors = result.get("errors").unwrap().as_array().unwrap(); + assert!(errors.len() >= 3); // At least version, empty ID, and invalid timestamp + } +} diff --git a/src/acp/stdio.rs b/src/acp/stdio.rs new file mode 100644 index 0000000..ca6dbc1 --- /dev/null +++ b/src/acp/stdio.rs @@ -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) -> 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(()) +} diff --git a/src/acp/stream.rs b/src/acp/stream.rs new file mode 100644 index 0000000..bf9c5c9 --- /dev/null +++ b/src/acp/stream.rs @@ -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, +} + +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 { + 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>, + + /// Cancellation flag shared across stream instances + cancelled: Arc, +} + +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, + ) -> impl Stream + '_ { + 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, data: impl Into) -> 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 = 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" + ); + } +} diff --git a/src/bin/dirigate.rs b/src/bin/dirigate.rs new file mode 100644 index 0000000..e42247b --- /dev/null +++ b/src/bin/dirigate.rs @@ -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); + } +} diff --git a/src/cli/args.rs b/src/cli/args.rs new file mode 100644 index 0000000..4306afc --- /dev/null +++ b/src/cli/args.rs @@ -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, + + /// Milliseconds between chunks for streaming (overrides fixture setting). + #[arg(long)] + chunk_interval_ms: Option, + + /// Enable or disable streaming (overrides fixture setting). + #[arg(long)] + streaming: Option, + }, + + /// Validate fixture files without starting the server. + Validate { + /// Directory or file to validate. Can be specified multiple times. + #[arg(value_name = "PATH")] + paths: Vec, + }, + + /// 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, + + /// 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, + + /// HTTP URL for HTTP+SSE transport (e.g., "http://localhost:8080"). + #[arg(long, conflicts_with = "command")] + url: Option, + + /// 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, + }, +} + +/// 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() + } +} diff --git a/src/cli/commands.rs b/src/cli/commands.rs new file mode 100644 index 0000000..f66364f --- /dev/null +++ b/src/cli/commands.rs @@ -0,0 +1,1274 @@ +//! Command execution logic. +//! +//! This module implements the execution logic for each CLI command. + +use crate::{ + acp::{server::AcpServer, BridgeConfig}, + cli::{Commands, PrintFormat}, + fixture::{load_and_validate, load_fixture, validate_fixture, Fixture}, + logging::LogFormat, + MockerConfig, MockerState, Result, StreamConfig, +}; +use owo_colors::OwoColorize; +use std::time::Duration; +use std::{path::Path, sync::Arc}; +use tabled::{ + settings::{object::Rows, Alignment, Modify, Style}, + Table, Tabled, +}; + +/// Execute a CLI command. +pub async fn execute_command(command: Commands) -> Result<()> { + match command { + Commands::Serve { + fixtures, + stdio, + port, + host, + tokens_per_chunk, + chunk_interval_ms, + streaming, + } => { + serve_command( + fixtures.as_path(), + stdio, + port, + host, + tokens_per_chunk, + chunk_interval_ms, + streaming, + ) + .await + } + Commands::Validate { paths } => validate_command(&paths).await, + Commands::Print { fixture, format } => print_command(fixture.as_path(), format).await, + #[cfg(feature = "ingest")] + Commands::Ingest { + base_url, + session_id, + all, + output, + merge, + } => ingest_command(&base_url, session_id, all, output.as_path(), merge).await, + Commands::Connect { + command, + url, + protocol_version, + auto_session, + } => connect_command(command, url, protocol_version, auto_session).await, + Commands::Bridge { + server_url, + verbose, + timeout, + auto_reconnect, + select_connector, + } => { + bridge_command( + server_url, + verbose, + timeout, + auto_reconnect, + select_connector, + ) + .await + } + } +} + +/// Execute the serve command. +async fn serve_command( + fixtures_path: &Path, + stdio: bool, + port: u16, + host: String, + tokens_per_chunk: Option, + chunk_interval_ms: Option, + streaming_override: Option, +) -> Result<()> { + tracing::info!( + "Loading and merging fixtures from: {}", + fixtures_path.display() + ); + + // Load fixture(s) + let mut fixture = if fixtures_path.is_file() { + // Single file + tracing::info!("Loading fixture file: {}", fixtures_path.display()); + load_and_validate(fixtures_path).await? + } else if fixtures_path.is_dir() { + // Directory - load and merge all fixtures + tracing::info!( + "Loading fixtures from directory: {}", + fixtures_path.display() + ); + let fixtures = load_fixtures_from_directory(fixtures_path).await?; + + if fixtures.is_empty() { + return Err(crate::MockerError::FixtureLoad(format!( + "No valid fixture files found in directory: {}", + fixtures_path.display() + ))); + } + + tracing::info!("Merging {} fixtures", fixtures.len()); + merge_fixtures(fixtures)? + } else { + return Err(crate::MockerError::FixtureLoad(format!( + "Path does not exist: {}", + fixtures_path.display() + ))); + }; + + // Apply CLI overrides to fixture configuration + if let Some(enabled) = streaming_override { + tracing::info!("Overriding streaming enabled: {}", enabled); + fixture.streaming.enabled = enabled; + } + if let Some(tokens) = tokens_per_chunk { + tracing::info!("Overriding tokens per chunk: {}", tokens); + fixture.streaming.tokens_per_chunk = tokens; + } + if let Some(interval) = chunk_interval_ms { + tracing::info!("Overriding chunk interval: {}ms", interval); + fixture.streaming.chunk_interval_ms = interval; + } + + // Validate merged fixture + tracing::info!("Validating merged fixture"); + validate_fixture(&fixture)?; + + // Create configuration with CLI overrides + let stream_config = StreamConfig { + tokens_per_chunk: fixture.streaming.tokens_per_chunk, + chunk_interval_ms: fixture.streaming.chunk_interval_ms, + jitter_ms: fixture.streaming.jitter_ms, + }; + + let config = MockerConfig { + port, + host: host.clone(), + default_stream_config: stream_config, + }; + + // Create mocker state + let state = Arc::new(MockerState::new(config, fixture)); + + tracing::info!( + "Fixture loaded with {} session(s)", + state.fixtures().sessions.len() + ); + + // Print startup information (only for HTTP mode, stdio uses stderr for logs) + if !stdio { + println!(); + println!("{}", "ACP Mock Server".bright_cyan().bold()); + println!("{}", "=".repeat(50).bright_black()); + println!(" {} {}:{}", "Address:".bright_green(), host, port); + println!( + " {} {}", + "Sessions:".bright_green(), + state.fixtures().sessions.len() + ); + println!( + " {} {}", + "Streaming:".bright_green(), + if state.fixtures().streaming.enabled { + "enabled".bright_green().to_string() + } else { + "disabled".bright_red().to_string() + } + ); + + if state.fixtures().streaming.enabled { + println!( + " {} {} tokens", + "Chunk size:".bright_black(), + state.fixtures().streaming.tokens_per_chunk + ); + println!( + " {} {}ms", + "Interval:".bright_black(), + state.fixtures().streaming.chunk_interval_ms + ); + } + + println!("{}", "=".repeat(50).bright_black()); + println!(); + println!("Press Ctrl+C to stop the server"); + println!(); + } + + // Start server based on transport mode + if stdio { + // Stdio transport (for Zed, etc.) + crate::acp::serve_stdio(state).await + } else { + // HTTP transport (for testing with curl/Python) + let server = AcpServer::new(state, port); + server.serve().await + } +} + +/// Execute the validate command. +async fn validate_command(paths: &[std::path::PathBuf]) -> Result<()> { + // Default to current fixtures directory if no paths provided + let paths_to_validate = if paths.is_empty() { + vec![std::path::PathBuf::from("./fixtures")] + } else { + paths.to_vec() + }; + + let mut all_valid = true; + let mut total_validated = 0; + + for path in &paths_to_validate { + if !path.exists() { + println!( + "{} {} (not found)", + "✗".bright_red().bold(), + path.display().to_string().bright_white() + ); + all_valid = false; + continue; + } + + if path.is_file() { + // Validate single file + total_validated += 1; + match load_and_validate(path).await { + Ok(fixture) => { + println!( + "{} {} ({} sessions)", + "✓".bright_green().bold(), + path.display().to_string().bright_white(), + fixture.sessions.len().to_string().bright_black() + ); + } + Err(e) => { + println!( + "{} {}", + "✗".bright_red().bold(), + path.display().to_string().bright_white() + ); + println!(" {}", e.to_string().bright_red()); + all_valid = false; + } + } + } else if path.is_dir() { + // Validate all files in directory + let fixtures = load_fixtures_from_directory(path).await?; + + if fixtures.is_empty() { + println!( + "{} {} (no fixture files found)", + "⚠".bright_yellow().bold(), + path.display().to_string().bright_white() + ); + continue; + } + + for fixture in &fixtures { + total_validated += 1; + // We know these are valid because load_fixtures_from_directory validates them + println!( + "{} fixture with {} session(s)", + "✓".bright_green().bold(), + fixture.sessions.len().to_string().bright_black() + ); + } + } + } + + println!(); + if all_valid { + println!( + "{}", + format!("All {} fixture(s) are valid!", total_validated) + .bright_green() + .bold() + ); + Ok(()) + } else { + println!("{}", "Some fixtures failed validation!".bright_red().bold()); + std::process::exit(1); + } +} + +/// Execute the print command. +async fn print_command(fixture_path: &Path, format: PrintFormat) -> Result<()> { + tracing::debug!("Loading fixture: {}", fixture_path.display()); + + // Load fixture + let fixture = load_fixture(fixture_path).await?; + + match format { + PrintFormat::Table => print_table(&fixture), + PrintFormat::Json => print_json(&fixture)?, + PrintFormat::Yaml => print_yaml(&fixture)?, + } + + Ok(()) +} + +/// Print fixture in table format. +fn print_table(fixture: &Fixture) { + println!(); + println!("{}", "Fixture Overview".bright_cyan().bold()); + println!("{}", "=".repeat(80).bright_black()); + println!(" {} {}", "Version:".bright_green(), fixture.version); + println!( + " {} {}", + "Sessions:".bright_green(), + fixture.sessions.len() + ); + println!( + " {} {}", + "Streaming:".bright_green(), + if fixture.streaming.enabled { + "enabled".bright_green().to_string() + } else { + "disabled".bright_red().to_string() + } + ); + println!(); + + // Session table + if !fixture.sessions.is_empty() { + #[derive(Tabled)] + struct SessionRow { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Title")] + title: String, + #[tabled(rename = "Messages")] + message_count: usize, + #[tabled(rename = "Participants")] + participant_count: usize, + #[tabled(rename = "Created At")] + created_at: String, + } + + let rows: Vec = fixture + .sessions + .iter() + .map(|s| SessionRow { + id: s.id.clone(), + title: s.title.clone(), + message_count: s.messages.len(), + participant_count: s.participants.len(), + created_at: s.created_at.clone(), + }) + .collect(); + + let mut table = Table::new(rows); + table + .with(Style::rounded()) + .with(Modify::new(Rows::first()).with(Alignment::center())); + + println!("{}", table); + println!(); + } + + // Responder configuration + println!("{}", "Responder Configuration".bright_cyan().bold()); + println!("{}", "=".repeat(80).bright_black()); + println!( + " {} {:?}", + "Default Strategy:".bright_green(), + fixture.responders.default_strategy + ); + println!( + " {} {}", + "Keyword Mappings:".bright_green(), + fixture.responders.keyword_map.len() + ); + + if !fixture.responders.keyword_map.is_empty() { + for (keyword, response) in &fixture.responders.keyword_map { + // Truncate long responses for display + let display_response = if response.len() > 60 { + format!("{}...", &response[..57]) + } else { + response.clone() + }; + println!( + " {} {} -> {}", + "•".bright_black(), + keyword.bright_white(), + display_response.bright_black() + ); + } + } + + if let Some(random_config) = &fixture.responders.random { + println!( + " {} {} responses (seed: {})", + "Random Pool:".bright_green(), + random_config.corpus.len(), + random_config.seed + ); + } + + println!(); + + // Streaming configuration + println!("{}", "Streaming Configuration".bright_cyan().bold()); + println!("{}", "=".repeat(80).bright_black()); + println!( + " {} {}", + "Enabled:".bright_green(), + fixture.streaming.enabled + ); + println!( + " {} {} tokens", + "Tokens per Chunk:".bright_green(), + fixture.streaming.tokens_per_chunk + ); + println!( + " {} {}ms", + "Chunk Interval:".bright_green(), + fixture.streaming.chunk_interval_ms + ); + println!( + " {} {:?}ms", + "Jitter:".bright_green(), + fixture.streaming.jitter_ms + ); + println!(); +} + +/// Print fixture in JSON format. +fn print_json(fixture: &Fixture) -> Result<()> { + let json = serde_json::to_string_pretty(fixture).map_err(|e| { + crate::MockerError::AcpProtocol(format!("Failed to serialize fixture to JSON: {}", e)) + })?; + println!("{}", json); + Ok(()) +} + +/// Print fixture in YAML format. +fn print_yaml(fixture: &Fixture) -> Result<()> { + let yaml = serde_yaml::to_string(fixture).map_err(|e| { + crate::MockerError::FixtureLoad(format!("Failed to serialize fixture to YAML: {}", e)) + })?; + println!("{}", yaml); + Ok(()) +} + +/// Execute the ingest command. +#[cfg(feature = "ingest")] +async fn ingest_command( + base_url: &str, + session_id: Option, + all: bool, + output: &Path, + merge: bool, +) -> Result<()> { + use crate::ingest::run_ingest; + + tracing::info!("Starting OpenCode ingestion"); + tracing::info!(" Base URL: {}", base_url); + tracing::info!(" Session ID: {:?}", session_id); + tracing::info!(" All sessions: {}", all); + tracing::info!(" Output: {}", output.display()); + tracing::info!(" Merge: {}", merge); + + // Validate arguments + if !all && session_id.is_none() { + return Err(crate::MockerError::Ingest( + "Must specify either --session-id or --all".to_string(), + )); + } + + if all && session_id.is_some() { + return Err(crate::MockerError::Ingest( + "Cannot specify both --session-id and --all".to_string(), + )); + } + + // Run the ingestion workflow + run_ingest(base_url, session_id, all, output, merge).await?; + + println!( + "\n{} Fixture written to: {}", + "✓".bright_green().bold(), + output.display() + ); + Ok(()) +} + +/// Execute the connect command. +async fn connect_command( + command: Option, + url: Option, + protocol_version: String, + auto_session: bool, +) -> Result<()> { + use dirigent_core::connectors::{ + acp::{ + config::{AcpConfig, TransportKind}, + AcpConnector, + }, + Connector, ConnectorCommand, + }; + use std::io::Write; + use tokio::io::{AsyncBufReadExt, BufReader}; + + // Validate arguments + if command.is_none() && url.is_none() { + return Err(crate::MockerError::AcpProtocol( + "Must specify either --command or --url".to_string(), + )); + } + + // Parse protocol version as u32 + let protocol_version: u32 = protocol_version.parse().unwrap_or(1); + + // Determine transport kind + let transport = if let Some(cmd) = &command { + // Parse command string into executable and arguments + // Simple shell-like parsing: split on whitespace, respecting quotes + let parts = shell_words::split(&cmd).map_err(|e| { + crate::MockerError::AcpProtocol(format!("Failed to parse command: {}", e)) + })?; + + if parts.is_empty() { + return Err(crate::MockerError::AcpProtocol( + "Command cannot be empty".to_string(), + )); + } + + let (executable, args) = parts.split_first().unwrap(); + + TransportKind::Stdio { + command: executable.clone(), + args: args.to_vec(), + cwd: None, + env: vec![], + } + } else if let Some(http_url) = &url { + TransportKind::Http { + base_url: http_url.clone(), + timeout_ms: Some(30_000), + } + } else { + unreachable!() + }; + + println!(); + println!("{}", "ACP Client".bright_cyan().bold()); + println!("{}", "=".repeat(60).bright_black()); + println!(" {} {:?}", "Transport:".bright_green(), transport); + println!(" {} {}", "Protocol:".bright_green(), protocol_version); + println!("{}", "=".repeat(60).bright_black()); + println!(); + println!( + "{}", + "💡 Tip: Set RUST_LOG=debug for packet-level logging".bright_black() + ); + println!( + "{}", + "💡 Tip: Set DIRIGENT_VERBOSE=1 for event details".bright_black() + ); + println!(); + + // Create ACP configuration + let acp_config = AcpConfig { + transport, + protocol_version, + cwd: ".".to_string(), + retry: Default::default(), + embedding: dirigent_tools::EmbeddingConfig::default(), + default_ownership: Default::default(), + acp_log_dir: None, + agent_type: Default::default(), // Custom/unknown agent type + }; + + // Create connector + let connector_id = uuid::Uuid::new_v4().to_string(); + let owner = uuid::Uuid::nil(); + let title = "CLI Client".to_string(); + + tracing::info!( + connector_id = %connector_id, + "Creating ACP connector" + ); + + let connector = AcpConnector::new( + connector_id.clone(), + owner.clone(), + title, + acp_config, + dirigent_core::sharing::bus::SharingBus::new(), + ) + .map_err(|e| crate::MockerError::AcpProtocol(format!("Failed to create connector: {}", e)))?; + + // Subscribe to events + let mut events_rx = connector.subscribe(); + + // Start the connector task + let _task_handle = connector.start_task().await; + + // Spawn event monitor task with verbose logging option + // Use oneshot channel to reliably capture the created session ID + let (session_tx, session_rx) = tokio::sync::oneshot::channel::(); + let mut session_tx = Some(session_tx); // Wrap in Option so we can take it once + + let verbose = std::env::var("DIRIGENT_VERBOSE").is_ok(); + let event_monitor = tokio::spawn(async move { + println!( + "{}", + "🎧 Event monitor task started, waiting for events...".bright_magenta() + ); + while let Ok(event) = events_rx.recv().await { + println!("{} Received event in monitor task", "🔔".bright_magenta()); + + if verbose { + println!("{} {:?}", "📦 EVENT:".bright_magenta(), event); + } + + // Capture session ID from SessionCreated events and send through channel + if let dirigent_protocol::Event::SessionCreated { + connector_id: _, + ref session, + } = event + { + println!( + "{} Detected SessionCreated event, session_id: {}", + "🆔".bright_green(), + session.id.bright_cyan() + ); + + if let Some(tx) = session_tx.take() { + println!( + "{} Sending session ID through oneshot channel...", + "📤".bright_green() + ); + match tx.send(session.id.clone()) { + Ok(_) => { + println!("{} Session ID sent successfully", "✓".bright_green()); + } + Err(_) => { + eprintln!( + "{} Failed to send session ID (receiver dropped)", + "✗".bright_red() + ); + } + } + } else { + println!( + "{} Oneshot channel already used, ignoring duplicate session creation", + "ℹ".bright_blue() + ); + } + } + + display_event(&event); + } + println!( + "{}", + "⚠️ Event monitor task ended (channel closed)".bright_yellow() + ); + }); + + // Wait for connector to be ready + println!( + "{}", + "Waiting for connector to initialize...".bright_yellow() + ); + + // Get command sender + let cmd_tx = connector.command_tx(); + + // Auto-create session if requested + let mut current_session: Option = None; + if auto_session { + println!("{}", "Creating new session...".bright_yellow()); + + cmd_tx + .send(ConnectorCommand::CreateSession { + cwd: None, + project_id: None, + ownership: dirigent_protocol::SessionOwnership::internal(), + }) + .await + .map_err(|e| { + crate::MockerError::AcpProtocol(format!( + "Failed to send create session command: {}", + e + )) + })?; + + println!( + "{} CreateSession command sent, waiting for response...", + "⏳".bright_blue() + ); + + // Wait for session creation event with timeout + // Timeout set to 30 seconds to accommodate slow agents like Claude Code (can take ~17s) + match tokio::time::timeout(std::time::Duration::from_secs(30), session_rx).await { + Ok(Ok(session_id)) => { + println!( + "{} Session ID received from oneshot channel", + "✓".bright_green() + ); + current_session = Some(session_id.clone()); + println!( + "{} Session created: {}", + "✓".bright_green(), + session_id.bright_cyan() + ); + println!( + "{} current_session set to: {}", + "📍".bright_green(), + session_id.bright_cyan() + ); + } + Ok(Err(_)) => { + eprintln!( + "{} Session channel closed unexpectedly", + "Warning:".bright_yellow() + ); + eprintln!( + "{} current_session remains: {:?}", + "📍".bright_yellow(), + current_session + ); + } + Err(_) => { + eprintln!( + "{} Session creation timed out after 30 seconds", + "Warning:".bright_yellow() + ); + eprintln!( + "{} The agent may still be processing the request...", + "Note:".bright_blue() + ); + eprintln!( + "{} current_session remains: {:?}", + "📍".bright_yellow(), + current_session + ); + } + } + } else { + println!( + "{} Auto-session disabled, current_session: {:?}", + "ℹ".bright_blue(), + current_session + ); + } + + println!(); + println!( + "{}", + "Connected! Type '/help' for commands, '/quit' to exit.".bright_green() + ); + println!(); + + // Interactive prompt loop + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + + loop { + // Print prompt + print!("{}", "> ".bright_cyan()); + std::io::stdout().flush().unwrap(); + + // Read line + let line = match reader.next_line().await { + Ok(Some(line)) => line, + Ok(None) => break, // EOF + Err(e) => { + eprintln!("{} Failed to read input: {}", "Error:".bright_red(), e); + continue; + } + }; + + let line = line.trim(); + + // Handle empty input + if line.is_empty() { + continue; + } + + // Handle commands + if line.starts_with('/') { + match handle_command(line, &cmd_tx, &mut current_session).await { + Ok(should_exit) => { + if should_exit { + break; + } + } + Err(e) => { + eprintln!("{} {}", "Error:".bright_red(), e); + } + } + continue; + } + + // Send as message to current session + if let Some(ref session_id) = current_session { + tracing::debug!( + session_id = %session_id, + text_len = line.len(), + "Sending message command to connector" + ); + + if let Err(e) = cmd_tx + .send(ConnectorCommand::SendMessage { + session_id: session_id.clone(), + text: line.to_string(), + }) + .await + { + eprintln!("{} Failed to send message: {}", "Error:".bright_red(), e); + } else { + tracing::debug!("Message command sent successfully"); + } + } else { + println!( + "{} No active session. Use '/session new' to create one or '/session list' to see available sessions.", + "Error:".bright_yellow() + ); + } + } + + println!(); + println!("{}", "Disconnecting...".bright_yellow()); + + // Abort event monitor + event_monitor.abort(); + + Ok(()) +} + +/// Handle CLI commands (lines starting with /). +async fn handle_command( + line: &str, + cmd_tx: &tokio::sync::mpsc::Sender, + current_session: &mut Option, +) -> Result { + use dirigent_core::connectors::ConnectorCommand as Command; + + let parts: Vec<&str> = line.split_whitespace().collect(); + let cmd = parts[0]; + + match cmd { + "/help" => { + println!(); + println!("{}", "Available Commands:".bright_cyan().bold()); + println!("{}", "=".repeat(60).bright_black()); + println!(" {} - Display this help message", "/help".bright_white()); + println!(" {} - Exit the client", "/quit".bright_white()); + println!( + " {} - List available sessions", + "/session list".bright_white() + ); + println!( + " {} - Create a new session", + "/session new [title]".bright_white() + ); + println!(" {} - Switch to a session", "/session ".bright_white()); + println!(" {} - Cancel current session", "/cancel".bright_white()); + println!("{}", "=".repeat(60).bright_black()); + println!(); + Ok(false) + } + "/quit" | "/exit" => Ok(true), + "/session" => { + if parts.len() < 2 { + println!( + "{} Usage: /session >", + "Error:".bright_yellow() + ); + return Ok(false); + } + + match parts[1] { + "list" => { + cmd_tx.send(Command::ListSessions).await.map_err(|e| { + crate::MockerError::AcpProtocol(format!("Failed to send command: {}", e)) + })?; + } + "new" => { + cmd_tx + .send(Command::CreateSession { + cwd: None, + project_id: None, + ownership: dirigent_protocol::SessionOwnership::internal(), + }) + .await + .map_err(|e| { + crate::MockerError::AcpProtocol(format!( + "Failed to send command: {}", + e + )) + })?; + } + session_id => { + *current_session = Some(session_id.to_string()); + println!( + "{} Switched to session: {}", + "✓".bright_green(), + session_id.bright_white() + ); + } + } + Ok(false) + } + "/cancel" => { + if let Some(ref session_id) = current_session { + cmd_tx + .send(Command::CancelGeneration { + session_id: session_id.clone(), + }) + .await + .map_err(|e| { + crate::MockerError::AcpProtocol(format!("Failed to send command: {}", e)) + })?; + } else { + println!("{} No active session", "Error:".bright_yellow()); + } + Ok(false) + } + _ => { + println!("{} Unknown command: {}", "Error:".bright_yellow(), cmd); + println!("Type '/help' for available commands"); + Ok(false) + } + } +} + +/// Display an event from the connector. +fn display_event(event: &dirigent_protocol::Event) { + use dirigent_protocol::{ContentBlock, Event, SessionUpdate, ToolCallStatus}; + use std::io::Write; + + match event { + Event::ConnectorStateChanged { + connector_id, + state, + error_kind, + } => { + if let Some(ref ek) = error_kind { + println!( + "{} Connector {} state: {} (error_kind: {})", + "→".bright_blue(), + connector_id.bright_black(), + state, + ek + ); + } else { + println!( + "{} Connector {} state: {}", + "→".bright_blue(), + connector_id.bright_black(), + state + ); + } + } + Event::SessionCreated { + connector_id: _, + session, + } => { + println!( + "{} Session created: {} - {}", + "✓".bright_green(), + session.id.bright_white(), + session.title.bright_black() + ); + } + Event::SessionsListed { + connector_id: _, + sessions, + } => { + println!(); + println!("{} {} session(s):", "→".bright_blue(), sessions.len()); + for session in sessions { + println!( + " {} {} - {}", + "•".bright_black(), + session.id.bright_white(), + session.title.bright_black() + ); + } + println!(); + } + Event::SessionUpdate { + connector_id: _, + session_id: _, + update, + } => { + match update { + SessionUpdate::UserMessageChunk { content, .. } => { + if let ContentBlock::Text { text } = content { + print!("{} {}", "User:".bright_cyan(), text); + std::io::stdout().flush().unwrap(); + } + } + SessionUpdate::AgentMessageChunk { content, .. } => { + if let ContentBlock::Text { text } = content { + print!("{}", text); + std::io::stdout().flush().unwrap(); + } + } + SessionUpdate::AgentThoughtChunk { content, .. } => { + if let ContentBlock::Text { text } = content { + print!("{} {}", "[Thinking]".bright_black(), text.bright_black()); + std::io::stdout().flush().unwrap(); + } + } + SessionUpdate::ToolCall { tool_call, .. } => { + println!(); + println!( + "{} Tool call: {} ({})", + "⚙".bright_yellow(), + tool_call.tool_name.bright_white(), + tool_call.id.bright_black() + ); + if let Some(input) = &tool_call.raw_input { + println!( + " Input: {}", + serde_json::to_string_pretty(input).unwrap().bright_black() + ); + } + } + SessionUpdate::ToolCallUpdate { tool_call, .. } => match tool_call.status { + ToolCallStatus::Completed => { + println!( + "{} Tool completed: {}", + "⚙".bright_green(), + tool_call.tool_name.bright_white() + ); + if let Some(output) = &tool_call.raw_output { + println!( + " Output: {}", + serde_json::to_string_pretty(output).unwrap().bright_black() + ); + } + } + ToolCallStatus::Error => { + println!( + "{} Tool error: {}", + "⚙".bright_red(), + tool_call.tool_name.bright_white() + ); + if let Some(error) = &tool_call.error { + println!(" Error: {}", error.bright_red()); + } + } + _ => {} + }, + SessionUpdate::Unknown { .. } => { + // Silently ignore unknown update types (forward compatibility) + } + } + } + Event::MessageStarted { + connector_id: _, + message, + } => { + println!(); + println!( + "{} Message started: {}", + "→".bright_blue(), + message.id.bright_black() + ); + } + Event::MessageCompleted { + connector_id: _, + message, + } => { + println!(); + println!( + "{} Message completed: {}", + "✓".bright_green(), + message.id.bright_black() + ); + } + Event::SessionIdle { session_id, .. } => { + println!(); + println!( + "{} Session idle: {}", + "✓".bright_green(), + session_id.bright_white() + ); + } + Event::Error { message } => { + println!("{} {}", "Error:".bright_red(), message); + } + _ => { + // Other events - display in debug format + println!("{} {:?}", "→".bright_black(), event); + } + } +} + +/// Execute the bridge command. +/// +/// This command bridges a stdio ACP client to a Dirigent ACP Server via HTTP/SSE. +async fn bridge_command( + server_url: String, + verbose: bool, + timeout: u64, + auto_reconnect: bool, + select_connector: Option, +) -> Result<()> { + use crate::acp::run_bridge; + + // Log to stderr only since stdout is used for JSON-RPC + eprintln!(); + eprintln!("{}", "Dirigate Bridge".bright_cyan().bold()); + eprintln!("{}", "=".repeat(60).bright_black()); + eprintln!(" {} {}", "Server URL:".bright_green(), server_url); + eprintln!(" {} {}s", "Timeout:".bright_green(), timeout); + eprintln!( + " {} {}", + "Auto-reconnect:".bright_green(), + if auto_reconnect { + "enabled" + } else { + "disabled" + } + ); + if let Some(ref connector) = select_connector { + eprintln!(" {} {}", "Select Connector:".bright_green(), connector); + } + eprintln!("{}", "=".repeat(60).bright_black()); + eprintln!(); + + if verbose { + eprintln!("Bridge starting in verbose mode..."); + } + + let config = BridgeConfig { + server_url, + timeout: Duration::from_secs(timeout), + verbose, + auto_reconnect, + select_connector, + }; + + run_bridge(config).await +} + +/// Parse log format string. +pub fn parse_log_format(format: &str) -> LogFormat { + match format.to_lowercase().as_str() { + "json" => LogFormat::Json, + "compact" => LogFormat::Compact, + _ => LogFormat::Pretty, + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Load all fixtures from a directory. +async fn load_fixtures_from_directory(dir: &Path) -> Result> { + let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| { + crate::MockerError::FixtureLoad(format!( + "Failed to read directory '{}': {}", + dir.display(), + e + )) + })?; + + let mut fixtures = Vec::new(); + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + crate::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) => { + tracing::info!("Successfully loaded fixture from '{}'", path.display()); + fixtures.push(fixture); + } + Err(e) => { + tracing::warn!("Failed to load fixture from '{}': {}", path.display(), e); + // Continue processing other files + } + } + } + + tracing::info!( + "Loaded {} fixture(s) from '{}'", + fixtures.len(), + dir.display() + ); + Ok(fixtures) +} + +/// Merge multiple fixtures into one. +/// +/// Combines sessions, responders, and streaming configurations from multiple fixtures. +/// Uses the version and streaming config from the first fixture, and merges all sessions +/// and responder configurations. +fn merge_fixtures(fixtures: Vec) -> Result { + if fixtures.is_empty() { + return Err(crate::MockerError::FixtureLoad( + "Cannot merge empty fixture list".to_string(), + )); + } + + if fixtures.len() == 1 { + return Ok(fixtures.into_iter().next().unwrap()); + } + + let mut merged = fixtures[0].clone(); + + // Merge sessions from all fixtures + for fixture in fixtures.iter().skip(1) { + merged.sessions.extend(fixture.sessions.clone()); + + // Merge keyword maps + for (keyword, strategy) in &fixture.responders.keyword_map { + merged + .responders + .keyword_map + .insert(keyword.clone(), strategy.clone()); + } + + // If any fixture has a random responder, use the first one we encounter + if merged.responders.random.is_none() && fixture.responders.random.is_some() { + merged.responders.random = fixture.responders.random.clone(); + } + } + + tracing::info!( + "Merged {} fixtures into one with {} total sessions", + fixtures.len(), + merged.sessions.len() + ); + + Ok(merged) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_log_format() { + assert_eq!(parse_log_format("json"), LogFormat::Json); + assert_eq!(parse_log_format("compact"), LogFormat::Compact); + assert_eq!(parse_log_format("pretty"), LogFormat::Pretty); + assert_eq!(parse_log_format("invalid"), LogFormat::Pretty); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..96da7bf --- /dev/null +++ b/src/cli/mod.rs @@ -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::*; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..be874ea --- /dev/null +++ b/src/error.rs @@ -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 = std::result::Result; + +/// 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 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"); + } +} diff --git a/src/fixture/loader.rs b/src/fixture/loader.rs new file mode 100644 index 0000000..4c81dfe --- /dev/null +++ b/src/fixture/loader.rs @@ -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>(path: P) -> Result { + 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>(path: P) -> Result { + 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>(dir: P) -> Result> { + 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) { + // 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, +) { + // 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 = 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")); + } +} diff --git a/src/fixture/mod.rs b/src/fixture/mod.rs new file mode 100644 index 0000000..5e49a3e --- /dev/null +++ b/src/fixture/mod.rs @@ -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::*; diff --git a/src/fixture/responders.rs b/src/fixture/responders.rs new file mode 100644 index 0000000..04c3e74 --- /dev/null +++ b/src/fixture/responders.rs @@ -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, + /// 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, 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, + /// 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) -> 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, + /// 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 = 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> { + 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> { + // 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) -> 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 = (0..20).map(|_| responder1.respond("test", &session)).collect(); + let responses2: Vec = (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"); + } +} diff --git a/src/fixture/types.rs b/src/fixture/types.rs new file mode 100644 index 0000000..c66977b --- /dev/null +++ b/src/fixture/types.rs @@ -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, + + /// 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, + + /// Messages in this session. + pub messages: Vec, + + /// Optional per-session behavior overrides. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub behavior: Option, +} + +/// 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, +} + +/// 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, + + /// Optional metadata (arbitrary JSON). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// 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, + + /// 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, +} + +/// 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, +} + +/// 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, +} + +/// 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, + + /// Override streaming configuration for this session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub streaming: Option, +} + +#[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); + } +} diff --git a/src/ingest/mod.rs b/src/ingest/mod.rs new file mode 100644 index 0000000..bf0e2c9 --- /dev/null +++ b/src/ingest/mod.rs @@ -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; diff --git a/src/ingest/opencode.rs b/src/ingest/opencode.rs new file mode 100644 index 0000000..74c98b3 --- /dev/null +++ b/src/ingest/opencode.rs @@ -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, + 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 { + 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> { + 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, +} + +// ============================================================================ +// Session Mapping +// ============================================================================ + +/// Map OpenCode sessions to fixture format. +fn map_sessions_to_fixture(opencode_sessions: Vec) -> Result { + let sessions: Vec = opencode_sessions + .into_iter() + .map(|oc_session| map_session(oc_session)) + .collect::>>()?; + + // 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 { + 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 = 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 { + // 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 { + let mut merged = existing.clone(); + let mut existing_ids: HashMap = 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 { + 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 + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..bbb51ff --- /dev/null +++ b/src/lib.rs @@ -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) -> 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, + + /// Messages in this session (grows as turns progress). + pub messages: Vec, + + /// Responder assigned to this session. + responder: Box, + + /// Stream controller for ongoing streams (if any). + pub stream_controller: Option, +} + +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, + messages: Vec, + responder: Box, + ) -> 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 { + &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>>, + + /// Loaded fixtures (immutable after creation). + fixtures: Arc, + + /// Server configuration. + config: MockerConfig, + + /// Global random number generator for ID generation. + _global_rng: Arc>, + + /// Broadcast channel for SSE notifications. + /// Clients subscribe to this channel to receive session updates. + event_tx: Arc>, +} + +/// 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::().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 { + 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) -> Result { + // 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 { + 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 { + 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 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); + } +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..574a1a3 --- /dev/null +++ b/src/logging.rs @@ -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); + } +} diff --git a/tests/dirigent_bridge_test.rs b/tests/dirigent_bridge_test.rs new file mode 100644 index 0000000..ac6d0f4 --- /dev/null +++ b/tests/dirigent_bridge_test.rs @@ -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, + request: Value, + timeout_secs: u64, +) -> Result { + // 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(()) +} diff --git a/tests/fixture_loader_tests.rs b/tests/fixture_loader_tests.rs new file mode 100644 index 0000000..d2965df --- /dev/null +++ b/tests/fixture_loader_tests.rs @@ -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); +} diff --git a/tests/fixtures/invalid_bad_timestamp.yaml b/tests/fixtures/invalid_bad_timestamp.yaml new file mode 100644 index 0000000..65322f4 --- /dev/null +++ b/tests/fixtures/invalid_bad_timestamp.yaml @@ -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 diff --git a/tests/fixtures/invalid_bad_yaml.yaml b/tests/fixtures/invalid_bad_yaml.yaml new file mode 100644 index 0000000..2111bb7 --- /dev/null +++ b/tests/fixtures/invalid_bad_yaml.yaml @@ -0,0 +1,5 @@ +version: "0.1" +sessions: + - id: "session-1 + title: "Unclosed quote + this is not valid yaml: [broken diff --git a/tests/fixtures/invalid_duplicate_session.yaml b/tests/fixtures/invalid_duplicate_session.yaml new file mode 100644 index 0000000..fdc26e3 --- /dev/null +++ b/tests/fixtures/invalid_duplicate_session.yaml @@ -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 diff --git a/tests/fixtures/invalid_version.yaml b/tests/fixtures/invalid_version.yaml new file mode 100644 index 0000000..a72179a --- /dev/null +++ b/tests/fixtures/invalid_version.yaml @@ -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 diff --git a/tests/fixtures/valid_basic.yaml b/tests/fixtures/valid_basic.yaml new file mode 100644 index 0000000..144dac5 --- /dev/null +++ b/tests/fixtures/valid_basic.yaml @@ -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 diff --git a/tests/fixtures/valid_complex.yaml b/tests/fixtures/valid_complex.yaml new file mode 100644 index 0000000..b02010d --- /dev/null +++ b/tests/fixtures/valid_complex.yaml @@ -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 diff --git a/tests/server_integration_test.rs b/tests/server_integration_test.rs new file mode 100644 index 0000000..39b7932 --- /dev/null +++ b/tests/server_integration_test.rs @@ -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()); +} diff --git a/tests/stdio_integration_test.rs b/tests/stdio_integration_test.rs new file mode 100644 index 0000000..cf033e3 --- /dev/null +++ b/tests/stdio_integration_test.rs @@ -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, + request: Value, +) -> anyhow::Result { + // 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(()) +}