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

No behavior change; just check-all stays green and fermata tests pass.
2026-04-30 21:58:57 +02:00

12 KiB

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:

{"jsonrpc":"2.0","method":"session/prompt","params":{...},"id":1}

It expects a JSON-RPC response with the same id:

{"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:

dirigent-acp-mocker serve --fixtures ./fixtures --port 8080

validate

Validate fixture files without starting server:

dirigent-acp-mocker validate --path ./fixtures

ingest (feature: ingest)

Import sessions from external sources:

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:

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:

dirigent_acp_mocker = { features = ["ingest"] }

Or build with:

cargo build --package dirigent_acp_mocker --features ingest

Implementation Status

Phase 0: Scaffold ✓ COMPLETE

  • Crate structure (lib + bin targets)
  • Core dependencies in Cargo.toml
  • Error handling with thiserror
  • Logging infrastructure with tracing
  • Complete module structure
  • CLI argument parsing with clap
  • 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

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

# 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

cargo test --package dirigent_acp_mocker

Building

# Without features
cargo build --package dirigent_acp_mocker

# With all features
cargo build --package dirigent_acp_mocker --all-features

Checking

cargo check --package dirigent_acp_mocker
  • dirigent_protocol - Protocol types (optional, with ingest feature)
  • opencode_client - OpenCode.ai client (optional, with ingest feature)
  • dirigent_core - Uses this for testing connector implementations

Documentation

  • README: ./README.md
  • Main project: ../../CLAUDE.md

Notes for Future Implementation

Fixture Loading (Phase 1)

  • Use tokio::fs for async file I/O
  • Support both .yaml and .yml extensions
  • Validate fixture structure on load
  • Provide helpful error messages for malformed YAML

Response Behavior (Phase 1)

  • Sequential mode: Track state per session
  • Random mode: Add rand crate dependency
  • Pattern mode: Add regex crate for pattern matching
  • Consider thread-safety for responder state

Server Implementation (Phase 2)

  • Use Axum's Router for endpoint setup
  • Add tower-http middleware for CORS
  • Implement SSE with axum::response::Sse
  • Consider shared state with Arc<Mutex>

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